changeset e7526f0686ec in trytond:default
details: https://hg.tryton.org/trytond?cmd=changeset&node=e7526f0686ec
description:
        Use immutable datastructures for Dict and MultiSelection fields

        In case a Dict field is sent during an on_change, modification of its 
value
        won't be detected because a shallow copy will keep its reference in the
        pseudo-record

        issue10105
        review347541002
diffstat:

 CHANGELOG                                  |   1 +
 trytond/model/fields/dict.py               |  26 +++++++++++++++++++++++++-
 trytond/model/fields/multiselection.py     |   7 ++++++-
 trytond/tests/modelview.py                 |  13 ++++++++++++-
 trytond/tests/test_field_dict.py           |  13 +++++++++++++
 trytond/tests/test_field_multiselection.py |  14 +++++++-------
 trytond/tests/test_modelstorage.py         |   2 +-
 trytond/tests/test_modelview.py            |  18 +++++++++++++++++-
 8 files changed, 82 insertions(+), 12 deletions(-)

diffs (267 lines):

diff -r 7a4a537f0f87 -r e7526f0686ec CHANGELOG
--- a/CHANGELOG Mon Apr 12 17:44:16 2021 +0200
+++ b/CHANGELOG Mon Apr 12 19:39:25 2021 +0200
@@ -1,3 +1,4 @@
+* Use immutable datastructures for Dict and MultiSelection fields
 * Skip warnings for non-interactive operations
 * Check rule only if _check_access is set
 * Add statistics to Cache
diff -r 7a4a537f0f87 -r e7526f0686ec trytond/model/fields/dict.py
--- a/trytond/model/fields/dict.py      Mon Apr 12 17:44:16 2021 +0200
+++ b/trytond/model/fields/dict.py      Mon Apr 12 19:39:25 2021 +0200
@@ -17,6 +17,25 @@
     json.dumps, cls=JSONEncoder, separators=(',', ':'), sort_keys=True)
 
 
+class ImmutableDict(dict):
+
+    __slots__ = ()
+
+    def _not_allowed(cls, *args, **kwargs):
+        raise TypeError("Operation not allowed on ImmutableDict")
+
+    __setitem__ = _not_allowed
+    __delitem__ = _not_allowed
+    __ior__ = _not_allowed
+    clear = _not_allowed
+    pop = _not_allowed
+    popitem = _not_allowed
+    setdefault = _not_allowed
+    update = _not_allowed
+
+    del _not_allowed
+
+
 class Dict(Field):
     'Define dict field.'
     _type = 'dict'
@@ -41,7 +60,7 @@
                 # If stored as JSON conversion is done on backend
                 if isinstance(data, str):
                     data = json.loads(data, object_hook=JSONDecoder())
-                dicts[value['id']] = data
+                dicts[value['id']] = ImmutableDict(data)
         return dicts
 
     def sql_format(self, value):
@@ -57,6 +76,11 @@
             value = dumps(d)
         return value
 
+    def __set__(self, inst, value):
+        if value:
+            value = ImmutableDict(value)
+        super().__set__(inst, value)
+
     def translated(self, name=None, type_='values'):
         "Return a descriptor for the translated value of the field"
         if name is None:
diff -r 7a4a537f0f87 -r e7526f0686ec trytond/model/fields/multiselection.py
--- a/trytond/model/fields/multiselection.py    Mon Apr 12 17:44:16 2021 +0200
+++ b/trytond/model/fields/multiselection.py    Mon Apr 12 19:39:25 2021 +0200
@@ -61,7 +61,7 @@
                 # If stored as JSON conversion is done on backend
                 if isinstance(data, str):
                     data = json.loads(data)
-                lists[value['id']] = data
+                lists[value['id']] = tuple(data)
         return lists
 
     def sql_format(self, value):
@@ -70,6 +70,11 @@
             value = dumps(sorted(set(value)))
         return value
 
+    def __set__(self, inst, value):
+        if value:
+            value = tuple(value)
+        super().__set__(inst, value)
+
     def _domain_column(self, operator, column):
         database = Transaction().database
         return database.json_get(super()._domain_column(operator, column))
diff -r 7a4a537f0f87 -r e7526f0686ec trytond/tests/modelview.py
--- a/trytond/tests/modelview.py        Mon Apr 12 17:44:16 2021 +0200
+++ b/trytond/tests/modelview.py        Mon Apr 12 19:39:25 2021 +0200
@@ -1,7 +1,7 @@
 # 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 ModelView, ModelSQL, fields
+from trytond.model import ModelView, ModelSQL, DictSchemaMixin, fields
 from trytond.pool import Pool
 from trytond.pyson import If, Eval
 
@@ -20,6 +20,16 @@
         'Targets')
     m2m_targets = fields.Many2Many('test.modelview.changed_values.target',
         None, None, 'Targets')
+    multiselection = fields.MultiSelection([
+            ('a', 'A'), ('b', 'B'),
+            ], "MultiSelection")
+    dictionary = fields.Dict(
+        'test.modelview.changed_values.dictionary', "Dictionary")
+
+
+class ModelViewChangedValuesDictSchema(DictSchemaMixin, ModelSQL):
+    'ModelView Changed Values Dict Schema'
+    __name__ = 'test.modelview.changed_values.dictionary'
 
 
 class ModelViewChangedValuesTarget(ModelView):
