diff options
Diffstat (limited to 'odoo')
49 files changed, 2481 insertions, 1556 deletions
diff --git a/odoo/addons/base/data/base_data.sql b/odoo/addons/base/data/base_data.sql index 0b7b2357457..7219f0c786f 100644 --- a/odoo/addons/base/data/base_data.sql +++ b/odoo/addons/base/data/base_data.sql @@ -87,7 +87,7 @@ CREATE TABLE ir_model_data ( create_date timestamp without time zone, write_date timestamp without time zone, write_uid integer, - noupdate boolean, + noupdate boolean DEFAULT False, name varchar NOT NULL, date_init timestamp without time zone, date_update timestamp without time zone, diff --git a/odoo/addons/base/models/ir_actions.py b/odoo/addons/base/models/ir_actions.py index 9f2f253f302..64387b0c5c1 100644 --- a/odoo/addons/base/models/ir_actions.py +++ b/odoo/addons/base/models/ir_actions.py @@ -100,6 +100,9 @@ class IrActions(models.Model): actions, where the latter is given by calling the method ``read`` on the action record. """ + # DLE P19: Need to flush before doing the SELECT, which act as a search. + # Test `test_bindings` + self.flush() cr = self.env.cr query = """ SELECT a.id, a.type, a.binding_type FROM ir_actions a, ir_model m @@ -238,14 +241,6 @@ class IrActionsActWindow(models.Model): def exists(self): ids = self._existing() existing = self.filtered(lambda rec: rec.id in ids) - if len(existing) < len(self): - # mark missing records in cache with a failed value - exc = MissingError( - _("Record does not exist or has been deleted.") - + '\n\n({} {}, {} {})'.format(_('Records:'), (self - existing).ids[:6], _('User:'), self._uid) - ) - for record in (self - existing): - record._cache.set_failed(self._fields, exc) return existing @api.model @@ -440,7 +435,7 @@ class IrActionsServer(models.Model): @api.model def run_action_multi(self, action, eval_context=None): res = False - for act in action.child_ids: + for act in action.child_ids.sorted(): result = act.run() if result: res = result diff --git a/odoo/addons/base/models/ir_attachment.py b/odoo/addons/base/models/ir_attachment.py index ac84fdf4b20..7583a01d631 100644 --- a/odoo/addons/base/models/ir_attachment.py +++ b/odoo/addons/base/models/ir_attachment.py @@ -331,6 +331,8 @@ class IrAttachment(models.Model): model_ids = defaultdict(set) # {model_name: set(ids)} require_employee = False if self: + # DLE P173: `test_01_portal_attachment` + self.env['ir.attachment'].flush(['res_model', 'res_id', 'create_uid', 'public', 'res_field']) self._cr.execute('SELECT res_model, res_id, create_uid, public, res_field FROM ir_attachment WHERE id IN %s', [tuple(self.ids)]) for res_model, res_id, create_uid, public, res_field in self._cr.fetchall(): if not self.env.is_system() and res_field: diff --git a/odoo/addons/base/models/ir_cron.py b/odoo/addons/base/models/ir_cron.py index b9ccfcfbec6..3542b8adc2a 100644 --- a/odoo/addons/base/models/ir_cron.py +++ b/odoo/addons/base/models/ir_cron.py @@ -150,6 +150,7 @@ class ir_cron(models.Model): fields.Datetime.to_string(now.astimezone(pytz.UTC)), job['id'] )) + cron.flush() cron.invalidate_cache() finally: diff --git a/odoo/addons/base/models/ir_http.py b/odoo/addons/base/models/ir_http.py index 1a64e34ffda..5c59223c240 100644 --- a/odoo/addons/base/models/ir_http.py +++ b/odoo/addons/base/models/ir_http.py @@ -324,6 +324,10 @@ class IrHttp(models.AbstractModel): # eg: Allow to download an attachment on a task from /my/task/task_id record.check('read') record = record_sudo + # We have prefetched some fields of record, among which the field + # 'write_date' used by '__last_update' below. In order to check + # access on record, we have to invalidate its cache first. + record._cache.clear() # check read access try: diff --git a/odoo/addons/base/models/ir_model.py b/odoo/addons/base/models/ir_model.py index effbc16fac8..f12d0aaa453 100644 --- a/odoo/addons/base/models/ir_model.py +++ b/odoo/addons/base/models/ir_model.py @@ -606,9 +606,9 @@ class IrModelFields(models.Model): else: # field hasn't been loaded (yet?) continue - for dependant, path in model._field_triggers.get(field, ()): - if dependant.manual: - failed_dependencies.append((field, dependant)) + for dep in model._dependent_fields(field): + if dep.manual: + failed_dependencies.append((field, dep)) for inverse in model._field_inverses.get(field, ()): if inverse.manual and inverse.type == 'one2many': failed_dependencies.append((field, inverse)) @@ -625,6 +625,12 @@ class IrModelFields(models.Model): if not self: return + # remove pending write of this field + # DLE P16: if there are pending towrite of the field we currently try to unlink, pop them out from the towrite queue + # test `test_unlink_with_dependant` + for record in self: + for record_values in self.env.all.towrite[record.model].values(): + record_values.pop(record.name, None) # remove fields from registry, and check that views are not broken fields = [self.env[record.model]._pop_field(record.name) for record in self] domain = expression.OR([('arch_db', 'like', record.name)] for record in self) @@ -749,6 +755,7 @@ class IrModelFields(models.Model): res = super(IrModelFields, self).write(vals) + self.flush() self.clear_caches() # for _existing_field_data() if column_rename: @@ -970,6 +977,7 @@ class IrModelSelection(models.Model): def _get_selection(self, field_id): """ Return the given field's selection as a list of pairs (value, string). """ + self.flush(['value', 'name', 'field_id', 'sequence']) self._cr.execute(""" SELECT value, name FROM ir_model_fields_selection diff --git a/odoo/addons/base/models/ir_module.py b/odoo/addons/base/models/ir_module.py index f453c2cd762..49a1659de77 100644 --- a/odoo/addons/base/models/ir_module.py +++ b/odoo/addons/base/models/ir_module.py @@ -938,7 +938,6 @@ class ModuleDependency(models.Model): for dep in self: dep.depend_id = name_mod.get(dep.name) - @api.depends('depend_id.state') def _compute_state(self): for dependency in self: dependency.state = dependency.depend_id.state or 'unknown' @@ -954,7 +953,7 @@ class ModuleExclusion(models.Model): # the module that excludes it module_id = fields.Many2one('ir.module.module', 'Module', ondelete='cascade') - # the module corresponding to the exclusion, and its status + # the module corresponding to the exclusion, and its status, must be stored as it's used in a @depends exclusion_id = fields.Many2one('ir.module.module', 'Exclusion Module', compute='_compute_exclusion') state = fields.Selection(DEP_STATES, string='Status', compute='_compute_state') @@ -969,7 +968,6 @@ class ModuleExclusion(models.Model): for excl in self: excl.exclusion_id = name_mod.get(excl.name) - @api.depends('exclusion_id.state') def _compute_state(self): for exclusion in self: exclusion.state = exclusion.exclusion_id.state or 'unknown' diff --git a/odoo/addons/base/models/ir_property.py b/odoo/addons/base/models/ir_property.py index 534119c4c22..d4a7af5ece0 100644 --- a/odoo/addons/base/models/ir_property.py +++ b/odoo/addons/base/models/ir_property.py @@ -110,6 +110,11 @@ class Property(models.Model): ) r = super(Property, self).write(self._update_values(values)) if default_set: + # DLE P44: test `test_27_company_dependent` + # Easy solution, need to flush write when changing a property. + # Maybe it would be better to be able to compute all impacted cache value and update those instead + # Then clear_caches must be removed as well. + self.flush() self.clear_caches() return r @@ -119,6 +124,8 @@ class Property(models.Model): created_default = any(not v.get('res_id') for v in vals_list) r = super(Property, self).create(vals_list) if created_default: + # DLE P44: test `test_27_company_dependent` + self.flush() self.clear_caches() return r diff --git a/odoo/addons/base/models/ir_rule.py b/odoo/addons/base/models/ir_rule.py index 1cbde0632a4..f516b7fa609 100644 --- a/odoo/addons/base/models/ir_rule.py +++ b/odoo/addons/base/models/ir_rule.py @@ -194,11 +194,18 @@ class IrRule(models.Model): @api.model_create_multi def create(self, vals_list): res = super(IrRule, self).create(vals_list) + # DLE P33: tests + self.flush() self.clear_caches() return res def write(self, vals): res = super(IrRule, self).write(vals) + # DLE P33: tests + # - odoo/addons/test_access_rights/tests/test_feedback.py + # - odoo/addons/test_access_rights/tests/test_ir_rules.py + # - odoo/addons/base/tests/test_orm.py (/home/dle/src/odoo/master-nochange-fp/odoo/addons/base/tests/test_orm.py) + self.flush() self.clear_caches() return res @@ -220,7 +227,7 @@ class IrRule(models.Model): # so it is relatively safe here to include the list of rules and # record names. rules = self._get_failing(records, mode=operation).sudo() - return AccessError(_("""The requested operation ("%(operation)s" on "%(document_kind)s" (%(document_model)s)) was rejected because of the following rules: + error = AccessError(_("""The requested operation ("%(operation)s" on "%(document_kind)s" (%(document_model)s)) was rejected because of the following rules: %(rules_list)s %(multi_company_warning)s (Records: %(example_records)s, User: %(user_id)s)""") % { @@ -233,6 +240,10 @@ class IrRule(models.Model): 'example_records': ' - '.join(['%s (id=%s)' % (rec.display_name, rec.id) for rec in records[:6].sudo()]), 'user_id': '%s (id=%s)' % (self.env.user.name, self.env.user.id), }) + # clean up the cache of records prefetched with display_name above + for record in records[:6]: + record._cache.clear() + return error # # Hack for field 'global': this field cannot be defined like others, because diff --git a/odoo/addons/base/models/ir_sequence.py b/odoo/addons/base/models/ir_sequence.py index 0a6b603e4f6..eafedd862d7 100644 --- a/odoo/addons/base/models/ir_sequence.py +++ b/odoo/addons/base/models/ir_sequence.py @@ -179,7 +179,10 @@ class IrSequence(models.Model): _create_sequence(self._cr, "ir_sequence_%03d" % seq.id, i, n) for sub_seq in seq.date_range_ids: _create_sequence(self._cr, "ir_sequence_%03d_%03d" % (seq.id, sub_seq.id), i, n) - return super(IrSequence, self).write(values) + res = super(IrSequence, self).write(values) + # DLE P179 + self.flush(values.keys()) + return res def _next_do(self): if self.implementation == 'standard': @@ -368,4 +371,14 @@ class IrSequenceDateRange(models.Model): if values.get('number_next'): seq_to_alter = self.filtered(lambda seq: seq.sequence_id.implementation == 'standard') seq_to_alter._alter_sequence(number_next=values.get('number_next')) - return super(IrSequenceDateRange, self).write(values) + # DLE P179: `test_in_invoice_line_onchange_sequence_number_1` + # _update_nogap do a select to get the next sequence number_next + # When changing (writing) the number next of a sequence, the number next must be flushed before doing the select. + # Normally in such a case, we flush just above the execute, but for the sake of performance + # I believe this is better to flush directly in the write: + # - Changing the number next of a sequence is really really rare, + # - But selecting the number next happens a lot, + # Therefore, if I chose to put the flush just above the select, it would check the flush most of the time for no reason. + res = super(IrSequenceDateRange, self).write(values) + self.flush(values.keys()) + return res diff --git a/odoo/addons/base/models/ir_translation.py b/odoo/addons/base/models/ir_translation.py index 357f60e816c..4217559fc63 100644 --- a/odoo/addons/base/models/ir_translation.py +++ b/odoo/addons/base/models/ir_translation.py @@ -226,6 +226,16 @@ class IrTranslation(models.Model): def _modified(self): """ Invalidate the ormcache if necessary, depending on the translations ``self``. """ + # DLE P63: test_views.py + for trans in self: + if trans.type == 'model_terms' and trans.res_id: + model, field = trans.name.split(',') + if model in self.env: + model = self.env[model] + if field in model._fields: + field = model._fields[field] + record = model.browse(trans.res_id) + record.modified([field.name]) for trans in self: if trans.type != 'model' or trans.name.split(',')[0] in self.CACHED_MODELS: self.clear_caches() @@ -530,6 +540,8 @@ class IrTranslation(models.Model): records = super(IrTranslation, self.sudo()).create(vals_list).with_env(self.env) records.check('create') records._modified() + # DLE P62: `test_translate.py`, `test_sync` + self.flush() return records def write(self, vals): @@ -541,6 +553,12 @@ class IrTranslation(models.Model): result = super(IrTranslation, self.sudo()).write(vals) self.check('write') self._modified() + # DLE P62: `test_translate.py`, `test_sync` + # when calling `flush` with a field list, if there is no value for one of these fields, + # the flush to database is not done. + # this causes issues when changing the src/value of a translation, as when we read, we ask the flush, + # but its not really the field which is in the towrite values, but its translation + self.flush() return result def unlink(self): @@ -549,6 +567,16 @@ class IrTranslation(models.Model): return super(IrTranslation, self.sudo()).unlink() @api.model + def _search(self, args, offset=0, limit=None, order=None, count=False, access_rights_uid=None): + # DLE P67, `test_new_fields.py`, `test_80_copy` + # When assigning a translation to a field + # e.g. email.with_context(lang='fr_FR').label = "bonjour" + # and then search on translations for this translation, must flush as the translation has not yet been written in database + if any(self.env[model]._fields[field].translate for model, ids in self.env.all.towrite.items() for record_id, fields in ids.items() for field in fields): + self.flush() + return super(IrTranslation, self)._search(args, offset=offset, limit=limit, order=order, count=count, access_rights_uid=access_rights_uid) + + @api.model def insert_missing(self, field, records): """ Insert missing translations for `field` on `records`. """ records = records.with_context(lang=None) diff --git a/odoo/addons/base/models/ir_ui_view.py b/odoo/addons/base/models/ir_ui_view.py index 2b255f80191..ba63d2afadf 100644 --- a/odoo/addons/base/models/ir_ui_view.py +++ b/odoo/addons/base/models/ir_ui_view.py @@ -321,6 +321,8 @@ actual arch. @api.depends('write_date') def _compute_model_data_id(self): # get the first ir_model_data record corresponding to self + for view in self: + view.model_data_id = False domain = [('model', '=', 'ir.ui.view'), ('res_id', 'in', self.ids)] for data in self.env['ir.model.data'].sudo().search_read(domain, ['res_id'], order='id desc'): view = self.browse(data['res_id']) diff --git a/odoo/addons/base/models/res_bank.py b/odoo/addons/base/models/res_bank.py index cd8c501028d..57255cdce65 100644 --- a/odoo/addons/base/models/res_bank.py +++ b/odoo/addons/base/models/res_bank.py @@ -112,6 +112,7 @@ class ResPartnerBank(models.Model): def _search(self, args, offset=0, limit=None, order=None, count=False, access_rights_uid=None): pos = 0 while pos < len(args): + # DLE P14 if args[pos][0] == 'acc_number': op = args[pos][1] value = args[pos][2] diff --git a/odoo/addons/base/models/res_config.py b/odoo/addons/base/models/res_config.py index 44d7082e8a3..3b562b6ba38 100644 --- a/odoo/addons/base/models/res_config.py +++ b/odoo/addons/base/models/res_config.py @@ -575,7 +575,6 @@ class ResConfigSettings(models.TransientModel, ResConfigModuleInstallationMixin) else: groups.write({'implied_ids': [(3, implied_group.id)]}) implied_group.write({'users': [(3, user.id) for user in groups.users]}) - self.recompute() # config fields: store ir.config_parameters IrConfigParameter = self.env['ir.config_parameter'].sudo() @@ -620,6 +619,9 @@ class ResConfigSettings(models.TransientModel, ResConfigModuleInstallationMixin) if module and module.state in ('installed', 'to upgrade'): to_uninstall_modules += module + if to_install or to_uninstall_modules: + self.flush() + if to_uninstall_modules: to_uninstall_modules.button_immediate_uninstall() diff --git a/odoo/addons/base/models/res_currency.py b/odoo/addons/base/models/res_currency.py index 86099e3faee..40f1952e219 100644 --- a/odoo/addons/base/models/res_currency.py +++ b/odoo/addons/base/models/res_currency.py @@ -46,6 +46,7 @@ class Currency(models.Model): ] def _get_rates(self, company, date): + self.env['res.currency.rate'].flush(['rate', 'currency_id', 'company_id', 'name']) query = """SELECT c.id, COALESCE((SELECT r.rate FROM res_currency_rate r WHERE r.currency_id = c.id AND r.name <= %s diff --git a/odoo/addons/base/models/res_lang.py b/odoo/addons/base/models/res_lang.py index f64ff07342f..22c6221b5c2 100644 --- a/odoo/addons/base/models/res_lang.py +++ b/odoo/addons/base/models/res_lang.py @@ -231,6 +231,7 @@ class Lang(models.Model): self.env['ir.default'].discard_values('res.partner', 'lang', lang_codes) res = super(Lang, self).write(vals) + self.flush() self.clear_caches() return res diff --git a/odoo/addons/base/models/res_partner.py b/odoo/addons/base/models/res_partner.py index 5468782d809..0e4078d1db6 100644 --- a/odoo/addons/base/models/res_partner.py +++ b/odoo/addons/base/models/res_partner.py @@ -230,8 +230,9 @@ class Partner(models.Model): self._cr.execute("""CREATE INDEX res_partner_vat_index ON res_partner (regexp_replace(upper(vat), '[^A-Z0-9]+', '', 'g'))""") @api.depends('is_company', 'name', 'parent_id.name', 'type', 'company_name') + @api.depends_context('show_address', 'show_address_only', 'show_email', 'html_format', 'show_vat') def _compute_display_name(self): - diff = dict(show_address=None, show_address_only=None, show_email=None, html_format=None, show_vat=False) + diff = dict(show_address=None, show_address_only=None, show_email=None, html_format=None, show_vat=None) names = dict(self.with_context(**diff).name_get()) for partner in self: partner.display_name = names.get(partner.id) @@ -535,6 +536,15 @@ class Partner(models.Model): def write(self, vals): if vals.get('active') is False: + # DLE: It should not be necessary to modify this to make work the ORM. The problem was just the recompute + # of partner.user_ids when you create a new user for this partner, see test test_70_archive_internal_partners + # You modified it in a previous commit, see original commit of this: + # https://github.com/odoo/odoo/commit/9d7226371730e73c296bcc68eb1f856f82b0b4ed + # + # RCO: when creating a user for partner, the user is automatically added in partner.user_ids. + # This is wrong if the user is not active, as partner.user_ids only returns active users. + # Hence this temporary hack until the ORM updates inverse fields correctly. + self.invalidate_cache(['user_ids'], self._ids) for partner in self: if partner.active and partner.user_ids: raise ValidationError(_('You cannot archive a contact linked to an internal user.')) @@ -742,6 +752,9 @@ class Partner(models.Model): @api.model def _name_search(self, name, args=None, operator='ilike', limit=100, name_get_uid=None): self = self.with_user(name_get_uid or self.env.uid) + # as the implementation is in SQL, we force the recompute of fields if necessary + self.recompute(['display_name']) + self.flush() if args is None: args = [] if name and operator in ('=', 'ilike', '=ilike', 'like', '=like'): diff --git a/odoo/addons/base/models/res_users.py b/odoo/addons/base/models/res_users.py index e792721ed21..9f6162bcdb7 100644 --- a/odoo/addons/base/models/res_users.py +++ b/odoo/addons/base/models/res_users.py @@ -173,8 +173,10 @@ class Groups(models.Model): raise UserError(_('The name of the group can not start with "-"')) # invalidate caches before updating groups, since the recomputation of # field 'share' depends on method has_group() - self.env['ir.model.access'].call_cache_clearing_methods() - self.env['res.users'].has_group.clear_cache(self.env['res.users']) + # DLE P139 + if self.ids: + self.env['ir.model.access'].call_cache_clearing_methods() + self.env['res.users'].has_group.clear_cache(self.env['res.users']) return super(Groups, self).write(vals) @@ -372,10 +374,10 @@ class Users(models.Model): def onchange_parent_id(self): return self.partner_id.onchange_parent_id() - def _read_from_database(self, field_names, inherited_field_names=[]): - super(Users, self)._read_from_database(field_names, inherited_field_names) + def _read(self, fields): + super(Users, self)._read(fields) canwrite = self.check_access_rights('write', raise_exception=False) - if not canwrite and set(USER_PRIVATE_FIELDS).intersection(field_names): + if not canwrite and set(USER_PRIVATE_FIELDS).intersection(fields): for record in self: for f in USER_PRIVATE_FIELDS: try: @@ -473,7 +475,9 @@ class Users(models.Model): self.env['ir.default'].clear_caches() # clear caches linked to the users - if 'groups_id' in values: + if self.ids and 'groups_id' in values: + # DLE P139: Calling invalidate_cache on a new, well you lost everything as you wont be able to take it back from the cache + # `test_00_equipment_multicompany_user` self.env['ir.model.access'].call_cache_clearing_methods() self.env['ir.rule'].clear_caches() self.has_group.clear_cache(self) diff --git a/odoo/addons/base/security/base_security.xml b/odoo/addons/base/security/base_security.xml index 47c0abb381e..e1438a76fc3 100644 --- a/odoo/addons/base/security/base_security.xml +++ b/odoo/addons/base/security/base_security.xml @@ -171,4 +171,3 @@ </record> </data> </odoo> - diff --git a/odoo/addons/base/tests/test_api.py b/odoo/addons/base/tests/test_api.py index b0e9a4bfdad..158fe2ee500 100644 --- a/odoo/addons/base/tests/test_api.py +++ b/odoo/addons/base/tests/test_api.py @@ -205,21 +205,6 @@ class TestAPI(common.TransactionCase): demo_partners[0].company_id.name @mute_logger('odoo.models') - def test_55_draft(self): - """ Test draft mode nesting. """ - env = self.env - self.assertFalse(env.in_draft) - with env.do_in_draft(): - self.assertTrue(env.in_draft) - with env.do_in_draft(): - self.assertTrue(env.in_draft) - with env.do_in_draft(): - self.assertTrue(env.in_draft) - self.assertTrue(env.in_draft) - self.assertTrue(env.in_draft) - self.assertFalse(env.in_draft) - - @mute_logger('odoo.models') def test_60_cache(self): """ Check the record cache behavior """ Partners = self.env['res.partner'] @@ -299,7 +284,11 @@ class TestAPI(common.TransactionCase): self.assertItemsEqual(partner_ids_with_field, partners.ids) # partners' states are ready for prefetching - state_ids = {sid for partner in partners for sid in partner._cache['state_id']} + state_ids = { + partner._cache['state_id'] + for partner in partners + if partner._cache['state_id'] is not None + } self.assertTrue(len(state_ids) > 1) self.assertItemsEqual(state_ids, state._prefetch_ids) diff --git a/odoo/addons/base/tests/test_cache.py b/odoo/addons/base/tests/test_cache.py index 66c5d4385b3..d6c18dd4132 100644 --- a/odoo/addons/base/tests/test_cache.py +++ b/odoo/addons/base/tests/test_cache.py @@ -23,14 +23,11 @@ class TestRecordCache(TransactionCase): def check1(record, field, value): # value is None means no value in cache self.assertEqual(cache.contains(record, field), value is not None) - self.assertEqual(cache.contains_value(record, field), value is not None) - self.assertEqual(cache.get_value(record, field), value) try: self.assertEqual(cache.get(record, field), value) self.assertIsNotNone(value) except CacheMiss: self.assertIsNone(value) - self.assertIsNone(cache.get_special(record, field)) self.assertEqual(field in cache.get_fields(record), value is not None) self.assertEqual(record in cache.get_records(record, field), value is not None) @@ -49,6 +46,7 @@ class TestRecordCache(TransactionCase): check(foo2, None, None) check(bar1, None, None) check(bar2, None, None) + self.assertCountEqual(cache.get_missing_ids(foo1 + bar1, name), [1, 2]) self.assertCountEqual(cache.get_missing_ids(foo2 + bar2, name), [1, 2]) @@ -58,38 +56,38 @@ class TestRecordCache(TransactionCase): cache.set(bar1, name, 'BAR1_NAME') cache.set(bar1, ref, 'BAR1_REF') check(foo1, 'FOO1_NAME', 'FOO1_REF') - check(foo2, None, None) + check(foo2, 'FOO1_NAME', 'FOO1_REF') check(bar1, 'BAR1_NAME', 'BAR1_REF') - check(bar2, None, None) + check(bar2, 'BAR1_NAME', 'BAR1_REF') self.assertCountEqual(cache.get_missing_ids(foo1 + bar1, name), []) - self.assertCountEqual(cache.get_missing_ids(foo2 + bar2, name), [1, 2]) + self.assertCountEqual(cache.get_missing_ids(foo2 + bar2, name), []) # set values in both environments cache.set(foo2, name, 'FOO2_NAME') cache.set(foo2, ref, 'FOO2_REF') cache.set(bar2, name, 'BAR2_NAME') cache.set(bar2, ref, 'BAR2_REF') - check(foo1, 'FOO1_NAME', 'FOO1_REF') + check(foo1, 'FOO2_NAME', 'FOO2_REF') check(foo2, 'FOO2_NAME', 'FOO2_REF') - check(bar1, 'BAR1_NAME', 'BAR1_REF') + check(bar1, 'BAR2_NAME', 'BAR2_REF') check(bar2, 'BAR2_NAME', 'BAR2_REF') self.assertCountEqual(cache.get_missing_ids(foo1 + bar1, name), []) self.assertCountEqual(cache.get_missing_ids(foo2 + bar2, name), []) # remove value in one environment cache.remove(foo1, name) - check(foo1, None, 'FOO1_REF') - check(foo2, 'FOO2_NAME', 'FOO2_REF') - check(bar1, 'BAR1_NAME', 'BAR1_REF') + check(foo1, None, 'FOO2_REF') + check(foo2, None, 'FOO2_REF') + check(bar1, 'BAR2_NAME', 'BAR2_REF') check(bar2, 'BAR2_NAME', 'BAR2_REF') self.assertCountEqual(cache.get_missing_ids(foo1 + bar1, name), [1]) - self.assertCountEqual(cache.get_missing_ids(foo2 + bar2, name), []) + self.assertCountEqual(cache.get_missing_ids(foo2 + bar2, name), [1]) # partial invalidation cache.invalidate([(name, None), (ref, foo1.ids)]) check(foo1, None, None) check(foo2, None, None) - check(bar1, None, 'BAR1_REF') + check(bar1, None, 'BAR2_REF') check(bar2, None, 'BAR2_REF') # total invalidation @@ -99,31 +97,6 @@ class TestRecordCache(TransactionCase): check(bar1, None, None) check(bar2, None, None) - # set a special value - cache.set_special(foo1, name, lambda: 'FOO1_SPECIAL_NAME') - self.assertTrue(cache.contains(foo1, name)) - self.assertFalse(cache.contains_value(foo1, name)) - self.assertEqual(cache.get(foo1, name), 'FOO1_SPECIAL_NAME') - self.assertIsNone(cache.get_value(foo1, name)) - self.assertIsNotNone(cache.get_special(foo1, name)) - - # copy cache - cache.set(foo1, name, 'FOO1_NAME') - cache.set(foo1, ref, 'FOO1_REF') - cache.set(bar1, name, 'BAR1_NAME') - cache.set(bar1, ref, 'BAR1_REF') - cache.set(foo2, name, 'FOO2_NAME') - check(foo1, 'FOO1_NAME', 'FOO1_REF') - check(foo2, 'FOO2_NAME', None) - check(bar1, 'BAR1_NAME', 'BAR1_REF') - check(bar2, None, None) - - cache.copy(foo1 + bar1, foo2.env) - check(foo1, 'FOO1_NAME', 'FOO1_REF') - check(foo2, 'FOO1_NAME', 'FOO1_REF') - check(bar1, 'BAR1_NAME', 'BAR1_REF') - check(bar2, 'BAR1_NAME', 'BAR1_REF') - @unittest.skipIf( not(platform.system() == 'Linux' and platform.machine() == 'x86_64'), "This test only makes sense on 64-bit Linux-like systems", diff --git a/odoo/addons/base/tests/test_db_cursor.py b/odoo/addons/base/tests/test_db_cursor.py index b0b6a5a7071..37026cc967a 100644 --- a/odoo/addons/base/tests/test_db_cursor.py +++ b/odoo/addons/base/tests/test_db_cursor.py @@ -50,10 +50,13 @@ class TestTestCursor(common.TransactionCase): self.record = self.env['res.partner'].create({'name': 'Foo'}) def write(self, record, value): - record.ref = value + record.ref = value + + def flush(self, record): + record.flush(['ref']) def check(self, record, value): - self.assertEqual(record.read(['ref'])[0]['ref'], value) + self.assertEqual(record.read(['ref'])[0]['ref'], value) def test_single_cursor(self): """ Check the behavior of a single test cursor. """ @@ -76,6 +79,7 @@ class TestTestCursor(common.TransactionCase): self.cr.commit() self.write(self.record, 'B') + self.flush(self.record) # check behavior of a "sub-cursor" that commits with self.registry.cursor() as cr: @@ -96,6 +100,7 @@ class TestTestCursor(common.TransactionCase): self.cr.commit() self.write(self.record, 'B') + self.flush(self.record) # check behavior of a "sub-cursor" that rollbacks with self.assertRaises(ValueError): diff --git a/odoo/addons/base/tests/test_expression.py b/odoo/addons/base/tests/test_expression.py index 201c825c9c2..06560f7f14c 100644 --- a/odoo/addons/base/tests/test_expression.py +++ b/odoo/addons/base/tests/test_expression.py @@ -10,6 +10,12 @@ from odoo.osv import expression class TestExpression(TransactionCase): + def _search(self, obj, domain, init_domain=[]): + sql = obj.search(domain) + allobj = obj.search(init_domain) + fil = allobj.filtered_domain(domain) + self.assertEqual(sql, fil, "filtered_domain do not match SQL search for domain: "+str(domain)) + return sql def test_00_in_not_in_m2m(self): # Create 4 partners with no category, or one or two categories (out of two categories). @@ -27,43 +33,42 @@ class TestExpression(TransactionCase): # On a one2many or many2many field, `in` should be read `contains` (and # `not in` should be read `doesn't contain`. - - with_a = partners.search([('category_id', 'in', [cat_a.id])]) + with_a = self._search(partners, [('category_id', 'in', [cat_a.id])]) self.assertEqual(a + ab, with_a, "Search for category_id in cat_a failed.") - with_b = partners.search([('category_id', 'in', [cat_b.id])]) + with_b = self._search(partners, [('category_id', 'in', [cat_b.id])]) self.assertEqual(b + ab, with_b, "Search for category_id in cat_b failed.") # Partners with the category A or the category B. - with_a_or_b = partners.search([('category_id', 'in', [cat_a.id, cat_b.id])]) + with_a_or_b = self._search(partners, [('category_id', 'in', [cat_a.id, cat_b.id])]) self.assertEqual(a + b + ab, with_a_or_b, "Search for category_id contains cat_a or cat_b failed.") # Show that `contains list` is really `contains element or contains element`. - with_a_or_with_b = partners.search(['|', ('category_id', 'in', [cat_a.id]), ('category_id', 'in', [cat_b.id])]) + with_a_or_with_b = self._search(partners, ['|', ('category_id', 'in', [cat_a.id]), ('category_id', 'in', [cat_b.id])]) self.assertEqual(a + b + ab, with_a_or_with_b, "Search for category_id contains cat_a or contains cat_b failed.") # If we change the OR in AND... - with_a_and_b = partners.search([('category_id', 'in', [cat_a.id]), ('category_id', 'in', [cat_b.id])]) + with_a_and_b = self._search(partners, [('category_id', 'in', [cat_a.id]), ('category_id', 'in', [cat_b.id])]) self.assertEqual(ab, with_a_and_b, "Search for category_id contains cat_a and cat_b failed.") # Partners without category A and without category B. - without_a_or_b = partners.search([('category_id', 'not in', [cat_a.id, cat_b.id])]) + without_a_or_b = self._search(partners, [('category_id', 'not in', [cat_a.id, cat_b.id])]) self.assertFalse(without_a_or_b & (a + b + ab), "Search for category_id doesn't contain cat_a or cat_b failed (1).") self.assertTrue(c in without_a_or_b, "Search for category_id doesn't contain cat_a or cat_b failed (2).") # Show that `doesn't contain list` is really `doesn't contain element and doesn't contain element`. - without_a_and_without_b = partners.search([('category_id', 'not in', [cat_a.id]), ('category_id', 'not in', [cat_b.id])]) + without_a_and_without_b = self._search(partners, [('category_id', 'not in', [cat_a.id]), ('category_id', 'not in', [cat_b.id])]) self.assertFalse(without_a_and_without_b & (a + b + ab), "Search for category_id doesn't contain cat_a and cat_b failed (1).") self.assertTrue(c in without_a_and_without_b, "Search for category_id doesn't contain cat_a and cat_b failed (2).") # We can exclude any partner containing the category A. - without_a = partners.search([('category_id', 'not in', [cat_a.id])]) + without_a = self._search(partners, [('category_id', 'not in', [cat_a.id])]) self.assertTrue(a not in without_a, "Search for category_id doesn't contain cat_a failed (1).") self.assertTrue(ab not in without_a, "Search for category_id doesn't contain cat_a failed (2).") self.assertLessEqual(b + c, without_a, "Search for category_id doesn't contain cat_a failed (3).") # (Obviously we can do the same for cateory B.) - without_b = partners.search([('category_id', 'not in', [cat_b.id])]) + without_b = self._search(partners, [('category_id', 'not in', [cat_b.id])]) self.assertTrue(b not in without_b, "Search for category_id doesn't contain cat_b failed (1).") self.assertTrue(ab not in without_b, "Search for category_id doesn't contain cat_b failed (2).") self.assertLessEqual(a + c, without_b, "Search for category_id doesn't contain cat_b failed (3).") @@ -91,7 +96,7 @@ class TestExpression(TransactionCase): base_domain = [('id', 'in', list(pids.values()))] def test(op, value, expected): - found_ids = partners.search(base_domain + [('category_id', op, value)]).ids + found_ids = self._search(partners, base_domain + [('category_id', op, value)]).ids expected_ids = [pids[name] for name in expected] self.assertItemsEqual(found_ids, expected_ids, '%s %r should return %r' % (op, value, expected)) @@ -106,7 +111,7 @@ class TestExpression(TransactionCase): Category = self.env['res.partner.category'] # search through m2m relation - partners = Partner.search([('category_id', 'child_of', self.ref('base.res_partner_category_0'))]) + partners = self._search(Partner, [('category_id', 'child_of', self.ref('base.res_partner_category_0'))]) self.assertTrue(partners) # setup test partner categories @@ -115,119 +120,141 @@ class TestExpression(TransactionCase): categ_1 = Category.create({'name': 'Child1', 'parent_id': categ_0.id}) # test hierarchical search in m2m with child id (list of ids) - cats = Category.search([('id', 'child_of', categ_root.ids)]) + cats = self._search(Category, [('id', 'child_of', categ_root.ids)]) self.assertEqual(len(cats), 3) # test hierarchical search in m2m with child id (single id) - cats = Category.search([('id', 'child_of', categ_root.id)]) + cats = self._search(Category, [('id', 'child_of', categ_root.id)]) self.assertEqual(len(cats), 3) # test hierarchical search in m2m with child ids - cats = Category.search([('id', 'child_of', (categ_0 + categ_1).ids)]) + cats = self._search(Category, [('id', 'child_of', (categ_0 + categ_1).ids)]) self.assertEqual(len(cats), 2) # test hierarchical search in m2m with child ids - cats = Category.search([('id', 'child_of', categ_0.ids)]) + cats = self._search(Category, [('id', 'child_of', categ_0.ids)]) self.assertEqual(len(cats), 2) # test hierarchical search in m2m with child ids - cats = Category.search([('id', 'child_of', categ_1.ids)]) + cats = self._search(Category, [('id', 'child_of', categ_1.ids)]) self.assertEqual(len(cats), 1) # test hierarchical search in m2m with an empty list - cats = Category.search([('id', 'child_of', [])]) + cats = self._search(Category, [('id', 'child_of', [])]) self.assertEqual(len(cats), 0) # test hierarchical search in m2m with 'False' value with self.assertLogs('odoo.osv.expression'): - cats = Category.search([('id', 'child_of', False)]) + cats = self._search(Category, [('id', 'child_of', False)]) self.assertEqual(len(cats), 0) # test hierarchical search in m2m with parent id (list of ids) - cats = Category.search([('id', 'parent_of', categ_1.ids)]) + cats = self._search(Category, [('id', 'parent_of', categ_1.ids)]) self.assertEqual(len(cats), 3) # test hierarchical search in m2m with parent id (single id) - cats = Category.search([('id', 'parent_of', categ_1.id)]) + cats = self._search(Category, [('id', 'parent_of', categ_1.id)]) self.assertEqual(len(cats), 3) # test hierarchical search in m2m with parent ids - cats = Category.search([('id', 'parent_of', (categ_root + categ_0).ids)]) + cats = self._search(Category, [('id', 'parent_of', (categ_root + categ_0).ids)]) self.assertEqual(len(cats), 2) # test hierarchical search in m2m with parent ids - cats = Category.search([('id', 'parent_of', categ_0.ids)]) + cats = self._search(Category, [('id', 'parent_of', categ_0.ids)]) self.assertEqual(len(cats), 2) # test hierarchical search in m2m with parent ids - cats = Category.search([('id', 'parent_of', categ_root.ids)]) + cats = self._search(Category, [('id', 'parent_of', categ_root.ids)]) self.assertEqual(len(cats), 1) # test hierarchical search in m2m with an empty list - cats = Category.search([('id', 'parent_of', [])]) + cats = self._search(Category, [('id', 'parent_of', [])]) self.assertEqual(len(cats), 0) # test hierarchical search in m2m with 'False' value with self.assertLogs('odoo.osv.expression'): - cats = Category.search([('id', 'parent_of', False)]) + cats = self._search(Category, [('id', 'parent_of', False)]) self.assertEqual(len(cats), 0) + def test_10_eq_lt_gt_lte_gte(self): + # test if less/greater than or equal operators work + currency = self.env['res.currency'].search([], limit=1) + # test equal + res = self._search(currency, [('rounding', '=', currency.rounding)]) + self.assertTrue(currency in res) + # test not equal + res = self._search(currency, [('rounding', '!=', currency.rounding)]) + self.assertTrue(currency not in res) + # test greater than + res = self._search(currency, [('rounding', '>', currency.rounding)]) + self.assertTrue(currency not in res) + # test greater than or equal + res = self._search(currency, [('rounding', '>=', currency.rounding)]) + self.assertTrue(currency in res) + # test less than + res = self._search(currency, [('rounding', '<', currency.rounding)]) + self.assertTrue(currency not in res) + # test less than or equal + res = self._search(currency, [('rounding', '<=', currency.rounding)]) + self.assertTrue(currency in res) + def test_10_equivalent_id(self): # equivalent queries Currency = self.env['res.currency'] non_currency_id = max(Currency.search([]).ids) + 1003 - res_0 = Currency.search([]) - res_1 = Currency.search([('name', 'not like', 'probably_unexisting_name')]) + res_0 = self._search(Currency, []) + res_1 = self._search(Currency, [('name', 'not like', 'probably_unexisting_name')]) self.assertEqual(res_0, res_1) - res_2 = Currency.search([('id', 'not in', [non_currency_id])]) + res_2 = self._search(Currency, [('id', 'not in', [non_currency_id])]) self.assertEqual(res_0, res_2) - res_3 = Currency.search([('id', 'not in', [])]) + res_3 = self._search(Currency, [('id', 'not in', [])]) self.assertEqual(res_0, res_3) - res_4 = Currency.search([('id', '!=', False)]) + res_4 = self._search(Currency, [('id', '!=', False)]) self.assertEqual(res_0, res_4) # equivalent queries, integer and string Partner = self.env['res.partner'] - all_partners = Partner.search([]) + all_partners = self._search(Partner, []) self.assertTrue(len(all_partners) > 1) one = all_partners[0] others = all_partners[1:] - res_1 = Partner.search([('id', '=', one.id)]) + res_1 = self._search(Partner, [('id', '=', one.id)]) self.assertEqual(one, res_1) # Partner.search([('id', '!=', others)]) # not permitted - res_2 = Partner.search([('id', 'not in', others.ids)]) + res_2 = self._search(Partner, [('id', 'not in', others.ids)]) self.assertEqual(one, res_2) - res_3 = Partner.search(['!', ('id', '!=', one.id)]) + res_3 = self._search(Partner, ['!', ('id', '!=', one.id)]) self.assertEqual(one, res_3) - res_4 = Partner.search(['!', ('id', 'in', others.ids)]) + res_4 = self._search(Partner, ['!', ('id', 'in', others.ids)]) self.assertEqual(one, res_4) # res_5 = Partner.search([('id', 'in', one)]) # TODO make it permitted, just like for child_of # self.assertEqual(one, res_5) - res_6 = Partner.search([('id', 'in', [one.id])]) + res_6 = self._search(Partner, [('id', 'in', [one.id])]) self.assertEqual(one, res_6) - res_7 = Partner.search([('name', '=', one.name)]) + res_7 = self._search(Partner, [('name', '=', one.name)]) self.assertEqual(one, res_7) - res_8 = Partner.search([('name', 'in', [one.name])]) + res_8 = self._search(Partner, [('name', 'in', [one.name])]) # res_9 = Partner.search([('name', 'in', one.name)]) # TODO def test_15_m2o(self): Partner = self.env['res.partner'] # testing equality with name - partners = Partner.search([('parent_id', '=', 'Deco Addict')]) + partners = self._search(Partner, [('parent_id', '=', 'Deco Addict')]) self.assertTrue(partners) # testing the in operator with name - partners = Partner.search([('parent_id', 'in', 'Deco Addict')]) + partners = self._search(Partner, [('parent_id', 'in', 'Deco Addict')]) self.assertTrue(partners) # testing the in operator with a list of names - partners = Partner.search([('parent_id', 'in', ['Deco Addict', 'Wood Corner'])]) + partners = self._search(Partner, [('parent_id', 'in', ['Deco Addict', 'Wood Corner'])]) self.assertTrue(partners) # check if many2one works with empty search list - partners = Partner.search([('company_id', 'in', [])]) + partners = self._search(Partner, [('company_id', 'in', [])]) self.assertFalse(partners) # create new company with partners, and partners with no company @@ -238,31 +265,33 @@ class TestExpression(TransactionCase): # check if many2one works with negative empty list all_partners = Partner.search([]) - res_partners = Partner.search(['|', ('company_id', 'not in', []), ('company_id', '=', False)]) + res_partners = self._search(Partner, ['|', ('company_id', 'not in', []), ('company_id', '=', False)]) self.assertEqual(all_partners, res_partners, "not in [] fails") # check that many2one will pick the correct records with a list - partners = Partner.search([('company_id', 'in', [False])]) + partners = self._search(Partner, [('company_id', 'in', [False])]) self.assertTrue(len(partners) >= 4, "We should have at least 4 partners with no company") # check that many2one will exclude the correct records with a list - partners = Partner.search([('company_id', 'not in', [1])]) + partners = self._search(Partner, [('company_id', 'not in', [1])]) self.assertTrue(len(partners) >= 4, "We should have at least 4 partners not related to company #1") # check that many2one will exclude the correct records with a list and False - partners = Partner.search(['|', ('company_id', 'not in', [1]), + partners = self._search(Partner, ['|', ('company_id', 'not in', [1]), ('company_id', '=', False)]) self.assertTrue(len(partners) >= 8, "We should have at least 8 partners not related to company #1") # check that multi-level expressions also work - partners = Partner.search([('company_id.partner_id', 'in', [])]) + partners = self._search(Partner, [('company_id.partner_id', 'in', [])]) self.assertFalse(partners) # check multi-level expressions with magic columns - partners = Partner.search([('create_uid.active', '=', True)]) + partners = self._search(Partner, [('create_uid.active', '=', True)]) # check that multi-level expressions with negative op work - all_partners = Partner.search([('company_id', '!=', False)]) + all_partners = self._search(Partner, [('company_id', '!=', False)]) + + # FP Note: filtered_domain differs res_partners = Partner.search([('company_id.partner_id', 'not in', [])]) self.assertEqual(all_partners, res_partners, "not in [] fails") @@ -270,7 +299,7 @@ class TestExpression(TransactionCase): # column are used because parent_id is a many2one, allowing to test the # Null value, and there are actually some null and non-null values in # the demo data. - all_partners = Partner.search([]) + all_partners = self._search(Partner, []) non_partner_id = max(all_partners.ids) + 1 with_parent = all_partners.filtered(lambda p: p.parent_id) @@ -295,97 +324,98 @@ class TestExpression(TransactionCase): # existing values be treated similarly if we simply check that some # existing value belongs to them. - res_0 = Partner.search([('parent_id', 'not like', 'probably_unexisting_name')]) # get all rows, included null parent_id + res_0 = self._search(Partner, [('parent_id', 'not like', 'probably_unexisting_name')]) # get all rows, included null parent_id self.assertEqual(res_0, all_partners) - res_1 = Partner.search([('parent_id', 'not in', [non_partner_id])]) # get all rows, included null parent_id + res_1 = self._search(Partner, [('parent_id', 'not in', [non_partner_id])]) # get all rows, included null parent_id self.assertEqual(res_1, all_partners) - res_2 = Partner.search([('parent_id', '!=', False)]) # get rows with not null parent_id, deprecated syntax + res_2 = self._search(Partner, [('parent_id', '!=', False)]) # get rows with not null parent_id, deprecated syntax self.assertEqual(res_2, with_parent) - res_3 = Partner.search([('parent_id', 'not in', [])]) # get all rows, included null parent_id + res_3 = self._search(Partner, [('parent_id', 'not in', [])]) # get all rows, included null parent_id self.assertEqual(res_3, all_partners) - res_4 = Partner.search([('parent_id', 'not in', [False])]) # get rows with not null parent_id + res_4 = self._search(Partner, [('parent_id', 'not in', [False])]) # get rows with not null parent_id self.assertEqual(res_4, with_parent) - res_4b = Partner.search([('parent_id', 'not ilike', '')]) # get only rows without parent + res_4b = self._search(Partner, [('parent_id', 'not ilike', '')]) # get only rows without parent self.assertEqual(res_4b, without_parent) # The results of these queries, when combined with queries 0..4 must # give the whole set of ids. - res_5 = Partner.search([('parent_id', 'like', 'probably_unexisting_name')]) + res_5 = self._search(Partner, [('parent_id', 'like', 'probably_unexisting_name')]) self.assertFalse(res_5) - res_6 = Partner.search([('parent_id', 'in', [non_partner_id])]) + res_6 = self._search(Partner, [('parent_id', 'in', [non_partner_id])]) self.assertFalse(res_6) - res_7 = Partner.search([('parent_id', '=', False)]) + res_7 = self._search(Partner, [('parent_id', '=', False)]) self.assertEqual(res_7, without_parent) - res_8 = Partner.search([('parent_id', 'in', [])]) + res_8 = self._search(Partner, [('parent_id', 'in', [])]) self.assertFalse(res_8) - res_9 = Partner.search([('parent_id', 'in', [False])]) + res_9 = self._search(Partner, [('parent_id', 'in', [False])]) self.assertEqual(res_9, without_parent) - res_9b = Partner.search([('parent_id', 'ilike', '')]) # get those with a parent + res_9b = self._search(Partner, [('parent_id', 'ilike', '')]) # get those with a parent self.assertEqual(res_9b, with_parent) # These queries must return exactly the results than the queries 0..4, # i.e. not ... in ... must be the same as ... not in ... . - res_10 = Partner.search(['!', ('parent_id', 'like', 'probably_unexisting_name')]) + res_10 = self._search(Partner, ['!', ('parent_id', 'like', 'probably_unexisting_name')]) self.assertEqual(res_0, res_10) - res_11 = Partner.search(['!', ('parent_id', 'in', [non_partner_id])]) + res_11 = self._search(Partner, ['!', ('parent_id', 'in', [non_partner_id])]) self.assertEqual(res_1, res_11) - res_12 = Partner.search(['!', ('parent_id', '=', False)]) + res_12 = self._search(Partner, ['!', ('parent_id', '=', False)]) self.assertEqual(res_2, res_12) - res_13 = Partner.search(['!', ('parent_id', 'in', [])]) + res_13 = self._search(Partner, ['!', ('parent_id', 'in', [])]) self.assertEqual(res_3, res_13) - res_14 = Partner.search(['!', ('parent_id', 'in', [False])]) + res_14 = self._search(Partner, ['!', ('parent_id', 'in', [False])]) self.assertEqual(res_4, res_14) # Testing many2one field is not enough, a regular char field is tested - res_15 = Partner.search([('website', 'in', [])]) + res_15 = self._search(Partner, [('website', 'in', [])]) self.assertFalse(res_15) - res_16 = Partner.search([('website', 'not in', [])]) + res_16 = self._search(Partner, [('website', 'not in', [])]) self.assertEqual(res_16, all_partners) - res_17 = Partner.search([('website', '!=', False)]) + res_17 = self._search(Partner, [('website', '!=', False)]) self.assertEqual(res_17, with_website) # check behavior for required many2one fields: currency_id is required companies = self.env['res.company'].search([]) - res_101 = companies.search([('currency_id', 'not ilike', '')]) # get no companies + res_101 = self._search(companies, [('currency_id', 'not ilike', '')]) # get no companies self.assertFalse(res_101) - res_102 = companies.search([('currency_id', 'ilike', '')]) # get all companies + res_102 = self._search(companies, [('currency_id', 'ilike', '')]) # get all companies self.assertEqual(res_102, companies) def test_in_operator(self): """ check that we can use the 'in' operator for plain fields """ - menus = self.env['ir.ui.menu'].search([('sequence', 'in', [1, 2, 10, 20])]) + menu = self.env['ir.ui.menu'] + menus = self._search(menu, [('sequence', 'in', [1, 2, 10, 20])]) self.assertTrue(menus) def test_15_o2m(self): Partner = self.env['res.partner'] # test one2many operator with empty search list - partners = Partner.search([('child_ids', 'in', [])]) + partners = self._search(Partner, [('child_ids', 'in', [])]) self.assertFalse(partners) # test one2many operator with False - partners = Partner.search([('child_ids', '=', False)]) + partners = self._search(Partner, [('child_ids', '=', False)]) for partner in partners: self.assertFalse(partner.child_ids) # verify domain evaluation for one2many != False and one2many == False categories = self.env['res.partner.category'].search([]) - parents = categories.search([('child_ids', '!=', False)]) + parents = self._search(categories, [('child_ids', '!=', False)]) self.assertEqual(parents, categories.filtered(lambda c: c.child_ids)) - leafs = categories.search([('child_ids', '=', False)]) + leafs = self._search(categories, [('child_ids', '=', False)]) self.assertEqual(leafs, categories.filtered(lambda c: not c.child_ids)) # test many2many operator with empty search list - partners = Partner.search([('category_id', 'in', [])]) + partners = self._search(Partner, [('category_id', 'in', [])]) self.assertFalse(partners) # test many2many operator with False - partners = Partner.search([('category_id', '=', False)]) + partners = self._search(Partner, [('category_id', '=', False)]) for partner in partners: self.assertFalse(partner.category_id) # filtering on nonexistent value across x2many should return nothing - partners = Partner.search([('child_ids.city', '=', 'foo')]) + partners = self._search(Partner, [('child_ids.city', '=', 'foo')]) self.assertFalse(partners) def test_15_equivalent_one2many_1(self): @@ -394,31 +424,31 @@ class TestExpression(TransactionCase): company4 = Company.create({'name': 'Acme 4', 'parent_id': company3.id}) # one2many towards same model - res_1 = Company.search([('child_ids', 'in', company3.child_ids.ids)]) # any company having a child of company3 as child + res_1 = self._search(Company, [('child_ids', 'in', company3.child_ids.ids)]) # any company having a child of company3 as child self.assertEqual(res_1, company3) - res_2 = Company.search([('child_ids', 'in', company3.child_ids[0].ids)]) # any company having the first child of company3 as child + res_2 = self._search(Company, [('child_ids', 'in', company3.child_ids[0].ids)]) # any company having the first child of company3 as child self.assertEqual(res_2, company3) # child_of x returns x and its children (direct or not). expected = company3 + company4 - res_1 = Company.search([('id', 'child_of', [company3.id])]) + res_1 = self._search(Company, [('id', 'child_of', [company3.id])]) self.assertEqual(res_1, expected) - res_2 = Company.search([('id', 'child_of', company3.id)]) + res_2 = self._search(Company, [('id', 'child_of', company3.id)]) self.assertEqual(res_2, expected) - res_3 = Company.search([('id', 'child_of', [company3.name])]) + res_3 = self._search(Company, [('id', 'child_of', [company3.name])]) self.assertEqual(res_3, expected) - res_4 = Company.search([('id', 'child_of', company3.name)]) + res_4 = self._search(Company, [('id', 'child_of', company3.name)]) self.assertEqual(res_4, expected) # parent_of x returns x and its parents (direct or not). expected = company3 + company4 - res_1 = Company.search([('id', 'parent_of', [company4.id])]) + res_1 = self._search(Company, [('id', 'parent_of', [company4.id])]) self.assertEqual(res_1, expected) - res_2 = Company.search([('id', 'parent_of', company4.id)]) + res_2 = self._search(Company, [('id', 'parent_of', company4.id)]) self.assertEqual(res_2, expected) - res_3 = Company.search([('id', 'parent_of', [company4.name])]) + res_3 = self._search(Company, [('id', 'parent_of', [company4.name])]) self.assertEqual(res_3, expected) - res_4 = Company.search([('id', 'parent_of', company4.name)]) + res_4 = self._search(Company, [('id', 'parent_of', company4.name)]) self.assertEqual(res_4, expected) # try testing real subsets with IN/NOT IN @@ -429,14 +459,22 @@ class TestExpression(TransactionCase): u1a = Users.create({'login': 'dbo', 'partner_id': p1}).id u1b = Users.create({'login': 'dbo2', 'partner_id': p1}).id u2 = Users.create({'login': 'rpo', 'partner_id': p2}).id - self.assertEqual([p1], Partner.search([('user_ids', 'in', u1a)]).ids, "o2m IN accept single int on right side") - self.assertEqual([p1], Partner.search([('user_ids', '=', 'Dédé Boitaclou')]).ids, "o2m NOT IN matches none on the right side") - self.assertEqual([], Partner.search([('user_ids', 'in', [10000])]).ids, "o2m NOT IN matches none on the right side") - self.assertEqual([p1,p2], Partner.search([('user_ids', 'in', [u1a,u2])]).ids, "o2m IN matches any on the right side") - all_ids = Partner.search([]).ids - self.assertEqual(set(all_ids) - set([p1]), set(Partner.search([('user_ids', 'not in', u1a)]).ids), "o2m NOT IN matches none on the right side") - self.assertEqual(set(all_ids) - set([p1]), set(Partner.search([('user_ids', '!=', 'Dédé Boitaclou')]).ids), "o2m NOT IN matches none on the right side") - self.assertEqual(set(all_ids) - set([p1,p2]), set(Partner.search([('user_ids', 'not in', [u1b, u2])]).ids), "o2m NOT IN matches none on the right side") + + res = self._search(Partner, [('user_ids', 'in', u1a)]) + self.assertEqual([p1], res.ids, "o2m IN accept single int on right side") + res = self._search(Partner, [('user_ids', '=', 'Dédé Boitaclou')]) + self.assertEqual([p1], res.ids, "o2m NOT IN matches none on the right side") + res = self._search(Partner, [('user_ids', 'in', [10000])]) + self.assertEqual([], res.ids, "o2m NOT IN matches none on the right side") + res = self._search(Partner, [('user_ids', 'in', [u1a,u2])]) + self.assertEqual([p1,p2], res.ids, "o2m IN matches any on the right side") + all_ids = self._search(Partner, []).ids + res = self._search(Partner, [('user_ids', 'not in', u1a)]) + self.assertEqual(set(all_ids) - set([p1]), set(res.ids), "o2m NOT IN matches none on the right side") + res = self._search(Partner, [('user_ids', '!=', 'Dédé Boitaclou')]) + self.assertEqual(set(all_ids) - set([p1]), set(res.ids), "o2m NOT IN matches none on the right side") + res = self._search(Partner, [('user_ids', 'not in', [u1b, u2])]) + self.assertEqual(set(all_ids) - set([p1,p2]), set(res.ids), "o2m NOT IN matches none on the right side") def test_15_equivalent_one2many_2(self): Currency = self.env['res.currency'] @@ -449,34 +487,34 @@ class TestExpression(TransactionCase): default_currency = Currency.browse(1) # search the currency via its rates one2many (the one2many must point back at the currency) - currency_rate1 = CurrencyRate.search([('name', 'not like', 'probably_unexisting_name')]) - currency_rate2 = CurrencyRate.search([('id', 'not in', [non_currency_id])]) + currency_rate1 = self._search(CurrencyRate, [('name', 'not like', 'probably_unexisting_name')]) + currency_rate2 = self._search(CurrencyRate, [('id', 'not in', [non_currency_id])]) self.assertEqual(currency_rate1, currency_rate2) - currency_rate3 = CurrencyRate.search([('id', 'not in', [])]) + currency_rate3 = self._search(CurrencyRate, [('id', 'not in', [])]) self.assertEqual(currency_rate1, currency_rate3) # one2many towards another model - res_3 = Currency.search([('rate_ids', 'in', default_currency.rate_ids.ids)]) # currencies having a rate of main currency + res_3 = self._search(Currency, [('rate_ids', 'in', default_currency.rate_ids.ids)]) # currencies having a rate of main currency self.assertEqual(res_3, default_currency) - res_4 = Currency.search([('rate_ids', 'in', default_currency.rate_ids[0].ids)]) # currencies having first rate of main currency + res_4 = self._search(Currency, [('rate_ids', 'in', default_currency.rate_ids[0].ids)]) # currencies having first rate of main currency self.assertEqual(res_4, default_currency) - res_5 = Currency.search([('rate_ids', 'in', default_currency.rate_ids[0].id)]) # currencies having first rate of main currency + res_5 = self._search(Currency, [('rate_ids', 'in', default_currency.rate_ids[0].id)]) # currencies having first rate of main currency self.assertEqual(res_5, default_currency) # res_6 = Currency.search([('rate_ids', 'in', [default_currency.rate_ids[0].name])]) # res_7 = Currency.search([('rate_ids', '=', default_currency.rate_ids[0].name)]) # res_8 = Currency.search([('rate_ids', 'like', default_currency.rate_ids[0].name)]) - res_9 = Currency.search([('rate_ids', 'like', 'probably_unexisting_name')]) + res_9 = self._search(Currency, [('rate_ids', 'like', 'probably_unexisting_name')]) self.assertFalse(res_9) # Currency.search([('rate_ids', 'unexisting_op', 'probably_unexisting_name')]) # TODO expected exception # get the currencies referenced by some currency rates using a weird negative domain - res_10 = Currency.search([('rate_ids', 'not like', 'probably_unexisting_name')]) - res_11 = Currency.search([('rate_ids', 'not in', [non_currency_id])]) + res_10 = self._search(Currency, [('rate_ids', 'not like', 'probably_unexisting_name')]) + res_11 = self._search(Currency, [('rate_ids', 'not in', [non_currency_id])]) self.assertEqual(res_10, res_11) - res_12 = Currency.search([('rate_ids', '!=', False)]) + res_12 = self._search(Currency, [('rate_ids', '!=', False)]) self.assertEqual(res_10, res_12) - res_13 = Currency.search([('rate_ids', 'not in', [])]) + res_13 = self._search(Currency, [('rate_ids', 'not in', [])]) self.assertEqual(res_10, res_13) def test_20_expression_parse(self): @@ -491,19 +529,19 @@ class TestExpression(TransactionCase): b2 = Users.create({'name': 'test_B2', 'login': 'test_B2', 'parent_id': b1.partner_id.id}) # Test1: simple inheritance - users = Users.search([('name', 'like', 'test')]) + users = self._search(Users, [('name', 'like', 'test')]) self.assertEqual(users, a + b1 + b2, 'searching through inheritance failed') - users = Users.search([('name', '=', 'test_B')]) + users = self._search(Users, [('name', '=', 'test_B')]) self.assertEqual(users, b1, 'searching through inheritance failed') # Test2: inheritance + relational fields - users = Users.search([('child_ids.name', 'like', 'test_B')]) + users = self._search(Users, [('child_ids.name', 'like', 'test_B')]) self.assertEqual(users, b1, 'searching through inheritance failed') # Special =? operator mean "is equal if right is set, otherwise always True" - users = Users.search([('name', 'like', 'test'), ('parent_id', '=?', False)]) + users = self._search(Users, [('name', 'like', 'test'), ('parent_id', '=?', False)]) self.assertEqual(users, a + b1 + b2, '(x =? False) failed') - users = Users.search([('name', 'like', 'test'), ('parent_id', '=?', b1.partner_id.id)]) + users = self._search(Users, [('name', 'like', 'test'), ('parent_id', '=?', b1.partner_id.id)]) self.assertEqual(users, b2, '(x =? id) failed') def test_30_normalize_domain(self): @@ -545,16 +583,16 @@ class TestExpression(TransactionCase): def test_like_wildcards(self): # check that =like/=ilike expressions are working on an untranslated field Partner = self.env['res.partner'] - partners = Partner.search([('name', '=like', 'W_od_C_rn_r')]) + partners = self._search(Partner, [('name', '=like', 'W_od_C_rn_r')]) self.assertTrue(len(partners) == 1, "Must match one partner (Wood Corner)") - partners = Partner.search([('name', '=ilike', 'G%')]) + partners = self._search(Partner, [('name', '=ilike', 'G%')]) self.assertTrue(len(partners) >= 1, "Must match one partner (Gemini Furniture)") # check that =like/=ilike expressions are working on translated field Country = self.env['res.country'] - countries = Country.search([('name', '=like', 'Ind__')]) + countries = self._search(Country, [('name', '=like', 'Ind__')]) self.assertTrue(len(countries) == 1, "Must match India only") - countries = Country.search([('name', '=ilike', 'z%')]) + countries = self._search(Country, [('name', '=ilike', 'z%')]) self.assertTrue(len(countries) == 2, "Must match only countries with names starting with Z (currently 2)") def test_translate_search(self): @@ -567,7 +605,7 @@ class TestExpression(TransactionCase): ] for domain in domains: - countries = Country.search(domain) + countries = self._search(Country, domain) self.assertEqual(countries, belgium) def test_long_table_alias(self): @@ -584,9 +622,15 @@ class TestExpression(TransactionCase): with self.assertRaises(ValueError): Country.search([('does_not_exist', '=', 'foo')]) + with self.assertRaises(KeyError): + Country.search([]).filtered_domain([('does_not_exist', '=', 'foo')]) + with self.assertRaises(ValueError): Country.search([('create_date', '>>', 'foo')]) + with self.assertRaises(ValueError): + Country.search([]).filtered_domain([('create_date', '>>', 'foo')]) + with self.assertRaises(psycopg2.DataError): Country.search([('create_date', '=', "1970-01-01'); --")]) @@ -600,11 +644,11 @@ class TestExpression(TransactionCase): 'child_ids': [(0, 0, {'name': 'address of OpenERP Test', 'country_id': self.ref("base.be")})], } Partner.create(vals) - partner = Partner.search([('category_id', 'ilike', 'vendor'), ('active', '=', False)]) + partner = self._search(Partner, [('category_id', 'ilike', 'vendor'), ('active', '=', False)], [('active', '=', False)]) self.assertTrue(partner, "Record not Found with category vendor and active False.") # testing for one2many field with country Belgium and active=False - partner = Partner.search([('child_ids.country_id','=','Belgium'),('active','=',False)]) + partner = self._search(Partner, [('child_ids.country_id','=','Belgium'),('active','=',False)], [('active', '=', False)]) self.assertTrue(partner, "Record not Found with country Belgium and active False.") def test_lp1071710(self): @@ -613,19 +657,20 @@ class TestExpression(TransactionCase): self.env['ir.translation']._load_module_terms(['base'], ['fr_FR']) self.env.ref('base.res_partner_2').country_id = self.env.ref('base.be') # actual test - Country = self.env['res.country'] + Country = self.env['res.country'].with_context(lang='fr_FR') be = self.env.ref('base.be') - not_be = Country.with_context(lang='fr_FR').search([('name', '!=', 'Belgique')]) + not_be = self._search(Country, [('name', '!=', 'Belgique')]) self.assertNotIn(be, not_be) # indirect search via m2o Partner = self.env['res.partner'] - deco_addict = Partner.search([('name', '=', 'Deco Addict')]) + deco_addict = self._search(Partner, [('name', '=', 'Deco Addict')]) - not_be = Partner.search([('country_id', '!=', 'Belgium')]) + not_be = self._search(Partner, [('country_id', '!=', 'Belgium')]) self.assertNotIn(deco_addict, not_be) - not_be = Partner.with_context(lang='fr_FR').search([('country_id', '!=', 'Belgique')]) + Partner = Partner.with_context(lang='fr_FR') + not_be = self._search(Partner, [('country_id', '!=', 'Belgique')]) self.assertNotIn(deco_addict, not_be) def test_or_with_implicit_and(self): @@ -706,8 +751,10 @@ class TestAutoJoin(TransactionCase): self.patch(model._fields[fname], 'domain', value) # Get country/state data - country_us = self.env['res.country'].search([('code', 'like', 'US')], limit=1) - states = self.env['res.country.state'].search([('country_id', '=', country_us.id)], limit=2) + Country = self.env['res.country'] + country_us = Country.search([('code', 'like', 'US')], limit=1) + State = self.env['res.country.state'] + states = State.search([('country_id', '=', country_us.id)], limit=2) # Create demo data: partners and bank object p_a = partner_obj.create({'name': 'test__A', 'state_id': states[0].id}) diff --git a/odoo/addons/base/tests/test_ir_actions.py b/odoo/addons/base/tests/test_ir_actions.py index 46c4da5d744..03a0159d7a8 100644 --- a/odoo/addons/base/tests/test_ir_actions.py +++ b/odoo/addons/base/tests/test_ir_actions.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from psycopg2 import IntegrityError +from psycopg2 import IntegrityError, ProgrammingError import odoo from odoo.exceptions import UserError, ValidationError, AccessError diff --git a/odoo/addons/base/tests/test_ir_http.py b/odoo/addons/base/tests/test_ir_http.py index 5d06b2f4293..542fe9c07a6 100644 --- a/odoo/addons/base/tests/test_ir_http.py +++ b/odoo/addons/base/tests/test_ir_http.py @@ -88,6 +88,13 @@ class test_ir_http_mimetype(common.TransactionCase): } def test_access(**kwargs): + # DLE P69: `test_ir_http_attachment_access` + # `binary_content` relies on the `__last_update` to determine if a user has the read access to an attachment. + # as the attachment has just been created above as sudo, the data is in cache and if we don't remove it the below + # `test_access` wont have to fetch it and therefore wont raise the accesserror as its already in the cache + # `__last_update` must be removed from the cache when `test_access` is called, which happens and recompute the todos + attachment.flush() + attachment.invalidate_cache() status, _, _ = self.env['ir.http'].with_user(public_user).binary_content( **dict(defaults, **kwargs) ) diff --git a/odoo/addons/base/tests/test_orm.py b/odoo/addons/base/tests/test_orm.py index b44c22f19cd..a78519afa40 100644 --- a/odoo/addons/base/tests/test_orm.py +++ b/odoo/addons/base/tests/test_orm.py @@ -319,10 +319,13 @@ class TestInherits(TransactionCase): }) foo_before, = user_foo.read() del foo_before['__last_update'] + del foo_before['create_date'] + del foo_before['write_date'] user_bar = user_foo.copy({'login': 'bar'}) foo_after, = user_foo.read() del foo_after['__last_update'] - + del foo_after['create_date'] + del foo_after['write_date'] self.assertEqual(foo_before, foo_after) self.assertEqual(user_bar.name, 'Foo (copy)') @@ -339,11 +342,15 @@ class TestInherits(TransactionCase): foo_before, = user_foo.read() del foo_before['__last_update'] + del foo_before['create_date'] + del foo_before['write_date'] del foo_before['login_date'] partners_before = self.env['res.partner'].search([]) user_bar = user_foo.copy({'partner_id': partner_bar.id, 'login': 'bar'}) foo_after, = user_foo.read() del foo_after['__last_update'] + del foo_after['create_date'] + del foo_after['write_date'] del foo_after['login_date'] partners_after = self.env['res.partner'].search([]) diff --git a/odoo/addons/base/tests/test_translate.py b/odoo/addons/base/tests/test_translate.py index 7caf01f0a45..ab0a852910a 100644 --- a/odoo/addons/base/tests/test_translate.py +++ b/odoo/addons/base/tests/test_translate.py @@ -363,6 +363,8 @@ class TestTranslation(TransactionCase): self.assertEqual(cheese.with_context(lang=None).name, 'Cheese') self.assertEqual(cheese.with_context(lang='fr_FR').name, 'Fromage') self.assertEqual(cheese.with_context(lang='en_US').name, 'The Cheese') + cheese.flush() + cheese.invalidate_cache() # set a new master value cheese.with_context(lang='en_US').write({'name': 'Delicious Cheese'}) @@ -508,6 +510,12 @@ class TestXMLTranslation(TransactionCase): 'model': 'res.partner', 'arch': archf % terms, }) + # DLE P70: `_sync_terms_translations`, which delete translations for which there is no value, is called sooner than before + # because it's called in `_write`, which is called by `flush`, which is called by the `search`. + # `arch_db` is in `_write` instead of `create` because `arch_db` is the inverse of `arch`. + # We need to flush `arch_db` before creating the translations otherwise the translation for which there is no value will be deleted, + # while the `test_sync_update` specifically needs empty translations + view.flush() for lang, trans_terms in kwargs.items(): for src, val in zip(terms, trans_terms): self.env['ir.translation'].create({ diff --git a/odoo/addons/base/tests/test_views.py b/odoo/addons/base/tests/test_views.py index 4ed37c7fcba..4fcdcef4e95 100644 --- a/odoo/addons/base/tests/test_views.py +++ b/odoo/addons/base/tests/test_views.py @@ -1868,6 +1868,7 @@ class ViewModeField(ViewCase): }) with self.assertRaises(IntegrityError): view_pure_primary.write({'mode': 'extension'}) + view_pure_primary.flush() def testInheritPrimaryToExtension(self): """ diff --git a/odoo/addons/base/wizard/base_partner_merge.py b/odoo/addons/base/wizard/base_partner_merge.py index cadefd5941a..8fca85d931a 100644 --- a/odoo/addons/base/wizard/base_partner_merge.py +++ b/odoo/addons/base/wizard/base_partner_merge.py @@ -107,6 +107,8 @@ class MergePartnerAutomatic(models.TransientModel): Partner = self.env['res.partner'] relations = self._get_fk_on('res_partner') + self.flush() + for table, column in relations: if 'base_partner_merge_' in table: # ignore two tables continue @@ -168,6 +170,8 @@ class MergePartnerAutomatic(models.TransientModel): query = 'DELETE FROM "%(table)s" WHERE "%(column)s" IN %%s' % query_dic self._cr.execute(query, (tuple(src_partners.ids),)) + self.invalidate_cache() + @api.model def _update_reference_fields(self, src_partners, dst_partner): """ Update all reference fields from the src_partner to dst_partner. @@ -182,12 +186,13 @@ class MergePartnerAutomatic(models.TransientModel): return records = Model.sudo().search([(field_model, '=', 'res.partner'), (field_id, '=', src.id)]) try: - with mute_logger('odoo.sql_db'), self._cr.savepoint(): - return records.sudo().write({field_id: dst_partner.id}) + with mute_logger('odoo.sql_db'), self._cr.savepoint(), self.env.clear_upon_failure(): + records.sudo().write({field_id: dst_partner.id}) + records.flush() except psycopg2.Error: # updating fails, most likely due to a violated unique constraint # keeping record with nonexistent partner_id is useless, better delete it - return records.sudo().unlink() + records.sudo().unlink() update_records = functools.partial(update_records) @@ -217,6 +222,8 @@ class MergePartnerAutomatic(models.TransientModel): } records_ref.sudo().write(values) + self.flush() + def _get_summable_fields(self): """ Returns the list of fields that should be summed when merging partners """ diff --git a/odoo/addons/test_access_rights/tests/test_feedback.py b/odoo/addons/test_access_rights/tests/test_feedback.py index 8eba72c9d11..09fd42589a8 100644 --- a/odoo/addons/test_access_rights/tests/test_feedback.py +++ b/odoo/addons/test_access_rights/tests/test_feedback.py @@ -98,6 +98,9 @@ class TestACLFeedback(Feedback): 'perm_create': True, }) self.record = self.env['test_access_right.some_obj'].create({'val': 5}) + # values are in cache, clear them up for the test + ACL.flush() + ACL.invalidate_cache() def test_no_groups(self): """ Operation is never allowed @@ -141,7 +144,7 @@ class TestIRRuleFeedback(Feedback): }).with_user(self.user) def _make_rule(self, name, domain, global_=False, attr='write'): - return self.env['ir.rule'].create({ + res = self.env['ir.rule'].create({ 'name': name, 'model_id': self.model.id, 'groups': [] if global_ else [(4, self.group2.id)], @@ -152,6 +155,7 @@ class TestIRRuleFeedback(Feedback): 'perm_unlink': False, 'perm_' + attr: True, }) + return res def test_local(self): self._make_rule('rule 0', '[("val", "=", 42)]') @@ -294,7 +298,8 @@ Note: this might be a multi-company issue. ) p = self.env['test_access_right.parent'].create({'obj_id': self.record.id}) - # p.with_user(self.user).val + p.flush() + p.invalidate_cache() self.assertRaisesRegex( AccessError, r"Implicitly accessed through \\'Object for testing related access rights\\' \(test_access_right.parent\)\.", diff --git a/odoo/addons/test_access_rights/tests/test_ir_rules.py b/odoo/addons/test_access_rights/tests/test_ir_rules.py index dcfb4c7a1dd..79946d0c5a5 100644 --- a/odoo/addons/test_access_rights/tests/test_ir_rules.py +++ b/odoo/addons/test_access_rights/tests/test_ir_rules.py @@ -32,6 +32,7 @@ class TestRules(TransactionCase): self.assertEqual(browse1.val, 1) # but this should + browse1.invalidate_cache(['val']) with self.assertRaises(AccessError): self.assertEqual(browse2.val, -1) @@ -50,6 +51,7 @@ class TestRules(TransactionCase): browse1 = env['test_access_right.some_obj'].browse(self.id1) # everything should blow up + (browse1 + browse2).invalidate_cache(['val']) with self.assertRaises(AccessError): self.assertEqual(browse2.val, -1) with self.assertRaises(AccessError): @@ -65,17 +67,22 @@ class TestRules(TransactionCase): # check the container as the public user container_user = container_admin.with_user(self.browse_ref('base.public_user')) + container_user.invalidate_cache(['some_ids']) self.assertItemsEqual(container_user.some_ids.ids, [self.id1]) # this should not fail container_user.write({'some_ids': [(6, 0, ids)]}) + container_user.invalidate_cache(['some_ids']) self.assertItemsEqual(container_user.some_ids.ids, [self.id1]) + container_admin.invalidate_cache(['some_ids']) self.assertItemsEqual(container_admin.some_ids.ids, ids) - # this removes accessible records only + # this removes all records container_user.write({'some_ids': [(5,)]}) + container_user.invalidate_cache(['some_ids']) self.assertItemsEqual(container_user.some_ids.ids, []) - self.assertItemsEqual(container_admin.some_ids.ids, [self.id2]) + container_admin.invalidate_cache(['some_ids']) + self.assertItemsEqual(container_admin.some_ids.ids, []) def test_access_rule_performance(self): env = self.env(user=self.browse_ref('base.public_user')) diff --git a/odoo/addons/test_assetsbundle/tests/test_assetsbundle.py b/odoo/addons/test_assetsbundle/tests/test_assetsbundle.py index 92d38a6e5d8..6e74fb0fc99 100644 --- a/odoo/addons/test_assetsbundle/tests/test_assetsbundle.py +++ b/odoo/addons/test_assetsbundle/tests/test_assetsbundle.py @@ -540,6 +540,7 @@ class TestAssetsBundleInBrowser(HttpCase): 'arch': view_arch, 'inherit_id': self.browse_ref('test_assetsbundle.bundle1').id, }) + self.env.user.flush() self.phantom_js( "/test_assetsbundle/js", @@ -602,6 +603,7 @@ class TestAssetsBundleWithIRAMock(FileTouchable): # has really been modified. If we do not update the write_date to a posterior date, we are # not able to reproduce the case where we compile this bundle again without changing # anything. + self.env['ir.attachment'].flush(['checksum']) self.cr.execute("update ir_attachment set write_date=clock_timestamp() + interval '10 seconds' where id = (select max(id) from ir_attachment)") # Compile a fourth time, without changes diff --git a/odoo/addons/test_impex/tests/test_load.py b/odoo/addons/test_impex/tests/test_load.py index aa3b0013676..3b53ead196f 100644 --- a/odoo/addons/test_impex/tests/test_load.py +++ b/odoo/addons/test_impex/tests/test_load.py @@ -743,8 +743,8 @@ class test_m2m(ImporterCase): self.assertEqual(len(result['ids']), 3) b = self.browse() - self.assertEqual(values(b[0].value), [3, 44]) - self.assertEqual(values(b[2].value), [44, 84]) + self.assertCountEqual(values(b[0].value), [3, 44]) + self.assertCountEqual(values(b[2].value), [44, 84]) def test_noxids(self): result = self.import_(['value/id'], [['noxidforthat']]) @@ -852,7 +852,7 @@ class test_o2m(ImporterCase): self.assertEqual(len(result['ids']), 1) (b,) = self.browse() - self.assertEqual(values(b.value), [63, 64, 65, 66]) + self.assertEqual(set(values(b.value)), set([63, 64, 65, 66])) def test_multi_subfields(self): result = self.import_(['value/str', 'const', 'value/value'], [ @@ -865,9 +865,9 @@ class test_o2m(ImporterCase): self.assertEqual(len(result['ids']), 1) (b,) = self.browse() - self.assertEqual(values(b.value), [63, 64, 65, 66]) + self.assertEqual(set(values(b.value.sorted())), set([63, 64, 65, 66])) self.assertEqual( - values(b.value, 'str'), + values(b.value.sorted(), 'str'), 'this is the rhythm'.split()) def test_link_inline(self): @@ -885,7 +885,7 @@ class test_o2m(ImporterCase): [b] = self.browse() self.assertEqual(b.const, 42) # automatically forces link between core record and o2ms - self.assertEqual(values(b.value), [109, 262]) + self.assertEqual(set(values(b.value)), set([109, 262])) self.assertEqual(values(b.value, field='parent_id'), [b, b]) def test_link(self): @@ -904,7 +904,7 @@ class test_o2m(ImporterCase): [b] = self.browse() self.assertEqual(b.const, 42) # automatically forces link between core record and o2ms - self.assertEqual(values(b.value), [109, 262]) + self.assertCountEqual(values(b.value), [109, 262]) self.assertEqual(values(b.value, field='parent_id'), [b, b]) def test_link_2(self): @@ -920,7 +920,7 @@ class test_o2m(ImporterCase): [b] = self.browse() self.assertEqual(b.const, 42) - self.assertEqual(values(b.value), [1, 2]) + self.assertEqual(set(values(b.value)), set([1, 2])) self.assertEqual(values(b.value, field='parent_id'), [b, b]) @@ -938,8 +938,8 @@ class test_o2m_multiple(ImporterCase): self.assertEqual(len(result['ids']), 1) [b] = self.browse() - self.assertEqual(values(b.child1), [11, 12, 13, 14]) - self.assertEqual(values(b.child2), [21, 22, 23]) + self.assertEqual(set(values(b.child1)), set([11, 12, 13, 14])) + self.assertEqual(set(values(b.child2)), set([21, 22, 23])) def test_multi(self): result = self.import_(['const', 'child1/value', 'child2/value'], [ @@ -954,8 +954,8 @@ class test_o2m_multiple(ImporterCase): self.assertEqual(len(result['ids']), 1) [b] = self.browse() - self.assertEqual(values(b.child1), [11, 12, 13, 14]) - self.assertEqual(values(b.child2), [21, 22, 23]) + self.assertEqual(set(values(b.child1)), set([11, 12, 13, 14])) + self.assertEqual(set(values(b.child2)), set([21, 22, 23])) def test_multi_fullsplit(self): result = self.import_(['const', 'child1/value', 'child2/value'], [ @@ -972,8 +972,8 @@ class test_o2m_multiple(ImporterCase): [b] = self.browse() self.assertEqual(b.const, 5) - self.assertEqual(values(b.child1), [11, 12, 13, 14]) - self.assertEqual(values(b.child2), [21, 22, 23]) + self.assertEqual(set(values(b.child1)), set([11, 12, 13, 14])) + self.assertEqual(set(values(b.child2)), set([21, 22, 23])) class test_realworld(common.TransactionCase): @@ -1015,10 +1015,10 @@ class test_realworld(common.TransactionCase): b = Model.browse(result['ids']) self.assertEqual((b[0].value, b[1].value), (4, 5)) - self.assertEqual([child.str for child in b[0].child[1].child1], + self.assertEqual([child.str for child in b[0].child.sorted()[1].child1], ['bar', 'baz']) - self.assertFalse(len(b[1].child[1].child1)) - self.assertEqual([child.value for child in b[1].child[1].child2], + self.assertFalse(len(b[1].child.sorted()[1].child1)) + self.assertEqual([child.value for child in b[1].child.sorted()[1].child2], [12]) diff --git a/odoo/addons/test_new_api/models.py b/odoo/addons/test_new_api/models.py index b4cf23290ba..9895fbd62aa 100644 --- a/odoo/addons/test_new_api/models.py +++ b/odoo/addons/test_new_api/models.py @@ -60,10 +60,13 @@ class Category(models.Model): # assign name of last category, and reassign display_name (to normalize it) cat.name = names[-1].strip() - def read(self, fields=None, load='_classic_read'): + def _read(self, fields): + # DLE P45: `test_31_prefetch`, + # with self.assertRaises(AccessError): + # cat1.name if self.search_count([('id', 'in', self._ids), ('name', '=', 'NOACCESS')]): raise AccessError('Sorry') - return super(Category, self).read(fields=fields, load=load) + return super(Category, self)._read(fields) class Discussion(models.Model): @@ -338,7 +341,7 @@ class Bar(models.Model): _description = 'Test New API Bar' name = fields.Char() - foo = fields.Many2one('test_new_api.foo', compute='_compute_foo') + foo = fields.Many2one('test_new_api.foo', compute='_compute_foo', search='_search_foo') value1 = fields.Integer(related='foo.value1', readonly=False) value2 = fields.Integer(related='foo.value2', readonly=False) @@ -347,6 +350,11 @@ class Bar(models.Model): for bar in self: bar.foo = self.env['test_new_api.foo'].search([('name', '=', bar.name)], limit=1) + def _search_foo(self, operator, value): + assert operator == 'in' + records = self.env['test_new_api.foo'].browse(value) + return [('name', 'in', records.mapped('name'))] + class Related(models.Model): _name = 'test_new_api.related' @@ -571,6 +579,22 @@ class Attachment(models.Model): for rec in self: rec.name = self.env[rec.res_model].browse(rec.res_id).display_name + # DLE P55: `test_cache_invalidation` + def modified(self, fnames, modified=None, create=False): + if not self: + return + comodel = self.env[self.res_model] + if 'res_id' in fnames and 'attachment_ids' in comodel: + field = comodel._fields['attachment_ids'] + record = comodel.browse(self.res_id) + self.env.cache.invalidate([(field, record._ids)]) + record.modified(['attachment_ids']) + if modified is None: + modified = {field: record} + else: + modified[field] = modified.get(field, record) | record + return super(Attachment, self).modified(fnames, modified=modified) + class AttachmentHost(models.Model): _name = 'test_new_api.attachment.host' diff --git a/odoo/addons/test_new_api/tests/test_new_fields.py b/odoo/addons/test_new_api/tests/test_new_fields.py index 49891ee6fd9..af34c5b6027 100644 --- a/odoo/addons/test_new_api/tests/test_new_fields.py +++ b/odoo/addons/test_new_api/tests/test_new_fields.py @@ -49,11 +49,10 @@ class TestFields(common.TransactionCase): self.assertEqual(len(record), 1) record.body = 'OK' - # field assignment fails on multiple records + # field assignment on multiple records should assign value to all records records = self.env['test_new_api.message'].search([]) - assert len(records) > 1 - with self.assertRaises(ValueError): - records.body = 'Faulty' + records.body = 'Updated' + self.assertTrue(all(map(lambda record:record.body=='Updated', records))) # field assigmenent does not cache the wrong value when write overridden record.priority = 4 @@ -101,6 +100,16 @@ class TestFields(common.TransactionCase): message.write({'body': (message.body or '') + "!!!"}) self.assertEqual(message.size, size + 3) + # create a message, assign body, and check size in several environments + message1 = self.env['test_new_api.message'].create({}) + message2 = message1.with_user(self.env.ref('base.user_demo')) + self.assertEqual(message1.size, 0) + self.assertEqual(message2.size, 0) + + message1.write({'body': "XXX"}) + self.assertEqual(message1.size, 3) + self.assertEqual(message2.size, 3) + # special case: computed field without dependency must be computed record = self.env['test_new_api.mixed'].create({}) self.assertTrue(record.now) @@ -203,6 +212,12 @@ class TestFields(common.TransactionCase): 'model_id': self.env['ir.model'].search([('model', '=', 'res.users')]).id, 'domain_force': "[('id', '!=', %d)]" % user2.id, }) + # DLE P72: Since we decided that we do not raise security access errors for data to which we had the occassion + # to put the value in the cache, we need to invalidate the cache for user1, user2 and user3 in order + # to test the below access error. Otherwise the above create calls set in the cache the information needed + # to compute `company_type` ('is_company'), and doesn't need to trigger a read. + # We need to force the read in order to test the security access + User.invalidate_cache() # group users as a recordset, and read them as user demo users = (user1 + user2 + user3).with_user(self.env.ref('base.user_demo')) user1, user2, user3 = users @@ -427,7 +442,7 @@ class TestFields(common.TransactionCase): self.env['test_new_api.message'].create({'discussion': discussion.id, 'body': 'Whatever'}) # make sure that assertRaises() does not leave fields to recompute - self.assertFalse(self.env.has_todo()) + self.assertFalse(self.env.fields_to_compute()) # put back oneself into discussion participants: now we can create # messages in discussion @@ -435,16 +450,23 @@ class TestFields(common.TransactionCase): self.env['test_new_api.message'].create({'discussion': discussion.id, 'body': 'Whatever'}) def test_20_float(self): - """ test float fields """ + """ test rounding of float fields """ record = self.env['test_new_api.mixed'].create({}) - - # assign value, and expect rounding - record.write({'number': 2.4999999999999996}) - self.assertEqual(record.number, 2.50) - - # same with field setter - record.number = 2.4999999999999996 - self.assertEqual(record.number, 2.50) + query = "SELECT 1 FROM test_new_api_mixed WHERE id=%s AND number=%s" + + # 2.49609375 (exact float) must be rounded to 2.5 + record.write({'number': 2.49609375}) + record.flush() + self.cr.execute(query, [record.id, '2.5']) + self.assertTrue(self.cr.rowcount) + self.assertEqual(record.number, 2.5) + + # 1.1 (1.1000000000000000888178420 in float) must be 1.1 in database + record.write({'number': 1.1}) + record.flush() + self.cr.execute(query, [record.id, '1.1']) + self.assertTrue(self.cr.rowcount) + self.assertEqual(record.number, 1.1) def test_21_float_digits(self): """ test field description """ @@ -467,6 +489,7 @@ class TestFields(common.TransactionCase): self.assertIn(record.amount, [ramount, samount], msg) # check the value in the database + record.flush() self.cr.execute('SELECT amount FROM test_new_api_mixed WHERE id=%s', [record.id]) value = self.cr.fetchone()[0] self.assertEqual(value, samount, msg) @@ -524,8 +547,9 @@ class TestFields(common.TransactionCase): record.date = date(2012, 5, 1) self.assertEqual(record.date, date(2012, 5, 1)) - with self.assertRaises(TypeError): - record.date = datetime(2012, 5, 1, 10, 45, 0) + # DLE P41: We now support to assign datetime to date. Not sure this is the good practice though. + # with self.assertRaises(TypeError): + # record.date = datetime(2012, 5, 1, 10, 45, 0) # one may assign dates and datetime in the default format, and it must be checked record.date = '2012-05-01' @@ -816,7 +840,6 @@ class TestFields(common.TransactionCase): 'moment': '1932-11-09 00:00:00', 'tag_id': tag1.id, }) - record.invalidate_cache() self.assertEqual(record.with_user(user0).foo, 'main') self.assertEqual(record.with_user(user1).foo, 'default') self.assertEqual(record.with_user(user2).foo, 'default') @@ -836,7 +859,6 @@ class TestFields(common.TransactionCase): 'moment': '1932-12-10 23:59:59', 'tag_id': tag2.id, }) - record.invalidate_cache() self.assertEqual(record.with_user(user0).foo, 'main') self.assertEqual(record.with_user(user1).foo, 'alpha') self.assertEqual(record.with_user(user2).foo, 'default') @@ -857,7 +879,6 @@ class TestFields(common.TransactionCase): self.assertEqual(record.with_user(user2).tag_id, tag0) record.with_user(user1).foo = False - record.invalidate_cache() self.assertEqual(record.with_user(user0).foo, 'main') self.assertEqual(record.with_user(user1).foo, False) self.assertEqual(record.with_user(user2).foo, 'default') @@ -869,30 +890,12 @@ class TestFields(common.TransactionCase): self.assertEqual(record.with_user(user1).foo, 'beta') self.assertEqual(record.with_user(user2).foo, 'default') - # create company record and attribute - company_record = self.env['test_new_api.company'].create({'foo': 'ABC'}) - attribute_record = self.env['test_new_api.company.attr'].create({ - 'company': company_record.id, - 'quantity': 1, - }) - self.assertEqual(attribute_record.bar, 'ABC') - - # change quantity, 'bar' should recompute to 'ABCABC' - attribute_record.quantity = 2 - self.assertEqual(attribute_record.bar, 'ABCABC') - self.assertFalse(self.env.has_todo()) - - # change company field 'foo', 'bar' should recompute to 'DEFDEF' - company_record.foo = 'DEF' - self.assertEqual(attribute_record.company.foo, 'DEF') - self.assertEqual(attribute_record.bar, 'DEFDEF') - self.assertFalse(self.env.has_todo()) - # add group on company-dependent field self.assertFalse(user0.has_group('base.group_system')) self.patch(type(record).foo, 'groups', 'base.group_system') with self.assertRaises(AccessError): record.with_user(user0).foo = 'forbidden' + record.flush() user0.write({'groups_id': [(4, self.env.ref('base.group_system').id)]}) record.with_user(user0).foo = 'yes we can' @@ -906,6 +909,24 @@ class TestFields(common.TransactionCase): }) with self.assertRaises(AccessError): record.with_user(user0).foo = 'forbidden' + record.flush() + + # create company record and attribute + company_record = self.env['test_new_api.company'].create({'foo': 'ABC'}) + attribute_record = self.env['test_new_api.company.attr'].create({ + 'company': company_record.id, + 'quantity': 1, + }) + self.assertEqual(attribute_record.bar, 'ABC') + + # change quantity, 'bar' should recompute to 'ABCABC' + attribute_record.quantity = 2 + self.assertEqual(attribute_record.bar, 'ABCABC') + + # change company field 'foo', 'bar' should recompute to 'DEFDEF' + company_record.foo = 'DEF' + self.assertEqual(attribute_record.company.foo, 'DEF') + self.assertEqual(attribute_record.bar, 'DEFDEF') def test_30_read(self): """ test computed fields as returned by read(). """ @@ -1137,8 +1158,11 @@ class TestFields(common.TransactionCase): # read the related field discussion_name self.assertEqual(message.discussion.env, env) self.assertEqual(message.discussion_name, discussion.name) - with self.assertRaises(AccessError): - message.discussion.name + # DLE P75: message.discussion.name is put in the cache as sudo thanks to the computation of message.discussion_name + # As we decided that now if we had the chance to access the value at some point in the code, and that it was stored in the cache + # it's not a big deal to no longer raise the accesserror, as we had the chance to get the value at some point + # with self.assertRaises(AccessError): + # message.discussion.name @mute_logger('odoo.addons.base.models.ir_model') def test_42_new_related(self): @@ -1224,6 +1248,26 @@ class TestFields(common.TransactionCase): self.assertEqual(len(discussion.important_messages), 2) self.assertEqual(len(discussion.very_important_messages), 2) + def test_70_relational_inverse(self): + """ Check the consistency of relational fields with inverse(s). """ + discussion = self.env.ref('test_new_api.discussion_0') + demo_discussion = discussion.with_user(self.env.ref('base.user_demo')) + + # check that the demo user sees the same messages + self.assertEqual(demo_discussion.messages, discussion.messages) + + # add a message as user demo + messages = demo_discussion.messages + message = messages.create({'discussion': discussion.id}) + self.assertEqual(demo_discussion.messages, messages + message) + self.assertEqual(demo_discussion.messages, discussion.messages) + + # add a message as superuser + messages = discussion.messages + message = messages.create({'discussion': discussion.id}) + self.assertEqual(discussion.messages, messages + message) + self.assertEqual(demo_discussion.messages, discussion.messages) + def test_80_copy(self): Translations = self.env['ir.translation'] discussion = self.env.ref('test_new_api.discussion_0') @@ -1433,6 +1477,7 @@ class TestFields(common.TransactionCase): record.write({ 'image_256': image_h, }) + record.invalidate_cache(fnames=['image_256'], ids=record.ids) self.assertEqual(Image.open(io.BytesIO(base64.b64decode(record.image_512))).size, (256, 512)) self.assertEqual(Image.open(io.BytesIO(base64.b64decode(record.image))).size, (2000, 4000)) self.assertEqual(Image.open(io.BytesIO(base64.b64decode(record.image_256))).size, (128, 256)) diff --git a/odoo/addons/test_new_api/tests/test_onchange.py b/odoo/addons/test_new_api/tests/test_onchange.py index ef196bf0e61..b3a765ed7e9 100644 --- a/odoo/addons/test_new_api/tests/test_onchange.py +++ b/odoo/addons/test_new_api/tests/test_onchange.py @@ -292,7 +292,6 @@ class TestOnChange(common.TransactionCase): 'tags': [(5,), (0, 0, {'name': 'Tag'})]})], } self.env.cache.invalidate() - result = multi.onchange(values, 'partner', field_onchange) expected_value = { 'name': partner2.name, diff --git a/odoo/addons/test_new_api/tests/test_one2many.py b/odoo/addons/test_new_api/tests/test_one2many.py index 2c5de0e1f2f..a72361a3697 100644 --- a/odoo/addons/test_new_api/tests/test_one2many.py +++ b/odoo/addons/test_new_api/tests/test_one2many.py @@ -169,37 +169,41 @@ class One2manyCase(TransactionCase): def test_cache_invalidation(self): """ Cache invalidation for one2many with integer inverse. """ - record = self.env['test_new_api.attachment.host'].create({}) + record0 = self.env['test_new_api.attachment.host'].create({}) with self.assertQueryCount(2): - self.assertFalse(record.attachment_ids, "inconsistent cache") + self.assertFalse(record0.attachment_ids, "inconsistent cache") # creating attachment must compute name and invalidate attachment_ids attachment = self.env['test_new_api.attachment'].create({ - 'res_model': record._name, - 'res_id': record.id, + 'res_model': record0._name, + 'res_id': record0.id, }) + attachment.flush() with self.assertQueryCount(1): - self.assertEqual(attachment.name, record.display_name, + self.assertEqual(attachment.name, record0.display_name, "field should be computed") with self.assertQueryCount(2): - self.assertEqual(record.attachment_ids, attachment, "inconsistent cache") + self.assertEqual(record0.attachment_ids, attachment, "inconsistent cache") # creating a host should not attempt to recompute attachment.name with self.assertQueryCount(1): - record = self.env['test_new_api.attachment.host'].create({}) + record1 = self.env['test_new_api.attachment.host'].create({}) with self.assertQueryCount(0): # field res_id should not have been invalidated attachment.res_id with self.assertQueryCount(2): - self.assertFalse(record.attachment_ids, "inconsistent cache") + self.assertFalse(record1.attachment_ids, "inconsistent cache") # writing on res_id must recompute name and invalidate attachment_ids - attachment.res_id = record.id + attachment.res_id = record1.id + attachment.flush() with self.assertQueryCount(1): - self.assertEqual(attachment.name, record.display_name, + self.assertEqual(attachment.name, record1.display_name, "field should be recomputed") with self.assertQueryCount(2): - self.assertEqual(record.attachment_ids, attachment, "inconsistent cache") + self.assertEqual(record1.attachment_ids, attachment, "inconsistent cache") + with self.assertQueryCount(2): + self.assertFalse(record0.attachment_ids, "inconsistent cache") def test_recompute(self): """ test recomputation of fields that indirecly depend on one2many """ @@ -210,14 +214,15 @@ class One2manyCase(TransactionCase): message = discussion.messages[0] message.discussion = False + # DLE P54: a computed stored field should not depend on the context # writing on the one2many and actually modifying the relation must # trigger recomputation of fields that depend on its inverse many2one - self.assertNotIn(message, discussion.messages) - discussion.with_context(compute_name='X').write({'messages': [(4, message.id)]}) - self.assertEqual(message.name, 'X') + # self.assertNotIn(message, discussion.messages) + # discussion.with_context(compute_name='X').write({'messages': [(4, message.id)]}) + # self.assertEqual(message.name, 'X') # writing on the one2many without modifying the relation should not # trigger recomputation of fields that depend on its inverse many2one - self.assertIn(message, discussion.messages) - discussion.with_context(compute_name='Y').write({'messages': [(4, message.id)]}) - self.assertEqual(message.name, 'X') + # self.assertIn(message, discussion.messages) + # discussion.with_context(compute_name='Y').write({'messages': [(4, message.id)]}) + # self.assertEqual(message.name, 'X') diff --git a/odoo/addons/test_performance/models/models.py b/odoo/addons/test_performance/models/models.py index 5874d0a784e..ba38983234f 100644 --- a/odoo/addons/test_performance/models/models.py +++ b/odoo/addons/test_performance/models/models.py @@ -9,7 +9,7 @@ class BaseModel(models.Model): _description = 'Test Performance Base' name = fields.Char() - value = fields.Integer() + value = fields.Integer(default=0) value_pc = fields.Float(compute="_value_pc", store=True) partner_id = fields.Many2one('res.partner', string='Customer') diff --git a/odoo/addons/test_performance/tests/test_performance.py b/odoo/addons/test_performance/tests/test_performance.py index 8385b97bbca..4b0dd4fd1bb 100644 --- a/odoo/addons/test_performance/tests/test_performance.py +++ b/odoo/addons/test_performance/tests/test_performance.py @@ -49,7 +49,7 @@ class TestPerformance(TransactionCase): records = self.env['test_performance.base'].search([]) self.assertEqual(len(records), 5) - with self.assertQueryCount(__system__=3, demo=3): + with self.assertQueryCount(__system__=1, demo=1): records.write({'value': 42}) @mute_logger('odoo.models.unlink') @@ -61,12 +61,12 @@ class TestPerformance(TransactionCase): # create N lines on rec1: O(N) queries rec1.invalidate_cache() - with self.assertQueryCount(9): + with self.assertQueryCount(4): rec1.write({'line_ids': [(0, 0, {'value': 0})]}) self.assertEqual(len(rec1.line_ids), 1) rec1.invalidate_cache() - with self.assertQueryCount(19): + with self.assertQueryCount(17): rec1.write({'line_ids': [(0, 0, {'value': val}) for val in range(1, 12)]}) self.assertEqual(len(rec1.line_ids), 12) @@ -74,23 +74,23 @@ class TestPerformance(TransactionCase): # update N lines: O(N) queries rec1.invalidate_cache() - with self.assertQueryCount(11): + with self.assertQueryCount(8): rec1.write({'line_ids': [(1, line.id, {'value': 42}) for line in lines[0]]}) self.assertEqual(rec1.line_ids, lines) rec1.invalidate_cache() - with self.assertQueryCount(61): + with self.assertQueryCount(28): rec1.write({'line_ids': [(1, line.id, {'value': 42 + line.id}) for line in lines[1:]]}) self.assertEqual(rec1.line_ids, lines) # delete N lines: O(1) queries rec1.invalidate_cache() - with self.assertQueryCount(__system__=16, demo=18): + with self.assertQueryCount(__system__=18, demo=18): rec1.write({'line_ids': [(2, line.id) for line in lines[0]]}) self.assertEqual(rec1.line_ids, lines[1:]) rec1.invalidate_cache() - with self.assertQueryCount(__system__=14, demo=16): + with self.assertQueryCount(__system__=16, demo=16): rec1.write({'line_ids': [(2, line.id) for line in lines[1:]]}) self.assertFalse(rec1.line_ids) self.assertFalse(lines.exists()) @@ -100,12 +100,12 @@ class TestPerformance(TransactionCase): # unlink N lines: O(1) queries rec1.invalidate_cache() - with self.assertQueryCount(__system__=16, demo=18): + with self.assertQueryCount(__system__=14, demo=14): rec1.write({'line_ids': [(3, line.id) for line in lines[0]]}) self.assertEqual(rec1.line_ids, lines[1:]) rec1.invalidate_cache() - with self.assertQueryCount(__system__=14, demo=16): + with self.assertQueryCount(__system__=16, demo=16): rec1.write({'line_ids': [(3, line.id) for line in lines[1:]]}) self.assertFalse(rec1.line_ids) self.assertFalse(lines.exists()) @@ -116,13 +116,13 @@ class TestPerformance(TransactionCase): # link N lines from rec1 to rec2: O(1) queries rec1.invalidate_cache() - with self.assertQueryCount(13): + with self.assertQueryCount(7): rec2.write({'line_ids': [(4, line.id) for line in lines[0]]}) self.assertEqual(rec1.line_ids, lines[1:]) self.assertEqual(rec2.line_ids, lines[0]) rec1.invalidate_cache() - with self.assertQueryCount(13): + with self.assertQueryCount(10): rec2.write({'line_ids': [(4, line.id) for line in lines[1:]]}) self.assertFalse(rec1.line_ids) self.assertEqual(rec2.line_ids, lines) @@ -139,7 +139,7 @@ class TestPerformance(TransactionCase): # empty N lines in rec2: O(1) queries rec1.invalidate_cache() - with self.assertQueryCount(__system__=15, demo=17): + with self.assertQueryCount(__system__=17, demo=17): rec2.write({'line_ids': [(5,)]}) self.assertFalse(rec2.line_ids) @@ -153,17 +153,17 @@ class TestPerformance(TransactionCase): # set N lines in rec2: O(1) queries rec1.invalidate_cache() - with self.assertQueryCount(14): + with self.assertQueryCount(8): rec2.write({'line_ids': [(6, 0, lines[0].ids)]}) self.assertEqual(rec1.line_ids, lines[1:]) self.assertEqual(rec2.line_ids, lines[0]) - with self.assertQueryCount(13): + with self.assertQueryCount(9): rec2.write({'line_ids': [(6, 0, lines.ids)]}) self.assertFalse(rec1.line_ids) self.assertEqual(rec2.line_ids, lines) - with self.assertQueryCount(7): + with self.assertQueryCount(4): rec2.write({'line_ids': [(6, 0, lines.ids)]}) self.assertEqual(rec2.line_ids, lines) @@ -205,18 +205,18 @@ class TestPerformance(TransactionCase): self.assertEqual(rec1.tag_ids, tags) rec1.invalidate_cache() - with self.assertQueryCount(13): + with self.assertQueryCount(3): rec1.write({'tag_ids': [(1, tag.id, {'name': 'X'}) for tag in tags[1:]]}) self.assertEqual(rec1.tag_ids, tags) # delete N tags: O(1) queries rec1.invalidate_cache() - with self.assertQueryCount(__system__=10, demo=12): + with self.assertQueryCount(__system__=10, demo=10): rec1.write({'tag_ids': [(2, tag.id) for tag in tags[0]]}) self.assertEqual(rec1.tag_ids, tags[1:]) rec1.invalidate_cache() - with self.assertQueryCount(__system__=10, demo=12): + with self.assertQueryCount(__system__=10, demo=10): rec1.write({'tag_ids': [(2, tag.id) for tag in tags[1:]]}) self.assertFalse(rec1.tag_ids) self.assertFalse(tags.exists()) @@ -296,14 +296,14 @@ class TestPerformance(TransactionCase): @warmup def test_create_base(self): """ Create records. """ - with self.assertQueryCount(__system__=6, demo=6): + with self.assertQueryCount(__system__=2, demo=2): self.env['test_performance.base'].create({'name': 'X'}) @users('__system__', 'demo') @warmup def test_create_base_with_lines(self): """ Create records with one2many lines. """ - with self.assertQueryCount(__system__=20, demo=20): + with self.assertQueryCount(__system__=12, demo=12): self.env['test_performance.base'].create({ 'name': 'X', 'line_ids': [(0, 0, {'value': val}) for val in range(10)], @@ -313,11 +313,11 @@ class TestPerformance(TransactionCase): @warmup def test_create_base_with_tags(self): """ Create records with many2many tags. """ - with self.assertQueryCount(6): + with self.assertQueryCount(2): self.env['test_performance.base'].create({'name': 'X'}) # create N tags: add O(N) queries - with self.assertQueryCount(17): + with self.assertQueryCount(13): self.env['test_performance.base'].create({ 'name': 'X', 'tag_ids': [(0, 0, {'name': val}) for val in range(10)], @@ -326,19 +326,19 @@ class TestPerformance(TransactionCase): # link N tags: add O(1) queries tags = self.env['test_performance.tag'].create([{'name': val} for val in range(10)]) - with self.assertQueryCount(7): + with self.assertQueryCount(3): self.env['test_performance.base'].create({ 'name': 'X', 'tag_ids': [(4, tag.id) for tag in tags], }) - with self.assertQueryCount(6): + with self.assertQueryCount(2): self.env['test_performance.base'].create({ 'name': 'X', 'tag_ids': [(6, 0, [])], }) - with self.assertQueryCount(7): + with self.assertQueryCount(3): self.env['test_performance.base'].create({ 'name': 'X', 'tag_ids': [(6, 0, tags.ids)], diff --git a/odoo/addons/test_testing_utilities/tests/__init__.py b/odoo/addons/test_testing_utilities/tests/__init__.py index 0c0095dfd37..7be78314304 100644 --- a/odoo/addons/test_testing_utilities/tests/__init__.py +++ b/odoo/addons/test_testing_utilities/tests/__init__.py @@ -1,2 +1,3 @@ # -*- coding: utf-8 -*- +from . import test_methods from . import test_form_impl diff --git a/odoo/addons/test_testing_utilities/tests/test_methods.py b/odoo/addons/test_testing_utilities/tests/test_methods.py new file mode 100644 index 00000000000..031d3a5e2ec --- /dev/null +++ b/odoo/addons/test_testing_utilities/tests/test_methods.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.tests import common + + +class TestBasic(common.TransactionCase): + def test_assertRecordValues(self): + X1 = {'f1': "X", 'f2': 1} + Y2 = {'f1': "Y", 'f2': 2} + Y3 = {'f1': "Y", 'f2': 3} + records = self.env['test_testing_utilities.a'].create([X1, Y2]) + + # match in any order + self.assertRecordValues(records, [X1, Y2]) + self.assertRecordValues(records, [Y2, X1]) + + # fail if wrong size + with self.assertRaises(AssertionError): + self.assertRecordValues(records, [X1]) + with self.assertRaises(AssertionError): + self.assertRecordValues(records, [X1, Y2, Y3]) + + # fail if fields don't match + with self.assertRaises(AssertionError): + self.assertRecordValues(records, [X1, Y3]) + with self.assertRaises(AssertionError): + self.assertRecordValues(records, [Y3, X1]) diff --git a/odoo/api.py b/odoo/api.py index f4eeae44516..d0d33886454 100644 --- a/odoo/api.py +++ b/odoo/api.py @@ -42,6 +42,7 @@ __all__ = [ import logging from collections import defaultdict, Mapping from contextlib import contextmanager +from copy import deepcopy from inspect import getargspec from pprint import pformat from weakref import WeakSet @@ -49,6 +50,7 @@ from weakref import WeakSet from decorator import decorate, decorator from werkzeug.local import Local, release_local +import odoo from odoo.tools import frozendict, classproperty, StackMap from odoo.exceptions import CacheMiss @@ -210,6 +212,25 @@ def depends(*args): return attrsetter('_depends', args) +def depends_context(*args): + """ Return a decorator that specifies the context dependencies of a non-stored "compute" + method (for new-style function fields). Each argument must be a string + that consists in a key in the context:: + + price = fields.Float(compute='_compute_product_price') + + @api.depends_context('pricelist') + def _compute_product_price(self): + for product in self: + if product.env.context.get('pricelist'): + pricelist = self.env['product.pricelist'].browse(product.env.context['pricelist']) + else: + pricelist = self.env['product.pricelist'].get_default_pricelist() + product.price = pricelist.get_products_price(product).get(product.id, 0.0) + """ + return attrsetter('_depends_context', args) + + def returns(model, downgrade=None, upgrade=None): """ Return a decorator for methods that return instances of ``model``. @@ -366,11 +387,13 @@ def call_kw(model, name, args, kwargs): method = getattr(type(model), name) api = getattr(method, '_api', None) if api == 'model': - return _call_kw_model(method, model, args, kwargs) + result = _call_kw_model(method, model, args, kwargs) elif api == 'model_create': - return _call_kw_model_create(method, model, args, kwargs) + result = _call_kw_model_create(method, model, args, kwargs) else: - return _call_kw_multi(method, model, args, kwargs) + result = _call_kw_multi(method, model, args, kwargs) + model.flush() + return result class Environment(Mapping): @@ -389,7 +412,7 @@ class Environment(Mapping): @classproperty def envs(cls): - return cls._local.environments + return getattr(cls._local, 'environments', ()) @classmethod @contextmanager @@ -429,7 +452,6 @@ class Environment(Mapping): self.cr, self.uid, self.context, self.su = self.args = args self.registry = Registry(cr.dbname) self.cache = envs.cache - self._cache_key = (cr, uid, su) self._protected = StackMap() # {field: ids, ...} self.all = envs envs.add(self) @@ -539,43 +561,46 @@ class Environment(Mapping): """ return the current language code """ return self.context.get('lang') - @contextmanager - def do_in_draft(self): - """ Context-switch to draft mode, where all field updates are done in - cache only. - """ - if self.all.in_draft: - yield - else: - try: - self.all.in_draft = True - yield - finally: - self.all.in_draft = False - - @property - def in_draft(self): - """ Return whether we are in draft mode. """ - return self.all.in_draft - def clear(self): """ Clear all record caches, and discard all fields to recompute. This may be useful when recovering from a failed ORM operation. """ self.cache.invalidate() - self.all.todo.clear() + self.all.tocompute.clear() + self.all.towrite.clear() @contextmanager def clear_upon_failure(self): """ Context manager that clears the environments (caches and fields to recompute) upon exception. """ + tocompute = { + field: set(ids) + for field, ids in self.all.tocompute.items() + } + towrite = { + model: { + record_id: dict(values) + for record_id, values in id_values.items() + } + for model, id_values in self.all.towrite.items() + } try: yield except Exception: self.clear() + self.all.tocompute.update(tocompute) + for model, id_values in towrite.items(): + for record_id, values in id_values.items(): + self.all.towrite[model][record_id].update(values) raise + def is_protected(self, field, record): + """ Return whether `record` is protected against invalidation or + recomputation for `field`. + """ + return record.id in self._protected.get(field, ()) + def protected(self, field): """ Return the recordset for which ``field`` should not be invalidated or recomputed. """ return self[field.model_name].browse(self._protected.get(field, ())) @@ -599,79 +624,53 @@ class Environment(Mapping): finally: protected.popmap() - def field_todo(self, field): - """ Return a recordset with all records to recompute for ``field``. """ - ids = {rid for recs in self.all.todo.get(field, ()) for rid in recs.ids} - return self[field.model_name].browse(ids) + def fields_to_compute(self): + """ Return a view on the field to compute. """ + return self.all.tocompute.keys() - def check_todo(self, field, record): - """ Check whether ``field`` must be recomputed on ``record``, and if so, - return the corresponding recordset to recompute. - """ - for recs in self.all.todo.get(field, []): - if recs & record: - return recs - - def add_todo(self, field, records): - """ Mark ``field`` to be recomputed on ``records``. """ - recs_list = self.all.todo.setdefault(field, []) - for i, recs in enumerate(recs_list): - if recs.env == records.env: - # only add records if not already in the recordset, much much - # cheaper in case recs is big and records is a singleton - # already present - if not records <= recs: - recs_list[i] |= records - break - else: - recs_list.append(records) - - def remove_todo(self, field, records): - """ Mark ``field`` as recomputed on ``records``. """ - recs_list = [recs - records for recs in self.all.todo.pop(field, [])] - recs_list = [r for r in recs_list if r] - if recs_list: - self.all.todo[field] = recs_list - - def has_todo(self): - """ Return whether some fields must be recomputed. """ - return bool(self.all.todo) - - def get_todo(self): - """ Return a pair ``(field, records)`` to recompute. - The field is such that none of its dependencies must be recomputed. - """ - field = min(self.all.todo, key=self.registry.field_sequence) - return field, self.all.todo[field][0] + def records_to_compute(self, field): + """ Return the records to compute for ``field``. """ + ids = self.all.tocompute.get(field, ()) + return self[field.model_name].browse(ids) - @property - def recompute(self): - return self.all.recompute + def is_to_compute(self, field, record): + """ Return whether ``field`` must be computed on ``record``. """ + return record.id in self.all.tocompute.get(field, ()) + + def add_to_compute(self, field, records): + """ Mark ``field`` to be computed on ``records``, return newly added records. """ + if not records: + return records + ids = self.all.tocompute[field] + added_ids = [id_ for id_ in records._ids if id_ not in ids] + ids.update(added_ids) + return records.browse(added_ids) + + def remove_to_compute(self, field, records): + """ Mark ``field`` as computed on ``records``. """ + if not records: + return + ids = self.all.tocompute.get(field, None) + if ids is None: + return + ids.difference_update(records._ids) + if not ids: + del self.all.tocompute[field] @contextmanager def norecompute(self): - tmp = self.all.recompute - self.all.recompute = False - try: - yield - finally: - self.all.recompute = tmp - - def cache_key(self, field): - """ Return the key to store the value of ``field`` in cache, the full - cache key being ``(key, field, record.id)``. - """ - return self if field.context_dependent else self._cache_key + """ Delay recomputations (deprecated: this is not the default behavior). """ + yield class Environments(object): """ A common object for all environments in a request. """ def __init__(self): - self.envs = WeakSet() # weak set of environments - self.cache = Cache() # cache for all records - self.todo = {} # recomputations {field: [records]} - self.in_draft = False # flag for draft - self.recompute = True + self.envs = WeakSet() # weak set of environments + self.cache = Cache() # cache for all records + self.tocompute = defaultdict(set) # recomputations {field: ids} + # updates {model: {id: {field: value}}} + self.towrite = defaultdict(lambda: defaultdict(dict)) def add(self, env): """ Add the environment ``env``. """ @@ -682,118 +681,109 @@ class Environments(object): return iter(self.envs) +# sentinel value for optional parameters +NOTHING = object() + + class Cache(object): """ Implementation of the cache of records. """ def __init__(self): - # {key: {field: {record_id: value}}} - self._data = defaultdict(lambda: defaultdict(dict)) + # {field: {record_id: value}} + self._data = defaultdict(dict) + + def _get_context_key(self, env, field): + get_context = env.context.get + + def get(key): + if key == 'force_company': + return get_context('force_company') or env.company.id + elif key == 'uid': + return (env.uid, env.su) + elif key == 'active_test': + return get_context('active_test', field.context.get('active_test', True)) + else: + return get_context(key) + + return tuple(get(key) for key in field.depends_context) def contains(self, record, field): """ Return whether ``record`` has a value for ``field``. """ - key = record.env.cache_key(field) - return record.id in self._data[key].get(field, ()) + if field.depends_context: + key = self._get_context_key(record.env, field) + return key in self._data.get(field, {}).get(record.id, {}) + return record.id in self._data.get(field, ()) - def get(self, record, field): + def get(self, record, field, default=NOTHING): """ Return the value of ``field`` for ``record``. """ - key = record.env.cache_key(field) try: - value = self._data[key][field][record._ids[0]] + value = self._data[field][record._ids[0]] + if field.depends_context: + key = self._get_context_key(record.env, field) + value = value[key] + return value except KeyError: - raise CacheMiss(record, field) - - return value.get() if isinstance(value, SpecialValue) else value + if default is NOTHING: + raise CacheMiss(record, field) + return default def set(self, record, field, value): """ Set the value of ``field`` for ``record``. """ - key = record.env.cache_key(field) - self._data[key][field][record._ids[0]] = value + if field.depends_context: + key = self._get_context_key(record.env, field) + self._data[field].setdefault(record._ids[0], {})[key] = value + else: + self._data[field][record._ids[0]] = value def update(self, records, field, values): """ Set the values of ``field`` for several ``records``. """ - key = records.env.cache_key(field) - self._data[key][field].update(zip(records._ids, values)) + if field.depends_context: + key = self._get_context_key(records.env, field) + field_cache = self._data[field] + for record_id, value in zip(records._ids, values): + field_cache.setdefault(record_id, {})[key] = value + else: + self._data[field].update(zip(records._ids, values)) def remove(self, record, field): """ Remove the value of ``field`` for ``record``. """ - key = record.env.cache_key(field) - del self._data[key][field][record.id] - - def contains_value(self, record, field): - """ Return whether ``record`` has a regular value for ``field``. """ - key = record.env.cache_key(field) - value = self._data[key][field].get(record.id, SpecialValue(None)) - return not isinstance(value, SpecialValue) - - def get_value(self, record, field, default=None): - """ Return the regular value of ``field`` for ``record``. """ - key = record.env.cache_key(field) - value = self._data[key][field].get(record.id, SpecialValue(None)) - return default if isinstance(value, SpecialValue) else value - - def get_values(self, records, field, default=None): - """ Return the regular values of ``field`` for ``records``. """ - key = records.env.cache_key(field) - field_cache = self._data[key][field] + try: + del self._data[field][record.id] + except KeyError: + pass + + def get_values(self, records, field): + """ Return the cached values of ``field`` for ``records``. """ + field_cache = self._data[field] + key = self._get_context_key(records.env, field) if field.depends_context else None for record_id in records._ids: - value = field_cache.get(record_id, SpecialValue(None)) - yield default if isinstance(value, SpecialValue) else value - - def get_special(self, record, field, default=None): - """ Return the special value of ``field`` for ``record``. """ - key = record.env.cache_key(field) - value = self._data[key][field].get(record.id) - return value.get if isinstance(value, SpecialValue) else default - - def set_special(self, record, field, getter): - """ Set the value of ``field`` for ``record`` to return ``getter()``. """ - key = record.env.cache_key(field) - self._data[key][field][record.id] = SpecialValue(getter) - - def set_failed(self, records, fields, exception): - """ Mark ``fields`` on ``records`` with the given exception. """ - def getter(): - raise exception - for field in fields: - for record in records: - self.set_special(record, field, getter) + try: + if key: + yield field_cache[record_id][key] + else: + yield field_cache[record_id] + except KeyError: + pass def get_fields(self, record): """ Return the fields with a value for ``record``. """ for name, field in record._fields.items(): - key = record.env.cache_key(field) - if name != 'id' and record.id in self._data[key].get(field, ()): + values = self._data.get(field, ()) + key = self._get_context_key(record.env, field) if field.depends_context else None + if name != 'id' and record.id in values and (not key or key in values[record.id]): yield field def get_records(self, model, field): """ Return the records of ``model`` that have a value for ``field``. """ - key = model.env.cache_key(field) - ids = list(self._data[key][field]) + ids = list(self._data[field]) return model.browse(ids) def get_missing_ids(self, records, field): """ Return the ids of ``records`` that have no value for ``field``. """ - key = records.env.cache_key(field) - field_cache = self._data[key][field] + field_cache = self._data[field] for record_id in records._ids: if record_id not in field_cache: yield record_id - def copy(self, records, env): - """ Copy the cache of ``records`` to ``env``. """ - src, dst = records.env, env - for src_key, dst_key in [(src, dst), (src._cache_key, dst._cache_key)]: - if src_key == dst_key: - break - src_cache = self._data[src_key] - dst_cache = self._data[dst_key] - for field, src_field_cache in src_cache.items(): - dst_field_cache = dst_cache[field] - for record_id, value in src_field_cache.items(): - if not isinstance(value, SpecialValue): - # But not if it's a SpecialValue, which often is an access error - # because the other environment (eg. sudo()) is well expected to have access. - dst_field_cache[record_id] = value - def invalidate(self, spec=None): """ Invalidate the cache, partially or totally depending on ``spec``. """ if spec is None: @@ -801,25 +791,25 @@ class Cache(object): elif spec: for field, ids in spec: if ids is None: - for data in self._data.values(): - data.pop(field, None) + self._data.pop(field, None) else: - for data in self._data.values(): - field_cache = data.get(field) - if field_cache: - for id in ids: - field_cache.pop(id, None) + field_cache = self._data.get(field) + if field_cache: + for id in ids: + field_cache.pop(id, None) def check(self, env): """ Check the consistency of the cache for the given environment. """ + # flush fields to be recomputed before evaluating the cache + env['res.partner'].recompute() + # make a full copy of the cache, and invalidate it dump = defaultdict(dict) - for key in [env, env._cache_key]: - key_cache = self._data[key] - for field, field_cache in key_cache.items(): - for record_id, value in field_cache.items(): - if record_id: - dump[field][record_id] = value + key_cache = self._data + for field, field_cache in key_cache.items(): + for record_id, value in field_cache.items(): + if record_id: + dump[field][record_id] = value self.invalidate() @@ -830,12 +820,21 @@ class Cache(object): for record in records: try: cached = field_dump[record.id] - cached = cached.get() if isinstance(cached, SpecialValue) else cached - value = field.convert_to_record(cached, record) - fetched = record[field.name] - if fetched != value: - info = {'cached': value, 'fetched': fetched} - invalids.append((record, field, info)) + if field.depends_context: + for context_keys, value in cached.items(): + context = dict(zip(field.depends_context, context_keys)) + value = field.convert_to_record(value, record) + fetched = record.with_context(context)[field.name] + if fetched != value: + info = {'cached': value, 'fetched': fetched} + invalids.append((record, field, info)) + else: + cached = field_dump[record.id] + fetched = record[field.name] + value = field.convert_to_record(cached, record) + if fetched != value: + info = {'cached': value, 'fetched': fetched} + invalids.append((record, field, info)) except (AccessError, MissingError): pass @@ -843,14 +842,6 @@ class Cache(object): raise UserError('Invalid cache for fields\n' + pformat(invalids)) -class SpecialValue(object): - """ Wrapper for a function to get the cached value of a field. """ - __slots__ = ['get'] - - def __init__(self, getter): - self.get = getter - - # keep those imports here in order to handle cyclic dependencies correctly from odoo import SUPERUSER_ID from odoo.exceptions import UserError, AccessError, MissingError diff --git a/odoo/fields.py b/odoo/fields.py index daa28dff6e1..e8c1d4bbb84 100644 --- a/odoo/fields.py +++ b/odoo/fields.py @@ -41,9 +41,6 @@ _schema = logging.getLogger(__name__[:-7] + '.schema') Default = object() # default value for __init__() methods -def copy_cache(records, env): - """ Recursively copy the cache of ``records`` to the environment ``env``. """ - env.cache.copy(records, env) def first(records): """ Return the first record in ``records``, with the same prefetching. """ @@ -157,8 +154,7 @@ class Field(MetaField('DummyField', (object,), {})): default ``False`` on computed fields) :param compute_sudo: whether the field should be recomputed as superuser - to bypass access rights (boolean, by default ``False``) - Note that this has no effects on non-stored computed fields + to bypass access rights (boolean, by default ``True``) The methods given for ``compute``, ``inverse`` and ``search`` are model methods. Their signature is shown in the following example:: @@ -291,13 +287,13 @@ class Field(MetaField('DummyField', (object,), {})): 'manual': False, # whether the field is a custom field 'copy': True, # whether the field is copied over by BaseModel.copy() 'depends': None, # collection of field dependencies + 'depends_context': None, # collection of context key dependencies 'recursive': False, # whether self depends on itself 'compute': None, # compute(recs) computes field on recs - 'compute_sudo': False, # whether field should be recomputed as admin + 'compute_sudo': True, # whether field should be recomputed as superuser 'inverse': None, # inverse(recs) inverses field on recs 'search': None, # search(recs, operator, value) searches on self 'related': None, # sequence of field names, for related fields - 'related_sudo': True, # whether related fields should be read as admin 'company_dependent': False, # whether ``self`` is company-dependent (property field) 'default': None, # default(recs) returns the default value @@ -314,7 +310,6 @@ class Field(MetaField('DummyField', (object,), {})): 'group_operator': None, # operator for aggregating values 'group_expand': None, # name of method to expand groups in read_group() 'prefetch': True, # whether the field is prefetched - 'context_dependent': False, # whether the field's value depends on context } def __init__(self, string=Default, **kwargs): @@ -416,7 +411,6 @@ class Field(MetaField('DummyField', (object,), {})): attrs['store'] = attrs.get('store', False) attrs['copy'] = attrs.get('copy', False) attrs['readonly'] = attrs.get('readonly', not attrs.get('inverse')) - attrs['context_dependent'] = attrs.get('context_dependent', True) if attrs.get('related'): # by default, related fields are not stored and not copied attrs['store'] = attrs.get('store', False) @@ -431,12 +425,14 @@ class Field(MetaField('DummyField', (object,), {})): if not attrs.get('readonly'): attrs['inverse'] = self._inverse_company_dependent attrs['search'] = self._search_company_dependent - attrs['context_dependent'] = attrs.get('context_dependent', True) + attrs['depends_context'] = attrs.get('depends_context', ()) + ('force_company',) if attrs.get('translate'): # by default, translatable fields are context-dependent - attrs['context_dependent'] = attrs.get('context_dependent', True) + attrs['depends_context'] = attrs.get('depends_context', ()) + ('lang',) if 'depends' in attrs: attrs['depends'] = tuple(attrs['depends']) + if 'related_sudo' in attrs: + attrs['compute_sudo'] = attrs['related_sudo'] return attrs @@ -498,6 +494,9 @@ class Field(MetaField('DummyField', (object,), {})): deps = getattr(func, '_depends', ()) return deps(model) if callable(deps) else deps + def get_depends_context(func): + return getattr(func, '_depends_context', ()) + if isinstance(self.compute, str): # if the compute method has been overridden, concatenate all their _depends self.depends = tuple( @@ -505,8 +504,16 @@ class Field(MetaField('DummyField', (object,), {})): for method in resolve_mro(model, self.compute, callable) for dep in get_depends(method) ) + depends_context = tuple( + dep + for method in resolve_mro(model, self.compute, callable) + for dep in get_depends_context(method) + ) else: self.depends = tuple(get_depends(self.compute)) + depends_context = tuple(get_depends_context(self.compute)) + + self.depends_context = (self.depends_context or ()) + depends_context def _setup_regular_full(self, model): """ Setup the inverse field(s) of ``self``. """ @@ -565,6 +572,9 @@ class Field(MetaField('DummyField', (object,), {})): if self.inherited: self._modules.update(field._modules) + if field.depends_context: + self.depends_context = field.depends_context + def traverse_related(self, record): """ Traverse the fields of the related field `self` except for the last one, and return it as a pair `(last_record, last_field)`. """ @@ -574,11 +584,6 @@ class Field(MetaField('DummyField', (object,), {})): def _compute_related(self, records): """ Compute the related field ``self`` on ``records``. """ - # when related_sudo, bypass access rights checks when reading values - others = records.sudo() if self.related_sudo else records - # copy the cache of draft records into others' cache - if not all(records._ids) and records.env != others.env: - copy_cache(records - records.filtered('id'), others.env) # # Traverse fields one by one for all records, in order to take advantage # of prefetching for each field access. In order to clarify the impact @@ -605,7 +610,7 @@ class Field(MetaField('DummyField', (object,), {})): # the case where 'bar' is a computed field that takes advantage of batch # computation. # - values = list(others) + values = list(records) for name in self.related[:-1]: try: values = [first(value[name]) for value in values] @@ -627,9 +632,11 @@ class Field(MetaField('DummyField', (object,), {})): # store record values, otherwise they may be lost by cache invalidation! record_value = {record: record[self.name] for record in records} for record in records: - other, field = self.traverse_related(record) - if other: - other[field.name] = record_value[record] + target, field = self.traverse_related(record) + # update 'target' only if 'record' and 'target' are both real or + # both new (see `test_base_objects.py`, `test_basic`) + if target and bool(target.id) == bool(record.id): + target[field.name] = record_value[record] def _search_related(self, records, operator, value): """ Determine the domain to search on field ``self``. """ @@ -641,7 +648,6 @@ class Field(MetaField('DummyField', (object,), {})): _related_help = property(attrgetter('help')) _related_groups = property(attrgetter('groups')) _related_group_operator = property(attrgetter('group_operator')) - _related_context_dependent = property(attrgetter('context_dependent')) @property def base_field(self): @@ -686,68 +692,59 @@ class Field(MetaField('DummyField', (object,), {})): # # Setup of field triggers # - # The triggers of ``self`` are a collection of pairs ``(field, path)`` of - # fields that depend on ``self``. When ``self`` is modified, it invalidates - # the cache of each ``field``, and determines the records to recompute based - # on ``path``. See method ``modified`` below for details. + # The triggers of a field F is a tree that contains the fields that depend + # on F, together with the fields to inverse to find out which records to + # recompute. # + # For instance, assume that G depends on F, H depends on X.F, I depends on + # W.X.F, and J depends on Y.F. The triggers of F will be the tree: + # + # [G] + # X/ \Y + # [H] [J] + # W/ + # [I] + # + # This tree provides perfect support for the trigger mechanism: + # when F is # modified on records, + # - mark G to recompute on records, + # - mark H to recompute on inverse(X, records), + # - mark I to recompute on inverse(W, inverse(X, records)), + # - mark J to recompute on inverse(Y, records). - def resolve_deps(self, model, path0=[], seen=frozenset()): - """ Return the dependencies of ``self`` as tuples ``(model, field, path)``, - where ``path`` is an optional list of field names. - """ - model0 = model - result = [] + def setup_triggers(self, model): + def add_trigger(field, path): + """ add a trigger on field to recompute self """ + field_model = model.env[field.model_name] + # trigger computations depending on one2many fields only at creation + nodes = [field_model._field_triggers_create.setdefault(field, {})] + if (field.type != 'one2many') or not field_model._field_inverses[field]: + nodes.append(field_model._field_triggers.setdefault(field, {})) + for node in nodes: + for f in reversed(path): + node = node.setdefault(f, {}) + node.setdefault(None, []).append(self) - # add self's own dependencies for dotnames in self.depends: - if dotnames == self.name: - _logger.warning("Field %s depends on itself; please fix its decorator @api.depends().", self) - model, path = model0, path0 + field_model = model + path = [] # fields from model to field_model for fname in dotnames.split('.'): - field = model._fields.get(fname) - if field is None: - raise KeyError(_("Field %s depends on unknown field %s") % (self, dotnames)) - result.append((model, field, path)) - model = model0.env.get(field.comodel_name) - path = None if path is None else path + [fname] - - # add self's model dependencies - for mname, fnames in model0._depends.items(): - model = model0.env[mname] - for fname in fnames: - field = model._fields[fname] - result.append((model, field, None)) - - # add indirect dependencies from the dependencies found above - seen = seen.union([self]) - for model, field, path in list(result): - # Fields that depend on the inverse of a one2many do not explicitly - # depend on the one2many. This avoids useless recomputations when - # writing on the one2many without actually modifying it. Actual - # modifications do write on the inverse, and therefore trigger the - # expected recomputations. - if field.type in ('one2many', 'many2many'): - for inv_field in model._field_inverses[field]: - inv_model = model0.env[inv_field.model_name] - inv_path = None if path is None else path + [field.name] - result.append((inv_model, inv_field, inv_path)) - if not field.store and field not in seen: - result += field.resolve_deps(model, path, seen) + field = field_model._fields[fname] + # Do not make self trigger itself + # e.g. `fields.One2many('stock.move.line', 'move_id', domain=[('product_qty', '=', 0.0)])` + # will have 'move_line_nosuggest_ids.product_qty' as a dependency + if (field is not self) or path: + add_trigger(field, path) - return result + if (field is self) and path: + self.recursive = True - def setup_triggers(self, model): - """ Add the necessary triggers to invalidate/recompute ``self``. """ - for model, field, path in self.resolve_deps(model): - if self.store and not field.store: - _logger.info("Field %s depends on non-stored field %s", self, field) - if field is not self: - path_str = None if path is None else ('.'.join(path) or 'id') - model._field_triggers.add(field, (self, path_str)) - elif path: - self.recursive = True - model._field_triggers.add(field, (self, '.'.join(path))) + path.append(field) + if field.type in ('one2many', 'many2many'): + for inv_field in field_model._field_inverses[field]: + add_trigger(inv_field, path) + + field_model = model.env.get(field.comodel_name) ############################################################################ # @@ -833,7 +830,7 @@ class Field(MetaField('DummyField', (object,), {})): If the value represents a recordset, it should share the prefetching of ``record``. """ - return value + return False if value is None else value def convert_to_read(self, value, record, use_name_get=True): """ Convert ``value`` from the record format to the format returned by @@ -845,10 +842,12 @@ class Field(MetaField('DummyField', (object,), {})): return False if value is None else value def convert_to_write(self, value, record): - """ Convert ``value`` from the record format to the format of method + """ Convert ``value`` from any format to the format of method :meth:`BaseModel.write`. """ - return self.convert_to_read(value, record) + cache_value = self.convert_to_cache(value, record, validate=False) + record_value = self.convert_to_record(cache_value, record) + return self.convert_to_read(record_value, record) def convert_to_onchange(self, value, record, names): """ Convert ``value`` from the record format to the format returned by @@ -928,6 +927,8 @@ class Field(MetaField('DummyField', (object,), {})): # the column is new or it becomes required; initialize its values if model._table_has_rows(): model._init_column(self.name) + # flush values before adding NOT NULL constraint + model.flush([self.name]) if self.required and not has_notnull: sql.set_not_null(model._cr, model._table, self.name) @@ -970,11 +971,32 @@ class Field(MetaField('DummyField', (object,), {})): self.write(record, value) def write(self, records, value): - """ Write the value of ``self`` on ``records``. + """ Write the value of ``self`` on ``records``. This method must update + the cache and prepare database updates. - :param value: a value in the format of method :meth:`BaseModel.write` + :param value: a value in any format + :return: the subset of `records` that have been modified """ - return NotImplementedError("Method write() undefined on %s" % self) + # discard recomputation of self on records + records.env.remove_to_compute(self, records) + + # update the cache, and discard the records that are not modified + cache = records.env.cache + NOTHING = object() + cache_value = self.convert_to_cache(value, records) + records = records.filtered(lambda record: cache.get(record, self, NOTHING) != cache_value) + if not records: + return records + cache.update(records, self, [cache_value] * len(records)) + + # update towrite + if self.store: + towrite = records.env.all.towrite[self.model_name] + column_value = self.convert_to_column(cache_value, records) + for record in records.filtered('id'): + towrite[record.id][self.name] = column_value + + return records ############################################################################ # @@ -986,174 +1008,126 @@ class Field(MetaField('DummyField', (object,), {})): if record is None: return self # the field is accessed through the owner class - if record._ids: - # only a single record may be accessed - record.ensure_one() - try: - value = record.env.cache.get(record, self) - except KeyError: - # cache miss, determine value and retrieve it - if record.id: - self.determine_value(record) - else: - self.determine_draft_value(record) - value = record.env.cache.get(record, self) - else: + if not record._ids: # null record -> return the null value for this field value = self.convert_to_cache(False, record, validate=False) + return self.convert_to_record(value, record) - return self.convert_to_record(value, record) - - def __set__(self, record, value): - """ set the value of field ``self`` on ``record`` """ env = record.env - # only a single record may be updated + # only a single record may be accessed record.ensure_one() - # adapt value to the cache level - value = self.convert_to_cache(value, record) + if self.compute and (record.id in env.all.tocompute.get(self, ())) \ + and not env.is_protected(self, record): + # self must be computed on record + recs = record if self.recursive else env.records_to_compute(self) + try: + self.compute_value(recs) + except AccessError: + self.compute_value(record) - if env.in_draft or not record.id: - # determine dependent fields - spec = self.modified_draft(record) + try: + value = env.cache.get(record, self) - # set value in cache - record.env.cache.set(record, self, value) + except KeyError: + # real record + if record.id and self.store: + recs = record._in_cache_without(self) + try: + recs._fetch_field(self) + except AccessError: + record._fetch_field(self) + if not env.cache.contains(record, self) and not record.exists(): + raise MissingError("\n".join([ + _("Record does not exist or has been deleted."), + _("(Record: %s, User: %s)") % (record, env.uid), + ])) + value = env.cache.get(record, self) + + elif self.compute: + if env.is_protected(self, record): + value = self.convert_to_cache(False, record, validate=False) + env.cache.set(record, self, value) + else: + recs = record if self.recursive or not record.id else record._in_cache_without(self) + try: + self.compute_value(recs) + except AccessError: + self.compute_value(record) + value = env.cache.get(record, self) - if not record.id: - # set inverse fields on new records in the comodel - if self.relational: - inv_recs = record[self.name].filtered(lambda r: not r.id) - if inv_recs: - for invf in record._field_inverses[self]: - invf._update(inv_recs, record) + elif (not record.id) and record._origin: + value = self.convert_to_cache(record._origin[self.name], record) + env.cache.set(record, self, value) - # determine more dependent fields, and invalidate them - if self.relational: - spec += self.modified_draft(record) - env.cache.invalidate(spec) + elif (not record.id) and self.type == 'many2one' and self.delegate: + # special case: parent records are new as well + parent = record.env[self.comodel_name].new() + value = self.convert_to_cache(parent, record) + env.cache.set(record, self, value) - elif (self.store or self.inverse or self.inherited): - # Write to database - write_value = self.convert_to_write(self.convert_to_record(value, record), record) - record.write({self.name: write_value}) + else: + value = self.convert_to_cache(False, record, validate=False) + env.cache.set(record, self, value) + defaults = record.default_get([self.name]) + if self.name in defaults: + # The null value above is necessary to convert x2many field values. + # For instance, converting [(4, id)] accesses the field's current + # value, then adds the given id. Without an initial value, the + # conversion ends up here to determine the field's value, and this + # generates an infinite recursion. + value = self.convert_to_cache(defaults[self.name], record) + env.cache.set(record, self, value) - else: - # Update the cache unless value contains a new record - if not (self.relational and not all(value)): - record.env.cache.set(record, self, value) + return self.convert_to_record(value, record) + + def __set__(self, records, value): + """ set the value of field ``self`` on ``records`` """ + protected_records = records & records.env.protected(self) + if protected_records: + # records being computed: no business logic, no recomputation + self.write(protected_records, value) + records -= protected_records + + new_records = records.filtered(lambda record: not record.id) + if new_records: + # new records: no business logic + with records.env.protecting(records._field_computed.get(self, [self]), records): + new_records.modified([self.name], create=True) + self.write(new_records, value) + if self.relational: + new_records.modified([self.name]) + records -= new_records + + if records: + # base case: full business logic + write_value = self.convert_to_write(value, records) + records.write({self.name: write_value}) ############################################################################ # # Computation of field values # - def _compute_value(self, records): - """ Invoke the compute method on ``records``. """ - # initialize the fields to their corresponding null value in cache - fields = records._field_computed[self] - cache = records.env.cache - for field in fields: - for record in records: - cache.set(record, field, field.convert_to_cache(False, record, validate=False)) - if isinstance(self.compute, str): - getattr(records, self.compute)() - else: - self.compute(records) - def compute_value(self, records): """ Invoke the compute method on ``records``; the results are in cache. """ + env = records.env + if self.compute_sudo: + records = records.sudo() fields = records._field_computed[self] - with records.env.do_in_draft(), records.env.protecting(fields, records): - try: - self._compute_value(records) - except (AccessError, MissingError): - # some record is forbidden or missing, retry record by record - for record in records: - try: - self._compute_value(record) - except Exception as exc: - record.env.cache.set_failed(record, [self], exc) - def determine_value(self, record): - """ Determine the value of ``self`` for ``record``. """ - env = record.env - - if self.store: - # this is a stored field or an old-style function field - if self.compute: - # this is a stored computed field, check for recomputation - recs = record._recompute_check(self) - if recs: - # recompute the value (only in cache) - if self.recursive: - recs = record - self.compute_value(recs) - # HACK: if result is in the wrong cache, copy values - if recs.env != env: - computed = record._field_computed[self] - for source, target in zip(recs, recs.with_env(env)): - try: - values = {f.name: source[f.name] for f in computed} - target._update_cache(values, validate=False) - except MissingError as exc: - target._cache.set_failed(target._fields, exc) - # the result is saved to database by BaseModel.recompute() - return - - # read the field from database - record._prefetch_field(self) - - elif self.compute: - # this is either a non-stored computed field, or a stored computed - # field in onchange mode - if self.recursive: - self.compute_value(record) - else: - recs = record._in_cache_without(self) - self.compute_value(recs) - - else: - # this is a non-stored non-computed field - record.env.cache.set(record, self, self.convert_to_cache(False, record, validate=False)) - - def determine_draft_value(self, record): - """ Determine the value of ``self`` for the given draft ``record``. """ - if self.compute: - if self.compute_sudo and not record.env.su: - record_sudo = record.sudo() - copy_cache(record, record_sudo.env) - self.compute_value(record_sudo) - record[self.name] = record_sudo[self.name] - else: - self.compute_value(record) - return + # just in case the compute method does not assign a value + for field in fields: + env.remove_to_compute(field, records) - origin = record._origin - if origin: - # retrieve value from original record - value = self.convert_to_cache(origin[self.name], record) - return record.env.cache.set(record, self, value) - - if self.type == 'many2one' and self.delegate: - # special case: parent records are new as well - parent = record.env[self.comodel_name].new() - value = self.convert_to_cache(parent, record) - return record.env.cache.set(record, self, value) - - null = self.convert_to_cache(False, record, validate=False) - record.env.cache.set_special(record, self, lambda: null) - - defaults = record.default_get([self.name]) - if self.name in defaults: - # The null value above is necessary to convert x2many field values. - # For instance, converting [(4, id)] accesses the field's current - # value, then adds the given id. Without an initial value, the - # conversion ends up here to determine the field's value, and this - # generates an infinite recursion. - value = self.convert_to_cache(defaults[self.name], record) - return record.env.cache.set(record, self, value) + try: + with records.env.protecting(fields, records): + records._compute_field_value(self) + except Exception: + for field in fields: + env.add_to_compute(field, records) + raise def determine_inverse(self, records): """ Given the value of ``self`` on ``records``, inverse the computation. """ @@ -1174,33 +1148,6 @@ class Field(MetaField('DummyField', (object,), {})): # Notification when fields are modified # - def modified_draft(self, record): - """ Same as :meth:`modified`, but in draft mode. """ - env = record.env - - # invalidate the fields on the records in cache that depend on - # ``record``, except fields currently being computed - spec = [] - for field, path in record._field_triggers[self]: - if not field.compute: - # Note: do not invalidate non-computed fields. Such fields may - # require invalidation in general (like *2many fields with - # domains) but should not be invalidated in this case, because - # we would simply lose their values during an onchange! - continue - - if path == 'id' and field.model_name == record._name: - target = record - else: - target = env.cache.get_records(env[field.model_name], field) - target = target.filtered(lambda t: not t.id) - target -= env.protected(field) - - if target: - spec.append((field, target._ids)) - - return spec - class Boolean(Field): type = 'boolean' @@ -1229,9 +1176,12 @@ class Integer(Field): def convert_to_cache(self, value, record, validate=True): if isinstance(value, dict): # special case, when an integer field is used as inverse for a one2many - return value.get('id', False) + return value.get('id', None) return int(value or 0) + def convert_to_record(self, value, record): + return value or 0 + def convert_to_read(self, value, record, use_name_get=True): # Integer values greater than 2^31-1 are not supported in pure XMLRPC, # so we have to pass them as floats :-( @@ -1305,6 +1255,9 @@ class Float(Field): digits = self.get_digits(record.env) return float_round(value, precision_digits=digits[1]) if digits else value + def convert_to_record(self, value, record): + return value or 0.0 + def convert_to_export(self, value, record): if value or value == 0.0: return value @@ -1375,6 +1328,9 @@ class Monetary(Field): value = record[self.currency_field].round(value) return value + def convert_to_record(self, value, record): + return value or 0.0 + def convert_to_read(self, value, record, use_name_get=True): return value @@ -1437,6 +1393,82 @@ class _String(Field): else: return value + def write(self, records, value): + # discard recomputation of self on records + records.env.remove_to_compute(self, records) + + # update the cache, and discard the records that are not modified + cache = records.env.cache + NOTHING = object() + cache_value = self.convert_to_cache(value, records) + records = records.filtered(lambda record: cache.get(record, self, NOTHING) != cache_value) + if not records: + return records + cache.update(records, self, [cache_value] * len(records)) + + if not self.store: + return records + + update_column = True + update_trans = False + single_lang = len(records.env['res.lang'].get_installed()) <= 1 + if self.translate: + lang = records.env.lang or 'en_US' + if single_lang: + # a single language is installed + update_trans = True + elif callable(self.translate) or lang == 'en_US': + # update the source and synchronize translations + update_column = True + update_trans = True + elif lang != 'en_US': + # update the translations only + update_column = False + update_trans = True + + # update towrite if modifying the source + if update_column: + towrite = records.env.all.towrite[self.model_name] + column_value = self.convert_to_column(cache_value, records) + for record in records.filtered('id'): + towrite[record.id][self.name] = column_value + if self.translate is True: + tname = "%s,%s" % (records._name, self.name) + records.env['ir.translation']._set_source(tname, records.ids, value) + + if update_trans: + if callable(self.translate): + # the source value of self has been updated, synchronize + # translated terms when possible + real_recs = records.filtered('id') + records.env['ir.translation']._sync_terms_translations(self, records) + + else: + # update translations + value = self.convert_to_column(value, records) + real_recs = records.filtered('id') + source_recs = real_recs.with_context(lang='en_US') + source_value = first(source_recs)[self.name] + if not source_value: + source_recs[self.name] = value + source_value = value + tname = "%s,%s" % (self.model_name, self.name) + if single_lang: + records.env['ir.translation']._update_translations([dict( + src=source_value, + value=value, + name=tname, + lang=lang, + type='model', + state='translated', + res_id=res_id) for res_id in real_recs.ids]) + else: + records.env['ir.translation']._set_ids( + tname, 'model', lang, real_recs.ids, value, source_value, + ) + + return records + class Char(_String): """ Basic string field, can be length-limited, usually displayed as a @@ -1492,7 +1524,7 @@ class Char(_String): def convert_to_cache(self, value, record, validate=True): if value is None or value is False: - return False + return None return pycompat.to_text(value)[:self.size] @@ -1512,7 +1544,7 @@ class Text(_String): def convert_to_cache(self, value, record, validate=True): if value is None or value is False: - return False + return None return ustr(value) @@ -1563,7 +1595,7 @@ class Html(_String): def convert_to_cache(self, value, record, validate=True): if value is None or value is False: - return False + return None if validate and self.sanitize: return html_sanitize( value, silent=True, @@ -1660,10 +1692,12 @@ class Date(Field): def convert_to_cache(self, value, record, validate=True): if not value: - return False + return None if isinstance(value, datetime): - raise TypeError("%s (field %s) must be string or date, not datetime." % (value, self)) - return self.from_string(value) + # TODO: better fix data files (crm demo data) + value = value.date() + # raise TypeError("%s (field %s) must be string or date, not datetime." % (value, self)) + return self.to_date(value) def convert_to_export(self, value, record): if not value: @@ -1747,10 +1781,9 @@ class Datetime(Field): raise ValueError("Datetime field expects a naive datetime: %s" % value) return value return datetime.combine(value, time.min) - try: - return datetime.strptime(value[:DATETIME_LENGTH], DATETIME_FORMAT) - except ValueError: - return datetime.strptime(value, DATE_FORMAT) + + # TODO: fix data files + return datetime.strptime(value, DATETIME_FORMAT[:len(value)-2]) # kept for backwards compatibility, but consider `from_string` as deprecated, will probably # be removed after V12 @@ -1769,11 +1802,7 @@ class Datetime(Field): return value.strftime(DATETIME_FORMAT) if value else False def convert_to_cache(self, value, record, validate=True): - if not value: - return False - if isinstance(value, date) and not isinstance(value, datetime): - raise TypeError("%s (field %s) must be string or datetime, not date." % (value, self)) - return self.from_string(value) + return self.to_datetime(value) def convert_to_export(self, value, record): if not value: @@ -1793,9 +1822,9 @@ _BINARY = memoryview class Binary(Field): type = 'binary' _slots = { - 'prefetch': False, # not prefetched by default - 'context_dependent': True, # depends on context (content or size) - 'attachment': True, # whether value is stored in attachment + 'prefetch': False, # not prefetched by default + 'depends_context': ('bin_size',), # depends on context (content or size) + 'attachment': True, # whether value is stored in attachment } @property @@ -1831,6 +1860,10 @@ class Binary(Field): def convert_to_cache(self, value, record, validate=True): if isinstance(value, _BINARY): return bytes(value) + if isinstance(value, str): + # the cache must contain bytes or memoryview, but sometimes a string + # is given when assigning a binary field (test `TestFileSeparator`) + return value.encode() if isinstance(value, int) and \ (record._context.get('bin_size') or record._context.get('bin_size_' + self.name)): @@ -1838,7 +1871,12 @@ class Binary(Field): # instead of the content. Presumably a separate request will be done # to read the actual content, if necessary. return human_size(value) - return value + return None if value is False else value + + def convert_to_record(self, value, record): + if isinstance(value, _BINARY): + return bytes(value) + return False if value is None else value def read(self, records): # values are stored in attachments, retrieve them @@ -1877,20 +1915,35 @@ class Binary(Field): ]) def write(self, records, value): - assert self.attachment + if not self.attachment: + return super().write(records, value) + + # discard recomputation of self on records + records.env.remove_to_compute(self, records) + + # update the cache, and discard the records that are not modified + cache = records.env.cache + NOTHING = object() + cache_value = self.convert_to_cache(value, records) + records = records.filtered(lambda record: cache.get(record, self, NOTHING) != cache_value) + if not records: + return records + cache.update(records, self, [cache_value] * len(records)) + # retrieve the attachments that store the values, and adapt them - atts = records.env['ir.attachment'].sudo().search([ - ('res_model', '=', self.model_name), - ('res_field', '=', self.name), - ('res_id', 'in', records.ids), - ]) - with records.env.norecompute(): + if self.store: + atts = records.env['ir.attachment'].sudo().search([ + ('res_model', '=', self.model_name), + ('res_field', '=', self.name), + ('res_id', 'in', records.ids), + ]) if value: # update the existing attachments atts.write({'datas': value}) atts_records = records.browse(atts.mapped('res_id')) # create the missing attachments - if len(atts_records) < len(records): + missing = (records - atts_records).filtered('id') + if missing: atts.create([{ 'name': self.name, 'res_model': record._name, @@ -1899,11 +1952,13 @@ class Binary(Field): 'type': 'binary', 'datas': value, } - for record in (records - atts_records) + for record in missing ]) else: atts.unlink() + return records + class Image(Binary): _slots = { @@ -1914,12 +1969,17 @@ class Image(Binary): def create(self, record_values): new_record_values = [] for record, value in record_values: - new_record_values.append((record, self._image_process(value))) + # strange behavior when setting related image field, when `self` + # does not resize the same way as its related field + new_value = self._image_process(value) + new_record_values.append((record, new_value)) + record.env.cache.update(record, self, [value if self.related else new_value] * len(record)) super(Image, self).create(new_record_values) def write(self, records, value): - value = self._image_process(value) - super(Image, self).write(records, value) + new_value = self._image_process(value) + super(Image, self).write(records, new_value) + records.env.cache.update(records, self, [value if self.related else new_value] * len(records)) def _image_process(self, value): if value and (self.max_width or self.max_height): @@ -2031,13 +2091,13 @@ class Selection(Field): def convert_to_cache(self, value, record, validate=True): if not validate: - return value or False + return value or None if value and self.column_type[0] == 'int4': value = int(value) if value in self.get_values(record.env): return value elif not value: - return False + return None raise ValueError("Wrong value for %s: %r" % (self, value)) def convert_to_export(self, value, record): @@ -2061,22 +2121,26 @@ class Reference(Selection): return Field.convert_to_column(self, value, record, values, validate) def convert_to_cache(self, value, record, validate=True): - # cache format: (res_model, res_id) or False + # cache format: str ("model,id") or None if isinstance(value, BaseModel): if not validate or (value._name in self.get_values(record.env) and len(value) <= 1): - return (value._name, value.id) if value else False + return "%s,%s" % (value._name, value.id) if value else None elif isinstance(value, str): res_model, res_id = value.split(',') - if record.env[res_model].browse(int(res_id)).exists(): - return (res_model, int(res_id)) - else: - return False + if not validate or res_model in self.get_values(record.env): + if record.env[res_model].browse(int(res_id)).exists(): + return value + else: + return None elif not value: - return False + return None raise ValueError("Wrong value for %s: %r" % (self, value)) def convert_to_record(self, value, record): - return value and record.env[value[0]].browse([value[1]]) + if value: + res_model, res_id = value.split(',') + return record.env[res_model].browse(int(res_id)) + return None def convert_to_read(self, value, record, use_name_get=True): return "%s,%s" % (value._name, value.id) if value else False @@ -2233,31 +2297,32 @@ class Many2one(_Relational): return value or None def convert_to_cache(self, value, record, validate=True): - # cache format: tuple(ids) + # cache format: id or None if type(value) in IdType: - ids = (value,) + id_ = value elif isinstance(value, BaseModel): if validate and (value._name != self.comodel_name or len(value) > 1): raise ValueError("Wrong value for %s: %r" % (self, value)) - ids = value._ids + id_ = value._ids[0] if value._ids else None elif isinstance(value, tuple): # value is either a pair (id, name), or a tuple of ids - ids = value[:1] + id_ = value[0] if value else None elif isinstance(value, dict): - ids = record.env[self.comodel_name].new(value)._ids + id_ = record.env[self.comodel_name].new(value).id else: - ids = () + id_ = None if self.delegate and record and not record.id: # the parent record of a new record is a new record - ids = tuple(it and NewId(it) for it in ids) + id_ = id_ and NewId(id_) - return ids + return id_ def convert_to_record(self, value, record): # use registry to avoid creating a recordset for the model - prefetch_ids = IterableGenerator(prefetch_value_ids, record, self) - return record.pool[self.comodel_name]._browse(record.env, value, prefetch_ids) + ids = () if value is None else (value,) + prefetch_ids = IterableGenerator(prefetch_many2one_ids, record, self) + return record.pool[self.comodel_name]._browse(record.env, ids, prefetch_ids) def convert_to_read(self, value, record, use_name_get=True): if use_name_get and value: @@ -2274,7 +2339,18 @@ class Many2one(_Relational): return value.id def convert_to_write(self, value, record): - return value.id + if type(value) in IdType: + return value + if not value: + return False + if isinstance(value, BaseModel) and value._name == self.comodel_name: + return value.id + if isinstance(value, tuple): + # value is either a pair (id, name), or a tuple of ids + return value[0] if value else False + if isinstance(value, dict): + return record.env[self.comodel_name].new(value).id + raise ValueError("Wrong value for %s: %r" % (self, value)) def convert_to_export(self, value, record): return value.display_name if value else '' @@ -2287,57 +2363,179 @@ class Many2one(_Relational): return False return super(Many2one, self).convert_to_onchange(value, record, names) + def write(self, records, value): + # discard recomputation of self on records + records.env.remove_to_compute(self, records) -class _RelationalMultiUpdate(object): - """ A getter to update the value of an x2many field, without reading its - value until necessary. - """ - __slots__ = ['record', 'field', 'value'] + # discard the records that are not modified + cache = records.env.cache + NOTHING = object() + cache_value = self.convert_to_cache(value, records) + records = records.filtered(lambda record: cache.get(record, self, NOTHING) != cache_value) + if not records: + return records - def __init__(self, record, field, value): - self.record = record - self.field = field - self.value = value + # remove records from the cache of one2many fields of old corecords + self._remove_inverses(records, cache_value) - def __call__(self): - # determine the current field's value, and update it in cache only - record, field, value = self.record, self.field, self.value - cache = record.env.cache - cache.remove(record, field) - val = field.convert_to_cache(record[field.name] | value, record, validate=False) - cache.set(record, field, val) - return val + # update the cache of self + cache.update(records, self, [cache_value] * len(records)) + # update the cache of one2many fields of new corecord + self._update_inverses(records, cache_value) + + # update towrite + if self.store: + towrite = records.env.all.towrite[self.model_name] + write_value = self.convert_to_column(self.convert_to_write(cache_value, records), records) + for record in records.filtered('id'): + towrite[record.id][self.name] = write_value + + return records + + def _remove_inverses(self, records, value): + """ Remove `records` from the cached values of the inverse fields of `self`. """ + cache = records.env.cache + record_ids = set(records._ids) + for invf in records._field_inverses[self]: + corecords = records.env[self.comodel_name].browse( + id_ for id_ in cache.get_values(records, self) + ) + for corecord in corecords: + ids0 = cache.get(corecord, invf, None) + if ids0 is not None: + ids1 = tuple(id_ for id_ in ids0 if id_ not in record_ids) + cache.set(corecord, invf, ids1) + + def _update_inverses(self, records, value): + """ Add `records` to the cached values of the inverse fields of `self`. """ + if value is None: + return + cache = records.env.cache + corecord = self.convert_to_record(value, records) + for invf in records._field_inverses[self]: + valid_records = records.filtered_domain(invf.get_domain_list(corecord)) + if not valid_records: + continue + ids0 = cache.get(corecord, invf, None) + # if the value for the corecord is not in cache, but this is a new + # record, assign it anyway, as you won't be able to fetch it from + # database (see `test_sale_order`) + if ids0 is not None or not corecord.id: + ids1 = tuple(unique((ids0 or ()) + valid_records._ids)) + cache.set(corecord, invf, ids1) + + +class Many2oneReference(Integer): + type = 'many2one_reference' -class _RelationalMulti(_Relational): - """ Abstract class for relational fields *2many. """ _slots = { - 'context_dependent': True, # depends on context (active_test) + 'model_field': None, } + def convert_to_cache(self, value, record, validate=True): + # cache format: id or None + if isinstance(value, BaseModel): + value = value._ids[0] if value._ids else None + + return value + + def _remove_inverses(self, records, value): + # TODO: unused + # remove records from the cache of one2many fields of old corecords + cache = records.env.cache + record_ids = set(records._ids) + model_ids = self._record_ids_per_res_model(records) + + for invf in records._field_inverses[self]: + records = records.browse(model_ids[invf.model_name]) + if not records: + continue + corecords = records.env[invf.model_name].browse( + id_ for id_ in cache.get_values(records, self) + ) + for corecord in corecords: + ids0 = cache.get(corecord, invf, None) + if ids0 is not None: + ids1 = tuple(id_ for id_ in ids0 if id_ not in record_ids) + cache.set(corecord, invf, ids1) + + def _update_inverses(self, records, value): + """ Add `records` to the cached values of the inverse fields of `self`. """ + cache = records.env.cache + model_ids = self._record_ids_per_res_model(records) + + for invf in records._field_inverses[self]: + records = records.browse(model_ids[invf.model_name]) + if not records: + continue + corecord = records.env[invf.model_name].browse(value) + records = records.filtered_domain(invf.get_domain_list(corecord)) + if not records: + continue + ids0 = cache.get(corecord, invf, None) + # if the value for the corecord is not in cache, but this is a new + # record, assign it anyway, as you won't be able to fetch it from + # database (see `test_sale_order`) + if ids0 is not None or not corecord.id: + ids1 = tuple(unique((ids0 or ()) + records._ids)) + cache.set(corecord, invf, ids1) + + def _record_ids_per_res_model(self, records): + model_ids = defaultdict(set) + for record in records: + model = record[self.model_field] + if not model and record._fields[self.model_field].compute: + # fallback when the model field is computed :-/ + record._fields[self.model_field].compute_value(record) + model = record[self.model_field] + if not model: + continue + model_ids[model].add(record.id) + return model_ids + + +class _RelationalMulti(_Relational): + """ Abstract class for relational fields *2many. """ + def _update(self, records, value): - """ Update the cached value of ``self`` for ``records`` with ``value``. """ + """ Update the cached value of ``self`` for ``records`` with ``value``, + and return whether everything is in cache. + """ if not isinstance(records, BaseModel): - # the inverse of self is a non-relational field; do not update in - # this case, as we do not know whether the records are the ones that - # value makes reference to (via a res_model/res_id pair) - return + # the inverse of self is a non-relational field; `value` is a + # corecord that refers to `records` by an integer field + model = value.env[self.model_name] + domain = self.domain(model) if callable(self.domain) else self.domain + if not value.filtered_domain(domain): + return + records = model.browse(records) + cache = records.env.cache - for record in records: - special = cache.get_special(record, self) - if isinstance(special, _RelationalMultiUpdate): - # include 'value' in the existing _RelationalMultiUpdate; this - # avoids reading the field's value (which may be large) - special.value |= value - elif cache.contains(record, self): - try: + result = True + if 'active_test' in (self.depends_context or ()): + updates = [ + (value.sudo().filtered('active'), records.with_context(active_test=True)), + (value, records.with_context(active_test=False)), + ] + else: + updates = [(value, records)] + + for value, recs in updates: + if not value: + continue + for record in recs: + if cache.contains(record, self): val = self.convert_to_cache(record[self.name] | value, record, validate=False) cache.set(record, self, val) - except Exception as exc: - # delay the failure until the field is necessary - cache.set_failed(record, [self], exc) - else: - cache.set_special(record, self, _RelationalMultiUpdate(record, self, value)) + else: + result = False + # DEEP FUCKING HACK TO FIX SOMEDAY: recs.env is different from + # records.env; we must trigger modifications in the original env + recs.env._protected = records.env._protected + recs.modified([self.name]) + + return result def convert_to_cache(self, value, record, validate=True): # cache format: tuple(ids) @@ -2394,60 +2592,49 @@ class _RelationalMulti(_Relational): def convert_to_record(self, value, record): # use registry to avoid creating a recordset for the model - prefetch_ids = IterableGenerator(prefetch_value_ids, record, self) + prefetch_ids = IterableGenerator(prefetch_x2many_ids, record, self) return record.pool[self.comodel_name]._browse(record.env, value, prefetch_ids) def convert_to_read(self, value, record, use_name_get=True): return value.ids def convert_to_write(self, value, record): - inv_names = {field.name for field in record._field_inverses[self]} - # make result with new and existing records - result = [(6, 0, [])] - for record in value: - origin = record._origin - if not origin: - values = record._convert_to_write({ - name: record[name] - for name in record._cache - if name not in inv_names - }) - result.append((0, 0, values)) - else: - result[0][2].append(origin.id) - if record != origin: + if isinstance(value, tuple): + # a tuple of ids, this is the cache format + value = record.env[self.comodel_name].browse(value) + + if isinstance(value, BaseModel) and value._name == self.comodel_name: + # make result with new and existing records + inv_names = {field.name for field in record._field_inverses[self]} + result = [(6, 0, [])] + for record in value: + origin = record._origin + if not origin: values = record._convert_to_write({ name: record[name] for name in record._cache - if name not in inv_names and record[name] != origin[name] + if name not in inv_names }) - if values: - result.append((1, origin.id, values)) - return result + result.append((0, 0, values)) + else: + result[0][2].append(origin.id) + if record != origin: + values = record._convert_to_write({ + name: record[name] + for name in record._cache + if name not in inv_names and record[name] != origin[name] + }) + if values: + result.append((1, origin.id, values)) + return result + + if value is False or value is None: + return [(5,)] + + if isinstance(value, list): + return value - def convert_to_onchange(self, value, record, names): - # return the recordset value as a list of commands; the commands may - # give all fields values, the client is responsible for figuring out - # which fields are actually dirty - vals = {record: {} for record in value} - for name, subnames in names.items(): - if name == 'id': - continue - field = value._fields[name] - # read all values before converting them (better prefetching) - rec_vals = [(rec, rec[name]) for rec in value] - for rec, val in rec_vals: - vals[rec][name] = field.convert_to_onchange(val, rec, subnames) - - result = [(5,)] - for record in value: - if not record.id and not record._origin: - result.append((0, record.id.ref or 0, vals[record])) - elif vals[record]: - result.append((1, record._origin.id, vals[record])) - else: - result.append((4, record._origin.id)) - return result + raise ValueError("Wrong value for %s: %s" % (self, value)) def convert_to_export(self, value, record): return ','.join(name for id, name in value.name_get()) if value else '' @@ -2455,17 +2642,6 @@ class _RelationalMulti(_Relational): def convert_to_display_name(self, value, record): raise NotImplementedError() - def _compute_related(self, records): - """ Compute the related field ``self`` on ``records``. """ - super(_RelationalMulti, self)._compute_related(records) - if self.related_sudo: - # determine which records in the relation are actually accessible - line_ids = set(records[self.name]._filter_access_rules('read')._ids) - accessible = lambda line: line.id in line_ids - # filter values to keep the accessible records only - for record in records: - record[self.name] = record[self.name].filtered(accessible) - def _setup_regular_base(self, model): super(_RelationalMulti, self)._setup_regular_base(model) if isinstance(self.domain, list): @@ -2475,6 +2651,50 @@ class _RelationalMulti(_Relational): if isinstance(arg, (tuple, list)) and isinstance(arg[0], str) ) + def _setup_regular_full(self, model): + super(_RelationalMulti, self)._setup_regular_full(model) + # make self depend on 'active_test' if there is a field 'active' in the comodel + if 'active' in model.env[self.comodel_name] and 'active_test' not in (self.depends_context or ()): + self.depends_context = (self.depends_context or ()) + ('active_test',) + + def create(self, record_values): + """ Write the value of ``self`` on the given records, which have just + been created. + + :param record_values: a list of pairs ``(record, value)``, where + ``value`` is in the format of method :meth:`BaseModel.write` + """ + self.write_batch(record_values, True) + + def write(self, records, value): + # discard recomputation of self on records + records.env.remove_to_compute(self, records) + return self.write_batch([(records, value)]) + + def write_batch(self, records_commands_list, create=False): + if not records_commands_list: + return False + + for idx, (recs, value) in enumerate(records_commands_list): + if isinstance(value, tuple): + value = [(6, 0, value)] + elif isinstance(value, BaseModel) and value._name == self.comodel_name: + value = [(6, 0, value._ids)] + elif value is False or value is None: + value = [(5,)] + elif isinstance(value, list) and value and not isinstance(value[0], (tuple, list)): + value = [(6, 0, tuple(value))] + if not isinstance(value, list): + raise ValueError("Wrong value for %s: %s" % (self, value)) + records_commands_list[idx] = (recs, value) + + record_ids = {rid for recs, cs in records_commands_list for rid in recs._ids} + if all(record_ids): + return self.write_real(records_commands_list, create) + else: + assert not any(record_ids) + return self.write_new(records_commands_list) + class One2many(_RelationalMulti): """ One2many field; the value of such a field is the recordset of all the @@ -2522,7 +2742,7 @@ class One2many(_RelationalMulti): # link self to its inverse field and vice-versa comodel = model.env[self.comodel_name] invf = comodel._fields[self.inverse_name] - if isinstance(invf, Many2one): + if isinstance(invf, (Many2one, Many2oneReference)): # setting one2many fields only invalidates many2one inverses; # integer inverses (res_model/res_id pairs) are not supported model._field_inverses.add(self, invf) @@ -2530,22 +2750,26 @@ class One2many(_RelationalMulti): _description_relation_field = property(attrgetter('inverse_name')) - def convert_to_onchange(self, value, record, names): - names = names.copy() - names.pop(self.inverse_name, None) - return super(One2many, self).convert_to_onchange(value, record, names) - def update_db(self, model, columns): if self.comodel_name in model.env: comodel = model.env[self.comodel_name] if self.inverse_name not in comodel._fields: raise UserError(_("No inverse field %r found for %r") % (self.inverse_name, self.comodel_name)) + def get_domain_list(self, records): + comodel = records.env.registry[self.comodel_name] + inverse_field = comodel._fields[self.inverse_name] + domain = super(One2many, self).get_domain_list(records) + if inverse_field.type == 'many2one_reference': + domain = domain + [(inverse_field.model_field, '=', records._name)] + return domain + def read(self, records): # retrieve the lines in the comodel comodel = records.env[self.comodel_name].with_context(**self.context) inverse = self.inverse_name - get_id = (lambda rec: rec.id) if comodel._fields[inverse].type == 'many2one' else int + inverse_field = comodel._fields[inverse] + get_id = (lambda rec: rec.id) if inverse_field.type == 'many2one' else int domain = self.get_domain_list(records) + [(inverse, 'in', records.ids)] lines = comodel.search(domain, limit=self.limit) @@ -2560,74 +2784,176 @@ class One2many(_RelationalMulti): for record in records: cache.set(record, self, tuple(group[record.id])) - def create(self, record_values): - self._write(record_values) - - def write(self, records, value): - self._write([(records, value)]) - - def _write(self, records_commands_list): + def write_real(self, records_commands_list, create=False): + """ Update real records. """ # records_commands_list = [(records, commands), ...] if not records_commands_list: return model = records_commands_list[0][0].browse() comodel = model.env[self.comodel_name].with_context(**self.context) - inverse = self.inverse_name - - to_create = [] # line vals to create - to_delete = [] # line ids to delete - to_relink = {} # lines to relink {line_id: record_id} - def unlink(line_ids): - if getattr(comodel._fields[inverse], 'ondelete', False) == 'cascade': - to_delete.extend(line_ids) - else: - to_relink.update(dict.fromkeys(line_ids, False)) + ids = {rid for recs, cs in records_commands_list for rid in recs.ids} + records = records_commands_list[0][0].browse(ids) - def flush(): - if to_delete: - comodel.browse(to_delete).unlink() - to_delete.clear() - if to_create: - comodel.create(to_create) - to_create.clear() - if to_relink: - # group line ids to update by record id, and update them - groups = defaultdict(list) - lines = comodel.browse(to_relink).sudo().with_context(prefetch_fields=False) - for line, record_id in zip(lines, to_relink.values()): - if int(line[inverse]) != record_id: - groups[record_id].append(line.id) - for record_id, line_ids in groups.items(): - comodel.browse(line_ids).write({inverse: record_id}) - to_relink.clear() - - with model.env.norecompute(): - for records, commands in records_commands_list: - for act in (commands or ()): - if act[0] == 0: - for record in records: - to_create.append(dict(act[2], **{inverse: record.id})) - elif act[0] == 1: - comodel.browse(act[1]).write(act[2]) - elif act[0] == 2: - to_delete.append(act[1]) - elif act[0] == 3: - unlink([act[1]]) - elif act[0] == 4: - to_relink[act[1]] = records[-1].id - elif act[0] in (5, 6): + if self.store: + inverse = self.inverse_name + to_create = [] # line vals to create + to_delete = [] # line ids to delete + to_inverse = {} + allow_full_delete = not create + + def unlink(lines): + if getattr(comodel._fields[inverse], 'ondelete', False) == 'cascade': + to_delete.extend(lines._ids) + else: + lines[inverse] = False + + def flush(): + if to_delete: + # unlink() will remove the lines from the cache + comodel.browse(to_delete).unlink() + to_delete.clear() + if to_create: + # create() will add the new lines to the cache of records + comodel.create(to_create) + to_create.clear() + if to_inverse: + for record, inverse_ids in to_inverse.items(): + comodel.browse(inverse_ids)[inverse] = record + + for recs, commands in records_commands_list: + for command in (commands or ()): + if command[0] == 0: + for record in recs: + to_create.append(dict(command[2], **{inverse: record.id})) + allow_full_delete = False + elif command[0] == 1: + comodel.browse(command[1]).write(command[2]) + elif command[0] == 2: + to_delete.append(command[1]) + elif command[0] == 3: + unlink(comodel.browse(command[1])) + elif command[0] == 4: + to_inverse.setdefault(recs[-1], set()).add(command[1]) + allow_full_delete = False + elif command[0] in (5, 6) : + # do not try to delete anything in creation mode if nothing has been created before + line_ids = command[2] if command[0] == 6 else [] + if not allow_full_delete and not line_ids: + continue flush() - ids = act[2] if act[0] == 6 else [] - domain = self.get_domain_list(model) + [(inverse, 'in', records.ids)] - if ids: - domain = domain + [('id', 'not in', ids)] - unlink(comodel.search(domain)._ids) - to_relink.update(dict.fromkeys(ids, records[-1].id)) + # assign the given lines to the last record only + lines = comodel.browse(line_ids) + domain = self.get_domain_list(model) + \ + [(inverse, 'in', recs.ids), ('id', 'not in', lines.ids)] + unlink(comodel.search(domain)) + lines[inverse] = recs[-1] flush() + else: + cache = records.env.cache + + def link(record, lines): + ids = record[self.name]._ids + cache.set(record, self, tuple(unique(ids + lines._ids))) + + def unlink(lines): + for record in records: + cache.set(record, self, (record[self.name] - lines)._ids) + + for recs, commands in records_commands_list: + for command in (commands or ()): + if command[0] == 0: + for record in recs: + link(record, comodel.new(command[2], ref=command[1])) + elif command[0] == 1: + comodel.browse(command[1]).write(command[2]) + elif command[0] == 2: + unlink(comodel.browse(command[1])) + elif command[0] == 3: + unlink(comodel.browse(command[1])) + elif command[0] == 4: + link(recs[-1], comodel.browse(command[1])) + elif command[0] in (5, 6): + # assign the given lines to the last record only + cache.update(recs, self, [()] * len(recs)) + lines = comodel.browse(command[2] if command[0] == 6 else []) + cache.set(recs[-1], self, lines._ids) + + return records + + def write_new(self, records_commands_list): + if not records_commands_list: + return + + model = records_commands_list[0][0].browse() + cache = model.env.cache + comodel = model.env[self.comodel_name].with_context(**self.context) + + ids = {record.id for records, _ in records_commands_list for record in records} + records = model.browse(ids) + + def browse(ids): + return comodel.browse([id_ and NewId(id_) for id_ in ids]) + + # make sure self is in cache + records[self.name] + + if self.store: + inverse = self.inverse_name + + for recs, commands in records_commands_list: + for command in commands: + if command[0] == 0: + for record in recs: + line = comodel.new(command[2], ref=command[1]) + line[inverse] = record + elif command[0] == 1: + browse([command[1]]).update(command[2]) + elif command[0] == 2: + browse([command[1]])[inverse] = False + elif command[0] == 3: + browse([command[1]])[inverse] = False + elif command[0] == 4: + browse([command[1]])[inverse] = recs[-1] + elif command[0] in (5, 6): + # assign the given lines to the last record only + cache.update(recs, self, [()] * len(recs)) + lines = comodel.browse(command[2] if command[0] == 6 else []) + cache.set(recs[-1], self, lines._ids) + + else: + def link(record, lines): + ids = record[self.name]._ids + cache.set(record, self, tuple(unique(ids + lines._ids))) + + def unlink(lines): + for record in records: + cache.set(record, self, (record[self.name] - lines)._ids) + + for recs, commands in records_commands_list: + for command in commands: + if command[0] == 0: + for record in recs: + link(record, comodel.new(command[2], ref=command[1])) + elif command[0] == 1: + browse([command[1]]).update(command[2]) + elif command[0] == 2: + unlink(browse([command[1]])) + elif command[0] == 3: + unlink(browse([command[1]])) + elif command[0] == 4: + link(recs[-1], browse([command[1]])) + elif command[0] in (5, 6): + # assign the given lines to the last record only + cache.update(recs, self, [()] * len(recs)) + lines = comodel.browse(command[2] if command[0] == 6 else []) + cache.set(recs[-1], self, lines._ids) + + return records + class Many2many(_RelationalMulti): """ Many2many field; the value of such a field is the recordset. @@ -2802,39 +3128,30 @@ class Many2many(_RelationalMulti): for record in records: cache.set(record, self, tuple(group[record.id])) - def create(self, record_values): - self._write(record_values, create=True) - - def write(self, records, value): - self._write([(records, value)]) - - def _write(self, records_commands_list, create=False): + def write_real(self, records_commands_list, create=False): # records_commands_list = [(records, commands), ...] if not records_commands_list: return - model = records_commands_list[0][0].browse() - comodel = model.env[self.comodel_name].with_context(**self.context) - cr = model.env.cr - - # determine old relation {x: ys} - old_relation = defaultdict(set) - if not create: - clauses, params, tables = comodel.env['ir.rule'].domain_get(comodel._name) - if '"%s"' % self.relation not in tables: - tables.append('"%s"' % self.relation) - query = """ - SELECT {rel}.{id1}, {rel}.{id2} FROM {tables} - WHERE {rel}.{id1} IN %s AND {rel}.{id2}={table}.id AND {cond} - """.format( - rel=self.relation, id1=self.column1, id2=self.column2, - table=comodel._table, tables=",".join(tables), - cond=" AND ".join(clauses) if clauses else "1=1", - ) - ids = {rid for recs, cs in records_commands_list for rid in recs.ids} - cr.execute(query, [tuple(ids)] + params) - for x, y in cr.fetchall(): - old_relation[x].add(y) + comodel = records_commands_list[0][0].env[self.comodel_name].with_context(**self.context) + cr = records_commands_list[0][0].env.cr + + # determine old and new relation {x: ys} + set = OrderedSet + ids = {rid for recs, cs in records_commands_list for rid in recs.ids} + records = records_commands_list[0][0].browse(ids) + + if self.store: + # Using `record[self.name]` generates 2 SQL queries when the value + # is not in cache: one that actually checks access rules for + # records, and the other one fetching the actual data. We use + # `self.read` instead to shortcut the first query. + missing_ids = list(records.env.cache.get_missing_ids(records, self)) + if missing_ids: + self.read(records.browse(missing_ids)) + + old_relation = {record.id: set(record[self.name]._ids) for record in records} + new_relation = {x: set(ys) for x, ys in old_relation.items()} # determine new relation {x: ys} new_relation = defaultdict(set) @@ -2857,33 +3174,31 @@ class Many2many(_RelationalMulti): def relation_delete(ys): # the pairs (x, y) have been cascade-deleted from relation for ys1 in old_relation.values(): - ys1.difference_update(ys) + ys1 -= ys for ys1 in new_relation.values(): - ys1.difference_update(ys) + ys1 -= ys - to_create = [] # line vals to create [(ids, vals)] + to_create = [] # line vals to create to_delete = [] # line ids to delete - with model.env.norecompute(): - for records, commands in records_commands_list: - for act in (commands or ()): - if not isinstance(act, (list, tuple)) or not act: - continue - if act[0] == 0: - to_create.append((records._ids, act[2])) - elif act[0] == 1: - comodel.browse(act[1]).write(act[2]) - elif act[0] == 2: - to_delete.append(act[1]) - elif act[0] == 3: - relation_remove(records._ids, act[1]) - elif act[0] == 4: - relation_add(records._ids, act[1]) - elif act[0] in (5, 6): - # new lines must no longer be linked to records - to_create = [(set(ids) - set(records._ids), vals) - for (ids, vals) in to_create] - relation_set(records._ids, act[2] if act[0] == 6 else ()) + for recs, commands in records_commands_list: + for command in (commands or ()): + if not isinstance(command, (list, tuple)) or not command: + continue + if command[0] == 0: + to_create.append((recs._ids, command[2])) + elif command[0] == 1: + comodel.browse(command[1]).write(command[2]) + elif command[0] == 2: + to_delete.append(command[1]) + elif command[0] == 3: + relation_remove(recs._ids, command[1]) + elif command[0] == 4: + relation_add(recs._ids, command[1]) + elif command[0] in (5, 6): + # new lines must no longer be linked to records + to_create = [(set(ids) - set(recs._ids), vals) for (ids, vals) in to_create] + relation_set(recs._ids, command[2] if command[0] == 6 else ()) if to_create: # create lines in batch, and link them @@ -2896,34 +3211,172 @@ class Many2many(_RelationalMulti): comodel.browse(to_delete).unlink() relation_delete(to_delete) + # update the cache of self + cache = records.env.cache + for record in records: + cache.set(record, self, tuple(new_relation[record.id])) + # process pairs to add (beware of duplicates) pairs = [(x, y) for x, ys in new_relation.items() for y in ys - old_relation[x]] if pairs: - query = "INSERT INTO {} ({}, {}) VALUES {} ON CONFLICT DO NOTHING".format( - self.relation, self.column1, self.column2, ", ".join(["%s"] * len(pairs)), - ) - cr.execute(query, pairs) + if self.store: + query = "INSERT INTO {} ({}, {}) VALUES {} ON CONFLICT DO NOTHING".format( + self.relation, self.column1, self.column2, ", ".join(["%s"] * len(pairs)), + ) + cr.execute(query, pairs) + + # update the cache of inverse fields + y_to_xs = defaultdict(set) + for x, y in pairs: + y_to_xs[y].add(x) + for invf in records._field_inverses[self]: + domain = invf.get_domain_list(comodel) + valid_ids = set(records.filtered_domain(domain)._ids) + if not valid_ids: + continue + for y, xs in y_to_xs.items(): + corecord = comodel.browse(y) + try: + ids0 = cache.get(corecord, invf) + ids1 = tuple(set(ids0) | (xs & valid_ids)) + cache.set(corecord, invf, ids1) + except KeyError: + pass # process pairs to remove pairs = [(x, y) for x, ys in old_relation.items() for y in ys - new_relation[x]] if pairs: - # express pairs as the union of cartesian products: - # pairs = [(1, 11), (1, 12), (1, 13), (2, 11), (2, 12), (2, 14)] - # -> y_to_xs = {11: {1, 2}, 12: {1, 2}, 13: {1}, 14: {2}} - # -> xs_to_ys = {{1, 2}: {11, 12}, {2}: {14}, {1}: {13}} y_to_xs = defaultdict(set) for x, y in pairs: y_to_xs[y].add(x) - xs_to_ys = defaultdict(set) - for y, xs in y_to_xs.items(): - xs_to_ys[frozenset(xs)].add(y) - # delete the rows where (id1 IN xs AND id2 IN ys) OR ... - COND = "{} IN %s AND {} IN %s".format(self.column1, self.column2) - query = "DELETE FROM {} WHERE {}".format( - self.relation, " OR ".join([COND] * len(xs_to_ys)), - ) - params = [arg for xs, ys in xs_to_ys.items() for arg in [tuple(xs), tuple(ys)]] - cr.execute(query, params) + + if self.store: + # express pairs as the union of cartesian products: + # pairs = [(1, 11), (1, 12), (1, 13), (2, 11), (2, 12), (2, 14)] + # -> y_to_xs = {11: {1, 2}, 12: {1, 2}, 13: {1}, 14: {2}} + # -> xs_to_ys = {{1, 2}: {11, 12}, {2}: {14}, {1}: {13}} + xs_to_ys = defaultdict(set) + for y, xs in y_to_xs.items(): + xs_to_ys[frozenset(xs)].add(y) + # delete the rows where (id1 IN xs AND id2 IN ys) OR ... + COND = "{} IN %s AND {} IN %s".format(self.column1, self.column2) + query = "DELETE FROM {} WHERE {}".format( + self.relation, " OR ".join([COND] * len(xs_to_ys)), + ) + params = [arg for xs, ys in xs_to_ys.items() for arg in [tuple(xs), tuple(ys)]] + cr.execute(query, params) + + # update the cache of inverse fields + for invf in records._field_inverses[self]: + for y, xs in y_to_xs.items(): + corecord = comodel.browse(y) + try: + ids0 = cache.get(corecord, invf) + ids1 = tuple(id_ for id_ in ids0 if id_ not in xs) + cache.set(corecord, invf, ids1) + except KeyError: + pass + + return records.filtered( + lambda record: new_relation[record.id] != old_relation[record.id] + ) + + def write_new(self, records_commands_list): + """ Update self on new records. """ + if not records_commands_list: + return + + model = records_commands_list[0][0].browse() + comodel = model.env[self.comodel_name].with_context(**self.context) + new = lambda id_: id_ and NewId(id_) + + # determine old and new relation {x: ys} + set = OrderedSet + old_relation = {record.id: set(record[self.name]._ids) for records, _ in records_commands_list for record in records} + new_relation = {x: set(ys) for x, ys in old_relation.items()} + ids = set(old_relation.keys()) + + records = model.browse(ids) + + for recs, commands in records_commands_list: + for command in commands: + if not isinstance(command, (list, tuple)) or not command: + continue + if command[0] == 0: + line_id = comodel.new(command[2], ref=command[1]).id + for line_ids in new_relation.values(): + line_ids.add(line_id) + elif command[0] == 1: + line_id = new(command[1]) + comodel.browse([line_id]).update(command[2]) + elif command[0] == 2: + line_id = new(command[1]) + for line_ids in new_relation.values(): + line_ids.discard(line_id) + elif command[0] == 3: + line_id = new(command[1]) + for line_ids in new_relation.values(): + line_ids.discard(line_id) + elif command[0] == 4: + line_id = new(command[1]) + for line_ids in new_relation.values(): + line_ids.add(line_id) + elif command[0] in (5, 6): + # new lines must no longer be linked to records + line_ids = command[2] if command[0] == 6 else () + line_ids = set(new(line_id) for line_id in line_ids) + for id_ in recs._ids: + new_relation[id_] = set(line_ids) + + if new_relation == old_relation: + return records.browse() + + # update the cache of self + cache = records.env.cache + for record in records: + cache.set(record, self, tuple(new_relation[record.id])) + + # process pairs to add (beware of duplicates) + pairs = [(x, y) for x, ys in new_relation.items() for y in ys - old_relation[x]] + if pairs: + # update the cache of inverse fields + y_to_xs = defaultdict(set) + for x, y in pairs: + y_to_xs[y].add(x) + for invf in records._field_inverses[self]: + domain = invf.get_domain_list(comodel) + valid_ids = set(records.filtered_domain(domain)._ids) + if not valid_ids: + continue + for y, xs in y_to_xs.items(): + corecord = comodel.browse([y]) + try: + ids0 = cache.get(corecord, invf) + ids1 = tuple(set(ids0) | (xs & valid_ids)) + cache.set(corecord, invf, ids1) + except KeyError: + pass + + # process pairs to remove + pairs = [(x, y) for x, ys in old_relation.items() for y in ys - new_relation[x]] + if pairs: + # update the cache of inverse fields + y_to_xs = defaultdict(set) + for x, y in pairs: + y_to_xs[y].add(x) + for invf in records._field_inverses[self]: + for y, xs in y_to_xs.items(): + corecord = comodel.browse([y]) + try: + ids0 = cache.get(corecord, invf) + ids1 = tuple(id_ for id_ in ids0 if id_ not in xs) + cache.set(corecord, invf, ids1) + except KeyError: + pass + + return records.filtered( + lambda record: new_relation[record.id] != old_relation[record.id] + ) class Id(Field): @@ -2957,13 +3410,22 @@ class Id(Field): raise TypeError("field 'id' cannot be assigned") -def prefetch_value_ids(record, field): - """ Return an iterator over the ids of the cached values of a relational +def prefetch_many2one_ids(record, field): + """ Return an iterator over the ids of the cached values of a many2one + field for the prefetch set of a record. + """ + records = record.browse(record._prefetch_ids) + ids = record.env.cache.get_values(records, field) + return unique(id_ for id_ in ids if id_ is not None) + + +def prefetch_x2many_ids(record, field): + """ Return an iterator over the ids of the cached values of an x2many field for the prefetch set of a record. """ records = record.browse(record._prefetch_ids) - ids_seq = record.env.cache.get_values(records, field, ()) - return unique(id_ for ids in ids_seq for id_ in ids) + ids_list = record.env.cache.get_values(records, field) + return unique(id_ for ids in ids_list for id_ in ids) # imported here to avoid dependency cycle issues diff --git a/odoo/models.py b/odoo/models.py index a2a538c9c0a..51656e0e9ec 100644 --- a/odoo/models.py +++ b/odoo/models.py @@ -21,10 +21,10 @@ """ -import datetime - import collections +import datetime import dateutil +import fnmatch import functools import itertools import io @@ -131,6 +131,16 @@ def fix_import_export_id_paths(fieldname): fixed_external_id = re.sub(r'([^/]):id', r'\1/id', fixed_db_id) return fixed_external_id.split('/') +def trigger_tree_merge(node1, node2): + """ Merge two trigger trees. """ + for key, val in node2.items(): + if key is None: + node1.setdefault(None, set()) + node1[None].update(val) + else: + node1.setdefault(key, {}) + trigger_tree_merge(node1[key], node2[key]) + class MetaModel(api.Meta): """ The metaclass of all model classes. @@ -280,9 +290,6 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): _needaction = False # whether the model supports "need actions" (see mail) _translate = True # False disables translations export for this model - _depends = {} # dependencies of models backed up by sql views - # {model_name: field_names, ...} - # default values for _transient_vacuum() _transient_check_count = 0 _transient_max_count = lazy_classproperty(lambda _: config.get('osv_memory_count_limit')) @@ -381,7 +388,8 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): # this field must override any other column or field self._add_field(self.CONCURRENCY_CHECK_FIELD, fields.Datetime( - string='Last Modified on', compute=last_modified_name, automatic=True)) + string='Last Modified on', compute=last_modified_name, + compute_sudo=False, automatic=True)) def compute_concurrency_field(self): for record in self: @@ -546,7 +554,6 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): cls._sequence = None cls._log_access = cls._auto cls._inherits = {} - cls._depends = {} cls._sql_constraints = {} for base in reversed(cls.__bases__): @@ -561,9 +568,6 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): cls._inherits.update(base._inherits) - for mname, fnames in base._depends.items(): - cls._depends[mname] = cls._depends.get(mname, []) + fnames - for cons in base._sql_constraints: cls._sql_constraints[cons[0]] = cons @@ -1146,7 +1150,17 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): parent_fields[field.model_name].append(field.name) # convert default values to the right format - defaults = self._convert_to_write(defaults) + # + # we explicitly avoid using _convert_to_write() for x2many fields, + # because the latter leaves values like [(4, 2), (4, 3)], which are not + # supported by the web client as default values; stepping through the + # cache allows to normalize such a list to [(6, 0, [2, 3])], which is + # properly supported by the web client + for fname, value in defaults.items(): + if fname in self._fields: + field = self._fields[fname] + value = field.convert_to_cache(value, self, validate=False) + defaults[fname] = field.convert_to_write(value, self) # add default values for inherited fields for model, names in parent_fields.items(): @@ -1653,7 +1667,6 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): name for name, field in self._fields.items() if name not in values - if self._log_access and name not in MAGIC_COLUMNS if not (field.inherited and field.related_field.model_name in avoid_models) } @@ -2090,6 +2103,7 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): aggregated_fields = [] select_terms = [] + fnames = [] # list of fields to flush for fspec in fields: if fspec == 'sequence': @@ -2116,6 +2130,8 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): continue func, fname = field.group_operator, name + fnames.append(fname) + if fname in groupby_fields: continue if name in aggregated_fields: @@ -2132,6 +2148,8 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): for gb in annotated_groupbys: select_terms.append('%s as "%s" ' % (gb['qualified_field'], gb['groupby'])) + self._flush_search(domain, fields=fnames + groupby_fields) + groupby_terms, orderby_terms = self._read_group_prepare(order, aggregated_fields, annotated_groupbys, query) from_clause, where_clause, where_clause_params = query.get_sql() if lazy and (len(groupby_fields) >= 2 or not self._context.get('group_by_no_leaf')): @@ -2306,8 +2324,6 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): field = self._fields[column_name] if field.default: value = field.default(self) - value = field.convert_to_cache(value, self, validate=False) - value = field.convert_to_record(value, self) value = field.convert_to_write(value, self) value = field.convert_to_column(value, self) else: @@ -2374,22 +2390,24 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): # update the database schema for fields columns = tools.table_columns(cr, self._table) - - def recompute(field): - _logger.info("Storing computed values of %s", field) - recs = self.with_context(active_test=False).search([]) - recs._recompute_todo(field) + fields_to_compute = [] for field in self._fields.values(): if not field.store: continue - if field.manual and not update_custom_fields: continue # don't update custom fields - new = field.update_db(self, columns) if new and field.compute: - self.pool.post_init(recompute, field) + fields_to_compute.append(field) + + if fields_to_compute: + @self.pool.post_init + def mark_fields_to_compute(): + recs = self.with_context(active_test=False).search([]) + for field in fields_to_compute: + _logger.info("Storing computed values of %s", field) + self.env.add_to_compute(field, recs) if self._auto: self._add_sql_constraints() @@ -2568,7 +2586,8 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): # 4. initialize more field metadata cls._field_computed = {} # fields computed with the same method cls._field_inverses = Collector() # inverse fields for related fields - cls._field_triggers = Collector() # list of (field, path) to invalidate + cls._field_triggers = {} # {depfield: {depfield: {...}, None: [compute_fields]}} + cls._field_triggers_create = {} # {depfield: {depfield: {...}, None: [compute_fields]}} cls._setup_done = True @@ -2633,6 +2652,13 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): elif 'x_name' in cls._fields: cls._rec_name = 'x_name' + if cls._rec_name: + rec_name_field = cls._fields[cls._rec_name] + if rec_name_field.translate: + # display_name depends on context['lang'] (`test_lp1071710`) + display_name_field = cls._fields['display_name'] + display_name_field.depends_context = (display_name_field.depends_context or ()) + ('lang',) + @api.model def fields_get(self, allfields=None, attributes=None): """ fields_get([fields][, attributes]) @@ -2707,8 +2733,9 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): description = self.env['ir.model']._get(self._name).name if not self.env.user.has_group('base.group_no_one'): raise AccessError( - _('The requested operation cannot be completed due to security restrictions. ' - 'Please contact your system administrator.\n\n(Document type: %(document_kind)s (%(document_model)s), Operation: %(operation)s)') % { + _('You do not have enough rights to access the fields "%(fields)s" on %(document_kind)s (%(document_model)s). '\ + 'Please contact your system administrator.\n\n(Operation: %(operation)s)') % { + 'fields': ','.join(list(invalid_fields)), 'document_kind': description, 'document_model': self._name, 'operation': operation, @@ -2766,33 +2793,25 @@ Fields: :raise AccessError: if user has no read rights on some of the given records """ - # check access rights - self.check_access_rights('read') fields = self.check_field_access_rights('read', fields) - # split fields into stored and computed fields - stored, inherited, computed = [], [], [] + # fetch stored fields from the database to the cache + stored_fields = set() for name in fields: - field = self._fields.get(name) - if field: - if field.store: - stored.append(name) - elif field.base_field.store: - inherited.append(name) - else: - computed.append(name) - else: - _logger.warning("%s.read() with unknown field '%s'", self._name, name) - - # fetch stored fields from the database to the cache; this should feed - # the prefetching of secondary records - self._read_from_database(stored, inherited) + field = self._fields[name] + if field.store: + stored_fields.add(name) + elif field.compute: + # optimization: prefetch direct field dependencies + for dotname in field.depends: + stored_fields.add(dotname.split('.')[0]) + self._read(stored_fields) # retrieve results from records; this takes values from the cache and # computes remaining fields data = [(record, {'id': record._ids[0]}) for record in self] use_name_get = (load == '_classic_read') - for name in (stored + inherited + computed): + for name in fields: convert = self._fields[name].convert_to_read for record, vals in data: # missing records have their vals empty @@ -2806,59 +2825,34 @@ Fields: return result - def _prefetch_field(self, field): + def _fetch_field(self, field): """ Read from the database in order to fetch ``field`` (:class:`Field` instance) for ``self`` in cache. """ - # fetch the records of this model without field_name in their cache - records = self._in_cache_without(field) - + self.check_field_access_rights('read', [field.name]) # determine which fields can be prefetched - fs = {field} if self._context.get('prefetch_fields', True) and field.prefetch: - fs.update( - f - for f in self._fields.values() + fnames = [ + name + for name, f in self._fields.items() # select fields that can be prefetched if f.prefetch # discard fields with groups that the user may not access if not (f.groups and not self.user_has_groups(f.groups)) # discard fields that must be recomputed - if not (f.compute and self.env.field_todo(f)) - ) - - # special case: discard records to recompute for field - records -= self.env.field_todo(field) - - # in onchange mode, discard computed fields and fields in cache - if not self.id: - for f in list(fs): - if f.compute or self.env.cache.contains(self, f): - fs.discard(f) - else: - records &= self._in_cache_without(f) + if not (f.compute and self.env.records_to_compute(f)) + ] + if field.name not in fnames: + fnames.append(field.name) + self = self - self.env.records_to_compute(field) + else: + fnames = [field.name] + self._read(fnames) - # fetch records with read() - assert self in records and field in fs - result = [] - try: - result = records.read([f.name for f in fs], load='_classic_write') - except AccessError: - # not all prefetched records may be accessible, try with only the current recordset - result = self.read([f.name for f in fs], load='_classic_write') - - # check the cache, and update it if necessary - if not self.env.cache.contains_value(self, field): - for values in result: - record = self.browse(values.pop('id')) - record._update_cache(values, validate=False) - if not self.env.cache.contains(self, field): - exc = AccessError("No value found for %s.%s" % (self, field.name)) - self.env.cache.set_failed(self, [field], exc) - - def _read_from_database(self, field_names, inherited_field_names=[]): + def _read(self, fields): """ Read the given fields of the records in ``self`` from the database, and store them in cache. Access errors are also stored in cache. + Skip fields that are not stored. :param field_names: list of column names of model ``self``; all those fields are guaranteed to be read @@ -2867,6 +2861,23 @@ Fields: """ if not self: return + self.check_access_rights('read') + + # if a read() follows a write(), we must flush updates, as read() will + # fetch from database and overwrites the cache (`test_update_with_id`) + self.flush(fields, self) + + field_names = [] + inherited_field_names = [] + for name in fields: + field = self._fields.get(name) + if field: + if field.store: + field_names.append(name) + elif field.base_field.store: + inherited_field_names.append(name) + else: + _logger.warning("%s.read() with unknown field '%s'", self._name, name) env = self.env cr, user, context, su = env.args @@ -2902,37 +2913,29 @@ Fields: query_str = "SELECT %s FROM %s WHERE %s" % (",".join(qual_names), from_clause, where_clause) # fetch one list of record values per field - field_values_list = [[] for name in qual_names] param_pos = params.index(param_ids) + + result = [] for sub_ids in cr.split_for_in_conditions(self.ids): params[param_pos] = tuple(sub_ids) cr.execute(query_str, params) - for row in cr.fetchall(): - for values, val in zip(field_values_list, row): - values.append(val) - - ids = field_values_list.pop(0) - fetched = self.browse(ids) - - if ids: - # translate the fields if necessary - if context.get('lang'): - for field, values in zip(fields_pre, field_values_list): - if not field.inherited and callable(field.translate): - name = field.name - translate = field.get_trans_func(fetched) - for index in range(len(ids)): - values[index] = translate(ids[index], values[index]) - - # store result in cache - target = self.browse() - for field, values in zip(fields_pre, field_values_list): - convert = field.convert_to_cache - # Note that the target record passed to convert below is empty. - # This does not harm in practice, as it is only used in Monetary - # fields for rounding the value. As the value comes straight - # from the database, it is expected to be rounded already. - values = [convert(value, target, validate=False) for value in values] + result += cr.fetchall() + + fetched = self.browse() + if result: + cols = zip(*result) + ids = next(cols) + fetched = self.browse(ids) + + for field in fields_pre: + values = next(cols) + if context.get('lang') and not field.inherited and callable(field.translate): + translate = field.get_trans_func(fetched) + values = list(values) + for index in range(len(ids)): + values[index] = translate(ids[index], values[index]) + + # store values in cache self.env.cache.update(fetched, field, values) # determine the fields that must be processed now; @@ -2941,14 +2944,10 @@ Fields: field = self._fields[name] if not field.column_type: field.read(fetched) + if field.deprecated: + _logger.warning('Field %s is deprecated: %s', field, field.deprecated) - # Warn about deprecated fields now that fields_pre and fields_post are computed - for name in field_names: - field = self._fields[name] - if field.deprecated: - _logger.warning('Field %s is deprecated: %s', field, field.deprecated) - - # store failed values in cache for the records that could not be read + # possibly raise exception for the records that could not be read missing = self - fetched if missing: extras = fetched - self @@ -2960,8 +2959,7 @@ Fields: # mark non-existing records in missing forbidden = missing.exists() if forbidden: - exc = self.env['ir.rule']._make_access_error('read', forbidden) - self.env.cache.set_failed(forbidden, self._fields.values(), exc) + raise self.env['ir.rule']._make_access_error('read', forbidden) def get_metadata(self): """ @@ -3049,7 +3047,9 @@ Fields: if self.env.su: return - invalid = self - self._filter_access_rules(operation) + # SQL Alternative if computing in-memory is too slow for large dataset + # invalid = self - self._filter_access_rules(operation) + invalid = self - self._filter_access_rules_python(operation) if not invalid: return @@ -3099,10 +3099,12 @@ Fields: return self # detemine ids in database that satisfy ir.rules + # TODO: we should add a flush here, based on domain's arguments valid_ids = set() query = "SELECT {}.id FROM {} WHERE {}.id IN %s AND {}".format( self._table, ",".join(tables), self._table, " AND ".join(where_clause), ) + self._flush_search([]) for sub_ids in self._cr.split_for_in_conditions(self.ids): self._cr.execute(query, [sub_ids] + where_params) valid_ids.update(row[0] for row in self._cr.fetchall()) @@ -3114,6 +3116,10 @@ Fields: if not (it or it.origin) or (it or it.origin) in valid_ids ]) + def _filter_access_rules_python(self, operation): + dom = self.env['ir.rule']._compute_domain(self._name, operation) + return self.filtered_domain(dom or []) + def unlink(self): """ unlink() @@ -3127,12 +3133,13 @@ Fields: if not self: return True - # for recomputing fields - self.modified(self._fields) - + self.check_access_rights('unlink') self._check_concurrency() - self.check_access_rights('unlink') + # mark fields that depend on 'self' to recompute them after 'self' has + # been deleted (like updating a sum of lines after deleting one line) + self.flush() + self.modified(self._fields) # Check if the records are used as default properties. refs = ['%s,%s' % (self._name, i) for i in self.ids] @@ -3147,7 +3154,16 @@ Fields: cr = self._cr Data = self.env['ir.model.data'].sudo().with_context({}) Defaults = self.env['ir.default'].sudo() - Attachment = self.env['ir.attachment'] + Attachment = self.env['ir.attachment'].sudo() + ir_model_data_unlink = Data + ir_attachment_unlink = Attachment + + # TOFIX: this avoids an infinite loop when trying to recompute a + # field, which triggers the recomputation of another field using the + # same compute function, which then triggers again the computation + # of those two fields + for field in self._fields.values(): + self.env.remove_to_compute(field, self) for sub_ids in cr.split_for_in_conditions(self.ids): query = "DELETE FROM %s WHERE id IN %%s" % self._table @@ -3162,7 +3178,7 @@ Fields: # side-effects during admin calls. data = Data.search([('model', '=', self._name), ('res_id', 'in', sub_ids)]) if data: - data.unlink() + ir_model_data_unlink |= data # For the same reason, remove the defaults having some of the # records as value @@ -3176,15 +3192,18 @@ Fields: cr.execute(query, (self._name, sub_ids)) attachments = Attachment.browse([row[0] for row in cr.fetchall()]) if attachments: - attachments.sudo().unlink() + ir_attachment_unlink |= attachments.sudo() # invalidate the *whole* cache, since the orm does not handle all # changes made in the database, like cascading delete! self.invalidate_cache() - - # recompute new-style fields - if self.env.recompute and self._context.get('recompute', True): - self.recompute() + if ir_model_data_unlink: + ir_model_data_unlink.unlink() + if ir_attachment_unlink: + ir_attachment_unlink.unlink() + # DLE P93: flush after the unlink, for recompute fields depending on + # the modified of the unlink + self.flush() # auditing: deletions are infrequent and leave no trace in the database _unlink.info('User #%s deleted %s records with IDs: %r', self._uid, self._name, self.ids) @@ -3272,110 +3291,100 @@ Fields: if not self: return True - self._check_concurrency() self.check_access_rights('write') + self.check_field_access_rights('write', vals.keys()) + self.check_access_rule('write') + env = self.env bad_names = {'id', 'parent_path'} if self._log_access: # the superuser can set log_access fields while loading registry - if not(self.env.su and not self.pool.ready): + if not(self.env.uid == SUPERUSER_ID and not self.pool.ready): bad_names.update(LOG_ACCESS_COLUMNS) - # distribute fields into sets for various purposes - store_vals = {} - inverse_vals = {} - inherited_vals = defaultdict(dict) # {modelname: {fieldname: value}} - unknown_names = [] - inverse_fields = [] - protected_fields = [] - for key, val in vals.items(): - if key in bad_names: - continue - field = self._fields.get(key) - if not field: - unknown_names.append(key) - continue - if field.store: - store_vals[key] = val - if field.inherited: - inherited_vals[field.related_field.model_name][key] = val - elif field.inverse: - inverse_vals[key] = val - inverse_fields.append(field) - protected_fields.extend(self._field_computed.get(field, [field])) - - if unknown_names: - _logger.warning("%s.write() with unknown fields: %s", - self._name, ', '.join(sorted(unknown_names))) - - with self.env.protecting(protected_fields, self): - # update references to parents - ref_store_vals = {k: store_vals[k] for k in self._inherits.values() if k in store_vals} - if ref_store_vals: - self._write(ref_store_vals) - - # update parent records - cr = self.env.cr - for model_name, parent_vals in inherited_vals.items(): - parent_name = self._inherits[model_name] - # optimization of self.mapped(parent_name) - parent_ids = set() - query = "SELECT %s FROM %s WHERE id IN %%s" % (parent_name, self._table) - for sub_ids in cr.split_for_in_conditions(self.ids): - cr.execute(query, [sub_ids]) - parent_ids.update(row[0] for row in cr.fetchall()) + determine_inverses = defaultdict(list) # {inverse: fields} + records_to_inverse = {} # {field: records} + relational_names = [] + protected = set() + for fname in vals: + field = self._fields[fname] + if field.inverse: + determine_inverses[field.inverse].append(field) + # DLE P150: `test_cancel_propagation`, `test_manufacturing_3_steps`, `test_manufacturing_flow` + # TODO: check whether still necessary + records_to_inverse[field] = self.filtered('id') + if field.relational or self._field_inverses[field]: + relational_names.append(fname) + protected.update(self._field_computed.get(field, [field])) + + # protect fields being written against recomputation + with env.protecting(protected, self): + # determine records depending on values + self.modified(relational_names) + + real_recs = self.filtered('id') + + # If there are only fields that do not trigger _write (e.g. only + # determine inverse), the below ensures that `write_date` and + # `write_uid` are updated (`test_orm.py`, `test_write_date`) + if self._log_access and self.ids: + towrite = env.all.towrite[self._name] + for record in real_recs: + towrite[record.id]['write_uid'] = self.env.uid + towrite[record.id]['write_date'] = False + self.env.cache.invalidate([ + (self._fields['write_date'], self.ids), + (self._fields['write_uid'], self.ids), + ]) + # for monetary field, their related currency field must be cached + # before the amount so it can be rounded correctly + for fname in sorted(vals, key=lambda x: self._fields[x].type=='monetary'): + if fname in bad_names: + continue + field = self._fields[fname] + field.write(self, vals[fname]) + + # determine records depending on new values + # + # Call modified after write, because the modified can trigger a + # search which can trigger a flush which can trigger a recompute + # which remove the field from the recompute list while all the + # values required for the computation could not be yet in cache. + # e.g. Write on `name` of `res.partner` trigger the recompute of + # `display_name`, which triggers a search on child_ids to find the + # childs to which the display_name must be recomputed, which + # triggers the flush of `display_name` because the _order of + # res.partner includes display_name. The computation of display_name + # is then done too soon because the parent_id was not yet written. + # (`test_01_website_reset_password_tour`) + self.modified(vals) + + if self._parent_store and self._parent_name in vals: + self.flush([self._parent_name]) + + # validate non-inversed fields first + inverse_fields = [f.name for fs in determine_inverses.values() for f in fs] + real_recs._validate_fields(set(vals) - set(inverse_fields)) + + for fields in determine_inverses.values(): + # inverse records that are not being computed try: - self.env[model_name].browse(parent_ids).write(parent_vals) + fields[0].determine_inverse(real_recs) except AccessError as e: - description = self.env['ir.model']._get(self._name).name - raise AccessError( - _("%(previous_message)s\n\nImplicitly accessed through '%(document_kind)s' (%(document_model)s).") % { - 'previous_message': e.args[0], - 'document_kind': description, - 'document_model': self._name, - } - ) - - # write stored fields with (low-level) method _write - if store_vals or inverse_vals or inherited_vals: - # if log_access is enabled, this updates 'write_date' and - # 'write_uid' and check access rules, even when old_vals is - # empty - self._write(store_vals) - - if inverse_vals: - self.check_field_access_rights('write', list(inverse_vals)) - - self.modified(set(inverse_vals) - set(store_vals)) + if fields[0].inherited: + description = self.env['ir.model']._get(self._name).name + raise AccessError( + _("%(previous_message)s\n\nImplicitly accessed through '%(document_kind)s' (%(document_model)s).") % { + 'previous_message': e.args[0], + 'document_kind': description, + 'document_model': self._name, + } + ) + raise - # group fields by inverse method (to call it once), and order - # groups by dependence (in case they depend on each other) - field_groups = sorted( - (fields for _inv, fields in groupby(inverse_fields, attrgetter('inverse'))), - key=lambda fields: min(map(self.pool.field_sequence, fields)), - ) - for fields in field_groups: - # If a field is not stored, its inverse method will probably - # write on its dependencies, which will invalidate the field - # on all records. We therefore inverse the field one record - # at a time. - batches = [self] if all(f.store for f in fields) else list(self) - # put the values of fields in cache, and inverse them - inv_vals = {f.name: inverse_vals[f.name] for f in fields} - for records in batches: - for record in records: - record._update_cache(inv_vals) - fields[0].determine_inverse(records) - - self.modified(set(inverse_vals) - set(store_vals)) - - # check Python constraints for inversed fields - self._validate_fields(set(inverse_vals) - set(store_vals)) - - # recompute fields - if self.env.recompute and self._context.get('recompute', True): - self.recompute() + # validate inversed fields + real_recs._validate_fields(inverse_fields) return True @@ -3383,8 +3392,8 @@ Fields: # low-level implementation of write() if not self: return True - self.check_field_access_rights('write', list(vals)) + self._check_concurrency() cr = self._cr # determine records that require updating parent_path @@ -3392,50 +3401,27 @@ Fields: # determine SQL values columns = [] # list of (column_name, format, value) - updated = [] # list of updated or translated columns - other_fields = [] # list of non-column fields - single_lang = len(self.env['res.lang'].get_installed()) <= 1 - has_translation = self.env.lang and self.env.lang != 'en_US' - # when there is only one language, update existing translations but - # do not create new ones - if single_lang: - process_translations = self.env['ir.translation']._update_translations - else: - process_translations = self.env['ir.translation']._upsert_translations for name, val in vals.items(): + if self._log_access and name in LOG_ACCESS_COLUMNS and not val: + continue field = self._fields[name] assert field.store if field.deprecated: _logger.warning('Field %s is deprecated: %s', field, field.deprecated) - if field.column_type: - if single_lang or not (has_translation and field.translate is True): - # val is not a translation: update the table - val = field.convert_to_column(val, self, vals) - columns.append((name, field.column_format, val)) - tname = "%s,%s" % (self._name, name) - if field.translate is True and self.env.lang: - self.env['ir.translation']._set_source(tname, self.ids, val) - updated.append(name) - else: - other_fields.append(field) + assert field.column_type + columns.append((name, field.column_format, val)) if self._log_access: - if 'write_uid' not in vals: + if not vals.get('write_uid'): columns.append(('write_uid', '%s', self._uid)) - updated.append('write_uid') - if 'write_date' not in vals: + if not vals.get('write_date'): columns.append(('write_date', '%s', AsIs("(now() at time zone 'UTC')"))) - updated.append('write_date') - - # mark fields to recompute (the ones that depend on old values) - self.modified(vals) # update columns if columns: - self.check_access_rule('write') query = 'UPDATE "%s" SET %s WHERE id IN %%s' % ( self._table, ','.join('"%s"=%s' % (column[0], column[1]) for column in columns), ) @@ -3448,54 +3434,6 @@ Fields: + '\n\n({} {}, {} {})'.format(_('Records:'), sub_ids[:6], _('User:'), self._uid) ) - translation_values = [] - for name in updated: - field = self._fields[name] - if callable(field.translate): - # The source value of a field has been modified, - # synchronize translated terms when possible. - self.env['ir.translation']._sync_terms_translations(field, self) - - elif self.env.lang and field.translate: - # The translated value of a field has been modified. - src_trans = self.with_context(lang=None).read([name])[0][name] - if not src_trans: - # Insert value to DB - src_trans = vals[name] - self.with_context(lang=None).write({name: src_trans}) - tname = "%s,%s" % (self._name, name) - val = field.convert_to_column(vals[name], self, vals) - translation_values += [dict( - src=src_trans, - value=val, - name=tname, - lang=self.env.lang, - type='model', - state='translated', - res_id=res_id) for res_id in self.ids] - - if translation_values: - process_translations(translation_values) - - # mark fields to recompute; do this before setting other fields, because - # the latter can require the value of computed fields, e.g., a one2many - # checking constraints on records - self.modified(updated) - - # set the value of non-column fields - if other_fields: - # discard default values from context - other = self.with_context(clean_context(self._context)) - - for field in sorted(other_fields, key=attrgetter('_sequence')): - field.write(other, vals[field.name]) - - # mark fields to recompute - self.modified(field.name for field in other_fields) - - # check Python constraints - self._validate_fields(vals) - # update parent_path if parent_records: parent_records._parent_store_update() @@ -3538,9 +3476,8 @@ Fields: bad_names = {'id', 'parent_path'} if self._log_access: # the superuser can set log_access fields while loading registry - if not(self.env.su and not self.pool.ready): + if not(self.env.uid == SUPERUSER_ID and not self.pool.ready): bad_names.update(LOG_ACCESS_COLUMNS) - unknown_names = set() # classify fields for each record data_list = [] @@ -3561,7 +3498,7 @@ Fields: continue field = self._fields.get(key) if not field: - unknown_names.add(key) + _logger.warning("%s.create() with unknown fields: %s", self._name, key) continue if field.store: stored[key] = val @@ -3570,14 +3507,15 @@ Fields: elif field.inverse: inversed[key] = val inversed_fields.add(field) + # ignore the protection of compute fields which do not have an + # inverse, otherwise their computation are not correctly + # performed, neither the fields which are computed in the same + # compute method (`test_validation_error`) + if not field.compute or field.inverse: protected.update(self._field_computed.get(field, [field])) data_list.append(data) - if unknown_names: - _logger.warning("%s.create() with unknown fields: %s", - self._name, ', '.join(sorted(unknown_names))) - # create or update parent records for model_name, parent_name in self._inherits.items(): parent_data_list = [] @@ -3599,15 +3537,12 @@ Fields: # create records with stored fields records = self._create(data_list) - # determine which fields to protect on which records + # protect fields being written against recomputation protected = [(data['protected'], data['record']) for data in data_list] with self.env.protecting(protected): # group fields by inverse method (to call it once), and order groups # by dependence (in case they depend on each other) - field_groups = sorted( - (fields for _inv, fields in groupby(inversed_fields, attrgetter('inverse'))), - key=lambda fields: min(map(self.pool.field_sequence, fields)), - ) + field_groups = (fields for _inv, fields in groupby(inversed_fields, attrgetter('inverse'))) for fields in field_groups: # determine which records to inverse for those fields inv_names = {field.name for field in fields} @@ -3635,17 +3570,10 @@ Fields: batch_recs = self.concat(*(record for record, vals in batch)) fields[0].determine_inverse(batch_recs) - # trick: no need to mark non-stored fields as modified, thanks - # to the transitive closure made over non-stored dependencies - # check Python constraints for non-stored inversed fields for data in data_list: data['record']._validate_fields(set(data['inversed']) - set(data['stored'])) - # recompute fields - if self.env.recompute and self._context.get('recompute', True): - self.recompute() - return records @api.model @@ -3655,12 +3583,6 @@ Fields: cr = self.env.cr quote = '"{}"'.format - # set boolean fields to False by default (avoid NULL in database) - for name, field in self._fields.items(): - if field.type == 'boolean' and field.store: - for data in data_list: - data['stored'].setdefault(name, False) - # insert rows ids = [] # ids of created records other_fields = set() # non-column fields @@ -3700,20 +3622,48 @@ Fields: cr.execute(query, params) ids.append(cr.fetchone()[0]) - # the new records + # put the new records in cache, and update inverse fields, for many2one + # + # cachetoclear is an optimization to avoid modified()'s cost until other_fields are processed + cachetoclear = [] records = self.browse(ids) + inverses_update = defaultdict(list) # {(field, value): ids} for data, record in zip(data_list, records): data['record'] = record + # DLE P104: test_inherit.py, test_50_search_one2many + vals = dict({k: v for d in data['inherited'].values() for k, v in d.items()}, **data['stored']) + set_vals = list(vals) + LOG_ACCESS_COLUMNS + [self.CONCURRENCY_CHECK_FIELD, 'id', 'parent_path'] + for field in self._fields.values(): + if field.type in ('one2many', 'many2many'): + self.env.cache.set(record, field, ()) + # DLE P123: `test_adv_activity`, `test_message_assignation_inbox`, `test_message_log`, `test_create_mail_simple`, ... + # Set `mail.message.parent_id` to False in cache so it doesn't do the useless SELECT when computing the modified of `child_ids` + # in other words, if `parent_id` is not set, no other message `child_ids` are impacted. + # + avoid the fetch of fields which are False. e.g. if a boolean field is not passed in vals and as no default set in the field attributes, + # then we know it can be set to False in the cache in the case of a create. + elif field.name not in set_vals and not field.compute: + self.env.cache.set(record, field, field.convert_to_cache(None, record)) + for fname, value in vals.items(): + field = self._fields[fname] + if field.type in ('one2many', 'many2many'): + cachetoclear.append((record, field)) + else: + cache_value = field.convert_to_cache(value, record) + self.env.cache.set(record, field, cache_value) + if field.type in ('many2one', 'many2one_reference') and record._field_inverses[field]: + inverses_update[(field, cache_value)].append(record.id) + + for (field, value), record_ids in inverses_update.items(): + field._update_inverses(self.browse(record_ids), value) # update parent_path records._parent_store_create() + # protect fields being written against recomputation protected = [(data['protected'], data['record']) for data in data_list] with self.env.protecting(protected): - # mark fields to recompute; do this before setting other fields, - # because the latter can require the value of computed fields, e.g., - # a one2many checking constraints on records - records.modified(self._fields) + # mark computed fields as todo + records.modified(self._fields, create=True) if other_fields: # discard default values from context for other fields @@ -3726,11 +3676,15 @@ Fields: ]) # mark fields to recompute - records.modified([field.name for field in other_fields]) + records.modified([field.name for field in other_fields], create=True) - # check Python constraints for stored fields - records._validate_fields(name for data in data_list for name in data['stored']) + # if value in cache has not been updated by other_fields, remove it + for record, field in cachetoclear: + if self.env.cache.contains(record, field) and not self.env.cache.get(record, field): + self.env.cache.remove(record, field) + # check Python constraints for stored fields + records._validate_fields(name for data in data_list for name in data['stored']) records.check_access_rule('create') # add translations @@ -3746,6 +3700,14 @@ Fields: return records + def _compute_field_value(self, field): + # This is for base automation, to have something to override to catch + # the changes of values for stored compute fields. + if isinstance(field.compute, str): + getattr(self, field.compute)() + else: + field.compute(self) + def _parent_store_create(self): """ Set the parent_path field on ``self`` after its creation. """ if not self._parent_store: @@ -4124,6 +4086,62 @@ Fields: return order_by_clause and (' ORDER BY %s ' % order_by_clause) or '' @api.model + def _flush_search(self, domain, fields=None, order=None): + """ Flush all the fields appearing in `domain`, `fields` and `order`. """ + to_flush = defaultdict(set) # {model_name: field_names} + if fields: + to_flush[self._name].update(fields) + # also take into account the fields in the record rules + domain = list(domain) + (self.env['ir.rule']._compute_domain(self._name, 'read') or []) + for arg in domain: + if isinstance(arg, str): + continue + if not isinstance(arg[0], str): + continue + model_name = self._name + for fname in arg[0].split('.'): + field = self.env[model_name]._fields.get(fname) + if not field: + break + to_flush[model_name].add(fname) + # DLE P111: `test_message_process_email_partner_find` + # Search on res.users with email_normalized in domain + # must trigger the recompute and flush of res.partner.email_normalized + if field.related_field: + model = self + # DLE P129: `test_transit_multi_companies` + # `self.env['stock.picking'].search([('product_id', '=', product.id)])` + # Should flush `stock.move.picking_ids` as `product_id` on `stock.picking` is defined as: + # `product_id = fields.Many2one('product.product', 'Product', related='move_lines.product_id', readonly=False)` + for f in field.related: + rfield = model._fields.get(f) + if rfield: + to_flush[model._name].add(f) + if rfield.type in ('many2one', 'one2many', 'many2many'): + model = self.env[rfield.comodel_name] + if rfield.type == 'one2many': + to_flush[rfield.comodel_name].add(rfield.inverse_name) + if field.comodel_name: + model_name = field.comodel_name + # hierarchy operators need the parent field + if arg[1] in ('child_of', 'parent_of'): + model = self.env[model_name] + if model._parent_store: + to_flush[model_name].add(model._parent_name) + + # flush the order fields + order_spec = order or self._order + for order_part in order_spec.split(','): + order_field = order_part.split()[0] + to_flush[self._name].add(order_field) + + if 'active' in self: + to_flush[self._name].add('active') + + for model_name, field_names in to_flush.items(): + self.env[model_name].flush(field_names) + + @api.model def _search(self, args, offset=0, limit=None, order=None, count=False, access_rights_uid=None): """ Private implementation of search() method, allowing specifying the uid to use for the access right check. @@ -4142,6 +4160,9 @@ Fields: # optimization: no need to query, as no record satisfies the domain return 0 if count else [] + # the flush must be done before the _where_calc(), as the latter can do some selects + self._flush_search(args, order=order) + query = self._where_calc(args) self._apply_ir_rules(query, 'read') order_by = self._generate_order_by(order, query) @@ -4197,8 +4218,6 @@ Fields: field = self._fields['state'] if field.default: value = field.default(self) - value = field.convert_to_cache(value, self) - value = field.convert_to_record(value, self) value = field.convert_to_write(value, self) default['state'] = value @@ -4353,15 +4372,7 @@ Fields: query = """SELECT id FROM "%s" WHERE id IN %%s""" % self._table self._cr.execute(query, [tuple(ids)]) ids = [r[0] for r in self._cr.fetchall()] - existing = self.browse(ids + new_ids) - if len(existing) < len(self): - # mark missing records in cache with a failed value - exc = MissingError( - _("Record does not exist or has been deleted.") - + '\n\n({} {}, {} {})'.format(_('Records:'), (self - existing).ids[:6], _('User:'), self._uid) - ) - self.env.cache.set_failed(self - existing, self._fields.values(), exc) - return existing + return self.browse(ids + new_ids) def _check_recursion(self, parent=None): """ @@ -4377,6 +4388,7 @@ Fields: # must ignore 'active' flag, ir.rules, etc. => direct SQL query cr = self._cr + self.flush([parent]) query = 'SELECT "%s" FROM "%s" WHERE id = %%s' % (parent, self._table) for id in self.ids: current_id = id @@ -4402,6 +4414,8 @@ Fields: # field must be a many2many on itself raise ValueError('invalid field_name: %r' % (field_name,)) + self.flush([field_name]) + cr = self._cr query = 'SELECT "%s", "%s" FROM "%s" WHERE "%s" IN %%s AND "%s" IS NOT NULL' % \ (field.column1, field.column2, field.relation, field.column1, field.column2) @@ -4872,9 +4886,19 @@ Fields: # set inverse fields on new records in the comodel if field.relational: inv_recs = self[field.name].filtered(lambda r: not r.id) - if inv_recs: - for invf in self._field_inverses[field]: - invf._update(inv_recs, self) + if not inv_recs: + continue + for invf in self._field_inverses[field]: + # DLE P98: `test_40_new_fields` + # /home/dle/src/odoo/master-nochange-fp/odoo/addons/test_new_api/tests/test_new_fields.py + # Be careful to not break `test_onchange_taxes_1`, `test_onchange_taxes_2`, `test_onchange_taxes_3` + # If you attempt to find a better solution + for inv_rec in inv_recs: + if not cache.contains(inv_rec, invf): + val = invf.convert_to_cache(self, inv_rec, validate=False) + cache.set(inv_rec, invf, val) + else: + invf._update(inv_rec, self) def _convert_to_record(self, values): """ Convert the ``values`` dictionary from the cache format to the @@ -4892,8 +4916,6 @@ Fields: for name, value in values.items(): if name in fields: field = fields[name] - value = field.convert_to_cache(value, self, validate=False) - value = field.convert_to_record(value, self) value = field.convert_to_write(value, self) if not isinstance(value, NewId): result[name] = value @@ -4943,7 +4965,7 @@ Fields: field = recs._fields[name] null = field.convert_to_cache(False, self, validate=False) if recs: - recs = recs.mapped(lambda rec: field.convert_to_record(rec._cache.get_value(name, null), rec)) + recs = recs.mapped(lambda rec: field.convert_to_record(rec._cache.get(name, null), rec)) else: recs = field.convert_to_record(null, recs) return recs @@ -4959,6 +4981,90 @@ Fields: func = lambda rec: any(rec.mapped(name)) return self.browse([rec.id for rec in self if func(rec)]) + def filtered_domain(self, domain): + if not domain: return self + result = [] + for d in reversed(domain): + if d == '|': + result.append(result.pop() | result.pop()) + elif d == '!': + result.append(self - result.pop()) + elif d == '&': + result.append(result.pop() & result.pop()) + elif d == expression.TRUE_LEAF: + result.append(self) + elif d == expression.FALSE_LEAF: + result.append(self.browse()) + else: + (key, comparator, value) = d + if key.endswith('.id'): + key = key[:-3] + if key == 'id': key='' + if comparator in ('like', 'ilike', '=like', '=ilike', 'not ilike', 'not like'): + value_esc = value.replace('_', '?').replace('%', '*').replace('[', '?') + records = self.browse() + for rec in self: + data = rec.mapped(key) + if comparator in ('child_of', 'parent_of'): + value = data.search([(data._parent_name, comparator, value)]).ids + comparator = 'in' + if isinstance(data, BaseModel): + v = value + if (isinstance(value, list) or isinstance(value, tuple)) and len(value): + v = value[0] + if isinstance(v, str): + data = data.mapped('display_name') + else: + data = data and data.ids or [False] + else: + data = [ + (isinstance(x, datetime.date) and x.strftime('%Y-%m-%d %H:%M:%S')) or + x for x in data] + if comparator in ('in', 'not in'): + if not (isinstance(value, list) or isinstance(value, tuple)): + value = [value] + + if comparator == '=': + ok = value in data + elif comparator == 'in': + ok = any(map(lambda x: x in data, value)) + elif comparator == '<': + ok = any(map(lambda x: x < value, data)) + elif comparator == '>': + ok = any(map(lambda x: x > value, data)) + elif comparator == '<=': + ok = any(map(lambda x: x <= value, data)) + elif comparator == '>=': + ok = any(map(lambda x: x >= value, data)) + elif comparator in ('!=', '<>'): + ok = value not in data + elif comparator == 'not in': + ok = all(map(lambda x: x not in data, value)) + elif comparator == 'not ilike': + ok = all(map(lambda x: value.lower() not in x.lower(), data)) + elif comparator == 'ilike': + data = [x.lower() for x in data] + ok = bool(fnmatch.filter(data, '*'+(value_esc or '').lower()+'*')) + elif comparator == 'not like': + ok = all(map(lambda x: value not in x, data)) + elif comparator == 'like': + ok = bool(fnmatch.filter(data, value and '*'+value_esc+'*')) + elif comparator == '=?': + ok = (value in data) or not value + elif comparator in ('=like'): + ok = bool(fnmatch.filter(data, value_esc)) + elif comparator in ('=ilike'): + data = [x.lower() for x in data] + ok = bool(fnmatch.filter(data, value and value_esc.lower())) + else: + raise ValueError + if ok: records |= rec + result.append(records) + while len(result)>1: + result.append(result.pop() & result.pop()) + return result[0] + + def sorted(self, key=None, reverse=False): """ Return the recordset ``self`` ordered by ``key``. @@ -4981,6 +5087,68 @@ Fields: for name, value in values.items(): record[name] = value + @api.model + def flush(self, fnames=None, records=None): + """ Process all the pending recomputations (or at least the given field + names `fnames` if present) and flush the pending updates to the + database. + """ + def process(model, id_vals): + # group record ids by vals, to update in batch when possible + updates = defaultdict(list) + for rid, vals in id_vals.items(): + updates[frozendict(vals)].append(rid) + + for vals, ids in updates.items(): + recs = model.browse(ids) + try: + recs._write(vals) + except MissingError: + recs.exists()._write(vals) + + if fnames is None: + # flush everything + self.recompute() + while self.env.all.towrite: + model_name, id_vals = self.env.all.towrite.popitem() + process(self.env[model_name], id_vals) + else: + # flush self's model if any of the fields must be flushed + self.recompute(fnames, records=records) + + # check whether any of 'records' must be flushed + if records is not None: + fnames = set(fnames) + towrite = self.env.all.towrite.get(self._name) + if not towrite or all( + fnames.isdisjoint(towrite.get(record.id, ())) + for record in records + ): + return + + # DLE P76: test_onchange_one2many_with_domain_on_related_field + # ``` + # email.important = True + # self.assertIn(email, discussion.important_emails) + # ``` + # When a search on a field coming from a related occurs (the domain + # on discussion.important_emails field), make sure the related field + # is flushed + model_fields = {} + for fname in fnames: + field = self._fields[fname] + model_fields.setdefault(field.model_name, []).append(field) + if field.related_field: + model_fields.setdefault(field.related_field.model_name, []).append(field.related_field) + for model_name, fields in model_fields.items(): + if any( + field.name in vals + for vals in self.env.all.towrite.get(model_name, {}).values() + for field in fields + ): + id_vals = self.env.all.towrite.pop(model_name) + process(self.env[model_name], id_vals) + # # New records - represent records that do not exist in the database yet; # they are used to perform onchanges. @@ -5005,6 +5173,7 @@ Fields: origin = origin.id record = self.browse([NewId(origin, ref)]) record._update_cache(values, validate=False) + return record @property @@ -5226,123 +5395,159 @@ Fields: [(invf, None) for f in fields for invf in self._field_inverses[f]] self.env.cache.invalidate(spec) - def modified(self, fnames): + def modified(self, fnames, modified=None, create=False): """ Notify that fields have been modified on ``self``. This invalidates the cache, and prepares the recomputation of stored function fields (new-style fields only). :param fnames: iterable of field names that have been modified on records ``self`` + :param modified: don't use this + :param create: whether modified is called in the context of record creation """ - # group triggers by (model, path) to minimize the calls to search() - invalids = [] - triggers = defaultdict(set) - for fname in fnames: - mfield = self._fields[fname] - # invalidate mfield on self, and its inverses fields - invalids.append((mfield, self._ids)) - for field in self._field_inverses[mfield]: - invalids.append((field, None)) - # group triggers by model and path to reduce the number of search() - for field, path in self._field_triggers[mfield]: - triggers[(field.model_name, path)].add(field) - - # process triggers, mark fields to be invalidated/recomputed - for model_path, fields in triggers.items(): - model_name, path = model_path - stored = {field for field in fields if field.compute and field.store} - # process stored fields - if path and stored: - # determine records of model_name linked by path to self - if path == 'id': - target0 = self - else: - Model = self.env[model_name] - f = Model._fields.get(path) - if f and f.store and f.type not in ('one2many', 'many2many'): - # path is direct (not dotted), stored, and inline -> optimise to raw sql - self.env.cr.execute('SELECT id FROM "%s" WHERE "%s" in %%s' % (Model._table, path), [tuple(self.ids)]) - target0 = Model.browse(i for [i] in self.env.cr.fetchall()) - else: - env = self.env(user=SUPERUSER_ID, context={'active_test': False}) - target0 = env[model_name].search([(path, 'in', self.ids)]) - target0 = target0.with_env(self.env) - # prepare recomputation for each field on linked records - for field in stored: - # discard records to not recompute for field - target = target0 - self.env.protected(field) - if not target: + if not self or not fnames: + return + field_triggers = self._field_triggers if not create else self._field_triggers_create + if len(fnames) == 1: + tree = field_triggers.get(self._fields[next(iter(fnames))]) + else: + # merge dependency trees to evaluate all triggers at once + tree = {} + for fname in fnames: + node = field_triggers.get(self._fields[fname]) + if node: + trigger_tree_merge(tree, node) + if tree: + self._modified_triggers(tree, modified=modified) + + def _modified_triggers(self, tree, modified=None): + """ Process a tree of field triggers on ``self``. """ + if not self: + return + for key, val in tree.items(): + if key is None: + # val is a list of fields to mark as todo + todo = defaultdict(list) + modified = modified or {} + for field in val: + records = self - self.env.protected(field) + if modified and field in modified: + records -= modified[field] + if not records: continue - invalids.append((field, target._ids)) - # mark field to be recomputed on target - if field.compute_sudo: - target = target.sudo() - target._recompute_todo(field) - # process non-stored fields - for field in (fields - stored): - invalids.append((field, None)) - - self.env.cache.invalidate(invalids) - - def _recompute_check(self, field): - """ If ``field`` must be recomputed on some record in ``self``, return the - corresponding records that must be recomputed. - """ - return self.env.check_todo(field, self) - - def _recompute_todo(self, field): - """ Mark ``field`` to be recomputed. """ - self.env.add_todo(field, self) - - def _recompute_done(self, field): - """ Mark ``field`` as recomputed. """ - self.env.remove_todo(field, self) + # Dont force the recomputation of compute fields which are + # not stored as this is not really necessary. + if field.compute and field.store: + records_to_invalidate = records.filtered(lambda r: not r.id) + self.env.add_to_compute(field, records - records_to_invalidate) + self.env.cache.invalidate([(field, records_to_invalidate._ids)]) + else: + self.env.cache.invalidate([(field, records._ids)]) + # recursively trigger recomputation of field's dependents + todo[records].append(field.name) + for records, fieldnames in todo.items(): + for fname in fieldnames: + if records._fields[fname] in modified: + modified[records._fields[fname]] += records + else: + modified[records._fields[fname]] = records + records.modified(fieldnames, modified=modified) + else: + # val is another tree of dependencies + model = self.env[key.model_name] + for invf in model._field_inverses[key]: + # use an inverse of field without domain + if not (invf.type in ('one2many', 'many2many') and invf.domain): + if invf.type == 'many2one_reference': + rec_ids = set() + for rec in self: + if rec[invf.model_field] == key.model_name: + rec_ids.add(rec[invf.name]) + records = model.browse(rec_ids) + else: + try: + records = self.mapped(invf.name) + except MissingError: + records = self.exists().mapped(invf.name) + + # TODO: find a better fix + if key.model_name == records._name: + break + else: + new_records = self.filtered(lambda r: not r.id) + real_records = self - new_records + records = model.browse() + if key.store and real_records: + records |= model.sudo().search([(key.name, 'in', real_records.ids)]) + if not key.store or new_records: + cache_records = self.env.cache.get_records(model, key) + records |= cache_records.filtered(lambda r: set(r[key.name]._ids) & set(self._ids)) + records._modified_triggers(val, modified=modified) @api.model - def recompute(self): - """ Recompute stored function fields. The fields and records to - recompute have been determined by method :meth:`modified`. - """ - while self.env.has_todo(): - field, recs = self.env.get_todo() - # determine the fields to recompute - fs = self.env[field.model_name]._field_computed[field] - ns = [f.name for f in fs if f.store] - # evaluate fields, and group record ids by update - updates = defaultdict(set) - for rec in recs: + def recompute(self, fnames=None, records=None): + """ Recompute all function fields (or the given ``fnames`` if present). + The fields and records to recompute have been determined by method + :meth:`modified`. + """ + def process(field): + recs = self.env.records_to_compute(field) + if not recs: + return + if field.compute and field.store: try: - vals = {n: rec[n] for n in ns} + recs.mapped(field.name) except MissingError: - continue - vals = rec._convert_to_write(vals) - updates[frozendict(vals)].add(rec.id) - # update records in batch when possible - with recs.env.norecompute(): - for vals, ids in updates.items(): - target = recs.browse(ids) - try: - target._write(dict(vals)) - except MissingError: - # retry without missing records - target.exists()._write(dict(vals)) - - # mark computed fields as done - for f in fs: - recs._recompute_done(f) + existing = recs.exists() + existing.mapped(field.name) + # mark the field as computed on missing records, otherwise + # they remain forever in the todo list, and lead to an + # infinite loop... + self.env.remove_to_compute(field, recs - existing) + else: + self.env.cache.invalidate([(field, recs._ids)]) + self.env.remove_to_compute(field, recs) + + if fnames is None: + # recompute everything + fields_to_compute = self.env.fields_to_compute() + while fields_to_compute: + process(next(iter(fields_to_compute))) + else: + fields = [self._fields[fname] for fname in fnames] + + # check whether any 'records' must be computed + if records is not None and not any( + records & self.env.records_to_compute(field) + for field in fields + ): + return + + # recompute the given fields on self's model + for field in fields: + process(field) # # Generic onchange method # + def _dependent_fields(self, field): + """ Return an iterator on the fields that depend on ``field``. """ + def traverse(node): + for key, val in node.items(): + if key is None: + yield from val + else: + yield from traverse(val) + return traverse(self._field_triggers_create.get(field, {})) + def _has_onchange(self, field, other_fields): """ Return whether ``field`` should trigger an onchange event in the presence of ``other_fields``. """ - # test whether self has an onchange method for field, or field is a - # dependency of any field in other_fields - return field.name in self._onchange_methods or \ - any(dep in other_fields for dep, _ in self._field_triggers[field]) + return (field.name in self._onchange_methods) or any( + dep in other_fields for dep in self._dependent_fields(field) + ) @api.model def _onchange_spec(self, view_info=None): @@ -5408,6 +5613,9 @@ Fields: :param field_onchange: dictionary mapping field names to their on_change attribute """ + # this is for tests using `Form` + self.flush() + env = self.env if isinstance(field_name, list): names = field_name @@ -5664,26 +5872,6 @@ class RecordCache(MutableMapping): """ Return the number of fields with a cached value. """ return sum(1 for name in self) - def has_value(self, name): - """ Return whether `record` has a cached, regular value for field ``name``. """ - field = self._record._fields[name] - return self._record.env.cache.contains_value(self._record, field) - - def get_value(self, name, default=None): - """ Return the cached, regular value of field ``name`` for `record`, or ``default``. """ - field = self._record._fields[name] - return self._record.env.cache.get_value(self._record, field, default) - - def set_special(self, name, getter): - """ Use the given getter to get the cached value of field ``name``. """ - field = self._record._fields[name] - self._record.env.cache.set_special(self._record, field, getter) - - def set_failed(self, names, exception): - """ Mark the given fields with the given exception. """ - fields = [self._record._fields[name] for name in names] - self._record.env.cache.set_failed(self._record, fields, exception) - AbstractModel = BaseModel diff --git a/odoo/modules/loading.py b/odoo/modules/loading.py index bc423861c90..1551bdc3800 100644 --- a/odoo/modules/loading.py +++ b/odoo/modules/loading.py @@ -268,6 +268,7 @@ def load_module_graph(cr, graph, status=None, perform_checks=True, for kind in ('init', 'demo', 'update'): if hasattr(package, kind): delattr(package, kind) + module.flush() if package.name is not None: registry._init_modules.add(package.name) @@ -391,7 +392,7 @@ def load_modules(db, force_demo=False, status=None, update_module=False): cr.execute("update ir_module_module set state=%s where name=%s", ('installed', 'base')) Module.invalidate_cache(['state']) - + Module.flush() # STEP 3: Load marked modules (skipping base which was done in STEP 1) # IMPORTANT: this is done in two parts, first loading all installed or @@ -451,6 +452,7 @@ def load_modules(db, force_demo=False, status=None, update_module=False): # Cleanup orphan records env['ir.model.data']._process_end(processed_modules) + env['base'].flush() for kind in ('init', 'demo', 'update'): tools.config[kind] = {} @@ -513,6 +515,7 @@ def load_modules(db, force_demo=False, status=None, update_module=False): env = api.Environment(cr, SUPERUSER_ID, {}) for model in env.values(): model._register_hook() + env['base'].flush() # STEP 9: save installed/updated modules for post-install tests registry.updated_modules += processed_modules diff --git a/odoo/modules/registry.py b/odoo/modules/registry.py index 99a920f233d..c3930d3f425 100644 --- a/odoo/modules/registry.py +++ b/odoo/modules/registry.py @@ -183,25 +183,6 @@ class Registry(Mapping): """ Add or replace a model in the registry.""" self.models[model_name] = model - @lazy_property - def field_sequence(self): - """ Return a function mapping a field to an integer. The value of a - field is guaranteed to be strictly greater than the value of the - field's dependencies. - """ - # map fields on their dependents - dependents = { - field: set(dep for dep, _ in model._field_triggers[field] if dep != field) - for model in self.values() - for field in model._fields.values() - } - # sort them topologically, and associate a sequence number to each field - mapping = { - field: num - for num, field in enumerate(reversed(topological_sort(dependents))) - } - return mapping.get - def descendants(self, model_names, *kinds): """ Return the models corresponding to ``model_names`` and all those that inherit/inherits from them. @@ -300,8 +281,7 @@ class Registry(Mapping): func = self._post_init_queue.popleft() func() - if models: - models[0].recompute() + env['base'].flush() # make sure all tables are present self.check_tables_exist(cr) @@ -317,12 +297,11 @@ class Registry(Mapping): if missing_tables: missing = {table2model[table] for table in missing_tables} _logger.info("Models have no table: %s.", ", ".join(missing)) - # recreate missing tables following model dependencies - deps = {name: model._depends for name, model in env.items()} - for name in topological_sort(deps): - if name in missing: - _logger.info("Recreate table of model %s.", name) - env[name].init() + # recreate missing tables + for name in missing: + _logger.info("Recreate table of model %s.", name) + env[name].init() + env['base'].flush() # check again, and log errors if tables are still missing missing_tables = set(table2model).difference(existing_tables(cr, table2model)) for table in missing_tables: diff --git a/odoo/sql_db.py b/odoo/sql_db.py index debc9f48c42..ed5cf07884d 100644 --- a/odoo/sql_db.py +++ b/odoo/sql_db.py @@ -22,6 +22,8 @@ from psycopg2.extensions import ISOLATION_LEVEL_AUTOCOMMIT, ISOLATION_LEVEL_READ from psycopg2.pool import PoolError from werkzeug import urls +from odoo.api import Environment + psycopg2.extensions.register_type(psycopg2.extensions.UNICODE) _logger = logging.getLogger(__name__) @@ -367,6 +369,10 @@ class Cursor(object): def commit(self): """ Perform an SQL `COMMIT` """ + for env in Environment.envs: + if env.cr is self: + env['base'].flush() + break result = self._cnx.commit() for func in self._pop_event_handlers()['commit']: func() @@ -376,6 +382,10 @@ class Cursor(object): def rollback(self): """ Perform an SQL `ROLLBACK` """ + for env in Environment.envs: + if env.cr is self: + env.clear() + break result = self._cnx.rollback() for func in self._pop_event_handlers()['rollback']: func() @@ -463,9 +473,17 @@ class TestCursor(object): _logger.debug("TestCursor.autocommit(%r) does nothing", on) def commit(self): + for env in Environment.envs: + if env.cr is self: + env['base'].flush() + break self._cursor.execute('SAVEPOINT "%s"' % self._savepoint) def rollback(self): + for env in Environment.envs: + if env.cr is self: + env.clear() + break self._cursor.execute('ROLLBACK TO SAVEPOINT "%s"' % self._savepoint) def __enter__(self): diff --git a/odoo/tests/common.py b/odoo/tests/common.py index e0370cc0f51..0dd6162c320 100644 --- a/odoo/tests/common.py +++ b/odoo/tests/common.py @@ -33,7 +33,7 @@ from lxml import etree, html from odoo.models import BaseModel from odoo.osv.expression import normalize_domain -from odoo.tools import single_email_re +from odoo.tools import float_compare, single_email_re from odoo.tools.misc import find_in_path from odoo.tools.safe_eval import safe_eval @@ -259,7 +259,7 @@ class BaseCase(TreeCase, MetaCase('DummyCase', (object,), {})): return self._assertRaises(exception) @contextmanager - def assertQueryCount(self, default=0, **counters): + def assertQueryCount(self, default=0, flush=True, **counters): """ Context manager that counts queries. It may be invoked either with one value, or with a set of named arguments like ``login=value``:: @@ -276,8 +276,12 @@ class BaseCase(TreeCase, MetaCase('DummyCase', (object,), {})): with self.subTest(), patch('random.random', lambda: 1): login = self.env.user.login expected = counters.get(login, default) + if flush: + self.env.user.flush() count0 = self.cr.sql_log_count yield + if flush: + self.env.user.flush() count = self.cr.sql_log_count - count0 if count != expected: # add some info on caller to allow semi-automatic update of query count @@ -293,6 +297,8 @@ class BaseCase(TreeCase, MetaCase('DummyCase', (object,), {})): logger.info(msg, login, count, expected, funcname, filename, linenum) else: yield + if flush: + self.env.user.flush() def assertRecordValues(self, records, expected_values): ''' Compare a recordset with a list of dictionaries representing the expected results. @@ -314,7 +320,8 @@ class BaseCase(TreeCase, MetaCase('DummyCase', (object,), {})): for field_name in candidate.keys(): record_value = record[field_name] candidate_value = candidate[field_name] - field_type = record._fields[field_name].type + field = record._fields[field_name] + field_type = field.type if field_type == 'monetary': # Compare monetary field. currency_field_name = record._fields[field_name].currency_field @@ -322,6 +329,8 @@ class BaseCase(TreeCase, MetaCase('DummyCase', (object,), {})): if record_currency.compare_amounts(candidate_value, record_value)\ if record_currency else candidate_value != record_value: return False + elif field_type == 'float' and field.get_digits(record.env): + return not float_compare(candidate_value, record_value, precision_digits=field.get_digits(record.env)[1]) elif field_type in ('one2many', 'many2many'): # Compare x2many relational fields. # Empty comparison must be an empty list to be True. @@ -363,8 +372,13 @@ class BaseCase(TreeCase, MetaCase('DummyCase', (object,), {})): msg = 'Wrong number of records to compare: %d != %d.\n\n' % (len(records), len(expected_values)) self.fail(msg + _format_message(records, expected_values)) + candidates = list(expected_values) for index, record in enumerate(records): - if not _compare_candidate(record, expected_values[index]): + for candidate_index, candidate in enumerate(candidates): + if _compare_candidate(record, candidate): + candidates.pop(candidate_index) + break + else: msg = 'Record doesn\'t match expected values at index %d.\n\n' % index self.fail(msg + _format_message(records, expected_values)) @@ -425,6 +439,10 @@ class SingleTransactionCase(BaseCase): cls.cr = cls.registry.cursor() cls.env = api.Environment(cls.cr, odoo.SUPERUSER_ID, {}) + def setUp(self): + super(SingleTransactionCase, self).setUp() + self.env.user.flush() + @classmethod def tearDownClass(cls): # rollback and close the cursor, and reset the environments @@ -885,6 +903,7 @@ class HttpCase(TransactionCase): self.opener.cookies['session_id'] = self.session_id def url_open(self, url, data=None, files=None, timeout=10, headers=None): + self.env['base'].flush() if url.startswith('/'): url = "http://%s:%s%s" % (HOST, PORT, url) if data or files: @@ -965,6 +984,9 @@ class HttpCase(TransactionCase): base_url = "http://%s:%s" % (HOST, PORT) ICP = self.env['ir.config_parameter'] ICP.set_param('web.base.url', base_url) + # flush updates to the database before launching the client side, + # otherwise they simply won't be visible + ICP.flush() url = "%s%s" % (base_url, url_path or '/') self._logger.info('Open "%s" in browser', url) @@ -1003,7 +1025,12 @@ class HttpCase(TransactionCase): step_delay = ', %s' % step_delay if step_delay else '' code = kwargs.pop('code', "odoo.startTour('%s'%s)" % (tour_name, step_delay)) ready = kwargs.pop('ready', "odoo.__DEBUG__.services['web_tour.tour'].tours.%s.ready" % tour_name) - return self.browser_js(url_path=url_path, code=code, ready=ready, **kwargs) + res = self.browser_js(url_path=url_path, code=code, ready=ready, **kwargs) + # some tests read the result after the tour, and as the tour does not + # use this environment's cache, invalidate it to fetch the data from the + # database + self.env.cache.invalidate() + return res phantom_js = browser_js @@ -1026,6 +1053,9 @@ def users(*logins): # switch user and execute func self.uid = user_id[login] func(*args, **kwargs) + # Invalidate the cache between subtests, in order to not reuse + # the former user's cache (`test_read_mail`, `test_write_mail`) + self.env.cache.invalidate() finally: self.uid = old_uid @@ -1040,13 +1070,16 @@ def warmup(func, *args, **kwargs): effects of the warmup phase are rolled back thanks to a savepoint. """ self = args[0] + self.env['base'].flush() + self.env.cache.invalidate() # run once to warm up the caches self.warm = False self.cr.execute('SAVEPOINT test_warmup') func(*args, **kwargs) + self.env['base'].flush() + # run once for real self.cr.execute('ROLLBACK TO SAVEPOINT test_warmup') self.env.cache.invalidate() - # run once for real self.warm = True func(*args, **kwargs) diff --git a/odoo/tools/sql.py b/odoo/tools/sql.py index 2a0594b7d34..a805202a019 100644 --- a/odoo/tools/sql.py +++ b/odoo/tools/sql.py @@ -74,7 +74,8 @@ def column_exists(cr, tablename, columnname): def create_column(cr, tablename, columnname, columntype, comment=None): """ Create a column with the given type. """ - cr.execute('ALTER TABLE "{}" ADD COLUMN "{}" {}'.format(tablename, columnname, columntype)) + coldefault = (columntype.upper()=='BOOLEAN') and 'DEFAULT false' or '' + cr.execute('ALTER TABLE "{}" ADD COLUMN "{}" {} {}'.format(tablename, columnname, columntype, coldefault)) if comment: cr.execute('COMMENT ON COLUMN "{}"."{}" IS %s'.format(tablename, columnname), (comment,)) _schema.debug("Table %r: added column %r of type %s", tablename, columnname, columntype) |
