[REF] core: _read_group for backend use

The `_read_group` was designed to be used by the web client to
efficiently compute aggregations grouped by one or more fields.
However, more and more developers have been using it from the backend
to make computations more efficient (avoid doing the aggregation
in Python). Unfortunately, the API was designed for the web client,
which added a lot of boilerplate when used in the Python (list of
dict with misleading key name choices).

`_read_group` was created to improve the performance of read_group
for backend use (4ef0c00b4b), but didn't
change the API and based the implementation on read_group itself.

Rewrite `_read_group` from scratch with a new API to make it easier
to use from the backend (see the method documentation). Also, split
the method to make it easy to override and add custom behavior.

Part-of: odoo/odoo#110737
This commit is contained in:
Rémy Voet (ryv)
2023-04-19 21:58:26 +02:00
parent 9c5554faba
commit cdebf336a5
5 changed files with 1084 additions and 130 deletions
-107
View File
@@ -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({
+1
View File
@@ -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
@@ -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
@@ -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
],
)
+394 -23
View File
@@ -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 "<field_name>:<agg>" example: "quantity:sum".\n'
'- Valid groupby specification looks like "<no_datish_field_name>" or "<datish_field_name>:<granularity>" 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 <reference/orm/domains>`. 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 <https://www.postgresql.org/docs/current/static/functions-aggregate.html>`_,
'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 = [] # [<SQL expression>,]
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 = [] # [<SQL expression>,]
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 (<SQL expression>, [<field names used in SQL expression>])
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 (<SQL expression>, [<field names used in SQL expression>])
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 (<SQL expression>, [<SQL expression parameter>], [<used field name>])
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 ([<SQL expression>], [<SQL expression>], [<field names used>])
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