From 82ae036d3ad542c014a07155019c8fe94ee89c69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20=C3=89vrard?= Date: Mon, 14 Aug 2023 22:32:14 +0200 Subject: [PATCH] Add a canonicalize function for domains --- sao/CHANGELOG | 1 + sao/src/common.js | 76 +++++++++++++++++++++++ sao/tests/sao.js | 59 ++++++++++++++++++ tryton/CHANGELOG | 1 + tryton/tryton/common/domain_inversion.py | 47 ++++++++++++++ trytond/CHANGELOG | 1 + trytond/trytond/tests/test_tools.py | 59 +++++++++++++++++- trytond/trytond/tools/domain_inversion.py | 47 ++++++++++++++ 8 files changed, 288 insertions(+), 3 deletions(-) diff --git a/sao/CHANGELOG b/sao/CHANGELOG index 87f4305e60..192b36dbac 100644 --- a/sao/CHANGELOG +++ b/sao/CHANGELOG @@ -1,3 +1,4 @@ +* Add a canonicalize function for domains * Add menu to copy selected record from tree view to clipboard * Support PYSON comparison of timedelta * Support encoding timedelta into PYSON TimeDelta diff --git a/sao/src/common.js b/sao/src/common.js index 76c4b36194..49f741eb62 100644 --- a/sao/src/common.js +++ b/sao/src/common.js @@ -2436,6 +2436,63 @@ e => this.localize_domain(e, field_name, strip_target)); } }, + + _sort_key: function(domain) { + if (!domain.length) { + return [0, domain]; + } else if (this.is_leaf(domain)) { + if (domain[2] === null) { + domain = domain.slice(); + domain[2] = Number.NEGATIVE_INFINITY; + } + return [1, domain]; + } else if (~['AND', 'OR'].indexOf(domain)) { + return [0, domain]; + } else { + var content = domain.map(this._sort_key.bind(this)); + var nestedness = Math.max(...content.map(e => e[0])); + return [nestedness + 1, content]; + } + }, + + _domain_compare: function(d1, d2) { + if ((d1 instanceof Array) && (d2 instanceof Array)) { + var elem_comparison; + var min_len = Math.min(d1.length, d2.length); + for (var i = 0; i < min_len; i++) { + elem_comparison = this._domain_compare(d1[i], d2[i]); + if (elem_comparison != 0) { + return elem_comparison; + } + } + if (d1.length == d2.length) { + return 0; + } else { + return d1.length < d2.length ? -1 : 1; + } + } else if (d1 == d2) { + return 0; + } else { + return d1 < d2 ? -1 : 1; + } + }, + + sort: function(domain) { + if (!domain.length) { + return []; + } else if (this.is_leaf(domain)) { + return domain; + } else if (~['AND', 'OR'].indexOf(domain)) { + return domain; + } else { + var sorted_elements = domain.map(this.sort.bind(this)); + sorted_elements.sort( + (d1, d2) => this._domain_compare(this._sort_key(d1), this._sort_key(d2)) + ); + return sorted_elements; + } + }, + prepare_reference_domain: function(domain, reference) { var value2reference = function(value) { @@ -2649,6 +2706,25 @@ return this.simplify_nested(this.simplify_duplicate(domain)); } }, + simplify_AND: function(domain) { + if (this.is_leaf(domain)) { + return domain; + } else if (domain == 'OR') { + return domain; + } else { + var simplified = []; + for (const e of domain) { + if (e == 'AND') { + continue; + } + simplified.push(this.simplify_AND(e)); + } + return simplified; + } + }, + canonicalize: function(domain) { + return this.simplify_AND(this.sort(this.simplify(domain))); + }, merge: function(domain, domoperator) { if (jQuery.isEmptyObject(domain) || ~['AND', 'OR'].indexOf(domain)) { diff --git a/sao/tests/sao.js b/sao/tests/sao.js index 406fa6d018..a09aca6fb5 100644 --- a/sao/tests/sao.js +++ b/sao/tests/sao.js @@ -3116,6 +3116,65 @@ [])); }); + QUnit.test('DomainInversion.sort', function() { + var domain_inversion = new Sao.common.DomainInversion(); + var sort = domain_inversion.sort.bind(domain_inversion); + var tests = [ + [[], []], + [['AND'], ['AND']], + [['OR'], ['OR']], + [[['foo', '=', 1]], [['foo', '=', 1]]], + [[['foo', '=', 1000], ['foo', '=', 0]], + [['foo', '=', 0], ['foo', '=', 1000]]], + [[['foo', '=', 1], ['foo', '=', null]], + [['foo', '=', null], ['foo', '=', 1]]], + [[['foo', '=', 1], []], [[], ['foo', '=', 1]]], + [[[[[['foo', '=', 1]]]], [[['foo', '=', 1]]]], + [[[['foo', '=', 1]]], [[[['foo', '=', 1]]]]]], + [[[['foo', '=', 1], ['bar', '=', 2]], + [['foo', '=', 1], ['bar', '=', 2], ['baz', '=', 3]]], + [[['bar', '=', 2], ['baz', '=', 3], ['foo', '=', 1]], + [['bar', '=', 2], ['foo', '=', 1]]]], + [['OR', ['foo', '=', 1], ['foo', '=', null], + ['AND', ['bar', '=', 2], ['baz', '=', 3]]], + ['OR', ['foo', '=', null], ['foo', '=', 1], + ['AND', ['bar', '=', 2], ['baz', '=', 3]]]], + ]; + for (const [domain, expected] of tests) { + QUnit.deepEqual(expected, sort(domain)); + } + }); + + QUnit.test('DomainInversion.canonicalize', function() { + var domain_inversion = new Sao.common.DomainInversion(); + var canonicalize = domain_inversion.canonicalize.bind(domain_inversion); + var tests = [ + [[], []], + [['AND'], []], + [['OR'], []], + [[['foo', '=', 1], ['bar', '=', 2]], + [['bar', '=', 2], ['foo', '=', 1]]], + [[[['foo', '=', 1]]], [['foo', '=', 1]]], + [['AND', ['OR', ['bar', '=', 2], ['baz', '=', 3]], + ['foo', '=', 1]], + [['foo', '=', 1], + ['OR', ['bar', '=', 2], ['baz', '=', 3]]]], + [['OR', ['AND', ['bar', '=', 2], ['baz', '=', 3]], + ['foo', '=', 1]], + ['OR', ['foo', '=', 1], + [['bar', '=', 2], ['baz', '=', 3]]]], + [['AND', ['AND', ['bar', '=', 2], ['baz', '=', 3]], + ['foo', '=', 1]], + [['bar', '=', 2], ['baz', '=', 3], ['foo', '=', 1]]], + [['OR', ['OR', ['bar', '=', 2], ['baz', '=', 3]], + ['foo', '=', 1]], + ['OR', ['bar', '=', 2], ['baz', '=', 3], ['foo', '=', 1]]], + ]; + for (const [domain, expected] of tests) { + QUnit.deepEqual(expected, canonicalize(domain)); + } + }); + QUnit.test('DomainParser.completion', function() { var compare = Sao.common.compare; var parser = new Sao.common.DomainParser({ diff --git a/tryton/CHANGELOG b/tryton/CHANGELOG index 32fedc3b8e..309b892340 100644 --- a/tryton/CHANGELOG +++ b/tryton/CHANGELOG @@ -1,3 +1,4 @@ +* Add a canonicalize function for domains * Support PYSON comparison of timedelta * Support encoding timedelta into PYSON TimeDelta * Transfer configuration, profiles and plugins from previous version diff --git a/tryton/tryton/common/domain_inversion.py b/tryton/tryton/common/domain_inversion.py index ea8c6974d7..cf56f049b0 100644 --- a/tryton/tryton/common/domain_inversion.py +++ b/tryton/tryton/common/domain_inversion.py @@ -284,6 +284,34 @@ def localize_domain(domain, field_name=None, strip_target=False): for part in domain] +def _sort_key(domain): + if not domain: + return (0, tuple()) + elif is_leaf(domain): + if domain[2] is None: + domain = list(domain).copy() + domain[2] = float('-inf') + return (1, tuple(domain)) + elif domain in ['AND', 'OR']: + return (0, domain) + else: + content = tuple(_sort_key(e) for e in domain) + nestedness = max(k[0] for k in content) + return (nestedness + 1, content) + + +def sort(domain): + "Sort a domain" + if not domain: + return domain + elif is_leaf(domain): + return domain + elif domain in ['AND', 'OR']: + return domain + else: + return sorted((sort(e) for e in domain), key=_sort_key) + + def bool_operator(domain): "Returns the boolean operator used by a domain" bool_op = 'AND' @@ -340,6 +368,16 @@ def simplify_duplicate(domain): return dedup_branches +def simplify_AND(domain): + """Remove useless ANDs""" + if is_leaf(domain): + return domain + elif domain == 'OR': + return domain + else: + return [simplify_AND(e) for e in domain if e != 'AND'] + + def simplify(domain): """Remove duplicate expressions and useless OR/AND""" if is_leaf(domain): @@ -350,6 +388,15 @@ def simplify(domain): return simplify_nested(simplify_duplicate(domain)) +def canonicalize(domain): + """Returns the canonical version of a domain. + + The canonical version of a domain is one where the domain is both + simplified and sorted + """ + return simplify_AND(sort(simplify(domain))) + + def merge(domain, domoperator=None): if not domain or domain in ('AND', 'OR'): return [] diff --git a/trytond/CHANGELOG b/trytond/CHANGELOG index f6ddf78d7b..d2d96393e1 100644 --- a/trytond/CHANGELOG +++ b/trytond/CHANGELOG @@ -1,3 +1,4 @@ +* Add a canonicalize function for domains * Enforce record rules when reading only non SQL fields (#12428) * Support PYSON comparison of timedelta * Support encoding timedelta into PYSON TimeDelta diff --git a/trytond/trytond/tests/test_tools.py b/trytond/trytond/tests/test_tools.py index 9f9c51a86a..e2920f8531 100644 --- a/trytond/trytond/tests/test_tools.py +++ b/trytond/trytond/tests/test_tools.py @@ -17,9 +17,9 @@ from trytond.tools import ( reduce_ids, remove_forbidden_chars, rstrip_wildcard, slugify, sortable_values, strip_wildcard, timezone, unescape_wildcard) from trytond.tools.domain_inversion import ( - concat, domain_inversion, eval_domain, extract_reference_models, - localize_domain, merge, parse, prepare_reference_domain, simplify, - unique_value) + canonicalize, concat, domain_inversion, eval_domain, + extract_reference_models, localize_domain, merge, parse, + prepare_reference_domain, simplify, sort, unique_value) from trytond.tools.immutabledict import ImmutableDict from trytond.tools.logging import format_args from trytond.tools.string_ import LazyString, StringPartitioned @@ -1099,6 +1099,59 @@ class DomainInversionTestCase(unittest.TestCase): extract_reference_models(domain, 'x'), {'model_A', 'model_B'}) self.assertEqual(extract_reference_models(domain, 'y'), set()) + def test_sort(self): + "Test sorting domain" + for (domain, expected) in [ + ([], []), + (['AND'], ['AND']), + (['OR'], ['OR']), + ([('foo', '=', 1)], [('foo', '=', 1)]), + ([('foo', '=', 1000), ('foo', '=', 0)], + [('foo', '=', 0), ('foo', '=', 1000)]), + ([('foo', '=', 1), ('foo', '=', None)], + [('foo', '=', None), ('foo', '=', 1)]), + ([('foo', '=', 1), []], [[], ('foo', '=', 1)]), + ([[[[('foo', '=', 1)]]], [[('foo', '=', 1)]]], + [[[('foo', '=', 1)]], [[[('foo', '=', 1)]]]]), + ([[('foo', '=', 1), ('bar', '=', 2)], + [('foo', '=', 1), ('bar', '=', 2), ('baz', '=', 3)]], + [[('bar', '=', 2), ('baz', '=', 3), ('foo', '=', 1)], + [('bar', '=', 2), ('foo', '=', 1)]]), + (['OR', ('foo', '=', 1), ('foo', '=', None), + ['AND', ('bar', '=', 2), ('baz', '=', 3)]], + ['OR', ('foo', '=', None), ('foo', '=', 1), + ['AND', ('bar', '=', 2), ('baz', '=', 3)]]), + ]: + with self.subTest(domain=domain): + self.assertEqual(sort(domain), expected) + + def test_canonicalize(self): + "Test domain canonicalization" + for (domain, expected) in [ + ([], []), + (['AND'], []), + (['OR'], []), + ([('foo', '=', 1), ('bar', '=', 2)], + [('bar', '=', 2), ('foo', '=', 1)]), + ([[('foo', '=', 1)]], [('foo', '=', 1)]), + (['AND', ['OR', ('bar', '=', 2), ('baz', '=', 3)], + ('foo', '=', 1)], + [('foo', '=', 1), + ['OR', ('bar', '=', 2), ('baz', '=', 3)]]), + (['OR', ['AND', ('bar', '=', 2), ('baz', '=', 3)], + ('foo', '=', 1)], + ['OR', ('foo', '=', 1), + [('bar', '=', 2), ('baz', '=', 3)]]), + (['AND', ['AND', ('bar', '=', 2), ('baz', '=', 3)], + ('foo', '=', 1)], + [('bar', '=', 2), ('baz', '=', 3), ('foo', '=', 1)]), + (['OR', ['OR', ('bar', '=', 2), ('baz', '=', 3)], + ('foo', '=', 1)], + ['OR', ('bar', '=', 2), ('baz', '=', 3), ('foo', '=', 1)]), + ]: + with self.subTest(domain=domain): + self.assertEqual(canonicalize(domain), expected) + @unittest.skipUnless(barcode, "required barcode") class BarcodeTestCase(unittest.TestCase): diff --git a/trytond/trytond/tools/domain_inversion.py b/trytond/trytond/tools/domain_inversion.py index ea8c6974d7..cf56f049b0 100644 --- a/trytond/trytond/tools/domain_inversion.py +++ b/trytond/trytond/tools/domain_inversion.py @@ -284,6 +284,34 @@ def localize_domain(domain, field_name=None, strip_target=False): for part in domain] +def _sort_key(domain): + if not domain: + return (0, tuple()) + elif is_leaf(domain): + if domain[2] is None: + domain = list(domain).copy() + domain[2] = float('-inf') + return (1, tuple(domain)) + elif domain in ['AND', 'OR']: + return (0, domain) + else: + content = tuple(_sort_key(e) for e in domain) + nestedness = max(k[0] for k in content) + return (nestedness + 1, content) + + +def sort(domain): + "Sort a domain" + if not domain: + return domain + elif is_leaf(domain): + return domain + elif domain in ['AND', 'OR']: + return domain + else: + return sorted((sort(e) for e in domain), key=_sort_key) + + def bool_operator(domain): "Returns the boolean operator used by a domain" bool_op = 'AND' @@ -340,6 +368,16 @@ def simplify_duplicate(domain): return dedup_branches +def simplify_AND(domain): + """Remove useless ANDs""" + if is_leaf(domain): + return domain + elif domain == 'OR': + return domain + else: + return [simplify_AND(e) for e in domain if e != 'AND'] + + def simplify(domain): """Remove duplicate expressions and useless OR/AND""" if is_leaf(domain): @@ -350,6 +388,15 @@ def simplify(domain): return simplify_nested(simplify_duplicate(domain)) +def canonicalize(domain): + """Returns the canonical version of a domain. + + The canonical version of a domain is one where the domain is both + simplified and sorted + """ + return simplify_AND(sort(simplify(domain))) + + def merge(domain, domoperator=None): if not domain or domain in ('AND', 'OR'): return []