[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:
@@ -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({
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user