[FIX] core: field recomputed on more records than expected

The issue occurs when a computed field depends on a many2many field with
a corresponding inverse field on its comodel.  Consider two models like

class User(models.Model):
    _name = _description = 'test_new_api.user'

    group_ids = fields.Many2many('test_new_api.group')
    group_count = fields.Integer(compute='_compute_group_count', store=True)

    @api.depends('group_ids')
    def _compute_group_count(self):
        for user in self:
            user.group_count = len(user.group_ids)

class Group(models.Model):
    _name = _description = 'test_new_api.group'

    user_ids = fields.Many2many('test_new_api.user')

When a user is added to a group with

    group.write({'user_ids': [Command.link(user.id)]})

we expect the field `group_count` to be recomputed on `user` only, but
it is actually triggered on *all* the records in `group.user_ids`.  This
is a real performance issue when there are many records in the relation.

The explanation comes from the fact that
 - the framework considers the field `user_ids` is modified on `group`;
 - the field `group_count` implicitly depends on `group_ids.user_ids`,
   which makes it triggered on the users `u` such that `u.group_ids`
   intersects `group`.

The solution consists in handling the dependencies on inverse many2many
field in the field itself.  The field no longer adds the implicit
dependency on its inverse field in the trigger tree, but instead
determines which records in the comodel are actually impacted by the
relation change in the method field.write().

closes odoo/odoo#111943

