diff options
| author | Raphael Collet <rco@odoo.com> | 2020-12-13 15:27:27 +0000 |
|---|---|---|
| committer | Raphael Collet <rco@odoo.com> | 2020-12-18 14:00:03 +0000 |
| commit | ba4665ce8267b020f6dc81bb7d36875b60f64f66 (patch) | |
| tree | 400a10f07fe5d49be5742cb46a0cc7f4dbccd697 /odoo | |
| parent | ea6952fd7ec881b79a7294c40427ce9966997e66 (diff) | |
[FIX] core: marking and traversing computed fields before modification
This fixes an issue that occurs when marking fields to compute in the
following situation:
- it occurs before the actual modification,
- a relational field is marked to compute,
- the same field is traversed to inverse some dependency.
When the marking occurs after the modification, the traversal of the
field should actually recompute the field. However, if the marking
occurs before the modification, the inversion of dependencies must be
based on the current value of the field. This is the case with method
unlink(): we call method modified() to mark the fields that currently
depend on the records to be deleted, and the fields must be computed
only after the deletion!
X-original-commit: 3a6ac95ddf6a8719aa714a83fc339ba838c3811d
Diffstat (limited to 'odoo')
| -rw-r--r-- | odoo/addons/test_new_api/models/test_new_api.py | 42 | ||||
| -rw-r--r-- | odoo/addons/test_new_api/security/ir.model.access.csv | 3 | ||||
| -rw-r--r-- | odoo/addons/test_new_api/tests/test_new_fields.py | 52 | ||||
| -rw-r--r-- | odoo/fields.py | 6 | ||||
| -rw-r--r-- | odoo/models.py | 106 |
5 files changed, 170 insertions, 39 deletions
diff --git a/odoo/addons/test_new_api/models/test_new_api.py b/odoo/addons/test_new_api/models/test_new_api.py index e8c868f212f..bfe9b2fcee9 100644 --- a/odoo/addons/test_new_api/models/test_new_api.py +++ b/odoo/addons/test_new_api/models/test_new_api.py @@ -493,8 +493,17 @@ class ComputeRecursive(models.Model): name = fields.Char(required=True) parent = fields.Many2one('test_new_api.recursive', ondelete='cascade') + full_name = fields.Char(compute='_compute_full_name') display_name = fields.Char(compute='_compute_display_name', store=True) + @api.depends('name', 'parent.full_name') + def _compute_full_name(self): + for rec in self: + if rec.parent: + rec.full_name = rec.parent.full_name + " / " + rec.name + else: + rec.full_name = rec.name + @api.depends('name', 'parent.display_name') def _compute_display_name(self): for rec in self: @@ -787,7 +796,7 @@ class Attachment(models.Model): rec.name = self.env[rec.res_model].browse(rec.res_id).display_name # DLE P55: `test_cache_invalidation` - def modified(self, fnames, create=False): + def modified(self, fnames, *args, **kwargs): if not self: return comodel = self.env[self.res_model] @@ -796,7 +805,7 @@ class Attachment(models.Model): record = comodel.browse(self.res_id) self.env.cache.invalidate([(field, record._ids)]) record.modified(['attachment_ids']) - return super(Attachment, self).modified(fnames, create) + return super(Attachment, self).modified(fnames, *args, **kwargs) class AttachmentHost(models.Model): @@ -1184,3 +1193,32 @@ class ComputeEditableLine(models.Model): def _compute_edit(self): for line in self: line.edit = line.value + + +class TriggerLeft(models.Model): + _name = 'test_new_api.trigger.left' + _description = 'model with a related many2one' + + middle_ids = fields.One2many('test_new_api.trigger.middle', 'left_id') + right_id = fields.Many2one(related='middle_ids.right_id', store=True) + + +class TriggerMiddle(models.Model): + _name = 'test_new_api.trigger.middle' + _description = 'model linking test_new_api.trigger.left and test_new_api.trigger.right' + + left_id = fields.Many2one('test_new_api.trigger.left', required=True) + right_id = fields.Many2one('test_new_api.trigger.right', required=True) + + +class TriggerRight(models.Model): + _name = 'test_new_api.trigger.right' + _description = 'model with a dependency on the inverse of the related many2one' + + left_ids = fields.One2many('test_new_api.trigger.left', 'right_id') + left_size = fields.Integer(compute='_compute_left_size', store=True) + + @api.depends('left_ids') + def _compute_left_size(self): + for record in self: + record.left_size = len(record.left_ids) diff --git a/odoo/addons/test_new_api/security/ir.model.access.csv b/odoo/addons/test_new_api/security/ir.model.access.csv index 021d7127b05..37f20cf852e 100644 --- a/odoo/addons/test_new_api/security/ir.model.access.csv +++ b/odoo/addons/test_new_api/security/ir.model.access.csv @@ -68,3 +68,6 @@ access_test_new_api_compute_container,access_test_new_api_compute_container,mode access_test_new_api_compute_member,access_test_new_api_compute_member,model_test_new_api_compute_member,,1,1,1,1 access_test_new_api_compute_editable,access_test_new_api_compute_editable,model_test_new_api_compute_editable,,1,1,1,1 access_test_new_api_compute_editable_line,access_test_new_api_compute_editable_line,model_test_new_api_compute_editable_line,,1,1,1,1 +access_test_new_api_trigger_left,access_test_new_api_trigger_left,model_test_new_api_trigger_left,,1,1,1,1 +access_test_new_api_trigger_middle,access_test_new_api_trigger_middle,model_test_new_api_trigger_middle,,1,1,1,1 +access_test_new_api_trigger_right,access_test_new_api_trigger_right,model_test_new_api_trigger_right,,1,1,1,1 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 4c9aaf00f5e..3a95a6c10f2 100644 --- a/odoo/addons/test_new_api/tests/test_new_fields.py +++ b/odoo/addons/test_new_api/tests/test_new_fields.py @@ -447,20 +447,42 @@ class TestFields(TransactionCaseWithUserDemo): b = self.env['test_new_api.recursive'].create({'name': 'B', 'parent': a.id}) c = self.env['test_new_api.recursive'].create({'name': 'C', 'parent': b.id}) d = self.env['test_new_api.recursive'].create({'name': 'D', 'parent': c.id}) + self.assertEqual(a.full_name, 'A') + self.assertEqual(b.full_name, 'A / B') + self.assertEqual(c.full_name, 'A / B / C') + self.assertEqual(d.full_name, 'A / B / C / D') self.assertEqual(a.display_name, 'A') self.assertEqual(b.display_name, 'A / B') self.assertEqual(c.display_name, 'A / B / C') self.assertEqual(d.display_name, 'A / B / C / D') + a.name = 'A1' + self.assertEqual(a.full_name, 'A1') + self.assertEqual(b.full_name, 'A1 / B') + self.assertEqual(c.full_name, 'A1 / B / C') + self.assertEqual(d.full_name, 'A1 / B / C / D') + self.assertEqual(a.display_name, 'A1') + self.assertEqual(b.display_name, 'A1 / B') + self.assertEqual(c.display_name, 'A1 / B / C') + self.assertEqual(d.display_name, 'A1 / B / C / D') + b.parent = False - self.assertEqual(a.display_name, 'A') + self.assertEqual(a.full_name, 'A1') + self.assertEqual(b.full_name, 'B') + self.assertEqual(c.full_name, 'B / C') + self.assertEqual(d.full_name, 'B / C / D') + self.assertEqual(a.display_name, 'A1') self.assertEqual(b.display_name, 'B') self.assertEqual(c.display_name, 'B / C') self.assertEqual(d.display_name, 'B / C / D') # rename several records to trigger several recomputations at once (d + c + b).write({'name': 'X'}) - self.assertEqual(a.display_name, 'A') + self.assertEqual(a.full_name, 'A1') + self.assertEqual(b.full_name, 'X') + self.assertEqual(c.full_name, 'X / X') + self.assertEqual(d.full_name, 'X / X / X') + self.assertEqual(a.display_name, 'A1') self.assertEqual(b.display_name, 'X') self.assertEqual(c.display_name, 'X / X') self.assertEqual(d.display_name, 'X / X / X') @@ -1138,6 +1160,32 @@ class TestFields(TransactionCaseWithUserDemo): self.assertEqual(bar.foo, oof) self.assertIn(bar, bar.search([('foo', 'in', oof.ids)])) + def test_25_one2many_inverse_related(self): + left = self.env['test_new_api.trigger.left'].create({}) + right = self.env['test_new_api.trigger.right'].create({}) + self.assertFalse(left.right_id) + self.assertFalse(right.left_ids) + self.assertFalse(right.left_size) + + # create middle: this should trigger left.right_id by traversing + # middle.left_id, and right.left_size by traversing left.right_id + # after its computation! + middle = self.env['test_new_api.trigger.middle'].create({ + 'left_id': left.id, + 'right_id': right.id, + }) + self.assertEqual(left.right_id, right) + self.assertEqual(right.left_ids, left) + self.assertEqual(right.left_size, 1) + + # delete middle: this should trigger left.right_id by traversing + # middle.left_id, and right.left_size by traversing left.right_id + # before its computation! + middle.unlink() + self.assertFalse(left.right_id) + self.assertFalse(right.left_ids) + self.assertFalse(right.left_size) + def test_26_inherited(self): """ test inherited fields. """ # a bunch of fields are inherited from res_partner diff --git a/odoo/fields.py b/odoo/fields.py index 8ddc0c1b422..4514c62baab 100644 --- a/odoo/fields.py +++ b/odoo/fields.py @@ -1097,10 +1097,10 @@ class Field(MetaField('DummyField', (object,), {})): # new records: no business logic new_records = records.browse(new_ids) with records.env.protecting(records.pool.field_computed.get(self, [self]), records): - new_records.modified([self.name]) - self.write(new_records, value) if self.relational: - new_records.modified([self.name]) + new_records.modified([self.name], before=True) + self.write(new_records, value) + new_records.modified([self.name]) if self.inherited: # special case: also assign parent records if they are new diff --git a/odoo/models.py b/odoo/models.py index 8993fbc9de3..9657e52a20a 100644 --- a/odoo/models.py +++ b/odoo/models.py @@ -3416,7 +3416,7 @@ Fields: # 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) + self.modified(self._fields, before=True) with self.env.norecompute(): self.check_access_rule('unlink') @@ -3616,8 +3616,25 @@ Fields: # protect fields being written against recomputation with env.protecting(protected, self): - # determine records depending on values - self.modified(relational_names) + # Determine records depending on values. When modifying a relational + # field, you have to recompute what depends on the field's values + # before and after modification. This is because the modification + # has an impact on the "data path" between a computed field and its + # dependency. Note that this double call to modified() is only + # necessary for relational fields. + # + # It is best explained with a simple example: consider two sales + # orders SO1 and SO2. The computed total amount on sales orders + # indirectly depends on the many2one field 'order_id' linking lines + # to their sales order. Now consider the following code: + # + # line = so1.line_ids[0] # pick a line from SO1 + # line.order_id = so2 # move the line to SO2 + # + # In this situation, the total amount must be recomputed on *both* + # sales order: the line's order before the modification, and the + # line's order after the modification. + self.modified(relational_names, before=True) real_recs = self.filtered('id') @@ -5703,14 +5720,14 @@ Fields: [(invf, None) for f in fields for invf in self._field_inverses[f]] self.env.cache.invalidate(spec) - def modified(self, fnames, 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). + def modified(self, fnames, create=False, before=False): + """ Notify that fields will be or have been modified on ``self``. This + invalidates the cache where necessary, and prepares the recomputation of + dependent stored fields. - :param fnames: iterable of field names that have been modified on - records ``self`` - :param create: whether modified is called in the context of record creation + :param fnames: iterable of field names modified on records ``self`` + :param create: whether called in the context of record creation + :param before: whether called before modifying records ``self`` """ if not self or not fnames: return @@ -5743,34 +5760,59 @@ Fields: node = self.pool.field_triggers.get(self._fields[fname]) if node: trigger_tree_merge(tree, node) + if tree: - self.sudo().with_context(active_test=False)._modified_triggers(tree, create) + # determine what to compute (through an iterator) + tocompute = self.sudo().with_context(active_test=False)._modified_triggers(tree, create) + + # When called after modification, one should traverse backwards + # dependencies by taking into account all fields already known to be + # recomputed. In that case, we mark fieds to compute as soon as + # possible. + # + # When called before modification, one should mark fields to compute + # after having inversed all dependencies. This is because we + # determine what currently depends on self, and it should not be + # recomputed before the modification! + if before: + tocompute = list(tocompute) + + # process what to compute + for field, records in tocompute: + records -= self.env.protected(field) + if not records: + continue + # Dont force the recomputation of compute fields which are + # not stored as this is not really necessary. + recursive = not create and field.recursive + if field.compute and field.store: + if recursive: + marked_records = self.env.not_to_compute(field, records) + self.env.add_to_compute(field, records) + else: + if recursive: + marked_records = records & self.env.cache.get_records(records, field) + self.env.cache.invalidate([(field, records._ids)]) + # recursively trigger recomputation of field's dependents + if recursive: + marked_records.modified([field.name]) def _modified_triggers(self, tree, create=False): - """ Process a tree of field triggers on ``self``. """ + """ Return an iterator traversing a tree of field triggers on ``self``, + traversing backwards field dependencies along the way, and yielding + pairs ``(field, records)`` to recompute. + """ if not self: return + + # first yield what to compute + for field in tree.get(None, ()): + yield field, self + + # then traverse dependencies backwards, and proceed recursively for key, val in tree.items(): if key is None: - # val is a list of fields to mark as todo - for field in val: - records = self - self.env.protected(field) - if not records: - continue - # Dont force the recomputation of compute fields which are - # not stored as this is not really necessary. - recursive = not create and field.recursive - if field.compute and field.store: - if recursive: - added = self.env.not_to_compute(field, records) - self.env.add_to_compute(field, records) - else: - if recursive: - added = self & self.env.cache.get_records(self, field) - self.env.cache.invalidate([(field, records._ids)]) - # recursively trigger recomputation of field's dependents - if recursive: - added.modified([field.name]) + continue elif create and key.type in ('many2one', 'many2one_reference'): # upon creation, no other record has a reference to self continue @@ -5810,7 +5852,7 @@ Fields: if 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) + yield from records._modified_triggers(val) @api.model def recompute(self, fnames=None, records=None): |
