diff --git a/CHANGELOG b/CHANGELOG index 15019b8208..d61b324f76 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,4 @@ +* Cache Function field getter without context in readonly transactions * Use expire delay instead of UTC datetime in reset password email * Use RENAME and DROP COLUMN with SQLite backend * Support window functions with SQLite backend diff --git a/doc/ref/models/fields.rst b/doc/ref/models/fields.rst index 8e6a6ac831..8d8556dfe0 100644 --- a/doc/ref/models/fields.rst +++ b/doc/ref/models/fields.rst @@ -961,7 +961,7 @@ Instance methods: Function -------- -.. class:: Function(field, getter[, setter[, searcher]]) +.. class:: Function(field, getter[, setter[, searcher[, getter_with_context]]]) A function field can emulate any other given ``field``. @@ -1019,6 +1019,14 @@ A function field can emulate any other given ``field``. It must return a list of :ref:`domain ` clauses but the ``operand`` can be a SQL query. +.. attribute:: Function.getter_with_context + + A boolean telling if the getter result depends on the context. + If it does not depend, the getter is called without context and the result + is stored in the transaction cache when readonly. + The default value is ``True``. + + Instance methods: .. method:: Function.get(ids, model, name[, values]) diff --git a/trytond/model/fields/function.py b/trytond/model/fields/function.py index 42eb42965a..47224ab8c3 100644 --- a/trytond/model/fields/function.py +++ b/trytond/model/fields/function.py @@ -3,6 +3,7 @@ import inspect import copy +from functools import wraps from trytond.i18n import gettext from trytond.model.fields.field import Field @@ -10,13 +11,29 @@ from trytond.tools import is_instance_method from trytond.transaction import Transaction +def getter_context(func): + @wraps(func) + def wrapper(self, *args, **kwargs): + if not self.getter_with_context: + transaction = Transaction() + context = { + k: v for k, v in transaction.context.items() + if k in transaction.cache_keys} + with transaction.reset_context(), \ + transaction.set_context(context): + return func(self, *args, **kwargs) + else: + return func(self, *args, **kwargs) + return wrapper + + class Function(Field): ''' Define function field (any). ''' def __init__(self, field, getter, setter=None, searcher=None, - loading='lazy'): + getter_with_context=True, loading='lazy'): ''' :param field: The field of the function. :param getter: The name of the function for getting values. @@ -29,6 +46,7 @@ class Function(Field): self._field = field self._type = field._type self.getter = getter + self.getter_with_context = getter_with_context self.setter = setter if not self.setter: self._field.readonly = True @@ -41,11 +59,15 @@ class Function(Field): def __copy__(self): return Function(copy.copy(self._field), self.getter, - setter=self.setter, searcher=self.searcher, loading=self.loading) + setter=self.setter, searcher=self.searcher, + getter_with_context=self.getter_with_context, + loading=self.loading) def __deepcopy__(self, memo): return Function(copy.deepcopy(self._field, memo), self.getter, - setter=self.setter, searcher=self.searcher, loading=self.loading) + setter=self.setter, searcher=self.searcher, + getter_with_context=self.getter_with_context, + loading=self.loading) def __getattr__(self, name): return getattr(self._field, name) @@ -81,6 +103,7 @@ class Function(Field): 'ir.msg_search_function_missing', **Model.__names__(self.name))) + @getter_context def get(self, ids, Model, name, values=None): ''' Call the getter. diff --git a/trytond/model/modelsql.py b/trytond/model/modelsql.py index 60b31bbcbf..086d4001bb 100644 --- a/trytond/model/modelsql.py +++ b/trytond/model/modelsql.py @@ -845,7 +845,9 @@ class ModelSQL(ModelStorage): for fname in getter_fields: field = cls._fields[fname] if isinstance(field, fields.Function): - key = (field.getter, getattr(field, 'datetime_field', None)) + key = ( + field.getter, field.getter_with_context, + getattr(field, 'datetime_field', None)) func_fields.setdefault(key, []) func_fields[key].append(fname) elif getattr(field, 'datetime_field', None): @@ -865,7 +867,7 @@ class ModelSQL(ModelStorage): field_list = func_fields[key] fname = field_list[0] field = cls._fields[fname] - _, datetime_field = key + _, getter_with_context, datetime_field = key if datetime_field: for row in result: with Transaction().set_context( @@ -895,6 +897,9 @@ class ModelSQL(ModelStorage): for row in sub_results: if row['id'] in sub_ids: row[fname] = getter_result[row['id']] + if (transaction.readonly + and not getter_with_context): + cache[row['id']][fname] = row[fname] def read_related(field, Target, rows, fields): name = field.name diff --git a/trytond/model/modelstorage.py b/trytond/model/modelstorage.py index a1f1f1f86a..3393834227 100644 --- a/trytond/model/modelstorage.py +++ b/trytond/model/modelstorage.py @@ -1720,7 +1720,9 @@ class ModelStorage(Model): if (field._type in no_local_cache or field.context or getattr(field, 'datetime_field', None) - or isinstance(field, fields.Function)): + or (isinstance(field, fields.Function) + and (not transaction.readonly + or field.getter_with_context))): to_delete.add(fname) self._cache[id_]._update( **{k: v for k, v in data.items() if k not in to_delete}) diff --git a/trytond/tests/field_function.py b/trytond/tests/field_function.py index a76e552dbb..7f1c616d79 100644 --- a/trytond/tests/field_function.py +++ b/trytond/tests/field_function.py @@ -3,6 +3,7 @@ from trytond.model import ModelSQL, fields from trytond.pool import Pool +from trytond.transaction import Transaction class FunctionAccessor(ModelSQL): @@ -25,8 +26,26 @@ class FunctionAccessorTarget(ModelSQL): __name__ = 'test.function.accessor.target' +class FunctionGetterContext(ModelSQL): + "Function Getter Context" + __name__ = 'test.function.getter_context' + + function_with_context = fields.Function( + fields.Char("Function"), + 'getter', getter_with_context=True) + function_without_context = fields.Function( + fields.Char("Function"), + 'getter', getter_with_context=False) + + def getter(self, name): + context = Transaction().context + return '%s - %s' % ( + context.get('language', 'empty'), context.get('test', 'empty')) + + def register(module): Pool.register( FunctionAccessor, FunctionAccessorTarget, + FunctionGetterContext, module=module, type_='model') diff --git a/trytond/tests/test_field_function.py b/trytond/tests/test_field_function.py index 7c663d7d2d..6d96e36506 100644 --- a/trytond/tests/test_field_function.py +++ b/trytond/tests/test_field_function.py @@ -1,9 +1,11 @@ # 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 unittest.mock import patch from trytond.pool import Pool from trytond.tests.test_tryton import activate_module, with_transaction +from trytond.transaction import Transaction class FieldFunctionTestCase(unittest.TestCase): @@ -27,6 +29,50 @@ class FieldFunctionTestCase(unittest.TestCase): self.assertEqual(record.function, target) + @with_transaction() + def test_getter_context(self): + "Test getter context" + pool = Pool() + Model = pool.get('test.function.getter_context') + + record = Model() + record.save() + + with Transaction().set_context(language='en', test='foo'): + record = Model(record.id) + + self.assertEqual(record.function_with_context, 'en - foo') + self.assertEqual(record.function_without_context, 'en - empty') + + @with_transaction() + def test_getter_cached_readonly_transaction(self): + "Test getter cached in read only transaction" + pool = Pool() + Model = pool.get('test.function.getter_context') + + with Transaction().new_transaction(readonly=True), \ + patch.object(Model, 'getter') as getter: + record = Model() + record.save() + record.function_with_context + record.function_without_context + self.assertEqual(getter.call_count, 2) + + getter.reset_mock() + with Transaction().set_context(test='foo'): + record = Model(record.id) + record.function_with_context + record.function_without_context + self.assertEqual(getter.call_count, 1) + + getter.reset_mock() + # Change transaction key context + with Transaction().set_context(language='en'): + record = Model(record.id) + record.function_with_context + record.function_without_context + self.assertEqual(getter.call_count, 2) + def suite(): return unittest.TestLoader().loadTestsFromTestCase(FieldFunctionTestCase)