diff --git a/odoo/addons/base/tests/test_base.py b/odoo/addons/base/tests/test_base.py index a68ec268587..86d43541c22 100644 --- a/odoo/addons/base/tests/test_base.py +++ b/odoo/addons/base/tests/test_base.py @@ -507,113 +507,6 @@ class TestBase(TransactionCase): self.assertEqual([g['title_count'] for g in groups_data], [2, 4], 'Incorrect number of results') self.assertEqual([g['color'] for g in groups_data], [-1, 10], 'Incorrect aggregation of int column') - def test_61_private_read_group(self): - """ - the _read_group should behave exactly like read_group (public method) except for sorting the one2many on ID - instead of name, so avoiding the join on the "to many" table to get the name - """ - title_sir = self.env['res.partner.title'].create({'name': 'Sir...'}) - title_lady = self.env['res.partner.title'].create({'name': 'Lady...'}) - user_vals_list = [ - {'name': 'Alice', 'login': 'alice', 'color': 1, 'function': 'Friend', 'date': '2015-03-28', 'title': title_lady.id}, - {'name': 'Alice', 'login': 'alice2', 'color': 0, 'function': 'Friend', 'date': '2015-01-28', 'title': title_lady.id}, - {'name': 'Bob', 'login': 'bob', 'color': 2, 'function': 'Friend', 'date': '2015-03-02', 'title': title_sir.id}, - {'name': 'Eve', 'login': 'eve', 'color': 3, 'function': 'Eavesdropper', 'date': '2015-03-20', 'title': title_lady.id}, - {'name': 'Nab', 'login': 'nab', 'color': -3, 'function': '5$ Wrench', 'date': '2014-09-10', 'title': title_sir.id}, - {'name': 'Nab', 'login': 'nab-she', 'color': 6, 'function': '5$ Wrench', 'date': '2014-01-02', 'title': title_lady.id}, - ] - res_users = self.env['res.users'] - users = res_users.create(user_vals_list) - domain = [('id', 'in', users.ids)] - - # group on local char field without domain and without active_test (-> empty WHERE clause) - groups_data = res_users.with_context(active_test=False)._read_group([], fields=['login'], groupby=['login'], orderby='login DESC') - self.assertGreater(len(groups_data), 6, "Incorrect number of results when grouping on a field") - - # group on local char field with limit - groups_data = res_users._read_group(domain, fields=['login'], groupby=['login'], orderby='login DESC', limit=3, offset=3) - self.assertEqual(len(groups_data), 3, "Incorrect number of results when grouping on a field with limit") - self.assertEqual(['bob', 'alice2', 'alice'], [g['login'] for g in groups_data], 'Result mismatch') - - # group on inherited char field, aggregate on int field (second groupby ignored on purpose) - groups_data = res_users._read_group(domain, fields=['name', 'color', 'function'], groupby=['function', 'login']) - self.assertEqual(len(groups_data), 3, "Incorrect number of results when grouping on a field") - self.assertEqual([g['function'] for g in groups_data], ['5$ Wrench', 'Eavesdropper', 'Friend'], 'incorrect _read_group order') - for group_data in groups_data: - self.assertIn('color', group_data, "Aggregated data for the column 'color' is not present in _read_group return values") - self.assertEqual(group_data['color'], 3, "Incorrect sum for aggregated data for the column 'color'") - - # group on inherited char field, reverse order - groups_data = res_users._read_group(domain, fields=['name', 'color'], groupby='name', orderby='name DESC') - self.assertEqual([g['name'] for g in groups_data], ['Nab', 'Eve', 'Bob', 'Alice'], 'Incorrect ordering of the list') - - # group on int field, default ordering - groups_data = res_users._read_group(domain, fields=['color'], groupby='color') - self.assertEqual([g['color'] for g in groups_data], [-3, 0, 1, 2, 3, 6], 'Incorrect ordering of the list') - - # multi group, second level is int field, should still be summed in first level grouping - groups_data = res_users._read_group(domain, fields=['name', 'color'], groupby=['name', 'color'], orderby='name DESC') - self.assertEqual([g['name'] for g in groups_data], ['Nab', 'Eve', 'Bob', 'Alice'], 'Incorrect ordering of the list') - self.assertEqual([g['color'] for g in groups_data], [3, 3, 2, 1], 'Incorrect ordering of the list') - - # group on inherited char field, multiple orders with directions - groups_data = res_users._read_group(domain, fields=['name', 'color'], groupby='name', orderby='color DESC, name') - self.assertEqual(len(groups_data), 4, "Incorrect number of results when grouping on a field") - self.assertEqual([g['name'] for g in groups_data], ['Eve', 'Nab', 'Bob', 'Alice'], 'Incorrect ordering of the list') - self.assertEqual([g['name_count'] for g in groups_data], [1, 2, 1, 2], 'Incorrect number of results') - - # group on inherited date column (res_partner.date) -> Year-Month, default ordering - groups_data = res_users._read_group(domain, fields=['function', 'color', 'date'], groupby=['date']) - self.assertEqual(len(groups_data), 4, "Incorrect number of results when grouping on a field") - self.assertEqual([g['date'] for g in groups_data], ['January 2014', 'September 2014', 'January 2015', 'March 2015'], 'Incorrect ordering of the list') - self.assertEqual([g['date_count'] for g in groups_data], [1, 1, 1, 3], 'Incorrect number of results') - - # group on inherited date column (res_partner.date) specifying the :year -> Year default ordering - groups_data = res_users._read_group(domain, fields=['function', 'color', 'date'], groupby=['date:year']) - self.assertEqual(len(groups_data), 2, "Incorrect number of results when grouping on a field") - self.assertEqual([g['date:year'] for g in groups_data], ['2014', '2015'], 'Incorrect ordering of the list') - self.assertEqual([g['date_count'] for g in groups_data], [2, 4], 'Incorrect number of results') - - # group on inherited date column (res_partner.date) -> Year-Month, custom order - groups_data = res_users._read_group(domain, fields=['function', 'color', 'date'], groupby=['date'], orderby='date DESC') - self.assertEqual(len(groups_data), 4, "Incorrect number of results when grouping on a field") - self.assertEqual([g['date'] for g in groups_data], ['March 2015', 'January 2015', 'September 2014', 'January 2014'], 'Incorrect ordering of the list') - self.assertEqual([g['date_count'] for g in groups_data], [3, 1, 1, 1], 'Incorrect number of results') - - # group on inherited many2one (res_partner.title), default order - groups_data = res_users._read_group(domain, fields=['function', 'color', 'title'], groupby=['title']) - self.assertEqual(len(groups_data), 2, "Incorrect number of results when grouping on a field") - # m2o is returned as a (id, label) pair - # here the order of the titles is by ID - self.assertEqual([g['title'] for g in groups_data], [(title_sir.id, 'Sir...'), (title_lady.id, 'Lady...')], 'Incorrect ordering of the list') - self.assertEqual([g['title_count'] for g in groups_data], [2, 4], 'Incorrect number of results') - self.assertEqual([g['color'] for g in groups_data], [-1, 10], 'Incorrect aggregation of int column') - - # group on inherited many2one (res_partner.title), reversed natural order - groups_data = res_users._read_group(domain, fields=['function', 'color', 'title'], groupby=['title'], orderby="title desc") - self.assertEqual(len(groups_data), 2, "Incorrect number of results when grouping on a field") - # m2o is returned as a (id, label) pair - # here the order of the titles is by ID DESC - self.assertEqual([g['title'] for g in groups_data], [(title_sir.id, 'Sir...'), (title_lady.id, 'Lady...')], 'Incorrect ordering of the list') - self.assertEqual([g['title_count'] for g in groups_data], [2, 4], 'Incorrect number of results') - self.assertEqual([g['color'] for g in groups_data], [-1, 10], 'Incorrect aggregation of int column') - - # group on inherited many2one (res_partner.title), multiple orders with m2o in second position - groups_data = res_users._read_group(domain, fields=['function', 'color', 'title'], groupby=['title'], orderby="color desc, title desc") - self.assertEqual(len(groups_data), 2, "Incorrect number of results when grouping on a field") - # m2o is returned as a (id, label) pair - self.assertEqual([g['title'] for g in groups_data], [(title_lady.id, 'Lady...'), (title_sir.id, 'Sir...')], 'Incorrect ordering of the list') - self.assertEqual([g['title_count'] for g in groups_data], [4, 2], 'Incorrect number of results') - self.assertEqual([g['color'] for g in groups_data], [10, -1], 'Incorrect aggregation of int column') - - # group on inherited many2one (res_partner.title), ordered by other inherited field (color) - groups_data = res_users._read_group(domain, fields=['function', 'color', 'title'], groupby=['title'], orderby='color') - self.assertEqual(len(groups_data), 2, "Incorrect number of results when grouping on a field") - # m2o is returned as a (id, label) pair - self.assertEqual([g['title'] for g in groups_data], [(title_sir.id, 'Sir...'), (title_lady.id, 'Lady...')], 'Incorrect ordering of the list') - self.assertEqual([g['title_count'] for g in groups_data], [2, 4], 'Incorrect number of results') - self.assertEqual([g['color'] for g in groups_data], [-1, 10], 'Incorrect aggregation of int column') - def test_70_archive_internal_partners(self): test_partner = self.env['res.partner'].create({'name':'test partner'}) test_user = self.env['res.users'].create({ diff --git a/odoo/addons/test_read_group/models.py b/odoo/addons/test_read_group/models.py index be49a2b6381..34306503641 100644 --- a/odoo/addons/test_read_group/models.py +++ b/odoo/addons/test_read_group/models.py @@ -29,6 +29,7 @@ class Aggregate(models.Model): key = fields.Integer() value = fields.Integer("Value") partner_id = fields.Many2one('res.partner') + display_name = fields.Char() # we use a selection that is in reverse lexical order, in order to check the diff --git a/odoo/addons/test_read_group/tests/__init__.py b/odoo/addons/test_read_group/tests/__init__.py index fed030f29e0..7f030c78496 100644 --- a/odoo/addons/test_read_group/tests/__init__.py +++ b/odoo/addons/test_read_group/tests/__init__.py @@ -7,3 +7,4 @@ from . import test_auto_join from . import test_m2m_grouping from . import test_date_range from . import test_groupby_week +from . import test_private_read_group diff --git a/odoo/addons/test_read_group/tests/test_private_read_group.py b/odoo/addons/test_read_group/tests/test_private_read_group.py new file mode 100644 index 00000000000..09b223d8e77 --- /dev/null +++ b/odoo/addons/test_read_group/tests/test_private_read_group.py @@ -0,0 +1,688 @@ +# -*- coding: utf-8 -*- +from odoo import fields +from odoo.tests import common +from odoo import Command + + +class TestPrivateReadGroup(common.TransactionCase): + + def test_simple_private_read_group(self): + Model = self.env['test_read_group.aggregate'] + partner_1 = self.env['res.partner'].create({'name': 'z_one'}) + partner_2 = self.env['res.partner'].create({'name': 'a_two'}) + Model.create({'key': 1, 'partner_id': partner_1.id, 'value': 1}) + Model.create({'key': 1, 'partner_id': partner_1.id, 'value': 2}) + Model.create({'key': 1, 'partner_id': partner_2.id, 'value': 3}) + Model.create({'key': 2, 'partner_id': partner_2.id, 'value': 4}) + Model.create({'key': 2, 'partner_id': partner_2.id}) + Model.create({'key': 2, 'value': 5}) + Model.create({'partner_id': partner_2.id, 'value': 5}) + Model.create({'value': 6}) + Model.create({}) + + with self.assertQueries([""" + SELECT "test_read_group_aggregate"."key", + SUM("test_read_group_aggregate"."value") + FROM "test_read_group_aggregate" + GROUP BY "test_read_group_aggregate"."key" + ORDER BY "test_read_group_aggregate"."key" ASC + """]): + self.assertEqual( + Model._read_group([], groupby=['key'], aggregates=['value:sum']), + [ + (1, 1 + 2 + 3), + (2, 4 + 5), + (False, 5 + 6), + ], + ) + + # Forcing order with many2one, traverse use the order of the comodel (res.partner) + with self.assertQueries([""" + SELECT "test_read_group_aggregate"."key", + "test_read_group_aggregate"."partner_id", + SUM("test_read_group_aggregate"."value") + FROM "test_read_group_aggregate" + LEFT JOIN "res_partner" AS "test_read_group_aggregate__partner_id" + ON ("test_read_group_aggregate"."partner_id" = "test_read_group_aggregate__partner_id"."id") + GROUP BY "test_read_group_aggregate"."key", + "test_read_group_aggregate"."partner_id", + "test_read_group_aggregate__partner_id"."display_name", + "test_read_group_aggregate__partner_id"."id" + ORDER BY "test_read_group_aggregate"."key" ASC, + "test_read_group_aggregate__partner_id"."display_name", + "test_read_group_aggregate__partner_id"."id" + """]): + self.assertEqual( + Model._read_group([], groupby=['key', 'partner_id'], aggregates=['value:sum'], order="key, partner_id"), + [ + (1, partner_2, 3), + (1, partner_1, 1 + 2), + (2, partner_2, 4), + (2, self.env['res.partner'], 5), + (False, partner_2, 5), + (False, self.env['res.partner'], 6), + ], + ) + + # Same than before but with private method, the order doesn't traverse + # many2one order, then the order is based on id of partner + with self.assertQueries([""" + SELECT "test_read_group_aggregate"."key", + "test_read_group_aggregate"."partner_id", + SUM("test_read_group_aggregate"."value") + FROM "test_read_group_aggregate" + GROUP BY "test_read_group_aggregate"."key", + "test_read_group_aggregate"."partner_id" + ORDER BY "test_read_group_aggregate"."key" ASC, + "test_read_group_aggregate"."partner_id" ASC + """]): + self.assertEqual( + Model._read_group([], groupby=['key', 'partner_id'], aggregates=['value:sum']), + [ + (1, partner_1, 1 + 2), + (1, partner_2, 3), + (2, partner_2, 4), + (2, self.env['res.partner'], 5), + (False, partner_2, 5), + (False, self.env['res.partner'], 6), + ], + ) + + def test_falsy_domain(self): + Model = self.env['test_read_group.aggregate'] + + with self.assertQueryCount(0): + result = Model._read_group([('id', 'in', [])], groupby=['partner_id'], aggregates=[]) + self.assertEqual(result, []) + + with self.assertQueryCount(0): + result = Model._read_group( + [('id', 'in', [])], + groupby=[], + aggregates=['__count', 'partner_id:count', 'partner_id:count_distinct'], + ) + # When there are no groupby, postgresql return always one row, check + # that it is the case when the domain is falsy and the query is not + # made at all + self.assertEqual(result, [(0, 0, 0)]) + + def test_prefetch_for_records(self): + Model = self.env['test_read_group.aggregate'] + Partner = self.env['res.partner'] + partner_1 = Partner.create({'name': 'z_one'}) + partner_2 = Partner.create({'name': 'a_two'}) + Model.create({'key': 1, 'partner_id': partner_1.id}) + Model.create({'key': 2, 'partner_id': partner_2.id}) + + self.env.invalidate_all() + + result = Model._read_group([], ['partner_id'], []) + + # partner_1 and partner_2 are records + self.assertEqual(result, [(partner_1,), (partner_2,)]) + [[value1], [value2]] = result + value1.name + with self.assertQueryCount(0): + # already prefetched with value1.name above + value2.name + + self.env.invalidate_all() + + result = Model._read_group([], ['key'], ['partner_id:recordset']) + self.assertEqual(result, [(1, partner_1), (2, partner_2)]) + [[__, value1], [__, value2]] = result + value1.name + with self.assertQueryCount(0): + # already prefetched with value1.name above + value2.name + + def test_ambiguous_field_name(self): + """ Check that _read_group doesn't generate ambiguous (display_name) alias for PostgreSQL + """ + Model = self.env['test_read_group.aggregate'] + partner_1 = self.env['res.partner'].create({'name': 'z_one'}) + Model.create({'key': 1, 'partner_id': partner_1.id, 'value': 1, 'display_name': 'blabla'}) + + with self.assertQueries([""" + SELECT "test_read_group_aggregate"."display_name", + "test_read_group_aggregate"."partner_id", + COUNT(*) + FROM "test_read_group_aggregate" + LEFT JOIN "res_partner" AS "test_read_group_aggregate__partner_id" + ON ("test_read_group_aggregate"."partner_id" = "test_read_group_aggregate__partner_id"."id") + GROUP BY "test_read_group_aggregate"."display_name", + "test_read_group_aggregate"."partner_id", + "test_read_group_aggregate__partner_id"."display_name", + "test_read_group_aggregate__partner_id"."id" + ORDER BY "test_read_group_aggregate__partner_id"."display_name" DESC, + "test_read_group_aggregate__partner_id"."id" DESC + """]): + result = Model._read_group( + [], + groupby=['display_name', 'partner_id'], + aggregates=['__count'], + order="partner_id DESC", + ) + self.assertEqual(result, [('blabla', partner_1, 1)]) + + def test_bool_read_groups(self): + Model = self.env['test_read_group.aggregate.boolean'] + Model.create({'key': 1, 'bool_and': True}) + Model.create({'key': 1, 'bool_and': True}) + + Model.create({'key': 2, 'bool_and': True}) + Model.create({'key': 2, 'bool_and': False}) + + Model.create({'key': 3, 'bool_and': False}) + Model.create({'key': 3, 'bool_and': False}) + + Model.create({'key': 4, 'bool_and': True, 'bool_or': True, 'bool_array': True}) + Model.create({'key': 4}) + + result = Model._read_group( + [], + groupby=['key'], + aggregates=['bool_and:bool_and', 'bool_and:bool_or', 'bool_and:array_agg'], + ) + self.assertEqual(result, [ + (1, True, True, [True, True]), + (2, False, True, [True, False]), + (3, False, False, [False, False]), + (4, False, True, [True, False]), + ]) + + def test_count_read_groups(self): + Model = self.env['test_read_group.aggregate'] + Model.create({'key': 1}) + Model.create({'key': 1}) + Model.create({}) + + self.assertEqual( + Model._read_group([], aggregates=['key:count']), + [(2,)], + ) + + self.assertEqual( + Model._read_group([], aggregates=['key:count_distinct']), + [(1,)], + ) + + def test_array_read_groups(self): + Model = self.env['test_read_group.aggregate'] + Model.create({'partner_id': 1}) + Model.create({'partner_id': 1}) + Model.create({'partner_id': 2}) + + self.assertEqual( + Model._read_group([], aggregates=['partner_id:array_agg']), + [([1, 1, 2],)], + ) + + self.assertEqual( + Model._read_group([], aggregates=['partner_id:recordset']), + [(self.env['res.partner'].browse([1, 2]),)], + ) + + def test_flush_read_group(self): + Model = self.env['test_read_group.aggregate'] + a = Model.create({'key': 1, 'value': 5}) + b = Model.create({'key': 1, 'value': 5}) + + self.assertEqual( + Model._read_group([], groupby=['key'], aggregates=['value:sum']), + [(1, 5 + 5)], + ) + + # Test flush of domain + a.key = 2 + self.assertEqual( + Model._read_group([('key', '>', 1)], groupby=['key'], aggregates=['value:sum']), + [ + (2, 5), + ], + ) + + # test flush of groupby clause + a.key = 3 + self.assertEqual( + Model._read_group([], groupby=['key'], aggregates=['value:sum']), + [ + (1, 5), + (3, 5), + ], + ) + + # Test flush of _read_groups + b.value = 8 + self.assertEqual( + Model._read_group([], groupby=['key'], aggregates=['value:sum']), + [ + (1, 8), + (3, 5), + ], + ) + + def test_having_clause(self): + Model = self.env['test_read_group.aggregate'] + Model.create({'key': 1, 'value': 8}) + Model.create({'key': 1, 'value': 2}) + Model.create({'key': 2, 'value': 5}) + Model.create({'key': 3, 'value': 2}) + Model.create({'key': 3, 'value': 4}) + Model.create({'key': 3, 'value': 1}) + + with self.assertQueries([""" + SELECT "test_read_group_aggregate"."key", + SUM("test_read_group_aggregate"."value") + FROM "test_read_group_aggregate" + GROUP BY "test_read_group_aggregate"."key" + HAVING SUM("test_read_group_aggregate"."value") > %s + ORDER BY "test_read_group_aggregate"."key" ASC + """]): + self.assertEqual( + Model._read_group( + [], + groupby=['key'], + aggregates=['value:sum'], + having=[('value:sum', '>', 8)], + ), + [(1, 2 + 8)], + ) + + with self.assertQueries([""" + SELECT "test_read_group_aggregate"."key", + SUM("test_read_group_aggregate"."value"), + COUNT(*) + FROM "test_read_group_aggregate" + GROUP BY "test_read_group_aggregate"."key" + HAVING (COUNT(*) < %s AND SUM("test_read_group_aggregate"."value") > %s) + ORDER BY "test_read_group_aggregate"."key" ASC + """]): + self.assertEqual( + Model._read_group( + [], + groupby=['key'], + aggregates=['value:sum', '__count'], + having=[('__count', '<', 3), ("value:sum", '>', 4)], + ), + [ + (1, 2 + 8, 2), + (2, 5, 1), + ], + ) + + def test_malformed_params(self): + Model = self.env['test_read_group.fill_temporal'] + # Test malformed groupby clause + with self.assertRaises(ValueError): + Model._read_group([], ['date:bad_granularity']) + + with self.assertRaises(ValueError): + Model._read_group([], ['Other stuff date:week']) + + with self.assertRaises(ValueError): + Model._read_group([], ['date']) # No granularity + + with self.assertRaises(ValueError): + Model._read_group([], ['"date:week']) + + # Test malformed aggregate clause + with self.assertRaises(ValueError): + Model._read_group([], aggregates=['value']) # No aggregate + + with self.assertRaises(ValueError): + Model._read_group([], aggregates=['__count_']) + + with self.assertRaises(ValueError): + Model._read_group([], aggregates=['value:__count']) + + with self.assertRaises(ValueError): + Model._read_group([], aggregates=['other value:sum']) + + with self.assertRaises(ValueError): + Model._read_group([], aggregates=['value:array_agg OR']) + + with self.assertRaises(ValueError): + Model._read_group([], aggregates=['"value:sum']) + + with self.assertRaises(ValueError): + Model._read_group([], aggregates=['label:sum(value)']) + + # Test malformed having clause + with self.assertRaises(ValueError): + Model._read_group([], ['value'], having=[('__count', '>')]) + + with self.assertRaises(ValueError): + Model._read_group([], ['value'], having=["COUNT(*) > 2"]) + + with self.assertRaises(ValueError): + Model._read_group([], ['value'], having=[('"="')]) + + # Test malformed order clause + with self.assertRaises(ValueError): + Model._read_group([], ['value'], order='__count DESC other') + + with self.assertRaises(ValueError): + Model._read_group([], ['value'], order='value" DESC') + + with self.assertRaises(ValueError): + Model._read_group([], ['value'], order='value ASCCC') + + def test_groupby_date(self): + """ Test what happens when grouping on date fields """ + Model = self.env['test_read_group.fill_temporal'] + Model.create({}) # Falsy date + Model.create({'date': '2022-01-29'}) # Saturday (week of '2022-01-24') + Model.create({'date': '2022-01-29'}) # Same day + Model.create({'date': '2022-01-30'}) # Sunday + Model.create({'date': '2022-01-31'}) # Monday (other week) + Model.create({'date': '2022-02-01'}) # (other month) + Model.create({'date': '2022-05-29'}) # other quarter + Model.create({'date': '2023-01-29'}) # other year + + result = Model._read_group([], [], ['date:array_agg']) + self.assertEqual(result, [ + ( + [ + None, + fields.Date.to_date('2022-01-29'), + fields.Date.to_date('2022-01-29'), + fields.Date.to_date('2022-01-30'), + fields.Date.to_date('2022-01-31'), + fields.Date.to_date('2022-02-01'), + fields.Date.to_date('2022-05-29'), + fields.Date.to_date('2023-01-29'), + ], + ), + ]) + + result = Model._read_group([], ['date:day'], ['__count']) + self.assertEqual(result, [ + (fields.Date.to_date('2022-01-29'), 2), + (fields.Date.to_date('2022-01-30'), 1), + (fields.Date.to_date('2022-01-31'), 1), + (fields.Date.to_date('2022-02-01'), 1), + (fields.Date.to_date('2022-05-29'), 1), + (fields.Date.to_date('2023-01-29'), 1), + (False, 1), + ]) + + result = Model._read_group([], ['date:week'], ['__count']) + self.assertEqual(result, [ + (fields.Date.to_date('2022-01-23'), 2), + (fields.Date.to_date('2022-01-30'), 3), + (fields.Date.to_date('2022-05-29'), 1), + (fields.Date.to_date('2023-01-29'), 1), + (False, 1), + ]) + + result = Model._read_group([], ['date:month'], ['__count']) + self.assertEqual(result, [ + (fields.Date.to_date('2022-01-01'), 4), + (fields.Date.to_date('2022-02-01'), 1), + (fields.Date.to_date('2022-05-01'), 1), + (fields.Date.to_date('2023-01-01'), 1), + (False, 1), + ]) + + result = Model._read_group([], ['date:quarter'], ['__count']) + self.assertEqual(result, [ + (fields.Date.to_date('2022-01-01'), 5), + (fields.Date.to_date('2022-04-01'), 1), + (fields.Date.to_date('2023-01-01'), 1), + (False, 1), + ]) + + result = Model._read_group([], ['date:year'], ['__count']) + self.assertEqual(result, [ + (fields.Date.to_date('2022-01-01'), 6), + (fields.Date.to_date('2023-01-01'), 1), + (False, 1), + ]) + # Reverse order + result = Model._read_group([], ['date:year'], ['__count'], order="date:year DESC") + self.assertEqual(result, [ + (False, 1), + (fields.Date.to_date('2023-01-01'), 1), + (fields.Date.to_date('2022-01-01'), 6), + ]) + + def test_groupby_datetime(self): + Model = self.env['test_read_group.fill_temporal'] + records = Model.create([ + {'datetime': False, 'value': 13}, + {'datetime': '1916-08-18 12:30:00', 'value': 1}, + {'datetime': '1916-08-18 12:50:00', 'value': 3}, + {'datetime': '1916-08-19 01:30:00', 'value': 7}, + {'datetime': '1916-10-18 23:30:00', 'value': 5}, + ]) + + # With "UTC" timezone (the default one) + Model = Model.with_context(tz="UTC") + + self.assertEqual( + Model._read_group([('id', 'in', records.ids)], ['datetime:day'], ['value:sum']), + [ + ( + fields.Datetime.to_datetime('1916-08-18 00:00:00'), + 3 + 1, + ), + ( + fields.Datetime.to_datetime('1916-08-19 00:00:00'), + 7, + ), + ( + fields.Datetime.to_datetime('1916-10-18 00:00:00'), + 5, + ), + ( + False, + 13, + ), + ], + ) + self.assertEqual( + Model._read_group([('id', 'in', records.ids)], ['datetime:hour'], ['value:sum']), + [ + ( + fields.Datetime.to_datetime('1916-08-18 12:00:00'), + 3 + 1, + ), + ( + fields.Datetime.to_datetime('1916-08-19 01:00:00'), + 7, + ), + ( + fields.Datetime.to_datetime('1916-10-18 23:00:00'), + 5, + ), + ( + False, + 13, + ), + ], + ) + + # With "Europe/Brussels" [+01:00 UTC | +02:00 UTC DST] timezone + Model = Model.with_context(tz="Europe/Brussels") + self.assertEqual( + Model._read_group([('id', 'in', records.ids)], ['datetime:day'], ['value:sum']), + [ + ( + fields.Datetime.to_datetime('1916-08-18 00:00:00'), + 3 + 1, + ), + ( + fields.Datetime.to_datetime('1916-08-19 00:00:00'), + 7, + ), + ( + fields.Datetime.to_datetime('1916-10-19 00:00:00'), + 5, + ), + ( + False, + 13, + ), + ], + ) + self.assertEqual( + Model._read_group([('id', 'in', records.ids)], ['datetime:hour'], ['value:sum']), + [ + ( + fields.Datetime.to_datetime('1916-08-18 14:00:00'), + 3 + 1, + ), + ( + fields.Datetime.to_datetime('1916-08-19 03:00:00'), + 7, + ), + ( + fields.Datetime.to_datetime('1916-10-19 00:00:00'), + 5, + ), + ( + False, + 13, + ), + ], + ) + + # With "America/Anchorage" [-09:00 UTC | -08:00 UTC DST] timezone + Model = Model.with_context(tz="America/Anchorage") + self.assertEqual( + Model._read_group([('id', 'in', records.ids)], ['datetime:day'], ['value:sum']), + [ + ( + fields.Datetime.to_datetime('1916-08-18 00:00:00'), + 7 + 3 + 1, + ), + ( + fields.Datetime.to_datetime('1916-10-18 00:00:00'), + 5, + ), + ( + False, + 13, + ), + ], + ) + # by hour + self.assertEqual( + Model._read_group([('id', 'in', records.ids)], ['datetime:hour'], ['value:sum']), + [ + ( + fields.Datetime.to_datetime('1916-08-18 02:00:00'), + 3 + 1, + ), + ( + fields.Datetime.to_datetime('1916-08-18 15:00:00'), + 7, + ), + ( + fields.Datetime.to_datetime('1916-10-18 13:00:00'), + 5, + ), + ( + False, + 13, + ), + ], + ) + + def test_aggregate_datetime(self): + Model = self.env['test_read_group.fill_temporal'] + records = Model.create([ + {'datetime': False, 'value': 13}, + {'datetime': '1916-08-18 01:50:00', 'value': 3}, + {'datetime': '1916-08-19 01:30:00', 'value': 7}, + {'datetime': '1916-10-18 02:30:00', 'value': 5}, + ]) + self.assertEqual( + Model._read_group([('id', 'in', records.ids)], [], ['datetime:max']), + [ + ( + fields.Datetime.to_datetime('1916-10-18 02:30:00'), + ), + ], + ) + + self.assertEqual( + Model._read_group([('id', 'in', records.ids)], [], ['datetime:array_agg']), + [( + [ + None, + fields.Datetime.to_datetime('1916-08-18 01:50:00'), + fields.Datetime.to_datetime('1916-08-19 01:30:00'), + fields.Datetime.to_datetime('1916-10-18 02:30:00'), + ], + )], + ) + + def test_auto_join(self): + """ Test what happens when grouping with a domain using a one2many field with auto_join. """ + model = self.env['test_read_group.order'] + records = model.create([{ + 'line_ids': [Command.create({'value': 1}), Command.create({'value': 2})], + }, { + 'line_ids': [Command.create({'value': 1})], + }]) + + domain1 = [('id', 'in', records.ids), ('line_ids.value', '=', 1)] + domain2 = [('id', 'in', records.ids), ('line_ids.value', '>', 0)] + + # reference results + self.assertEqual(len(model.search(domain1)), 2) + self.assertEqual(len(model.search(domain2)), 2) + + result1 = model._read_group(domain1, aggregates=['__count']) + self.assertEqual(result1, [(2,)]) + + result2 = model._read_group(domain2, aggregates=['__count']) + self.assertEqual(result1, [(2,)]) + + # same requests, with auto_join + self.patch(type(model).line_ids, 'auto_join', True) + + self.assertEqual(len(model.search(domain1)), 2) + self.assertEqual(len(model.search(domain2)), 2) + + result1 = model._read_group(domain1, aggregates=['__count']) + self.assertEqual(result1, [(2,)]) + + result2 = model._read_group(domain2, aggregates=['__count']) + self.assertEqual(result2, [(2,)]) + + def test_groupby_many2many(self): + User = self.env['test_read_group.user'] + mario, luigi = User.create([{'name': 'Mario'}, {'name': 'Luigi'}]) + tasks = self.env['test_read_group.task'].create([ + { # both users + 'name': "Super Mario Bros.", + 'user_ids': [Command.set((mario + luigi).ids)], + }, + { # mario only + 'name': "Paper Mario", + 'user_ids': [Command.set(mario.ids)], + }, + { # luigi only + 'name': "Luigi's Mansion", + 'user_ids': [Command.set(luigi.ids)], + }, + { # no user + 'name': 'Donkey Kong', + }, + ]) + + # TODO: should we order by the relation and not by the id also for many2many (same than many2one) ? for public methods ? + self.assertEqual(tasks._read_group( + [('id', 'in', tasks.ids)], + ['user_ids'], + ['name:array_agg'], + ), + [ + (mario, ["Super Mario Bros.", "Paper Mario"]), # tasks of Mario + (luigi, ["Super Mario Bros.", "Luigi's Mansion"]), # tasks of Luigi + (User, ["Donkey Kong"]), # tasks of nobody + ], + ) diff --git a/odoo/models.py b/odoo/models.py index b1c92c631f4..17070615fea 100644 --- a/odoo/models.py +++ b/odoo/models.py @@ -82,13 +82,25 @@ regex_order = re.compile(r''' ''', re.IGNORECASE | re.VERBOSE) regex_object_name = re.compile(r'^[a-z0-9_.]+$') regex_pg_name = re.compile(r'^[a-z_][a-z0-9_$]*$', re.I) -regex_field_agg = re.compile(r'(\w+)(?::(\w+)(?:\((\w+)\))?)?') +regex_field_agg = re.compile(r'(\w+)(?::(\w+)(?:\((\w+)\))?)?') # For read_group +regex_read_group_spec = re.compile(r'(\w+)(?::(\w+))?$') # For _read_group AUTOINIT_RECALCULATE_STORED_FIELDS = 1000 INSERT_BATCH_SIZE = 100 SQL_DEFAULT = psycopg2.extensions.AsIs("DEFAULT") +def parse_read_group_spec(spec: str) -> tuple: + """ Return a pair corresponding to the given groupby/aggregate specification. """ + res_match = regex_read_group_spec.match(spec) + if not res_match: + raise ValueError( + f'Invalid aggregate/groupby specification {spec!r}.\n' + '- Valid aggregate specification looks like ":" example: "quantity:sum".\n' + '- Valid groupby specification looks like "" or ":" example: "date:month".' + ) + return res_match.groups() + def check_object_name(name): """ Check if the given name is a valid model name. @@ -302,10 +314,47 @@ PREFETCH_MAX = 1000 LOG_ACCESS_COLUMNS = ['create_uid', 'create_date', 'write_uid', 'write_date'] MAGIC_COLUMNS = ['id'] + LOG_ACCESS_COLUMNS +# read_group stuff +READ_GROUP_TIME_GRANULARITY = { + 'hour': dateutil.relativedelta.relativedelta(hours=1), + 'day': dateutil.relativedelta.relativedelta(days=1), + 'week': datetime.timedelta(days=7), + 'month': dateutil.relativedelta.relativedelta(months=1), + 'quarter': dateutil.relativedelta.relativedelta(months=3), + 'year': dateutil.relativedelta.relativedelta(years=1) +} + # valid SQL aggregation functions -VALID_AGGREGATE_FUNCTIONS = { - 'array_agg', 'count', 'count_distinct', - 'bool_and', 'bool_or', 'max', 'min', 'avg', 'sum', +READ_GROUP_AGGREGATE = { + 'sum': lambda table, expr: f'SUM({expr})', + 'avg': lambda table, expr: f'AVG({expr})', + 'max': lambda table, expr: f'MAX({expr})', + 'min': lambda table, expr: f'MIN({expr})', + 'bool_and': lambda table, expr: f'BOOL_AND({expr})', + 'bool_or': lambda table, expr: f'BOOL_OR({expr})', + 'array_agg': lambda table, expr: f'ARRAY_AGG({expr} ORDER BY "{table}"."id")', + # 'recordset' aggregates will be post-processed to become recordsets + 'recordset': lambda table, expr: f'ARRAY_AGG({expr} ORDER BY "{table}"."id")', + 'count': lambda table, expr: f'COUNT({expr})', + 'count_distinct': lambda table, expr: f'COUNT(DISTINCT {expr})', +} + +READ_GROUP_DISPLAY_FORMAT = { + # Careful with week/year formats: + # - yyyy (lower) must always be used, *except* for week+year formats + # - YYYY (upper) must always be used for week+year format + # e.g. 2006-01-01 is W52 2005 in some locales (de_DE), + # and W1 2006 for others + # + # Mixing both formats, e.g. 'MMM YYYY' would yield wrong results, + # such as 2006-01-01 being formatted as "January 2005" in some locales. + # Cfr: http://babel.pocoo.org/en/latest/dates.html#date-fields + 'hour': 'hh:00 dd MMM', + 'day': 'dd MMM yyyy', # yyyy = normal year + 'week': "'W'w YYYY", # w YYYY = ISO week-year + 'month': 'MMMM yyyy', + 'quarter': 'QQQ yyyy', + 'year': 'yyyy', } @@ -1741,6 +1790,346 @@ class BaseModel(metaclass=MetaModel): """ cls.pool._clear_cache() + @api.model + def _read_group(self, domain, groupby=(), aggregates=(), having=(), offset=0, limit=None, order=None): + """ Get fields aggregations specified by ``aggregates`` grouped by the given ``groupby`` + fields where record are filtered by the ``domain``. + + :param list domain: :ref:`A search domain `. Use an empty + list to match all records. + :param list groupby: list of groupby descriptions by which the records will be grouped. + A groupby description is either a field (then it will be grouped by that field) + or a string 'field:granularity'. Right now, the only supported granularities + are 'day', 'week', 'month', 'quarter' or 'year', and they only make sense for + date/datetime fields. + :param list aggregates: list of aggregates specification. + Each element is 'field:agg' (aggregate field with aggregation function 'agg'). + The possible aggregation functions are the ones provided by + `PostgreSQL `_, + 'count_distinct' with the expected meaning and 'recordset' to act like 'array_agg' + converted into a recordset. + :param list having: A domain where the valid "fields" are the aggregates. + :param int offset: optional number of groups to skip + :param int limit: optional max number of groups to return + :param str order: optional ``order by`` specification, for + overriding the natural sort ordering of the groups, + see also :py:meth:`~osv.osv.osv.search`. + :return: list of tuple containing in the order the groups values and aggregates values (flatten): + `[(groupby_1_value, ... , aggregate_1_value_aggregate, ...), ...]`. + If group is related field, the value of it will be a recordset (with a correct prefetch set). + + :rtype: list + :raise AccessError: if user is not allowed to access requested information + """ + self.check_access_rights('read') + + if expression.is_false(self, domain): + if not groupby: + # when there is no group, postgresql always return a row + return [tuple( + self._read_group_empty_value(spec) + for spec in itertools.chain(groupby, aggregates) + )] + return [] + + query = self._where_calc(domain) + self._apply_ir_rules(query, 'read') + + fnames_to_flush = OrderedSet() + groupby_terms = [] # [,] + for spec in groupby: + sql_expression, fnames_used = self._read_group_groupby(spec, query) + groupby_terms.append(sql_expression) + fnames_to_flush.update(fnames_used) + + select_terms = [] # [,] + for spec in aggregates: + sql_expression, fnames_used = self._read_group_select(spec, query) + select_terms.append(sql_expression) + fnames_to_flush.update(fnames_used) + + having_expression, having_params, fnames_used = self._read_group_having(having, query) + fnames_to_flush.update(fnames_used) + + orderby_terms, extra_groupby_terms, fnames_used = self._read_group_orderby(order, groupby, query) + fnames_to_flush.update(fnames_used) + + from_clause, where_clause, query_params = query.get_sql() + query_parts = [ + f"SELECT {', '.join(groupby_terms + select_terms)}", + f"FROM {from_clause}", + ] + if where_clause: + query_parts.append(f"WHERE {where_clause}") + if groupby_terms: + query_parts.append(f"GROUP BY {', '.join(groupby_terms + extra_groupby_terms)}") + if having_expression: + query_parts.append(f"HAVING {having_expression}") + query_params.extend(having_params) + if orderby_terms: + query_parts.append(f"ORDER BY {', '.join(orderby_terms)}") + if limit: + query_parts.append("LIMIT %s") + query_params.append(limit) + if offset: + query_parts.append("OFFSET %s") + query_params.append(offset) + + self._flush_search(domain, fnames_to_flush) + if fnames_to_flush: + self.check_field_access_rights('read', fnames_to_flush) + + self.env.cr.execute('\n'.join(query_parts), query_params) + # row_values: [(a1, b1, c1), (a2, b2, c2), ...] + row_values = self.env.cr.fetchall() + + if not row_values: + return row_values + + # post-process values column by column + column_iterator = zip(*row_values) + + # column_result: [(a1, a2, ...), (b1, b2, ...), (c1, c2, ...)] + column_result = [] + for spec in groupby: + column = self._read_group_postprocess_groupby(spec, next(column_iterator)) + column_result.append(column) + for spec in aggregates: + column = self._read_group_postprocess_aggregate(spec, next(column_iterator)) + column_result.append(column) + assert next(column_iterator, None) is None + + # return [(a1, b1, c1), (a2, b2, c2), ...] + return list(zip(*column_result)) + + @api.model + def _read_group_select(self, aggregate_spec, query): + """ Return a pair (, []) + corresponding to the given aggregation. + """ + if aggregate_spec == '__count': + return 'COUNT(*)', [] + + fname, func = parse_read_group_spec(aggregate_spec) + + if fname not in self: + raise ValueError(f"Invalid field {fname!r} on model {self._name!r} for {aggregate_spec!r}.") + if not func: + raise ValueError(f"Aggregate method is mandatory for {fname!r}") + if func not in READ_GROUP_AGGREGATE: + raise ValueError(f"Invalid aggregate method {func!r} for {aggregate_spec!r}.") + + field = self._fields[fname] + if func == 'recordset' and not (field.relational or fname == 'id'): + raise ValueError(f"Aggregate method {func!r} can be only used on relational field (or id) (for {aggregate_spec!r}).") + + field_expression = self._inherits_join_calc(self._table, fname, query) + + expression = READ_GROUP_AGGREGATE[func](self._table, field_expression) + return expression, [fname] + + @api.model + def _read_group_groupby(self, groupby_spec, query): + """ Return a pair (, []) + corresponding to the given groupby element. + """ + fname, granularity = parse_read_group_spec(groupby_spec) + if fname not in self: + raise ValueError(f"Invalid field {fname!r} on model {self._name!r}") + + field = self._fields[fname] + + if granularity and field.type not in ('datetime', 'date'): + raise ValueError(f"Granularity set on a no-datetime field: {groupby_spec!r}") + + if not field.store and not field.inherited: # TODO: should be manage by _inherits_join_calc + raise ValueError(f"Groupby on no-store field: {groupby_spec!r}") + + field_expression = self._inherits_join_calc(self._table, fname, query) + if field.type == 'datetime' and self.env.context.get('tz') in pytz.all_timezones_set: + # TODO: `self.env.context.get('tz')` should be passed as args + field_expression = f"timezone('{self.env.context.get('tz')}', timezone('UTC', {field_expression}))" + + if field.type in ('datetime', 'date'): + if not granularity: + raise ValueError(f"Granularity not set on a date(time) field: {groupby_spec!r}") + if granularity not in READ_GROUP_TIME_GRANULARITY: + raise ValueError(f"Granularity specification isn't correct: {granularity!r}") + + # TODO: `granularity` should be passed as args + if granularity == 'week': + # first_week_day: 0=Monday, 1=Tuesday, ... + first_week_day = int(get_lang(self.env).week_start) - 1 + days_offset = first_week_day and 7 - first_week_day + field_expression = f"(date_trunc('week', {field_expression}::timestamp - INTERVAL '-{days_offset} DAY') + INTERVAL '-{days_offset} DAY')" + else: + field_expression = f"date_trunc('{granularity}', {field_expression}::timestamp)" + if field.type == 'date': + field_expression += '::date' + elif field.type == 'boolean': + field_expression = f"COALESCE({field_expression}, FALSE)" + + return field_expression, [fname] + + @api.model + def _read_group_having(self, having_domain, query): + """ Return (, [], []) + corresponding to the having domain. + """ + if not having_domain: + return "", [], [] + + # stack of [(sql_expr, params)] + stack = [] + fnames_used = [] + SUPPORTED = ('in', 'not in', '<', '>', '<=', '>=', '=', '!=') + for item in reversed(having_domain): + if item == '!': + expr, params = stack.pop() + stack.append((f'(NOT {expr})', params)) + elif item in ('&', '|'): + expr1, params1 = stack.pop() + expr2, params2 = stack.pop() + operator = 'AND' if item == '&' else 'OR' + stack.append((f'({expr1} {operator} {expr2})', params1 + params2)) + elif isinstance(item, (list, tuple)) and len(item) == 3: + left, operator, right = item + if operator not in SUPPORTED: + raise ValueError(f"Invalid having clause {item!r}: supported comparators are {SUPPORTED}") + expr, fnames = self._read_group_select(left, query) + stack.append((f'{expr} {operator} %s', [right])) + fnames_used.extend(fnames) + else: + raise ValueError(f"Invalid having clause {item!r}: it should be a domain-like clause") + + while len(stack) > 1: + expr1, params1 = stack.pop() + expr2, params2 = stack.pop() + stack.append((f'({expr1} AND {expr2})', params1 + params2)) + + expr, params = stack[0] + return expr, params, fnames_used + + @api.model + def _read_group_orderby(self, order, groupby, query): + """ Return ([], [], []) + corresponding to the given order and groupby terms. + """ + if order: + traverse_many2one = True + else: + order = ','.join(groupby) + traverse_many2one = False + + orderby_terms = [] + extra_groupby_terms = [] + fnames_used = [] + if not order: + return orderby_terms, extra_groupby_terms, fnames_used + + for order_part in order.split(','): + order_match = regex_order.match(order_part) + if not order_match: + raise ValueError(f"Invalid order {order!r} for _read_group()") + order_field = order_match['field'] + if order_match['property']: + raise ValueError(f"Invalid order {order!r} for _read_group(): properties are not supported") + order_direction = (order_match['direction'] or 'ASC').upper() + order_nulls = (order_match['nulls'] or '').upper() + + if order_field not in groupby: + try: + sql_expression, fnames_used_select = self._read_group_select(order_field, query) + fnames_used.extend(fnames_used_select) + orderby_terms.append(f'{sql_expression} {order_direction} {order_nulls}') + continue + except ValueError as e: + raise ValueError(f"Order term {order_part!r} is not a valid aggregate nor valid groupby") from e + + if traverse_many2one and order_field in self and self._fields[order_field].type == 'many2one': + # It will generated extra clause to add in the group by. + extra_order = self._generate_order_by(f'{order_field} {order_direction} {order_nulls}', query) + extra_order = extra_order.replace(' ORDER BY ', '') + orderby_terms.extend(order.strip() for order in extra_order.split(",") if order.strip()) + extra_groupby_terms.extend(order.strip().split()[0] for order in extra_order.split(",") if order.strip()) + else: + sql_expression, __ = self._read_group_groupby(order_field, query) + orderby_terms.append(f'{sql_expression} {order_direction} {order_nulls}') + + return orderby_terms, extra_groupby_terms, fnames_used + + @api.model + def _read_group_empty_value(self, spec): + """ Return the empty value corresponding to the given groupby spec or aggregate spec. """ + if spec == '__count': + return 0 + fname, func = parse_read_group_spec(spec) # func is either None, granularity or an aggregate + if func in ('count', 'count_distinct'): + return 0 + if func == 'array_agg': + return [] + field = self._fields[fname] + if (not func or func == 'recordset') and (field.relational or fname == 'id'): + return self.env[field.comodel_name] if field.relational else self.env[self._name] + return False + + def _read_group_postprocess_groupby(self, groupby_spec, raw_values): + """ Convert the given values of ``groupby_spec`` + from PostgreSQL to the format returned by method ``_read_group()``. + + The formatting rules can be summarized as: + - groupby values of relational fields are converted to recordsets with a correct prefetch set; + - NULL values are converted to empty values corresponding to the given aggregate. + """ + empty_value = self._read_group_empty_value(groupby_spec) + + fname, __ = parse_read_group_spec(groupby_spec) + field = self._fields[fname] + + if field.relational or fname == 'id': + Model = self.pool[field.comodel_name] if field.relational else self.pool[self._name] + prefetch_ids = tuple(raw_value for raw_value in raw_values if raw_value) + + def recordset(value): + return Model(self.env, (value,), prefetch_ids) if value else empty_value + + return (recordset(value) for value in raw_values) + + return ((value if value is not None else empty_value) for value in raw_values) + + def _read_group_postprocess_aggregate(self, aggregate_spec, raw_values): + """ Convert the given values of ``aggregate_spec`` + from PostgreSQL to the format returned by method ``_read_group()``. + + The formatting rules can be summarized as: + - 'recordset' aggregates are turned into recordsets with a correct prefetch set; + - NULL values are converted to empty values corresponding to the given aggregate. + """ + empty_value = self._read_group_empty_value(aggregate_spec) + + if aggregate_spec == '__count': + return ((value if value is not None else empty_value) for value in raw_values) + + fname, func = parse_read_group_spec(aggregate_spec) + if func == 'recordset': + field = self._fields[fname] + Model = self.pool[field.comodel_name] if field.relational else self.pool[self._name] + prefetch_ids = tuple(unique( + id_ + for array_values in raw_values if array_values + for id_ in array_values if id_ + )) + + def recordset(value): + if not value: + return empty_value + ids = tuple(unique(id_ for id_ in value if id_)) + return Model(self.env, ids, prefetch_ids) + + return (recordset(value) for value in raw_values) + + return ((value if value is not None else empty_value) for value in raw_values) + @api.model def _read_group_expand_full(self, groups, domain, order): """Extend the group to include all target records by default.""" @@ -2189,24 +2578,6 @@ class BaseModel(metaclass=MetaModel): del data['id'] return data - @api.model - def _read_group(self, domain, fields, groupby, offset=0, limit=None, orderby=False, lazy=True): - """ - Executes exactly what the public read_group() does, except it doesn't - order many2one fields on their comodel's order but on their ID instead. - """ - if not orderby: - if isinstance(groupby, str): - groupby = [groupby] - groupby_list = groupby[:1] if lazy else groupby - order_list = [] - for order_spec in groupby_list: - field_name = order_spec.split(":")[0] # field name could be formatted like "field:group_func" - if self._fields[field_name].type == 'many2one': - order_spec = f"{field_name}.id" # do not order by comodel's order - order_list.append(order_spec) - orderby = ','.join(order_list) - return self.read_group(domain, fields, groupby, offset, limit, orderby, lazy) @api.model def read_group(self, domain, fields, groupby, offset=0, limit=None, orderby=False, lazy=True): @@ -2315,7 +2686,7 @@ class BaseModel(metaclass=MetaModel): raise ValueError("Invalid field %r on model %r" % (fname, self._name)) if not (field.base_field.store and field.base_field.column_type): raise UserError(_("Cannot aggregate field %r.", fname)) - if func not in VALID_AGGREGATE_FUNCTIONS: + if func not in READ_GROUP_AGGREGATE: raise UserError(_("Invalid aggregation function %r.", func)) else: # we have 'name', retrieve the aggregator on the field