Skip to content

Commit dc09fa2

Browse files
committed
[FIX] base: fix search on company depend fields
This is a bunch of fixes (65 corner cases) for the search on company-dependent fields: Without a default value: - `char` fields: - operators `not like`/`not ilike` don't return records with unset value - `(..., '=', False)` doesn't return records with unset value - `(..., '!=', '<string>')` doesn't return records with unset value - `(..., 'in', [..., False])` doesn't return records with unset value - `(..., 'not in', value)` without `False` inside `value` doesn't return records with unset value - `date` and `datetime` fields: - `(..., '!=', <Date/datetime>)` doesn't return records with unset value - `(..., '=', False)` doesn't return records with unset value - `many2one` fields: - operators `not like`/`not ilike` don't return records with unset value - `(..., 'in', [..., False])` doesn't return records with unset value - `(..., 'not in', value)` without `False` inside `value` doesn't return records with unset value - `boolean` fields: - `(..., '=', False)` and `(..., '!=', True)` don't return records with unset and `False` values - `integer`/`float` fields: - `(..., '!=', <number>)` doesn't return records with unset value With a truthy default value: - `many2one` fields: - operators `not like`/`not ilike` don't return records with unset value - `(..., '=', False)` returns the record with the default value (which isn't `False`) - `boolean` fields: - `(..., '=', False)` doesn't return records with unset and `False` value - `(..., '!=', False)` returns records with unset and `False` value - `integer`/`float` fields: - all `(..., operator, value)` which include value 0, return all records with unset value, even if the default value does not satisfy the domain closes odoo#80982 X-original-commit: 3e3be65 Signed-off-by: Raphael Collet <[email protected]>
1 parent 88e7d27 commit dc09fa2

File tree

3 files changed

+172
-37
lines changed

3 files changed

+172
-37
lines changed

odoo/addons/base/models/ir_property.py

+44-37
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
from odoo import api, fields, models, _
55
from odoo.exceptions import UserError
6+
from odoo.osv.expression import TERM_OPERATORS_NEGATION
67
from odoo.tools import ormcache
78

