Files
odoo_source/odoo/addons/base/tests/test_search.py
T
Petar Najman 6a89f5b4f9 [IMP] core: add support for NULLS {FIRST | LAST} in ORDER BY clauses
Recent Odoo versions require modern postgres (e.g. use of jsonb), the
`NULLS {FIRST | LAST}` clause was added in 8.3 so should be well
supported.

While Odoo's use of nulls is not always consistent, the NULLS ordering
clauses can be quite useful especially when sorting `DESC`: `NULLS
FIRST` and `NULLS LAST` are literal positions so they put nulls at
that location regardless of sort order whereas the default Postgres
ordering is to consider nulls larger than every other value so they
appear first when sorting DESC, which is often undesirable (putting
nulls first when sorting ASC can also be useful to fill records).

Update `_generate_order_by_inner` to correctly process `NULLS` clauses:

- Use `regex_order` to ensure we parse orderings correctly and
  consistently, also update `regex_order` to use named groups to make
  the relevant bits clearer (and VERBOSE for readability).
- Given `a_id DESC` and `_fields[a_id].relation._order = 'xxx NULLS
  LAST'`, reversing the clause should order by `xxx DESC NULLS FIRST`
  to correctly flip the original as `NULLS` clauses are not relative
  to the sorting order (they put the `NULL`-valued records at the
  specified location regardless). Therefore apply `reverse_direction`
  to `NULLS`.
- Manually expand `NULLS` clauses on m2o fields: propagating the
  clause through the join would yield unexpected and illogical results
  when mixing NULL m2o fields and non-NULL m2o fields with NULL
  `_order` fields (as they would get mixed rather than clearly layered
  / separated).

  However because SQL booleans are ordered the usual way (`true >
  false`) the `NULLS` clause is fundamentally equivalent to sorting on
  the field being NULL (if `NULLS LAST`) or not (if `NULLS FIRST`). So
  we can prepend the expanded m2o's ordering with such a clause to get
  the correct behavior.

Replaces #116464
Supersedes #116664

closes odoo/odoo#117439

Signed-off-by: Raphael Collet <rco@odoo.com>
Co-authored-by: Petar Najman <petar.najman@modoolar.com>
2023-04-06 14:58:49 +02:00

281 lines
15 KiB
Python

# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.tests.common import TransactionCase
from odoo import Command
class test_search(TransactionCase):
def patch_order(self, model, order):
self.patch(self.registry[model], '_order', order)
def test_00_search_order(self):
# Create 6 partners with a given name, and a given creation order to
# ensure the order of their ID. Some are set as inactive to verify they
# are by default excluded from the searches and to provide a second
# `order` argument.
Partner = self.env['res.partner']
c = Partner.create({'name': 'test_search_order_C'})
d = Partner.create({'name': 'test_search_order_D', 'active': False})
a = Partner.create({'name': 'test_search_order_A'})
b = Partner.create({'name': 'test_search_order_B'})
ab = Partner.create({'name': 'test_search_order_AB'})
e = Partner.create({'name': 'test_search_order_E', 'active': False})
# The tests.
# The basic searches should exclude records that have active = False.
# The order of the returned ids should be given by the `order`
# parameter of search().
name_asc = Partner.search([('name', 'like', 'test_search_order%')], order="name asc")
self.assertEqual([a, ab, b, c], list(name_asc), "Search with 'NAME ASC' order failed.")
name_desc = Partner.search([('name', 'like', 'test_search_order%')], order="name desc")
self.assertEqual([c, b, ab, a], list(name_desc), "Search with 'NAME DESC' order failed.")
id_asc = Partner.search([('name', 'like', 'test_search_order%')], order="id asc")
self.assertEqual([c, a, b, ab], list(id_asc), "Search with 'ID ASC' order failed.")
id_desc = Partner.search([('name', 'like', 'test_search_order%')], order="id desc")
self.assertEqual([ab, b, a, c], list(id_desc), "Search with 'ID DESC' order failed.")
# The inactive records shouldn't be excluded as soon as a condition on
# that field is present in the domain. The `order` parameter of
# search() should support any legal coma-separated values.
active_asc_id_asc = Partner.search([('name', 'like', 'test_search_order%'), '|', ('active', '=', True), ('active', '=', False)], order="active asc, id asc")
self.assertEqual([d, e, c, a, b, ab], list(active_asc_id_asc), "Search with 'ACTIVE ASC, ID ASC' order failed.")
active_desc_id_asc = Partner.search([('name', 'like', 'test_search_order%'), '|', ('active', '=', True), ('active', '=', False)], order="active desc, id asc")
self.assertEqual([c, a, b, ab, d, e], list(active_desc_id_asc), "Search with 'ACTIVE DESC, ID ASC' order failed.")
active_asc_id_desc = Partner.search([('name', 'like', 'test_search_order%'), '|', ('active', '=', True), ('active', '=', False)], order="active asc, id desc")
self.assertEqual([e, d, ab, b, a, c], list(active_asc_id_desc), "Search with 'ACTIVE ASC, ID DESC' order failed.")
active_desc_id_desc = Partner.search([('name', 'like', 'test_search_order%'), '|', ('active', '=', True), ('active', '=', False)], order="active desc, id desc")
self.assertEqual([ab, b, a, c, e, d], list(active_desc_id_desc), "Search with 'ACTIVE DESC, ID DESC' order failed.")
id_asc_active_asc = Partner.search([('name', 'like', 'test_search_order%'), '|', ('active', '=', True), ('active', '=', False)], order="id asc, active asc")
self.assertEqual([c, d, a, b, ab, e], list(id_asc_active_asc), "Search with 'ID ASC, ACTIVE ASC' order failed.")
id_asc_active_desc = Partner.search([('name', 'like', 'test_search_order%'), '|', ('active', '=', True), ('active', '=', False)], order="id asc, active desc")
self.assertEqual([c, d, a, b, ab, e], list(id_asc_active_desc), "Search with 'ID ASC, ACTIVE DESC' order failed.")
id_desc_active_asc = Partner.search([('name', 'like', 'test_search_order%'), '|', ('active', '=', True), ('active', '=', False)], order="id desc, active asc")
self.assertEqual([e, ab, b, a, d, c], list(id_desc_active_asc), "Search with 'ID DESC, ACTIVE ASC' order failed.")
id_desc_active_desc = Partner.search([('name', 'like', 'test_search_order%'), '|', ('active', '=', True), ('active', '=', False)], order="id desc, active desc")
self.assertEqual([e, ab, b, a, d, c], list(id_desc_active_desc), "Search with 'ID DESC, ACTIVE DESC' order failed.")
a.ref = "ref1"
c.ref = "ref2"
ids = (a | b | c).ids
for order, result in [
('ref', a | c | b),
('ref desc', b | c | a),
('ref asc nulls first', b | a | c),
('ref asc nulls last', a | c | b),
('ref desc nulls first', b | c | a),
('ref desc nulls last', c | a | b)
]:
with self.subTest(order):
self.assertEqual(
Partner.search([('id', 'in', ids)], order=order).mapped('name'),
result.mapped('name'))
# sorting by an m2o should alias to the natural order of the m2o
self.patch_order('res.country', 'phone_code')
a.country_id, c.country_id = self.env['res.country'].create([{
'name': "Country 1",
'code': 'C1',
'phone_code': '01',
}, {
'name': 'Country 2',
'code': 'C2',
'phone_code': '02'
}])
for order, result in [
('country_id', a | c | b),
('country_id desc', b | c | a),
('country_id asc nulls first', b | a | c),
('country_id asc nulls last', a | c | b),
('country_id desc nulls first', b | c | a),
('country_id desc nulls last', c | a | b)
]:
with self.subTest(order):
self.assertEqual(
Partner.search([('id', 'in', ids)], order=order).mapped('name'),
result.mapped('name'))
# NULLS applies to the m2o itself, not its sub-fields, so a null `phone_code`
# will sort normally (larger than non-null codes)
b.country_id = self.env['res.country'].create({'name': "Country X", 'code': 'C3'})
for order, result in [
('country_id', a | c | b),
('country_id desc', b | c | a),
('country_id asc nulls first', a | c | b),
('country_id asc nulls last', a | c | b),
('country_id desc nulls first', b | c | a),
('country_id desc nulls last', b | c | a)
]:
with self.subTest(order):
self.assertEqual(
Partner.search([('id', 'in', ids)], order=order).mapped('name'),
result.mapped('name'))
# a field DESC should reverse the nested behaviour (and thus the inner
# NULLS clauses), but the outer NULLS clause still has no effect
self.patch_order('res.country', 'phone_code NULLS FIRST')
for order, result in [
('country_id', b | a | c),
('country_id desc', c | a | b),
('country_id asc nulls first', b | a | c),
('country_id asc nulls last', b | a | c),
('country_id desc nulls first', c | a | b),
('country_id desc nulls last', c | a | b)
]:
with self.subTest(order):
self.assertEqual(
Partner.search([('id', 'in', ids)], order=order).mapped('name'),
result.mapped('name'))
def test_10_inherits_m2order(self):
Users = self.env['res.users']
# Find Employee group
group_employee = self.env.ref('base.group_user')
# Get country/state data
country_be = self.env.ref('base.be')
country_us = self.env.ref('base.us')
states_us = country_us.state_ids[:2]
# Create test users
u = Users.create({'name': '__search', 'login': '__search', 'groups_id': [Command.set([group_employee.id])]})
a = Users.create({'name': '__test_A', 'login': '__test_A', 'country_id': country_be.id, 'state_id': country_be.id})
b = Users.create({'name': '__test_B', 'login': '__a_test_B', 'country_id': country_us.id, 'state_id': states_us[1].id})
c = Users.create({'name': '__test_B', 'login': '__z_test_B', 'country_id': country_us.id, 'state_id': states_us[0].id})
# Search as search user
Users = Users.with_user(u)
# Do: search on res.users, order on a field on res.partner to try inherits'd fields, then res.users
expected_ids = [u.id, a.id, c.id, b.id]
user_ids = Users.search([('id', 'in', expected_ids)], order='name asc, login desc').ids
self.assertEqual(user_ids, expected_ids, 'search on res_users did not provide expected ids or expected order')
# Do: order on many2one and inherits'd fields
expected_ids = [c.id, b.id, a.id, u.id]
user_ids = Users.search([('id', 'in', expected_ids)], order='state_id asc, country_id desc, name asc, login desc').ids
self.assertEqual(user_ids, expected_ids, 'search on res_users did not provide expected ids or expected order')
# Do: order on many2one and inherits'd fields
expected_ids = [u.id, b.id, c.id, a.id]
user_ids = Users.search([('id', 'in', expected_ids)], order='country_id desc, state_id desc, name asc, login desc').ids
self.assertEqual(user_ids, expected_ids, 'search on res_users did not provide expected ids or expected order')
# Do: order on many2one, but not by specifying in order parameter of search, but by overriding _order of res_users
self.patch_order('res.users', 'country_id desc, name asc, login desc')
expected_ids = [u.id, c.id, b.id, a.id]
user_ids = Users.search([('id', 'in', expected_ids)]).ids
self.assertEqual(user_ids, expected_ids, 'search on res_users did not provide expected ids or expected order')
def test_11_indirect_inherits_m2o_order(self):
Cron = self.env['ir.cron']
Users = self.env['res.users']
user_ids = {}
cron_ids = {}
for u in 'BAC':
user_ids[u] = Users.create({'name': u, 'login': u}).id
cron_ids[u] = Cron.create({'name': u, 'model_id': self.env.ref('base.model_res_partner').id, 'user_id': user_ids[u]}).id
ids = Cron.search([('id', 'in', list(cron_ids.values()))], order='user_id').ids
expected_ids = [cron_ids[l] for l in 'ABC']
self.assertEqual(ids, expected_ids)
def test_12_m2o_order_loop_self(self):
Cats = self.env['ir.module.category']
cat_ids = {}
def create(name, **kw):
cat_ids[name] = Cats.create(dict(kw, name=name)).id
self.patch_order('ir.module.category', 'parent_id desc, name')
create('A')
create('B', parent_id=cat_ids['A'])
create('C', parent_id=cat_ids['A'])
create('D')
create('E', parent_id=cat_ids['D'])
create('F', parent_id=cat_ids['D'])
expected_ids = [cat_ids[x] for x in 'ADEFBC']
found_ids = Cats.search([('id', 'in', list(cat_ids.values()))]).ids
self.assertEqual(found_ids, expected_ids)
def test_13_m2o_order_loop_multi(self):
Users = self.env['res.users']
# will sort by login desc of the creator, then by name
self.patch_order('res.partner', 'create_uid, name')
self.patch_order('res.users', 'partner_id, login desc')
kw = dict(groups_id=[Command.set([self.ref('base.group_system'),
self.ref('base.group_partner_manager')])])
u1 = Users.create(dict(name='Q', login='m', **kw)).id
u2 = Users.with_user(u1).create(dict(name='B', login='f', **kw)).id
u3 = Users.create(dict(name='C', login='c', **kw)).id
u4 = Users.with_user(u2).create(dict(name='D', login='z', **kw)).id
expected_ids = [u2, u4, u3, u1]
found_ids = Users.search([('id', 'in', expected_ids)]).ids
self.assertEqual(found_ids, expected_ids)
def test_20_x_active(self):
"""Check the behaviour of the x_active field."""
# test that a custom field x_active filters like active
# we take the model res.country as a test model as it is included in base and does
# not have an active field
model_country = self.env['res.country']
self.assertNotIn('active', model_country._fields) # just in case someone adds the active field in the model
self.env['ir.model.fields'].create({
'name': 'x_active',
'model_id': self.env.ref('base.model_res_country').id,
'ttype': 'boolean',
})
self.assertEqual('x_active', model_country._active_name)
country_ussr = model_country.create({'name': 'USSR', 'x_active': False, 'code': 'ZV'})
ussr_search = model_country.search([('name', '=', 'USSR')])
self.assertFalse(ussr_search)
ussr_search = model_country.with_context(active_test=False).search([('name', '=', 'USSR')])
self.assertIn(country_ussr, ussr_search, "Search with active_test on a custom x_active field failed")
ussr_search = model_country.search([('name', '=', 'USSR'), ('x_active', '=', False)])
self.assertIn(country_ussr, ussr_search, "Search with active_test on a custom x_active field failed")
# test that a custom field x_active on a model with the standard active
# field does not interfere with the standard behaviour
# use res.bank since it has an active field and is simple to use
model_bank = self.env['res.bank']
self.env['ir.model.fields'].create({
'name': 'x_active',
'model_id': self.env.ref('base.model_res_bank').id,
'ttype': 'boolean',
})
self.assertEqual('active', model_bank._active_name)
bank_credit_communal = model_bank.create({'name': 'Crédit Communal', 'x_active': False, 'active': True})
cc_search = model_bank.search([('name', '=', 'Crédit Communal')])
self.assertIn(bank_credit_communal, cc_search, "Search for active record with x_active set to False has failed")
bank_credit_communal.write({
'active': False,
'x_active': True,
})
cc_search = model_bank.search([('name', '=', 'Crédit Communal')])
self.assertNotIn(bank_credit_communal, cc_search, "Search for inactive record with x_active set to True has failed")
def test_21_search_count(self):
Partner = self.env['res.partner']
count_partner_before = Partner.search_count([])
partners = Partner.create([
{'name': 'abc'},
{'name': 'zer'},
{'name': 'christope'},
{'name': 'runbot'},
])
self.assertEqual(len(partners) + count_partner_before, Partner.search_count([]))
self.assertEqual(3, Partner.search_count([], limit=3))