[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:
co-authored by
Raphael Collet
parent
be19a9e9aa
commit
b4487e8ea4
@@ -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
|
||||
|
||||
|
@@ -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)
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user