From 66a0dcf2b542815504435828919df06fa300fbb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Sun, 3 Oct 2021 01:14:08 +0200 Subject: [PATCH] Add stored path as alternative to MPTT issue7177 review352501002 --- CHANGELOG | 1 + doc/ref/models/fields.rst | 17 +- trytond/model/fields/many2one.py | 28 +- trytond/model/modelsql.py | 124 ++++++- trytond/model/modelstorage.py | 9 +- trytond/tests/__init__.py | 2 + trytond/tests/field_many2one.py | 8 + trytond/tests/path.py | 19 + trytond/tests/test_field_many2one.py | 514 ++++++++++++--------------- trytond/tests/test_mptt.py | 122 +------ trytond/tests/test_path.py | 38 ++ trytond/tests/test_tree.py | 145 ++++++++ 12 files changed, 600 insertions(+), 427 deletions(-) create mode 100644 trytond/tests/path.py create mode 100644 trytond/tests/test_path.py diff --git a/CHANGELOG b/CHANGELOG index ebc12f980c..976e2575d2 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,4 @@ +* Add stored path as alternative to MPTT * Activate and upgrade modules from configuration wizard * Add set_from_header in email tools * Add grouping attribute to numeric widgets diff --git a/doc/ref/models/fields.rst b/doc/ref/models/fields.rst index 9df0993f8a..434b90efe1 100644 --- a/doc/ref/models/fields.rst +++ b/doc/ref/models/fields.rst @@ -647,7 +647,7 @@ Instance methods: Many2One -------- -.. class:: Many2One(model_name, string[, left[, right[, ondelete[, datetime_field[, target_search[, search_order[, search_context[, \**options]]]]]]]) +.. class:: Many2One(model_name, string[, left[, right[, path[, ondelete[, datetime_field[, target_search[, search_order[, search_context[, \**options]]]]]]]]]) A many-to-one relation field. @@ -674,6 +674,21 @@ A many-to-one relation field. The name of the field that stores the right value. See :attr:`left`. +.. attribute:: Many2One.path + + The name of the :class:`Char` field that stores the path. + It only works if the :attr:`model_name` is the same as the model. + +.. note:: The path is used to optimize searches using the ``child_of`` or + ``parent_of`` operators. + +.. warning:: + + The paths in the tree will be rebuilt during the database update if any of + the records are found to have a path field equal to the default, or + ``NULL``. + + .. attribute:: Many2One.ondelete Define the behavior of the record when the target record is deleted. diff --git a/trytond/model/fields/many2one.py b/trytond/model/fields/many2one.py index 8d2c76f963..f03d1e6b30 100644 --- a/trytond/model/fields/many2one.py +++ b/trytond/model/fields/many2one.py @@ -21,7 +21,7 @@ class Many2One(Field): _sql_type = 'INTEGER' _py_type = int - def __init__(self, model_name, string='', left=None, right=None, + def __init__(self, model_name, string='', left=None, right=None, path=None, ondelete='SET NULL', datetime_field=None, target_search='join', search_order=None, search_context=None, help='', required=False, readonly=False, domain=None, states=None, select=False, @@ -33,6 +33,7 @@ class Many2One(Field): Modified Preorder Tree Traversal. See http://en.wikipedia.org/wiki/Tree_traversal :param right: The name of the field to store the right value. See left + :param path: The name of the field used to store the path. :param ondelete: Define the behavior of the record when the target record is deleted. (``CASCADE``, ``RESTRICT``, ``SET NULL``) ``SET NULL`` will be changed into ``RESTRICT`` if required is set. @@ -58,6 +59,7 @@ class Many2One(Field): self.model_name = model_name self.left = left self.right = right + self.path = path self.datetime_field = datetime_field assert target_search in ['subquery', 'join'] self.target_search = target_search @@ -115,6 +117,27 @@ class Many2One(Field): assert value is not False return super().sql_format(value) + def convert_domain_path(self, domain, tables): + cursor = Transaction().connection.cursor() + table, _ = tables[None] + name, operator, ids = domain + red_sql = reduce_ids(table.id, (i for i in ids if i is not None)) + Target = self.get_target() + path_column = getattr(Target, self.path).sql_column(table) + cursor.execute(*table.select(path_column, where=red_sql)) + if operator.endswith('child_of'): + where = Or() + for path, in cursor: + where.append(path_column.like(path + '%')) + else: + ids = [int(x) for path, in cursor for x in path.split('/')[:-1]] + where = reduce_ids(table.id, ids) + if not where: + where = Literal(False) + if operator.startswith('not'): + return ~where + return where + def convert_domain_mptt(self, domain, tables): cursor = Transaction().connection.cursor() table, _ = tables[None] @@ -203,6 +226,9 @@ class Many2One(Field): elif self.left and self.right: return self.convert_domain_mptt( (name, operator, ids), tables) + elif self.path: + return self.convert_domain_path( + (name, operator, ids), tables) else: return self.convert_domain_tree( (name, operator, ids), tables) diff --git a/trytond/model/modelsql.py b/trytond/model/modelsql.py index cc04c66119..69ab3d18d4 100644 --- a/trytond/model/modelsql.py +++ b/trytond/model/modelsql.py @@ -6,10 +6,10 @@ from collections import OrderedDict, defaultdict from functools import wraps from sql import (Table, Column, Literal, Desc, Asc, Expression, Null, - NullsFirst, NullsLast, For, Union) -from sql.functions import CurrentTimestamp, Extract + NullsFirst, NullsLast, For, Union, With) +from sql.functions import CurrentTimestamp, Extract, Substring from sql.conditionals import Coalesce -from sql.operators import Or, And, Operator, Equal +from sql.operators import Or, And, Operator, Equal, Concat from sql.aggregate import Count, Max from trytond.i18n import gettext @@ -289,19 +289,32 @@ class ModelSQL(ModelStorage): field_name, action=required and 'add' or 'remove') for field_name, field in cls._fields.items(): - if isinstance(field, fields.Many2One) \ - and field.model_name == cls.__name__ \ - and field.left and field.right: - left_default = cls._defaults.get(field.left, lambda: None)() - right_default = cls._defaults.get(field.right, lambda: None)() - cursor.execute(*sql_table.select(sql_table.id, - where=(Column(sql_table, field.left) == left_default) - | (Column(sql_table, field.left) == Null) - | (Column(sql_table, field.right) == right_default) - | (Column(sql_table, field.right) == Null), - limit=1)) - if cursor.fetchone(): - cls._rebuild_tree(field_name, None, 0) + if (isinstance(field, fields.Many2One) + and field.model_name == cls.__name__): + if field.path: + default_path = cls._defaults.get( + field.path, lambda: None)() + cursor.execute(*sql_table.select(sql_table.id, + where=( + Column(sql_table, field.path) == default_path) + | (Column(sql_table, field.path) == Null), + limit=1)) + if cursor.fetchone(): + cls._rebuild_path(field_name) + if field.left and field.right: + left_default = cls._defaults.get( + field.left, lambda: None)() + right_default = cls._defaults.get( + field.right, lambda: None)() + cursor.execute(*sql_table.select(sql_table.id, + where=( + Column(sql_table, field.left) == left_default) + | (Column(sql_table, field.left) == Null) + | (Column(sql_table, field.right) == right_default) + | (Column(sql_table, field.right) == Null), + limit=1)) + if cursor.fetchone(): + cls._rebuild_tree(field_name, None, 0) for ident, constraint, _ in cls._sql_constraints: table.add_constraint(ident, constraint) @@ -635,6 +648,10 @@ class ModelSQL(ModelStorage): transaction.create_records[cls.__name__].update(new_ids) + # Update path before fields_to_set which could create children + if cls._path_fields: + field_names = list(sorted(cls._path_fields)) + cls._set_path(field_names, repeat(new_ids, len(field_names))) # Update mptt before fields_to_set which could create children if cls._mptt_fields: field_names = list(sorted(cls._mptt_fields)) @@ -1031,6 +1048,11 @@ class ModelSQL(ModelStorage): if hasattr(field, 'set'): fields_to_set.setdefault(fname, []).extend((ids, value)) + path_fields = cls._path_fields & values.keys() + if path_fields: + cls._update_path( + list(sorted(path_fields)), repeat(ids, len(path_fields))) + mptt_fields = cls._mptt_fields & values.keys() if mptt_fields: cls._update_mptt( @@ -1504,6 +1526,76 @@ class ModelSQL(ModelStorage): <= transaction.context['_datetime']) return tables, expression + @classmethod + def _rebuild_path(cls, field_name): + "Rebuild path for the tree." + cursor = Transaction().connection.cursor() + field = cls._fields[field_name] + table = cls.__table__() + tree = With('id', 'path', recursive=True) + tree.query = table.select( + table.id, Concat(table.id, '/'), + where=Column(table, field_name) == Null) + tree.query |= (table + .join(tree, + condition=Column(table, field_name) == tree.id) + .select(table.id, Concat(Concat(tree.path, table.id), '/'))) + query = table.update( + [Column(table, field.path)], + [tree.path], + from_=[tree], where=table.id == tree.id, + with_=[tree]) + cursor.execute(*query) + + @classmethod + def _set_path(cls, field_names, list_ids): + cursor = Transaction().connection.cursor() + table = cls.__table__() + parent = cls.__table__() + for field_name, ids in zip(field_names, list_ids): + field = cls._fields[field_name] + parent_column = Column(table, field_name) + path_column = Column(table, field.path) + query = table.update( + [path_column], + [Concat(Concat(Coalesce( + parent.select(parent.path, + where=parent.id == parent_column), + ''), table.id), '/')]) + for sub_ids in grouped_slice(ids): + query.where = reduce_ids(table.id, sub_ids) + cursor.execute(*query) + + @classmethod + def _update_path(cls, field_names, list_ids): + transaction = Transaction() + cursor = transaction.connection.cursor() + update = transaction.connection.cursor() + table = cls.__table__() + parent = cls.__table__() + for field_name, ids in zip(field_names, list_ids): + field = cls._fields[field_name] + parent_column = Column(table, field_name) + parent_path_column = Column(parent, field.path) + path_column = Column(table, field.path) + query = (table + .join(parent, 'LEFT', + condition=parent_column == parent.id) + .select(path_column, + Concat(Concat( + Coalesce(parent_path_column, ''), table.id), '/'))) + for sub_ids in grouped_slice(ids): + query.where = reduce_ids(table.id, sub_ids) + cursor.execute(*query) + for old_path, new_path in cursor: + if old_path == new_path: + continue + update.execute(*table.update( + [path_column], + [Concat(new_path, + Substring(table.path, len(old_path) + 1))], + where=table.path.like(old_path + '%'))) + @classmethod def _update_mptt(cls, field_names, list_ids, values=None): for field_name, ids in zip(field_names, list_ids): diff --git a/trytond/model/modelstorage.py b/trytond/model/modelstorage.py index ec24f3b9d4..bb803a8b8d 100644 --- a/trytond/model/modelstorage.py +++ b/trytond/model/modelstorage.py @@ -147,11 +147,14 @@ class ModelStorage(Model): super().__post_setup__() cls._mptt_fields = set() + cls._path_fields = set() for name, field in cls._fields.items(): if (isinstance(field, fields.Many2One) - and field.model_name == cls.__name__ - and field.left and field.right): - cls._mptt_fields.add(name) + and field.model_name == cls.__name__): + if field.path: + cls._path_fields.add(name) + if field.left and field.right: + cls._mptt_fields.add(name) @staticmethod def default_create_uid(): diff --git a/trytond/tests/__init__.py b/trytond/tests/__init__.py index 46926c8aff..b822417da3 100644 --- a/trytond/tests/__init__.py +++ b/trytond/tests/__init__.py @@ -41,6 +41,7 @@ def register(): from . import modelview from . import mptt from . import multivalue + from . import path from . import resource from . import rule from . import tree @@ -81,6 +82,7 @@ def register(): modelview.register('tests') mptt.register('tests') multivalue.register('tests') + path.register('tests') resource.register('tests') rule.register('tests') tree.register('tests') diff --git a/trytond/tests/field_many2one.py b/trytond/tests/field_many2one.py index 6c8cc07986..6d3deb42c4 100644 --- a/trytond/tests/field_many2one.py +++ b/trytond/tests/field_many2one.py @@ -65,6 +65,13 @@ class Many2OneMPTT(ModelSQL): return 0 +class Many2OnePath(ModelSQL): + "Many2One Path" + __name__ = 'test.many2one_path' + many2one = fields.Many2One('test.many2one_path', 'many2one', path='path') + path = fields.Char("Path") + + class Many2OneContext(ModelSQL): "Many2One Context" __name__ = 'test.many2one_context' @@ -92,6 +99,7 @@ def register(module): Many2OneTargetStorage, Many2OneTree, Many2OneMPTT, + Many2OnePath, Many2OneContext, Many2OneTargetContext, module=module, type_='model') diff --git a/trytond/tests/path.py b/trytond/tests/path.py new file mode 100644 index 0000000000..5a7ebf0524 --- /dev/null +++ b/trytond/tests/path.py @@ -0,0 +1,19 @@ +# This file is part of Tryton. The COPYRIGHT file at the top level of +# this repository contains the full copyright notices and license terms. +from trytond.model import ModelSQL, DeactivableMixin, tree, fields +from trytond.pool import Pool + + +class Path(DeactivableMixin, tree(), ModelSQL): + "Path" + __name__ = 'test.path' + name = fields.Char("Name", required=True) + parent = fields.Many2One('test.path', "Parent", select=True, path='path') + path = fields.Char("Path") + children = fields.One2Many('test.path', 'parent', "Children") + + +def register(module): + Pool.register( + Path, + module=module, type_='model') diff --git a/trytond/tests/test_field_many2one.py b/trytond/tests/test_field_many2one.py index 90fef4efc0..daebe41bfd 100644 --- a/trytond/tests/test_field_many2one.py +++ b/trytond/tests/test_field_many2one.py @@ -176,297 +176,6 @@ class FieldMany2OneTestCase(unittest.TestCase): "Test search by many2one subquery" self._test_search_join('subquery') - def create_tree(self, Many2One): - self.root1, self.root2 = Many2One.create([{}, {}]) - self.first1, self.first2, self.first3, self.first4 = Many2One.create([ - {'many2one': self.root1}, - {'many2one': self.root1}, - {'many2one': self.root2}, - {'many2one': self.root2}, - ]) - self.second1, self.second2, self.second3, self.second4 = ( - Many2One.create([ - {'many2one': self.first1}, - {'many2one': self.first1}, - {'many2one': self.first2}, - {'many2one': self.first2}, - ])) - - @with_transaction() - def _test_search_child_of_root1(self, model_name): - Many2One = Pool().get(model_name) - self.create_tree(Many2One) - - result = Many2One.search([ - ('many2one', 'child_of', [self.root1.id]), - ]) - - self.assertListEqual( - sorted(result), - sorted([self.root1, - self.first1, self.first2, - self.second1, self.second2, self.second3, self.second4])) - - def test_search_tree_child_of_root1(self): - "Test search many2one tree child of root1" - self._test_search_child_of_root1('test.many2one_tree') - - def test_search_mptt_child_of_root1(self): - "Test search many2one mptt child of root1" - self._test_search_child_of_root1('test.many2one_mptt') - - @with_transaction() - def _test_search_not_child_of_root1(self, model_name): - Many2One = Pool().get(model_name) - self.create_tree(Many2One) - - result = Many2One.search([ - ('many2one', 'not child_of', [self.root1.id]), - ]) - - self.assertListEqual( - sorted(result), - sorted([self.root2, self.first3, self.first4])) - - def test_search_tree_not_child_of_root1(self): - "Test search many2one tree not child of root1" - self._test_search_not_child_of_root1('test.many2one_tree') - - def test_search_mptt_not_child_of_root1(self): - "Test search many2one mptt not child of root1" - self._test_search_not_child_of_root1('test.many2one_mptt') - - @with_transaction() - def _test_search_child_of_second1(self, model_name): - Many2One = Pool().get(model_name) - self.create_tree(Many2One) - - result = Many2One.search([ - ('many2one', 'child_of', [self.second1.id]), - ]) - - self.assertListEqual(result, [self.second1]) - - def test_search_tree_child_of_second1(self): - "Test search many2one tree child of second1" - self._test_search_child_of_second1('test.many2one_tree') - - def test_search_mptt_child_of_second1(self): - "Test search many2one mptt child of second1" - self._test_search_child_of_second1('test.many2one_mptt') - - @with_transaction() - def _test_search_not_child_of_second1(self, model_name): - Many2One = Pool().get(model_name) - self.create_tree(Many2One) - - result = Many2One.search([ - ('many2one', 'not child_of', [self.second1.id]), - ]) - - self.assertListEqual( - sorted(result), - sorted([self.root1, self.root2, - self.first1, self.first2, self.first3, self.first4, - self.second2, self.second3, self.second4])) - - def test_search_tree_not_child_of_second1(self): - "Test search many2one tree not child of second1" - self._test_search_not_child_of_second1('test.many2one_tree') - - def test_search_mptt_not_child_of_second1(self): - "Test search many2one mptt not child of second1" - self._test_search_not_child_of_second1('test.many2one_mptt') - - @with_transaction() - def _test_search_child_of_empty(self, model_name): - Many2One = Pool().get(model_name) - self.create_tree(Many2One) - - result = Many2One.search([ - ('many2one', 'child_of', []), - ]) - - self.assertListEqual(result, []) - - def test_search_tree_child_of_empty(self): - "Test search many2one tree child of empty" - self._test_search_child_of_empty('test.many2one_tree') - - def test_search_mptt_child_of_empty(self): - "Test search many2one mptt child of empty" - self._test_search_child_of_empty('test.many2one_mptt') - - @with_transaction() - def _test_search_not_child_of_empty(self, model_name): - Many2One = Pool().get(model_name) - self.create_tree(Many2One) - - result = Many2One.search([ - ('many2one', 'not child_of', []), - ]) - - self.assertListEqual( - sorted(result), - sorted([self.root1, self.root2, - self.first1, self.first2, self.first3, self.first4, - self.second1, self.second2, self.second3, self.second4])) - - def test_search_tree_not_child_of_empty(self): - "Test search many2one tree not child of empty" - self._test_search_not_child_of_empty('test.many2one_tree') - - def test_search_mptt_not_child_of_empty(self): - "Test search many2one mptt not child of empty" - self._test_search_not_child_of_empty('test.many2one_mptt') - - @with_transaction() - def _test_search_child_of_none(self, model_name): - Many2One = Pool().get(model_name) - self.create_tree(Many2One) - - result = Many2One.search([ - ('many2one', 'child_of', [None]), - ]) - - self.assertListEqual(result, []) - - def test_search_tree_child_of_none(self): - "Test search many2one tree child of None" - self._test_search_child_of_none('test.many2one_tree') - - def test_search_mptt_child_of_none(self): - "Test search many2one mptt child of None" - self._test_search_child_of_none('test.many2one_mptt') - - @with_transaction() - def _test_search_parent_of_root1(self, model_name): - Many2One = Pool().get(model_name) - self.create_tree(Many2One) - - result = Many2One.search([ - ('many2one', 'parent_of', [self.root1.id]), - ]) - - self.assertListEqual(result, [self.root1]) - - def test_search_tree_parent_of_root1(self): - "Test search many2one tree parent of root1" - self._test_search_parent_of_root1('test.many2one_tree') - - def test_search_mptt_parent_of_root1(self): - "Test search many2one mptt parent of root1" - self._test_search_parent_of_root1('test.many2one_mptt') - - @with_transaction() - def _test_search_not_parent_of_root1(self, model_name): - Many2One = Pool().get(model_name) - self.create_tree(Many2One) - - result = Many2One.search([ - ('many2one', 'not parent_of', [self.root1.id]), - ]) - - self.assertListEqual( - sorted(result), - sorted([self.root2, - self.first1, self.first2, self.first3, self.first4, - self.second1, self.second2, self.second3, self.second4])) - - def test_search_tree_not_parent_of_root1(self): - "Test search many2one tree not parent of root1" - self._test_search_not_parent_of_root1('test.many2one_tree') - - def test_search_mptt_not_parent_of_root1(self): - "Test search many2one mptt not parent of root1" - self._test_search_not_parent_of_root1('test.many2one_mptt') - - @with_transaction() - def _test_search_parent_of_second4(self, model_name): - Many2One = Pool().get(model_name) - self.create_tree(Many2One) - - result = Many2One.search([ - ('many2one', 'parent_of', [self.second4.id]), - ]) - - self.assertListEqual( - sorted(result), - sorted([self.root1, self.first2, self.second4])) - - def test_search_tree_parent_of_second4(self): - "Test search many2one tree parent of second4" - self._test_search_parent_of_second4('test.many2one_tree') - - def test_search_mptt_parent_of_second4(self): - "Test search many2one mptt parent of second4" - self._test_search_parent_of_second4('test.many2one_mptt') - - @with_transaction() - def _test_search_not_parent_of_second4(self, model_name): - Many2One = Pool().get(model_name) - self.create_tree(Many2One) - - result = Many2One.search([ - ('many2one', 'not parent_of', [self.second4.id]), - ]) - - self.assertListEqual( - sorted(result), - sorted([self.root2, - self.first1, self.first3, self.first4, - self.second1, self.second2, self.second3])) - - def test_search_tree_not_parent_of_second4(self): - "Test search many2one tree not parent of second4" - self._test_search_not_parent_of_second4('test.many2one_tree') - - def test_search_mptt_not_parent_of_second4(self): - "Test search many2one mptt not parent of second4" - self._test_search_not_parent_of_second4('test.many2one_mptt') - - @with_transaction() - def _test_search_parent_of_empty(self, model_name): - Many2One = Pool().get(model_name) - self.create_tree(Many2One) - - result = Many2One.search([ - ('many2one', 'parent_of', []), - ]) - - self.assertListEqual(result, []) - - def test_search_tree_parent_of_empty(self): - "Test search many2one tree parent of empty" - self._test_search_parent_of_empty('test.many2one_tree') - - def test_search_mptt_parent_of_empty(self): - "Test search many2one mptt parent of empty" - self._test_search_parent_of_empty('test.many2one_mptt') - - @with_transaction() - def _test_search_not_parent_of_empty(self, model_name): - Many2One = Pool().get(model_name) - self.create_tree(Many2One) - - result = Many2One.search([ - ('many2one', 'not parent_of', []), - ]) - - self.assertListEqual( - sorted(result), - sorted([self.root1, self.root2, - self.first1, self.first2, self.first3, self.first4, - self.second1, self.second2, self.second3, self.second4])) - - def test_search_tree_not_parent_of_empty(self): - "Test search many2one tree not parent of empty" - self._test_search_not_parent_of_empty('test.many2one_tree') - - def test_search_mptt_not_parent_of_empty(self): - "Test search many2one mptt not parent of empty" - self._test_search_not_parent_of_empty('test.many2one_mptt') - @with_transaction() def test_context_attribute(self): "Test context on many2one attribute" @@ -509,5 +218,226 @@ class FieldMany2OneTestCase(unittest.TestCase): self.assertEqual(record.target.context, 'foo') +class FieldMany2OneTreeTestCase(unittest.TestCase): + "Test Field Many2One Tree" + model_name = 'test.many2one_tree' + + def create_tree(self, Many2One): + self.root1, self.root2 = Many2One.create([{}, {}]) + self.first1, self.first2, self.first3, self.first4 = Many2One.create([ + {'many2one': self.root1}, + {'many2one': self.root1}, + {'many2one': self.root2}, + {'many2one': self.root2}, + ]) + self.second1, self.second2, self.second3, self.second4 = ( + Many2One.create([ + {'many2one': self.first1}, + {'many2one': self.first1}, + {'many2one': self.first2}, + {'many2one': self.first2}, + ])) + + @with_transaction() + def test_search_child_of_root1(self): + "Test search many2one child of root1" + Many2One = Pool().get(self.model_name) + self.create_tree(Many2One) + + result = Many2One.search([ + ('many2one', 'child_of', [self.root1.id]), + ]) + + self.assertListEqual( + sorted(result), + sorted([self.root1, + self.first1, self.first2, + self.second1, self.second2, self.second3, self.second4])) + + @with_transaction() + def test_search_not_child_of_root1(self): + "Test search many2one not child of root1" + Many2One = Pool().get(self.model_name) + self.create_tree(Many2One) + + result = Many2One.search([ + ('many2one', 'not child_of', [self.root1.id]), + ]) + + self.assertListEqual( + sorted(result), + sorted([self.root2, self.first3, self.first4])) + + @with_transaction() + def test_search_child_of_second1(self): + "Test search many2one child of second1" + Many2One = Pool().get(self.model_name) + self.create_tree(Many2One) + + result = Many2One.search([ + ('many2one', 'child_of', [self.second1.id]), + ]) + + self.assertListEqual(result, [self.second1]) + + @with_transaction() + def test_search_not_child_of_second1(self): + "Test search many2one not child of second1" + Many2One = Pool().get(self.model_name) + self.create_tree(Many2One) + + result = Many2One.search([ + ('many2one', 'not child_of', [self.second1.id]), + ]) + + self.assertListEqual( + sorted(result), + sorted([self.root1, self.root2, + self.first1, self.first2, self.first3, self.first4, + self.second2, self.second3, self.second4])) + + @with_transaction() + def test_search_child_of_empty(self): + "Test search many2one child of empty" + Many2One = Pool().get(self.model_name) + self.create_tree(Many2One) + + result = Many2One.search([ + ('many2one', 'child_of', []), + ]) + + self.assertListEqual(result, []) + + @with_transaction() + def test_search_not_child_of_empty(self): + "Test search many2one not child of empty" + Many2One = Pool().get(self.model_name) + self.create_tree(Many2One) + + result = Many2One.search([ + ('many2one', 'not child_of', []), + ]) + + self.assertListEqual( + sorted(result), + sorted([self.root1, self.root2, + self.first1, self.first2, self.first3, self.first4, + self.second1, self.second2, self.second3, self.second4])) + + @with_transaction() + def test_search_child_of_none(self): + "Test search many2one child of None" + Many2One = Pool().get(self.model_name) + self.create_tree(Many2One) + + result = Many2One.search([ + ('many2one', 'child_of', [None]), + ]) + + self.assertListEqual(result, []) + + @with_transaction() + def test_search_parent_of_root1(self): + "Test search many2one parent of root1" + Many2One = Pool().get(self.model_name) + self.create_tree(Many2One) + + result = Many2One.search([ + ('many2one', 'parent_of', [self.root1.id]), + ]) + + self.assertListEqual(result, [self.root1]) + + @with_transaction() + def test_search_not_parent_of_root1(self): + "Test search many2one not parent of root1" + Many2One = Pool().get(self.model_name) + self.create_tree(Many2One) + + result = Many2One.search([ + ('many2one', 'not parent_of', [self.root1.id]), + ]) + + self.assertListEqual( + sorted(result), + sorted([self.root2, + self.first1, self.first2, self.first3, self.first4, + self.second1, self.second2, self.second3, self.second4])) + + @with_transaction() + def test_search_parent_of_second4(self): + "Test search many2one parent of second4" + Many2One = Pool().get(self.model_name) + self.create_tree(Many2One) + + result = Many2One.search([ + ('many2one', 'parent_of', [self.second4.id]), + ]) + + self.assertListEqual( + sorted(result), + sorted([self.root1, self.first2, self.second4])) + + @with_transaction() + def test_search_not_parent_of_second4(self): + "Test search many2one not parent of second4" + Many2One = Pool().get(self.model_name) + self.create_tree(Many2One) + + result = Many2One.search([ + ('many2one', 'not parent_of', [self.second4.id]), + ]) + + self.assertListEqual( + sorted(result), + sorted([self.root2, + self.first1, self.first3, self.first4, + self.second1, self.second2, self.second3])) + + @with_transaction() + def test_search_parent_of_empty(self): + "Test search many2one parent of empty" + Many2One = Pool().get(self.model_name) + self.create_tree(Many2One) + + result = Many2One.search([ + ('many2one', 'parent_of', []), + ]) + + self.assertListEqual(result, []) + + @with_transaction() + def test_search_not_parent_of_empty(self): + "Test search many2one not parent of empty" + Many2One = Pool().get(self.model_name) + self.create_tree(Many2One) + + result = Many2One.search([ + ('many2one', 'not parent_of', []), + ]) + + self.assertListEqual( + sorted(result), + sorted([self.root1, self.root2, + self.first1, self.first2, self.first3, self.first4, + self.second1, self.second2, self.second3, self.second4])) + + +class FieldMany2OneMPTTTestCase(FieldMany2OneTreeTestCase): + "Test Field Many2One MPTT" + model_name = 'test.many2one_mptt' + + +class FieldMany2OnePathTestCase(FieldMany2OneTreeTestCase): + "Test Field Many2One Path" + model_name = 'test.many2one_path' + + def suite(): - return unittest.TestLoader().loadTestsFromTestCase(FieldMany2OneTestCase) + suite_ = unittest.TestSuite() + loader = unittest.TestLoader() + suite_.addTests(loader.loadTestsFromTestCase(FieldMany2OneTestCase)) + suite_.addTests(loader.loadTestsFromTestCase(FieldMany2OneTreeTestCase)) + suite_.addTests(loader.loadTestsFromTestCase(FieldMany2OneMPTTTestCase)) + suite_.addTests(loader.loadTestsFromTestCase(FieldMany2OnePathTestCase)) + return suite_ diff --git a/trytond/tests/test_mptt.py b/trytond/tests/test_mptt.py index 55071f713f..7a03d8132b 100644 --- a/trytond/tests/test_mptt.py +++ b/trytond/tests/test_mptt.py @@ -9,9 +9,12 @@ from trytond.tests.test_tryton import activate_module, with_transaction from trytond.transaction import Transaction from trytond.pool import Pool +from .test_tree import TreeTestCaseMixin -class MPTTTestCase(unittest.TestCase): + +class MPTTTestCase(TreeTestCaseMixin, unittest.TestCase): 'Test Modified Preorder Tree Traversal' + model_name = 'test.mptt' @classmethod def setUpClass(cls): @@ -19,7 +22,7 @@ class MPTTTestCase(unittest.TestCase): def check_tree(self, parent_id=None, left=-1, right=sys.maxsize): pool = Pool() - Mptt = pool.get('test.mptt') + Mptt = pool.get(self.model_name) with Transaction().set_context(active_test=False): childs = Mptt.search([ @@ -51,119 +54,10 @@ class MPTTTestCase(unittest.TestCase): child, child.right, previous_right)) previous_right = child.left - def reparent(self, parent=None): + def rebuild(self): pool = Pool() - Mptt = pool.get('test.mptt') - - records = Mptt.search([ - ('parent', '=', parent), - ]) - if not records: - return - for record in records: - for record2 in records: - if record != record2: - record.parent = record2 - record.save() - record.parent = parent - record.save() - for record in records: - self.reparent(record) - - def create(self): - pool = Pool() - Mptt = pool.get('test.mptt') - - new_records = [None, None, None] - for j in range(3): - parent_records = new_records - new_records = [] - k = 0 - to_create = [] - for parent_record in parent_records: - to_create.extend([{ - 'name': 'Test %d %d %d' % (j, k, i), - 'parent': (parent_record.id - if parent_record else None), - } for i in range(3)]) - k += 1 - new_records = Mptt.create(to_create) - - @with_transaction() - def test_create(self): - 'Test create tree' - self.create() - self.check_tree() - - @with_transaction() - def test_reparent(self): - 'Test re-parent' - self.create() - self.reparent() - - @with_transaction() - def test_active(self): - 'Test active' - pool = Pool() - Mptt = pool.get('test.mptt') - - self.create() - - records = Mptt.search([]) - for record in records: - if record.id % 2: - record.active = False - record.save() - self.check_tree() - self.reparent() - self.check_tree() - - records = Mptt.search([]) - Mptt.write(records, { - 'active': True, - }) - Mptt.write(records[::2], { - 'active': False - }) - self.check_tree() - - records = Mptt.search([]) - Mptt.write(records, { - 'active': True, - }) - Mptt.write(records[:len(records) // 2], { - 'active': False - }) - self.check_tree() - - records = Mptt.search([]) - Mptt.write(records, { - 'active': False - }) - self.reparent() - self.check_tree() - - @with_transaction() - def test_delete(self): - 'Test delete' - pool = Pool() - Mptt = pool.get('test.mptt') - - self.create() - - records = Mptt.search([]) - for record in records: - if record.id % 2: - Mptt.delete([record]) - self.check_tree() - - records = Mptt.search([]) - Mptt.delete(records[:len(records) // 2]) - self.check_tree() - - records = Mptt.search([]) - Mptt.delete(records) - self.check_tree() + Mptt = pool.get(self.model_name) + Mptt._rebuild_tree('parent', None, 0) @with_transaction() def test_update_only_if_parent_is_modified(self): diff --git a/trytond/tests/test_path.py b/trytond/tests/test_path.py new file mode 100644 index 0000000000..03618950d1 --- /dev/null +++ b/trytond/tests/test_path.py @@ -0,0 +1,38 @@ +# This file is part of Tryton. The COPYRIGHT file at the top level of +# this repository contains the full copyright notices and license terms. +import unittest + +from trytond.pool import Pool +from trytond.transaction import Transaction + +from .test_tree import TreeTestCaseMixin + + +class PathTestCase(TreeTestCaseMixin, unittest.TestCase): + "Test Path" + model_name = 'test.path' + + def check_tree(self, parent_id=None): + pool = Pool() + Path = pool.get(self.model_name) + + with Transaction().set_context(active_test=False): + children = Path.search([ + ('parent', '=', parent_id), + ]) + for child in children: + if child.parent: + self.assertEqual( + child.path, '%s%s/' % (child.parent.path, child.id)) + else: + self.assertEqual(child.path, '%s/' % child.id) + self.check_tree(child.id) + + def rebuild(self): + pool = Pool() + Path = pool.get(self.model_name) + Path._rebuild_path('parent') + + +def suite(): + return unittest.TestLoader().loadTestsFromTestCase(PathTestCase) diff --git a/trytond/tests/test_tree.py b/trytond/tests/test_tree.py index c04f74c774..c8992487ba 100644 --- a/trytond/tests/test_tree.py +++ b/trytond/tests/test_tree.py @@ -8,6 +8,151 @@ from trytond.tests.test_tryton import activate_module, with_transaction from trytond.pool import Pool +class TreeTestCaseMixin: + model_name = None + + @classmethod + def setUpClass(cls): + activate_module('tests') + + def create(self): + pool = Pool() + Model = pool.get(self.model_name) + + new_records = [None, None, None] + for j in range(3): + parent_records = new_records + new_records = [] + k = 0 + to_create = [] + for parent_record in parent_records: + to_create.extend([{ + 'name': 'Test %d %d %d' % (j, k, i), + 'parent': (parent_record.id + if parent_record else None), + } for i in range(3)]) + k += 1 + new_records = Model.create(to_create) + + def check_tree(self): + raise NotImplementedError + + def change_parent(self, parent=None, restore=False): + pool = Pool() + Model = pool.get(self.model_name) + + records = Model.search([ + ('parent', '=', parent), + ]) + if not records: + return + for record in records[::2]: + for record2 in records[1::2]: + record.parent = record2 + record.save() + if restore: + record.parent = parent + record.save() + for record in records: + self.change_parent(record, restore=restore) + + @with_transaction() + def test_create(self): + "Test create tree" + self.create() + self.check_tree() + + @with_transaction() + def test_reparent(self): + "Test re-parent" + self.create() + self.change_parent(restore=True) + self.check_tree() + + @with_transaction() + def test_move(self): + "Test move" + self.create() + self.change_parent() + self.check_tree() + + @with_transaction() + def test_active(self): + "Test active" + pool = Pool() + Model = pool.get(self.model_name) + + self.create() + + records = Model.search([]) + for record in records: + if record.id % 2: + record.active = False + record.save() + self.check_tree() + self.change_parent() + self.check_tree() + + records = Model.search([]) + Model.write(records, { + 'active': True, + }) + Model.write(records[::2], { + 'active': False + }) + self.check_tree() + + records = Model.search([]) + Model.write(records, { + 'active': True, + }) + Model.write(records[:len(records) // 2], { + 'active': False + }) + self.check_tree() + + records = Model.search([]) + Model.write(records, { + 'active': False + }) + self.change_parent() + self.check_tree() + + @with_transaction() + def test_delete(self): + "Test delete" + pool = Pool() + Model = pool.get(self.model_name) + + self.create() + + records = Model.search([]) + for record in records: + if record.id % 2: + Model.delete([record]) + self.check_tree() + + records = Model.search([]) + Model.delete(records[:len(records) // 2]) + self.check_tree() + + records = Model.search([]) + Model.delete(records) + self.check_tree() + + def rebuild(self): + raise NotImplementedError + + @with_transaction() + def test_rebuild(self): + "Test rebuild" + self.create() + + self.rebuild() + + self.check_tree() + + class TreeMixinTestCase(unittest.TestCase): "Test TreeMixin"