@@ -253,6 +263,7 @@
 def register(module):
     Pool.register(
         ModelViewChangedValues,
+        ModelViewChangedValuesDictSchema,
         ModelViewChangedValuesTarget,
         ModelViewChangedValuesStoredTarget,
         ModelViewStoredChangedValues,
diff -r 7a4a537f0f87 -r e7526f0686ec trytond/tests/test_field_dict.py
--- a/trytond/tests/test_field_dict.py  Mon Apr 12 17:44:16 2021 +0200
+++ b/trytond/tests/test_field_dict.py  Mon Apr 12 19:39:25 2021 +0200
@@ -886,6 +886,19 @@
         self.assertDictEqual(
             dict_.dico_string_keys, {'a': 'A', 'type': "Type"})
 
+    @with_transaction
+    def test_set_key(self):
+        "Test setting a key of dict"
+        Dict = Pool().get('test.dict')
+        self.create_schema()
+
+        dict_, = Dict.create([{
+                    'dico': {'a': 1, 'type': 'arabic'},
+                    }])
+
+        with self.assertRaises(TypeError):
+            dict.dico['a'] = 5
+
 
 @unittest.skipUnless(backend.name == 'postgresql',
     "unaccent works only on postgresql")
diff -r 7a4a537f0f87 -r e7526f0686ec trytond/tests/test_field_multiselection.py
--- a/trytond/tests/test_field_multiselection.py        Mon Apr 12 17:44:16 
2021 +0200
+++ b/trytond/tests/test_field_multiselection.py        Mon Apr 12 19:39:25 
2021 +0200
@@ -28,7 +28,7 @@
                     'selects': None,
                     }])
 
-        self.assertEqual(selection.selects, ['bar', 'foo'])
+        self.assertEqual(selection.selects, ('bar', 'foo'))
         self.assertEqual(selection_none.selects, None)
 
     @with_transaction()
@@ -38,7 +38,7 @@
 
         with self.assertRaises(SelectionValidationError):
             Selection.create([{
-                        'selects': ['invalid'],
+                        'selects': ('invalid'),
                         }])
 
     @with_transaction()
@@ -54,8 +54,8 @@
                     'dyn_selects': ['baz'],
                     }])
 
-        self.assertEqual(selection_foo.dyn_selects, ['foo'])
-        self.assertEqual(selection_bar.dyn_selects, ['baz'])
+        self.assertEqual(selection_foo.dyn_selects, ('foo',))
+        self.assertEqual(selection_bar.dyn_selects, ('baz',))
 
     @with_transaction()
     def test_create_dynamic_none(self):
@@ -89,7 +89,7 @@
                     'static_selects': ['foo', 'bar'],
                     }])
 
-        self.assertEqual(selection.static_selects, ['bar', 'foo'])
+        self.assertEqual(selection.static_selects, ('bar', 'foo'))
 
     @with_transaction()
     def test_create_static_none(self):
@@ -121,7 +121,7 @@
                     'selects': ['foo', 'bar'],
                     }])
 
-        self.assertEqual(selection.selects, ['bar', 'foo'])
+        self.assertEqual(selection.selects, ('bar', 'foo'))
 
     @with_transaction()
     def test_create_required_without_value(self):
@@ -163,7 +163,7 @@
                 'selects': ['foo', 'bar'],
                 })
 
-        self.assertEqual(selection.selects, ['bar', 'foo'])
+        self.assertEqual(selection.selects, ('bar', 'foo'))
 
     @with_transaction()
     def test_string(self):
diff -r 7a4a537f0f87 -r e7526f0686ec trytond/tests/test_modelstorage.py
--- a/trytond/tests/test_modelstorage.py        Mon Apr 12 17:44:16 2021 +0200
+++ b/trytond/tests/test_modelstorage.py        Mon Apr 12 19:39:25 2021 +0200
@@ -638,7 +638,7 @@
         record = Model(multiselection=['value1', 'value2'])
         env = EvalEnvironment(record, Model)
 
-        self.assertEqual(env.get('multiselection'), ['value1', 'value2'])
+        self.assertEqual(env.get('multiselection'), ('value1', 'value2'))
 
     @with_transaction()
     def test_parent_field(self):
diff -r 7a4a537f0f87 -r e7526f0686ec trytond/tests/test_modelview.py
--- a/trytond/tests/test_modelview.py   Mon Apr 12 17:44:16 2021 +0200
+++ b/trytond/tests/test_modelview.py   Mon Apr 12 19:39:25 2021 +0200
@@ -34,6 +34,8 @@
         record.target = Target(1)
         record.ref_target = Target(2)
         record.targets = [Target(name='bar')]
+        record.multiselection = ['a']
+        record.dictionary = {'key': 'value'}
         self.assertEqual(record._changed_values, {
                 'name': 'foo',
                 'target': 1,
@@ -43,12 +45,18 @@
                         (0, {'id': None, 'name': 'bar'}),
                         ],
                     },
+                'multiselection': ('a',),
+                'dictionary': {'key': 'value'},
                 })
 
         record = Model(name='test', target=1, targets=[
                 {'id': 1, 'name': 'foo'},
                 {'id': 2},
-                ], m2m_targets=[5, 6, 7])
+                ],
+            m2m_targets=[5, 6, 7],
+            multiselection=['a'],
+            dictionary={'key': 'value'},
+            )
 
         self.assertEqual(record._changed_values, {})
 
@@ -56,6 +64,12 @@
         target.name = 'bar'
         record.targets = [target]
         record.m2m_targets = [Target(9), Target(10)]
+        ms = list(record.multiselection)
+        ms.append('b')
+        record.multiselection = ms
+        dico = record.dictionary.copy()
+        dico['key'] = 'another value'
+        record.dictionary = dico
         self.assertEqual(record._changed_values, {
                 'targets': {
                     'update': [{'id': 1, 'name': 'bar'}],
@@ -65,6 +79,8 @@
                     'remove': [5, 6, 7],
                     'add': [(0, {'id': 9}), (1, {'id': 10})],
                     },
+                'multiselection': ('a', 'b'),
+                'dictionary': {'key': 'another value'},
                 })
 
         # change only one2many record

Reply via email to