diff --git a/odoo/addons/base/tests/test_expression.py b/odoo/addons/base/tests/test_expression.py index 7703d70bbc0..5b248a502a4 100644 --- a/odoo/addons/base/tests/test_expression.py +++ b/odoo/addons/base/tests/test_expression.py @@ -1171,7 +1171,7 @@ class TestQueries(TransactionCase): with self.assertQueries([''' SELECT "res_partner_title".id FROM "res_partner_title" - WHERE COALESCE("res_partner_title"."name"->>'fr_FR', "res_partner_title"."name"->>'en_US') like %s + WHERE (COALESCE("res_partner_title"."name"->>'fr_FR', "res_partner_title"."name"->>'en_US') like %s) ORDER BY COALESCE("res_partner_title"."name"->>'fr_FR', "res_partner_title"."name"->>'en_US') ''']): Model.search([('name', 'like', 'foo')]) @@ -1221,7 +1221,7 @@ class TestQueries(TransactionCase): SELECT "ir_model".id FROM "ir_model" WHERE ( - "ir_model"."name"->>'en_US' ILIKE %s + ("ir_model"."name"->>'en_US' ILIKE %s) OR ("ir_model"."model"::text ILIKE %s) ) ORDER BY "ir_model"."model" @@ -1234,7 +1234,7 @@ class TestQueries(TransactionCase): SELECT "ir_model".id FROM "ir_model" WHERE ( - "ir_model"."name"->>'en_US' NOT ILIKE %s + ("ir_model"."name" is NULL OR "ir_model"."name"->>'en_US' not ilike %s) AND (("ir_model"."model"::text NOT ILIKE %s) OR "ir_model"."model" IS NULL) ) ORDER BY "ir_model"."model" diff --git a/odoo/addons/test_new_api/models/test_new_api.py b/odoo/addons/test_new_api/models/test_new_api.py index 2330cd4eabd..b984d7dd245 100644 --- a/odoo/addons/test_new_api/models/test_new_api.py +++ b/odoo/addons/test_new_api/models/test_new_api.py @@ -1737,3 +1737,9 @@ class IndexedTranslation(models.Model): _description = 'A model to indexed translated fields' name = fields.Text('Name trigram', translate=True, index='trigram') + +class EmptyChar(models.Model): + _name = 'test_new_api.empty_char' + _description = 'A model to test emtpy char' + + name = fields.Char('Name') diff --git a/odoo/addons/test_new_api/security/ir.model.access.csv b/odoo/addons/test_new_api/security/ir.model.access.csv index 137db627427..dc2b7b3adbe 100644 --- a/odoo/addons/test_new_api/security/ir.model.access.csv +++ b/odoo/addons/test_new_api/security/ir.model.access.csv @@ -100,3 +100,4 @@ access_test_new_api_related_translation_1,access_test_new_api_related_translatio access_test_new_api_related_translation_2,access_test_new_api_related_translation_2,model_test_new_api_related_translation_2,,1,1,1,1 access_test_new_api_related_translation_3,access_test_new_api_related_translation_3,model_test_new_api_related_translation_3,,1,1,1,1 access_test_new_api_indexed_translation,access_test_new_api_indexed_translation,model_test_new_api_indexed_translation,,1,1,1,1 +access_test_new_api_empty_char,access_test_new_api_empty_char,model_test_new_api_empty_char,,1,1,1,1 diff --git a/odoo/addons/test_new_api/tests/test_domain.py b/odoo/addons/test_new_api/tests/test_domain.py index c10d3de6832..63b1a912903 100644 --- a/odoo/addons/test_new_api/tests/test_domain.py +++ b/odoo/addons/test_new_api/tests/test_domain.py @@ -1,4 +1,6 @@ # -*- coding: utf-8 -*- +from itertools import combinations + from odoo.tests import common @@ -39,3 +41,69 @@ class test_domain(common.TransactionCase): self.assertEqual(eq_1+eq_2, all_bool, 'True + False != all') self.assertEqual(neq_1+neq_2, all_bool, 'not True + not False != all') + + def test_empty_char(self): + EmptyChar = self.env['test_new_api.empty_char'] + EmptyChar.create([ + {'name': 'name'}, + {'name': ''}, + {'name': False}, + ]) + + self.assertListEqual(EmptyChar.search([('name', '=', 'name')]).mapped('name'), ['name']) + self.assertListEqual(EmptyChar.search([('name', '!=', 'name')]).mapped('name'), ['', False]) + self.assertListEqual(EmptyChar.search([('name', 'ilike', 'name')]).mapped('name'), ['name']) + self.assertListEqual(EmptyChar.search([('name', 'not ilike', 'name')]).mapped('name'), ['', False]) + + self.assertListEqual(EmptyChar.search([('name', '=', '')]).mapped('name'), ['']) + self.assertListEqual(EmptyChar.search([('name', '!=', '')]).mapped('name'), ['name']) + self.assertListEqual(EmptyChar.search([('name', 'ilike', '')]).mapped('name'), ['name', '', False]) + self.assertListEqual(EmptyChar.search([('name', 'not ilike', '')]).mapped('name'), [False]) + + self.assertListEqual(EmptyChar.search([('name', '=', False)]).mapped('name'), [False]) + self.assertListEqual(EmptyChar.search([('name', '!=', False)]).mapped('name'), ['name', '']) + self.assertListEqual(EmptyChar.search([('name', 'ilike', False)]).mapped('name'), ['name', '', False]) + self.assertListEqual(EmptyChar.search([('name', 'not ilike', False)]).mapped('name'), [False]) + + values = ['name', '', False] + for length in range(len(values) + 1): + for subset in combinations(values, length): + sublist = list(subset) + self.assertListEqual(EmptyChar.search([('name', 'in', sublist)]).mapped('name'), sublist) + sublist_remained = [v for v in values if v not in subset] + self.assertListEqual(EmptyChar.search([('name', 'not in', sublist)]).mapped('name'), sublist_remained) + + def test_empty_translation(self): + records_en = self.env['test_new_api.indexed_translation'].with_context(lang='en_US').create([ + {'name': 'English'}, + {'name': 'English'}, + {'name': 'English'}, + ]) + self.env['res.lang']._activate_lang('fr_FR') + records_fr = records_en.with_context(lang='fr_FR') + records_fr[0].name = 'name' + records_fr[1].name = '' + records_fr[2].name = False + + self.assertListEqual(records_fr.search([('name', '=', 'name')]).mapped('name'), ['name']) + self.assertListEqual(records_fr.search([('name', '!=', 'name')]).mapped('name'), ['', False]) + self.assertListEqual(records_fr.search([('name', 'ilike', 'name')]).mapped('name'), ['name']) + self.assertListEqual(records_fr.search([('name', 'not ilike', 'name')]).mapped('name'), ['', False]) + + self.assertListEqual(records_fr.search([('name', '=', '')]).mapped('name'), ['']) + self.assertListEqual(records_fr.search([('name', '!=', '')]).mapped('name'), ['name']) + self.assertListEqual(records_fr.search([('name', 'ilike', '')]).mapped('name'), ['name', '', False]) + self.assertListEqual(records_fr.search([('name', 'not ilike', '')]).mapped('name'), [False]) + + self.assertListEqual(records_fr.search([('name', '=', False)]).mapped('name'), [False]) + self.assertListEqual(records_fr.search([('name', '!=', False)]).mapped('name'), ['name', '']) + self.assertListEqual(records_fr.search([('name', 'ilike', False)]).mapped('name'), ['name', '', False]) + self.assertListEqual(records_fr.search([('name', 'not ilike', False)]).mapped('name'), [False]) + + values = ['name', '', False] + for length in range(len(values) + 1): + for subset in combinations(values, length): + sublist = list(subset) + self.assertListEqual(records_fr.search([('name', 'in', sublist)]).mapped('name'), sublist) + sublist_remained = [v for v in values if v not in subset] + self.assertListEqual(records_fr.search([('name', 'not in', sublist)]).mapped('name'), sublist_remained) diff --git a/odoo/osv/expression.py b/odoo/osv/expression.py index 8a4a03e3228..11cab35b7a7 100644 --- a/odoo/osv/expression.py +++ b/odoo/osv/expression.py @@ -960,11 +960,18 @@ class expression(object): expr, params = self.__leaf_to_sql(leaf, model, alias) push_result(expr, params) - elif field.translate and right: + elif field.translate and isinstance(right, str): sql_operator = {'=like': 'like', '=ilike': 'ilike'}.get(operator, operator) expr = '' params = [] + need_wildcard = operator in ('like', 'ilike', 'not like', 'not ilike') + if not need_wildcard: + right = field.convert_to_column(right, model, validate=False).adapted['en_US'] + + if (need_wildcard and not right) or (right and sql_operator in NEGATIVE_TERM_OPERATORS): + expr += f'"{alias}"."{left}" is NULL OR ' + if self._has_trigram and field.index == 'trigram' and sql_operator in ('=', 'like', 'ilike'): # a prefilter using trigram index to speed up '=', 'like', 'ilike' # '!=', '<=', '<', '>', '>=', 'in', 'not in', 'not like', 'not ilike' cannot use this trick @@ -977,7 +984,7 @@ class expression(object): _unaccent = self._unaccent(field) _left = _unaccent(f'''jsonb_path_query_array("{alias}"."{left}", '$.*')::text''') _sql_operator = 'like' if sql_operator == '=' else sql_operator - expr = f"{_left} {_sql_operator} {_unaccent('%s')} AND " + expr += f"{_left} {_sql_operator} {_unaccent('%s')} AND " params.append(_right) unaccent = self._unaccent(field) if sql_operator.endswith('like') else lambda x: x @@ -987,16 +994,32 @@ class expression(object): else: left = unaccent(f'''COALESCE("{alias}"."{left}"->>'{lang}', "{alias}"."{left}"->>'en_US')''') - need_wildcard = operator in ('like', 'ilike', 'not like', 'not ilike') if need_wildcard: - right = '%%%s%%' % right - if sql_operator in ('in', 'not in'): - right = tuple(right) + right = f'%{right}%' expr += f"{left} {sql_operator} {unaccent('%s')}" params.append(right) - push_result(expr, params) + push_result(f'({expr})', params) + elif field.translate and operator in ['in', 'not in'] and isinstance(right, (list, tuple)): + params = [it for it in right if it is not False and it is not None] + check_null = len(params) < len(right) + if params: + params = [field.convert_to_column(p, model, validate=False).adapted['en_US'] for p in params] + lang = model.env.lang or 'en_US' + if lang == 'en_US': + query = f'''("{alias}"."{left}"->>'en_US' {operator} %s)''' + else: + query = f'''(COALESCE("{alias}"."{left}"->>'{lang}', "{alias}"."{left}"->>'en_US') {operator} %s)''' + params = [tuple(params)] + else: + # The case for (left, 'in', []) or (left, 'not in', []). + query = 'FALSE' if operator == 'in' else 'TRUE' + if (operator == 'in' and check_null) or (operator == 'not in' and not check_null): + query = '(%s OR %s."%s" IS NULL)' % (query, alias, left) + elif operator == 'not in' and check_null: + query = '(%s AND %s."%s" IS NOT NULL)' % (query, alias, left) # needed only for TRUE. + push_result(query, params) else: expr, params = self.__leaf_to_sql(leaf, model, alias) push_result(expr, params) @@ -1058,7 +1081,7 @@ class expression(object): params = [it for it in (True, False) if it in right] check_null = False in right else: - params = [it for it in right if it != False] + params = [it for it in right if it is not False and it is not None] check_null = len(params) < len(right) if params: if left != 'id':