X-original-commit: bb3a6e378b5f6b2e14b74ce4efb6d749ad54cb1e
Signed-off-by: Raphael Collet <rco@odoo.com>
This commit is contained in:
Raphael Collet
2023-02-04 18:48:07 +01:00
parent 0b69bf0258
commit ed762a3cef
4 changed files with 89 additions and 1 deletions
@@ -1379,6 +1379,24 @@ class ComputeMember(models.Model):
member.container_id = container.search([('name', '=', member.name)], limit=1)
class User(models.Model):
_name = _description = 'test_new_api.user'
group_ids = fields.Many2many('test_new_api.group')
group_count = fields.Integer(compute='_compute_group_count', store=True)
@api.depends('group_ids')
def _compute_group_count(self):
for user in self:
user.group_count = len(user.group_ids)
class Group(models.Model):
_name = _description = 'test_new_api.group'
user_ids = fields.Many2many('test_new_api.user')
class ComputeEditable(models.Model):
_name = _description = 'test_new_api.compute_editable'
@@ -76,6 +76,8 @@ access_test_new_api_model_shared_cache_compute_parent,access_test_new_api.model_
access_test_new_api_model_shared_cache_compute_line,access_test_new_api.model_shared_cache_compute_line,model_test_new_api_model_shared_cache_compute_line,,1,1,1,1
access_test_new_api_compute_container,access_test_new_api_compute_container,model_test_new_api_compute_container,,1,1,1,1
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_user,access_test_new_api_user,model_test_new_api_user,,1,1,1,1
access_test_new_api_group,access_test_new_api_group,model_test_new_api_group,,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_model_constrained_unlinks,access_test_new_api_model_constrained_unlinks,model_test_new_api_model_constrained_unlinks,,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
76 access_test_new_api_model_shared_cache_compute_line access_test_new_api.model_shared_cache_compute_line model_test_new_api_model_shared_cache_compute_line 1 1 1 1
77 access_test_new_api_compute_container access_test_new_api_compute_container model_test_new_api_compute_container 1 1 1 1
78 access_test_new_api_compute_member access_test_new_api_compute_member model_test_new_api_compute_member 1 1 1 1
79 access_test_new_api_user access_test_new_api_user model_test_new_api_user 1 1 1 1
80 access_test_new_api_group access_test_new_api_group model_test_new_api_group 1 1 1 1
81 access_test_new_api_compute_editable access_test_new_api_compute_editable model_test_new_api_compute_editable 1 1 1 1
82 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
83 access_test_new_api_model_constrained_unlinks access_test_new_api_model_constrained_unlinks model_test_new_api_model_constrained_unlinks 1 1 1 1
@@ -820,6 +820,44 @@ class TestFields(TransactionCaseWithUserDemo):
records[1].foo = "assign"
self.env.flush_all()
def test_17_compute_depends_on_many2many(self):
user1, user2, user3 = self.env['test_new_api.user'].create([{}, {}, {}])
group = self.env['test_new_api.group'].create({'user_ids': [Command.link(user1.id)]})
self.env.flush_all()
field = type(user1).group_count
self.assertFalse(self.env.records_to_compute(field))
# should mark user2 and user3 to compute only
group.write({'user_ids': [Command.link(user1.id), Command.link(user2.id), Command.link(user3.id)]})
self.assertEqual(self.env.records_to_compute(field), user2 + user3)
# should mark user2 to compute only
self.env.flush_all()
group.write({'user_ids': [Command.unlink(user2.id)]})
self.assertEqual(self.env.records_to_compute(field), user2)
# should mark user2 and user3 to compute only
self.env.flush_all()
group.write({'user_ids': [Command.set([user1.id, user2.id])]})
self.assertEqual(self.env.records_to_compute(field), user2 + user3)
# should mark user3 to compute only
self.env.flush_all()
user3.write({'group_ids': [Command.link(group.id)]})
self.assertEqual(self.env.records_to_compute(field), user3)
# similar with new records, but only check recomputation
user1 = self.env['test_new_api.user'].new({})
user2 = self.env['test_new_api.user'].new({})
group = self.env['test_new_api.group'].new({'user_ids': [user1.id]})
self.assertEqual(user1.group_count, 1)
self.assertEqual(user2.group_count, 0)
group.user_ids += user2
self.assertEqual(user1.group_count, 1)
self.assertEqual(user2.group_count, 1)
def test_20_float(self):
""" test rounding of float fields """
record = self.env['test_new_api.mixed'].create({})
+31 -1
View File
@@ -804,7 +804,7 @@ class Field(MetaField('DummyField', (object,), {})):
if not (field is self and not index):
yield tuple(field_seq)
if field.type in ('one2many', 'many2many'):
if field.type == 'one2many':
for inv_field in Model.pool.field_inverses[field]:
yield tuple(field_seq) + (inv_field,)
@@ -4823,6 +4823,9 @@ class Many2many(_RelationalMulti):
for record in records:
cache.set(record, self, tuple(new_relation[record.id]))
# determine the corecords for which the relation has changed
modified_corecord_ids = set()
# 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:
@@ -4838,6 +4841,7 @@ class Many2many(_RelationalMulti):
y_to_xs = defaultdict(set)
for x, y in pairs:
y_to_xs[y].add(x)
modified_corecord_ids.add(y)
for invf in records.pool.field_inverses[self]:
domain = invf.get_domain_list(comodel)
valid_ids = set(records.filtered_domain(domain)._ids)
@@ -4858,6 +4862,7 @@ class Many2many(_RelationalMulti):
y_to_xs = defaultdict(set)
for x, y in pairs:
y_to_xs[y].add(x)
modified_corecord_ids.add(y)
if self.store:
# express pairs as the union of cartesian products:
@@ -4890,6 +4895,16 @@ class Many2many(_RelationalMulti):
except KeyError:
pass
if modified_corecord_ids:
# trigger the recomputation of fields that depend on the inverse
# fields of self on the modified corecords
corecords = comodel.browse(modified_corecord_ids)
corecords.modified([
invf.name
for invf in model.pool.field_inverses[self]
if invf.model_name == self.comodel_name
])
return records.filtered(
lambda record: new_relation[record.id] != old_relation[record.id]
)
@@ -4949,6 +4964,9 @@ class Many2many(_RelationalMulti):
for record in records:
cache.set(record, self, tuple(new_relation[record.id]))
# determine the corecords for which the relation has changed
modified_corecord_ids = set()
# 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:
@@ -4956,6 +4974,7 @@ class Many2many(_RelationalMulti):
y_to_xs = defaultdict(set)
for x, y in pairs:
y_to_xs[y].add(x)
modified_corecord_ids.add(y)
for invf in records.pool.field_inverses[self]:
domain = invf.get_domain_list(comodel)
valid_ids = set(records.filtered_domain(domain)._ids)
@@ -4977,6 +4996,7 @@ class Many2many(_RelationalMulti):
y_to_xs = defaultdict(set)
for x, y in pairs:
y_to_xs[y].add(x)
modified_corecord_ids.add(y)
for invf in records.pool.field_inverses[self]:
for y, xs in y_to_xs.items():
corecord = comodel.browse([y])
@@ -4987,6 +5007,16 @@ class Many2many(_RelationalMulti):
except KeyError:
pass
if modified_corecord_ids:
# trigger the recomputation of fields that depend on the inverse
# fields of self on the modified corecords
corecords = comodel.browse(modified_corecord_ids)
corecords.modified([
invf.name
for invf in model.pool.field_inverses[self]
if invf.model_name == self.comodel_name
])
return records.filtered(
lambda record: new_relation[record.id] != old_relation[record.id]
)