[IMP] core: allow grouping by m2m fields in read_group

With this commit, it is now possible to group the records of a model by
a Many2many field of said model.

The result of a read_group grouped by a m2m will return as many entries
or "groups" as there are different records in the comodel that are
linked to the model through the m2m, plus a null/false group, for records
of the model that have no linked records of the comodel or for records
of the comodel for which the current user has no read access to due to
ir.rules.

For a more illustrated explanation, the tests should cover all cases in
detail.

Note that this commit only introduces this change at the ORM level, the
frontend does not yet handle grouping by m2m fields but it is planned
in the near future.

Task-2428971

closes odoo/odoo#68958

Signed-off-by: Raphael Collet (rco) <rco@openerp.com>
Co-authored-by: Raphael Collet <rco@odoo.com>
This commit is contained in:
Adrian Torres
2021-04-20 12:08:35 +00:00
co-authored by Raphael Collet
parent be19a9e9aa
commit b4487e8ea4
6 changed files with 303 additions and 8 deletions
@@ -6,3 +6,5 @@ access_test_read_group_on_selection,access_test_read_group_on_selection,model_te
access_test_read_group_fill_temporal,access_test_read_group_fill_temporal,model_test_read_group_fill_temporal,,1,0,0,0
access_test_read_group_order,access_test_read_group_order,model_test_read_group_order,,1,0,0,0
access_test_read_group_order_line,access_test_read_group_order_line,model_test_read_group_order_line,,1,0,0,0
access_test_read_group_user,access_test_read_group_user,model_test_read_group_user,,1,0,0,0
access_test_read_group_task,access_test_read_group_task,model_test_read_group_task,,1,0,0,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
6 access_test_read_group_fill_temporal access_test_read_group_fill_temporal model_test_read_group_fill_temporal 1 0 0 0
7 access_test_read_group_order access_test_read_group_order model_test_read_group_order 1 0 0 0
8 access_test_read_group_order_line access_test_read_group_order_line model_test_read_group_order_line 1 0 0 0
9 access_test_read_group_user access_test_read_group_user model_test_read_group_user 1 0 0 0
10 access_test_read_group_task access_test_read_group_task model_test_read_group_task 1 0 0 0
+28
View File
@@ -65,3 +65,31 @@ class OrderLine(models.Model):
order_id = fields.Many2one('test_read_group.order', ondelete='cascade')
value = fields.Integer()
class User(models.Model):
_name = 'test_read_group.user'
_description = "User"
name = fields.Char(required=True)
task_ids = fields.Many2many(
'test_read_group.task',
'test_read_group_task_user_rel',
'user_id',
'task_id',
string="Tasks",
)
class Task(models.Model):
_name = 'test_read_group.task'
_description = "Project task"
name = fields.Char(required=True)
user_ids = fields.Many2many(
'test_read_group.user',
'test_read_group_task_user_rel',
'task_id',
'user_id',
string="Collaborators",
)
@@ -4,3 +4,4 @@ from . import test_group_expand
from . import test_group_operator
from . import test_fill_temporal
from . import test_auto_join
from . import test_m2m_grouping
@@ -0,0 +1,228 @@
""" Test read_group grouping with many2many fields """
from odoo.fields import Command
from odoo.tests import common
@common.tagged('test_m2m_read_group')
class TestM2MGrouping(common.TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.users = cls.env['test_read_group.user'].create([
{'name': 'Mario'},
{'name': 'Luigi'},
])
cls.tasks = cls.env['test_read_group.task'].create([
{ # both users
'name': "Super Mario Bros.",
'user_ids': [Command.set(cls.users.ids)],
},
{ # mario only
'name': "Paper Mario",
'user_ids': [Command.set(cls.users[0].ids)],
},
{ # luigi only
'name': "Luigi's Mansion",
'user_ids': [Command.set(cls.users[1].ids)],
},
{ # no user
'name': 'Donkey Kong',
'user_ids': [Command.set([])],
},
])
def test_base_users(self):
# group users
user_by_tasks = self.users.read_group(
domain=[],
fields=['name:array_agg'],
groupby=['task_ids'],
)
self.assertEqual(user_by_tasks, [
{ # first task: both users
'task_ids': self.tasks[0].id,
'task_ids_count': 2,
'name': ['Mario', 'Luigi'],
'__domain': [('task_ids', '=', self.tasks[0].id)],
},
{ # second task: Mario only
'task_ids': self.tasks[1].id,
'task_ids_count': 1,
'name': ['Mario'],
'__domain': [('task_ids', '=', self.tasks[1].id)],
},
{ # third task: Luigi only
'task_ids': self.tasks[2].id,
'task_ids_count': 1,
'name': ['Luigi'],
'__domain': [('task_ids', '=', self.tasks[2].id)],
},
])
def test_base_tasks(self):
# consider the simplest case first: one task with two users
task_by_users = self.tasks.read_group(
domain=[('id', '=', self.tasks[0].id)],
fields=['name:array_agg'],
groupby=['user_ids'],
)
self.assertEqual(task_by_users, [
{ # task of Mario
'user_ids': self.users[0].id,
'user_ids_count': 1,
'name': ["Super Mario Bros."],
'__domain': ['&', ('user_ids', '=', self.users[0].id), ('id', '=', self.tasks[0].id)],
},
{ # task of Luigi
'user_ids': self.users[1].id,
'user_ids_count': 1,
'name': ["Super Mario Bros."],
'__domain': ['&', ('user_ids', '=', self.users[1].id), ('id', '=', self.tasks[0].id)],
},
])
# now consider the full case: all tasks, with all user combinations
task_by_users = self.tasks.read_group(
domain=[],
fields=['name:array_agg'],
groupby=['user_ids'],
)
self.assertEqual(task_by_users, [
{ # tasks of Mario
'user_ids': self.users[0].id,
'user_ids_count': 2,
'name': unordered(["Super Mario Bros.", "Paper Mario"]),
'__domain': [('user_ids', '=', self.users[0].id)],
},
{ # tasks of Luigi
'user_ids': self.users[1].id,
'user_ids_count': 2,
'name': unordered(["Super Mario Bros.", "Luigi's Mansion"]),
'__domain': [('user_ids', '=', self.users[1].id)],
},
{ # tasks of nobody
'user_ids': False,
'user_ids_count': 1,
'name': unordered(["Donkey Kong"]),
'__domain': [('user_ids', '=', False)],
},
])
# check that the domain returned by read_group is valid
tasks_from_domain = self.tasks.search(task_by_users[0]['__domain'])
self.assertEqual(tasks_from_domain, self.tasks[:2])
tasks_from_domain = self.tasks.search(task_by_users[1]['__domain'])
self.assertEqual(tasks_from_domain, self.tasks[0] + self.tasks[2])
tasks_from_domain = self.tasks.search(task_by_users[2]['__domain'])
self.assertEqual(tasks_from_domain, self.tasks[3])
def test_complex_case(self):
# group tasks with some ir.rule on users
users_model = self.env['ir.model']._get(self.users._name)
self.env['ir.rule'].create({
'name': "Only The Lone Wanderer allowed",
'model_id': users_model.id,
'domain_force': [('id', '=', self.users[0].id)],
})
# as superuser, ir.rule should not apply
expected = """
SELECT
min("test_read_group_task".id) AS id,
count("test_read_group_task".id) AS "user_ids_count",
array_agg("test_read_group_task"."name") AS "name",
"test_read_group_task__user_ids"."user_id" AS "user_ids"
FROM "test_read_group_task"
LEFT JOIN "test_read_group_task_user_rel" AS "test_read_group_task__user_ids"
ON ("test_read_group_task"."id" = "test_read_group_task__user_ids"."task_id")
GROUP BY "test_read_group_task__user_ids"."user_id"
ORDER BY "user_ids"
"""
with self.assertQueries([expected]):
as_admin = self.tasks.read_group(
domain=[],
fields=['name:array_agg'],
groupby=['user_ids'],
)
self.assertEqual(as_admin, [
{ # tasks of Mario
'user_ids': self.users[0].id,
'user_ids_count': 2,
'name': unordered(["Super Mario Bros.", "Paper Mario"]),
'__domain': [('user_ids', '=', self.users[0].id)],
},
{ # tasks of Luigi
'user_ids': self.users[1].id,
'user_ids_count': 2,
'name': unordered(["Super Mario Bros.", "Luigi's Mansion"]),
'__domain': [('user_ids', '=', self.users[1].id)],
},
{ # tasks of nobody
'user_ids': False,
'user_ids_count': 1,
'name': unordered(["Donkey Kong"]),
'__domain': [('user_ids', '=', False)],
},
])
# as demo user, ir.rule should apply
tasks = self.tasks.with_user(self.browse_ref('base.user_demo'))
# warming up various caches; this avoids extra queries
tasks.read_group(domain=[], fields=['name:array_agg'], groupby=['user_ids'])
expected = """
SELECT
min("test_read_group_task".id) AS id,
count("test_read_group_task".id) AS "user_ids_count",
array_agg("test_read_group_task"."name") AS "name",
"test_read_group_task__user_ids"."user_id" AS "user_ids"
FROM "test_read_group_task"
LEFT JOIN "test_read_group_task_user_rel" AS "test_read_group_task__user_ids"
ON (
"test_read_group_task"."id" = "test_read_group_task__user_ids"."task_id"
AND "test_read_group_task__user_ids"."user_id" IN (
SELECT "test_read_group_user".id
FROM "test_read_group_user"
WHERE ("test_read_group_user"."id" = %s)
)
)
GROUP BY "test_read_group_task__user_ids"."user_id"
ORDER BY "user_ids"
"""
with self.assertQueries([expected]):
as_demo = tasks.read_group(
domain=[],
fields=['name:array_agg'],
groupby=['user_ids'],
)
self.assertEqual(as_demo, [
{ # tasks of Mario
'user_ids': self.users[0].id,
'user_ids_count': 2,
'name': unordered(['Super Mario Bros.', 'Paper Mario']),
'__domain': [('user_ids', '=', self.users[0].id)],
},
{ # tasks of Luigi and no user
'user_ids': False,
'user_ids_count': 2,
'name': unordered(["Luigi's Mansion", 'Donkey Kong']),
'__domain': [('user_ids', '=', False)],
},
])
class unordered(list):
""" A list where equality is interpreted without ordering. """
__slots__ = ()
def __eq__(self, other):
return sorted(self) == sorted(other)
def __ne__(self, other):
return sorted(self) != sorted(other)
+11
View File
@@ -589,6 +589,13 @@ class Field(MetaField('DummyField', (object,), {})):
""" Return the base field of an inherited field, or ``self``. """
return self.inherited_field.base_field if self.inherited_field else self
@property
def groupable(self):
"""
Return whether the field may be used for grouping in :meth:`~odoo.models.BaseModel.read_group`.
"""
return self.store and self.column_type
#
# Company-dependent fields
#
@@ -3637,6 +3644,10 @@ class Many2many(_RelationalMulti):
model, self._module,
)
@property
def groupable(self):
return self.store
def read(self, records):
context = {'active_test': False}
context.update(self.context)
+33 -8
View File
@@ -2044,7 +2044,7 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})):
def _read_group_prepare(self, orderby, aggregated_fields, annotated_groupbys, query):
"""
Prepares the GROUP BY and ORDER BY terms for the read_group method. Adds the missing JOIN clause
to the query if order should be computed against m2o field.
to the query if order should be computed against m2o field.
:param orderby: the orderby definition in the form "%(field)s %(order)s"
:param aggregated_fields: list of aggregated fields in the query
:param annotated_groupbys: list of dictionaries returned by _read_group_process_groupby
@@ -2141,9 +2141,9 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})):
return {
'field': split[0],
'groupby': gb,
'type': field_type,
'type': field_type,
'display_format': display_formats[gb_function or 'month'] if temporal else None,
'interval': time_intervals[gb_function or 'month'] if temporal else None,
'interval': time_intervals[gb_function or 'month'] if temporal else None,
'tz_convert': tz_convert,
'qualified_field': qualified_field,
}
@@ -2307,8 +2307,10 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})):
self._apply_ir_rules(query, 'read')
for gb in groupby_fields:
assert gb in self._fields, "Unknown field %r in 'groupby'" % gb
gb_field = self._fields[gb].base_field
assert gb_field.store and gb_field.column_type, "Fields in 'groupby' must be regular database-persisted fields (no function or related fields), or function fields with store=True"
assert self._fields[gb].base_field.groupable, (
f"Field {gb} of model {self._name} is not a stored field, only stored fields "
"(regular or many2many) are valid for the 'groupby' parameter."
)
aggregated_fields = []
select_terms = []
@@ -2416,7 +2418,7 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})):
# Right now, read_group only fill results in lazy mode (by default).
# If you need to have the empty groups in 'eager' mode, then the
# method _read_group_fill_results need to be completely reimplemented
# in a sane way
# in a sane way
result = self._read_group_fill_results(
domain, groupby_fields[0], groupby[len(annotated_groupbys):],
aggregated_fields, count_field, result, read_group_order=order,
@@ -2468,9 +2470,32 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})):
alias, parent_fname, parent_model._table, 'id', parent_fname,
)
model, alias, field = parent_model, parent_alias, field.related_field
# handle the case where the field is translated
if field.translate is True:
if field.type == 'many2many':
# special case for many2many fields: prepare a query on the comodel
# in order to reuse the mechanism _apply_ir_rules, then inject the
# query as an extra condition of the left join
comodel = self.env[field.comodel_name]
subquery = Query(self.env.cr, comodel._table)
comodel._apply_ir_rules(subquery)
# add the extra join condition only if there is an actual subquery
extra, extra_params = None, ()
if subquery.where_clause:
subquery_str, extra_params = subquery.select()
extra = '"{rhs}"."%s" IN (%s)' % (field.column2, subquery_str)
# LEFT JOIN field_relation ON
# alias.id = field_relation.field_column1
# AND field_relation.field_column2 IN (subquery)
rel_alias = query.left_join(
alias, 'id', field.relation, field.column1, field.name,
extra=extra, extra_params=extra_params,
)
return '"%s"."%s"' % (rel_alias, field.column2)
elif field.translate is True:
# handle the case where the field is translated
return model._generate_translated_field(alias, fname, query)
else:
return '"%s"."%s"' % (alias, fname)