89
TYPE2FIELD = {
@@ -381,55 +382,61 @@ def clean(value):
381382
def search_multi(self, name, model, operator, value):
382383
""" Return a domain for the records that match the given condition. """
383384
default_matches = False
384-
include_zero = False
385+
negate = False
386+
387+
# For "is set" and "is not set", same logic for all types
388+
if operator == 'in' and False in value:
389+
operator = 'not in'
390+
negate = True
391+
elif operator == 'not in' and False not in value:
392+
operator = 'in'
393+
negate = True
394+
elif operator in ('!=', 'not like', 'not ilike') and value:
395+
operator = TERM_OPERATORS_NEGATION[operator]
396+
negate = True
397+
elif operator == '=' and not value:
398+
operator = '!='
399+
negate = True
385400

386401
field = self.env[model]._fields[name]
402+
387403
if field.type == 'many2one':
388-
comodel = field.comodel_name
389404
def makeref(value):
390-
return value and '%s,%s' % (comodel, value)
391-
if operator == "=":
392-
value = makeref(value)
393-
# if searching properties not set, search those not in those set
394-
if value is False:
395-
default_matches = True
396-
elif operator in ('!=', '<=', '<', '>', '>='):
405+
return value and f'{field.comodel_name},{value}'
406+
407+
if operator in ('=', '!=', '<=', '<', '>', '>='):
397408
value = makeref(value)
398409
elif operator in ('in', 'not in'):
399410
value = [makeref(v) for v in value]
400411
elif operator in ('=like', '=ilike', 'like', 'not like', 'ilike', 'not ilike'):
401412
# most probably inefficient... but correct
402-
target = self.env[comodel]
413+
target = self.env[field.comodel_name]
403414
target_names = target.name_search(value, operator=operator, limit=None)
404415
target_ids = [n[0] for n in target_names]
405416
operator, value = 'in', [makeref(v) for v in target_ids]
417+
406418
elif field.type in ('integer', 'float'):
407419
# No record is created in ir.property if the field's type is float or integer with a value
408420
# equal to 0. Then to match with the records that are linked to a property field equal to 0,
409421
# the negation of the operator must be taken to compute the goods and the domain returned
410422
# to match the searched records is just the opposite.
411-
if value == 0 and operator == '=':
412-
operator = '!='
413-
include_zero = True
414-
elif value <= 0 and operator == '>=':
423+
value = float(value) if field.type == 'float' else int(value)
424+
if operator == '>=' and value <= 0:
415425
operator = '<'
416-
include_zero = True
417-
elif value < 0 and operator == '>':
426+
negate = True
427+
elif operator == '>' and value < 0:
418428
operator = '<='
419-
include_zero = True
420-
elif value >= 0 and operator == '<=':
429+
negate = True
430+
elif operator == '<=' and value >= 0:
421431
operator = '>'
422-
include_zero = True
423-
elif value > 0 and operator == '<':
432+
negate = True
433+
elif operator == '<' and value > 0:
424434
operator = '>='
425-
include_zero = True
435+
negate = True
436+
426437
elif field.type == 'boolean':
427-
if not value and operator == '=':
428-
operator = '!='
429-
include_zero = True
430-
elif value and operator == '!=':
431-
operator = '='
432-
include_zero = True
438+
# the value must be mapped to an integer value
439+
value = int(value)
433440

434441
# retrieve the properties that match the condition
435442
domain = self._get_domain(name, model)
@@ -441,21 +448,21 @@ def makeref(value):
441448
good_ids = []
442449
for prop in props:
443450
if prop.res_id:
444-
res_model, res_id = prop.res_id.split(',')
451+
__, res_id = prop.res_id.split(',')
445452
good_ids.append(int(res_id))
446453
else:
447454
default_matches = True
448455

449-
if include_zero:
450-
return [('id', 'not in', good_ids)]
451-
elif default_matches:
456+
if default_matches:
452457
# exclude all records with a property that does not match
453-
all_ids = []
454458
props = self.search(domain + [('res_id', '!=', False)])
455-
for prop in props:
456-
res_model, res_id = prop.res_id.split(',')
457-
all_ids.append(int(res_id))
458-
bad_ids = list(set(all_ids) - set(good_ids))
459-
return [('id', 'not in', bad_ids)]
459+
all_ids = {int(res_id.split(',')[1]) for res_id in props.mapped('res_id')}
460+
bad_ids = list(all_ids - set(good_ids))
461+
if negate:
462+
return [('id', 'in', bad_ids)]
463+
else:
464+
return [('id', 'not in', bad_ids)]
465+
elif negate:
466+
return [('id', 'not in', good_ids)]
460467
else:
461468
return [('id', 'in', good_ids)]

odoo/addons/test_new_api/models/test_new_api.py

+3
Original file line numberDiff line numberDiff line change
@@ -533,6 +533,9 @@ class CompanyDependent(models.Model):
533533
date = fields.Date(company_dependent=True)
534534
moment = fields.Datetime(company_dependent=True)
535535
tag_id = fields.Many2one('test_new_api.multi.tag', company_dependent=True)
536+
truth = fields.Boolean(company_dependent=True)
537+
count = fields.Integer(company_dependent=True)
538+
phi = fields.Float(company_dependent=True, digits=(2, 5))
536539

537540

538541
class CompanyDependentAttribute(models.Model):

odoo/addons/test_new_api/tests/test_new_fields.py

+125
Original file line numberDiff line numberDiff line change
@@ -1427,6 +1427,131 @@ def test_27_company_dependent(self):
14271427
company_records = self.env['test_new_api.company'].search([('foo', '=', 'DEF')])
14281428
self.assertEqual(len(company_records), 1)
14291429

1430+
def test_28_company_dependent_search(self):
1431+
""" Test the search on company-dependent fields in all corner cases.
1432+
This assumes that filtered_domain() correctly filters records when
1433+
its domain refers to company-dependent fields.
1434+
"""
1435+
Property = self.env['ir.property']
1436+
Model = self.env['test_new_api.company']
1437+
1438+
# create 4 records for all cases: two with explicit truthy values, one
1439+
# with an explicit falsy value, and one without an explicit value
1440+
records = Model.create([{}] * 4)
1441+
1442+
# For each field, we assign values to the records, and test a number of
1443+
# searches. The search cases are given by comparison operators, and for
1444+
# each operator, we test a number of possible operands. Every search()
1445+
# returns a subset of the records, and we compare it to an equivalent
1446+
# search performed by filtered_domain().
1447+
1448+
def test_field(field_name, truthy_values, operations):
1449+
# set ir.properties to all records except the last one
1450+
Property._set_multi(
1451+
field_name, Model._name,
1452+
{rec.id: val for rec, val in zip(records, truthy_values + [False])},
1453+
# Using this sentinel for 'default_value' forces the method to
1454+
# create 'ir.property' records for the value False. Without it,
1455+
# no property would be created because False is the default
1456+
# value.
1457+
default_value=object(),
1458+
)
1459+
1460+
# test without default value
1461+
test_cases(field_name, operations)
1462+
1463+
# set default value to False
1464+
Property._set_default(field_name, Model._name, False)
1465+
Property.flush()
1466+
Property.invalidate_cache()
1467+
test_cases(field_name, operations, False)
1468+
1469+
# set default value to truthy_values[0]
1470+
Property._set_default(field_name, Model._name, truthy_values[0])
1471+
Property.flush()
1472+
Property.invalidate_cache()
1473+
test_cases(field_name, operations, truthy_values[0])
1474+
1475+
def test_cases(field_name, operations, default=None):
1476+
for operator, values in operations.items():
1477+
for value in values:
1478+
domain = [(field_name, operator, value)]
1479+
with self.subTest(domain=domain, default=default):
1480+
search_result = Model.search([('id', 'in', records.ids)] + domain)
1481+
filter_result = records.filtered_domain(domain)
1482+
self.assertEqual(
1483+
search_result, filter_result,
1484+
f"Got values {[r[field_name] for r in search_result]} "
1485+
f"instead of {[r[field_name] for r in filter_result]}",
1486+
)
1487+
1488+
# boolean fields
1489+
test_field('truth', [True, True], {
1490+
'=': (True, False),
1491+
'!=': (True, False),
1492+
})
1493+
# integer fields
1494+
test_field('count', [10, -2], {
1495+
'=': (10, -2, 0, False),
1496+
'!=': (10, -2, 0, False),
1497+
'<': (10, -2, 0),
1498+
'>=': (10, -2, 0),
1499+
'<=': (10, -2, 0),
1500+
'>': (10, -2, 0),
1501+
})
1502+
# float fields
1503+
test_field('phi', [1.61803, -1], {
1504+
'=': (1.61803, -1, 0, False),
1505+
'!=': (1.61803, -1, 0, False),
1506+
'<': (1.61803, -1, 0),
1507+
'>=': (1.61803, -1, 0),
1508+
'<=': (1.61803, -1, 0),
1509+
'>': (1.61803, -1, 0),
1510+
})
1511+
# char fields
1512+
test_field('foo', ['qwer', 'azer'], {
1513+
'like': ('qwer', 'azer'),
1514+
'ilike': ('qwer', 'azer'),
1515+
'not like': ('qwer', 'azer'),
1516+
'not ilike': ('qwer', 'azer'),
1517+
'=': ('qwer', 'azer', False),
1518+
'!=': ('qwer', 'azer', False),
1519+
'not in': (['qwer', 'azer'], ['qwer', False], [False], []),
1520+
'in': (['qwer', 'azer'], ['qwer', False], [False], []),
1521+
})
1522+
# date fields
1523+
date1, date2 = date(2021, 11, 22), date(2021, 11, 23)
1524+
test_field('date', [date1, date2], {
1525+
'=': (date1, date2, False),
1526+
'!=': (date1, date2, False),
1527+
'<': (date1, date2),
1528+
'>=': (date1, date2),
1529+
'<=': (date1, date2),
1530+
'>': (date1, date2),
1531+
})
1532+
# datetime fields
1533+
moment1, moment2 = datetime(2021, 11, 22), datetime(2021, 11, 23)
1534+
test_field('moment', [moment1, moment2], {
1535+
'=': (moment1, moment2, False),
1536+
'!=': (moment1, moment2, False),
1537+
'<': (moment1, moment2),
1538+
'>=': (moment1, moment2),
1539+
'<=': (moment1, moment2),
1540+
'>': (moment1, moment2),
1541+
})
1542+
# many2one fields
1543+
tag1, tag2 = self.env['test_new_api.multi.tag'].create([{'name': 'one'}, {'name': 'two'}])
1544+
test_field('tag_id', [tag1.id, tag2.id], {
1545+
'like': (tag1.name, tag2.name),
1546+
'ilike': (tag1.name, tag2.name),
1547+
'not like': (tag1.name, tag2.name),
1548+
'not ilike': (tag1.name, tag2.name),
1549+
'=': (tag1.id, tag2.id, False),
1550+
'!=': (tag1.id, tag2.id, False),
1551+
'in': ([tag1.id, tag2.id], [tag2.id, False], [False], []),
1552+
'not in': ([tag1.id, tag2.id], [tag2.id, False], [False], []),
1553+
})
1554+
14301555
def test_30_read(self):
14311556
""" test computed fields as returned by read(). """
14321557
discussion = self.env.ref('test_new_api.discussion_0')

0 commit comments

Comments
 (0)