From b3f8d125f97c404d7b1a545b80bc3539bd7e2bac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Sat, 13 Jun 2020 09:48:03 +0200 Subject: [PATCH] Use custom class to store record data By using a class with slots instead of standard dict, we can reduce a lot the memory usage to store the record data in the caches and values. Also we can rely on the data in the cache to exist only for real field which can be used to create a fast path in ModelStorage.__getattr__. As now storing data in the cache has no extra cost, we can store data from the transactional cache in the local. This allow also to have a shorter path in ModelStorage.__getattr__. For more memory efficiency, we remove the record from the cache instead of clearing them. It was better to clear a dict but a record is less expensive to re-instantiate than clearing. issue9369 issue9225 review297861002 --- CHANGELOG | 1 + doc/ref/models/models.rst | 11 +++ trytond/cache.py | 15 ++- trytond/model/fields/field.py | 5 +- trytond/model/model.py | 131 +++++++++++++++++++++++++- trytond/model/modelsql.py | 9 +- trytond/model/modelstorage.py | 75 +++++++-------- trytond/model/modelview.py | 10 +- trytond/modules/__init__.py | 2 + trytond/res/user.py | 3 +- trytond/tests/modelsql.py | 7 ++ trytond/tests/test_cache.py | 86 ++++++++++++++++- trytond/tests/test_model.py | 157 +++++++++++++++++++++++++++++++- trytond/tests/test_modelsql.py | 14 +++ trytond/tests/test_modelview.py | 2 +- 15 files changed, 465 insertions(+), 63 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 4eab53976e..e3c7775a8c 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,4 @@ +* Use custom class to store record data * Add configuration check * Add support for properties to fields.depends * Allow combining authentication methods together diff --git a/doc/ref/models/models.rst b/doc/ref/models/models.rst index d03ab49c2c..57ec28ca04 100644 --- a/doc/ref/models/models.rst +++ b/doc/ref/models/models.rst @@ -36,6 +36,17 @@ Class attributes are: It returns a queue caller for the model. The called method will be pushed into the queue. +.. attribute:: Model._fields + It contains a dictionary with the field name as key and its + :class:`~trytond.model.field` instance as value. + +.. attribute:: Model._record + It stores the record class to store internaly the values of the instances. + +.. attribute:: Model._defaults + It contains a dictionary with the field name as key and its default method + as value. + Class methods: .. classmethod:: Model.__setup__() diff --git a/trytond/cache.py b/trytond/cache.py index b80e555575..988fe61400 100644 --- a/trytond/cache.py +++ b/trytond/cache.py @@ -18,7 +18,7 @@ from trytond.config import config from trytond.transaction import Transaction from trytond.tools import resolve, grouped_slice -__all__ = ['BaseCache', 'Cache', 'LRUDict'] +__all__ = ['BaseCache', 'Cache', 'LRUDict', 'LRUDictTransaction'] _clear_timeout = config.getint('cache', 'clean_timeout', default=5 * 60) logger = logging.getLogger(__name__) @@ -331,19 +331,28 @@ class LRUDict(OrderedDict): """ Dictionary with a size limit. If size limit is reached, it will remove the first added items. + The default_factory provides the same behavior as in standard + collections.defaultdict. """ __slots__ = ('size_limit',) - def __init__(self, size_limit, *args, **kwargs): + def __init__(self, size_limit, default_factory=None, *args, **kwargs): assert size_limit > 0 self.size_limit = size_limit super(LRUDict, self).__init__(*args, **kwargs) + self.default_factory = default_factory self._check_size_limit() def __setitem__(self, key, value): super(LRUDict, self).__setitem__(key, value) self._check_size_limit() + def __missing__(self, key): + if self.default_factory is None: + raise KeyError(key) + self[key] = value = self.default_factory() + return value + def update(self, *args, **kwargs): super(LRUDict, self).update(*args, **kwargs) self._check_size_limit() @@ -360,7 +369,7 @@ class LRUDict(OrderedDict): class LRUDictTransaction(LRUDict): """ - Dictionary with a size limit. (see LRUDict) + Dictionary with a size limit and default_factory. (see LRUDict) It is refreshed when transaction counter is changed. """ __slots__ = ('transaction', 'counter') diff --git a/trytond/model/fields/field.py b/trytond/model/fields/field.py index 0e4b3f452b..0644e7321b 100644 --- a/trytond/model/fields/field.py +++ b/trytond/model/fields/field.py @@ -142,7 +142,8 @@ def instanciate_values(Target, value, **extra): kwargs = {} ids = [] if issubclass(Target, ModelStorage): - kwargs['_local_cache'] = LRUDictTransaction(cache_size()) + kwargs['_local_cache'] = LRUDictTransaction( + cache_size(), Target._record) kwargs['_ids'] = ids def instance(data): @@ -338,7 +339,7 @@ class Field(object): def __set__(self, inst, value): assert self.name is not None if inst._values is None: - inst._values = {} + inst._values = inst._record() inst._values[self.name] = value def sql_format(self, value): diff --git a/trytond/model/model.py b/trytond/model/model.py index 66aedd749e..993c296eb3 100644 --- a/trytond/model/model.py +++ b/trytond/model/model.py @@ -2,8 +2,11 @@ # this repository contains the full copyright notices and license terms. import copy +import collections.abc +import sys from collections import defaultdict from functools import total_ordering +from itertools import chain from trytond.i18n import lazy_gettext from trytond.model import fields @@ -71,6 +74,7 @@ class Model(URLMixin, PoolBase, metaclass=ModelMeta): continue if isinstance(getattr(cls, attr), fields.Field): cls._fields[attr] = getattr(cls, attr) + cls._record = record(cls.__name__ + '._record', cls._fields.keys()) # Set _defaults cls._defaults = {} @@ -248,7 +252,7 @@ class Model(URLMixin, PoolBase, metaclass=ModelMeta): self._id = id self._deleted = self._removed = None if kwargs: - self._values = {} + self._values = self._record() parent_values = defaultdict(dict) has_context = {} for name, value in kwargs.items(): @@ -268,7 +272,7 @@ class Model(URLMixin, PoolBase, metaclass=ModelMeta): # to ensure it was evaluated with all the fields for name, value in has_context.items(): setattr(self, name, value) - self._init_values = self._values.copy() + self._init_values = self._values._copy() else: self._values = None self._init_values = None @@ -328,7 +332,7 @@ class Model(URLMixin, PoolBase, metaclass=ModelMeta): """ values = {} if self._values: - for fname, value in self._values.items(): + for fname, value in self._values._items(): field = self._fields[fname] if field._type in ('many2one', 'one2one', 'reference'): if value: @@ -343,3 +347,124 @@ class Model(URLMixin, PoolBase, metaclass=ModelMeta): value = [r.id for r in value] values[fname] = value return values + + +def record(name, field_names): + def __init__(self, **kwargs): + for key, value in kwargs.items(): + setattr(self, key, value) + + def _getitem(self, field): + try: + return getattr(self, field) + except AttributeError: + raise KeyError(field) + + def _setitem(self, field, value): + try: + return setattr(self, field, value) + except AttributeError: + raise KeyError(field) + + def _contains(self, field): + try: + getattr(self, field) + return True + except AttributeError: + return False + + def _clear(self): + for fname in self.__slots__: + try: + delattr(self, fname) + except AttributeError: + pass + + def _copy(self): + return copy.copy(self) + + def _get(self, field, default=None): + if field not in self.__slots__: + raise KeyError(field) + return getattr(self, field, default) + + def _keys(self): + for fname in self.__slots__: + if hasattr(self, fname): + yield fname + + def _items(self): + for fname in self.__slots__: + try: + yield fname, getattr(self, fname) + except AttributeError: + pass + + _undefined = object() + + def _pop(self, field, value=_undefined): + if field not in self.__slots__: + raise KeyError(field) + if value != _undefined: + value = getattr(self, field, value) + else: + try: + value = getattr(self, field) + except AttributeError: + raise KeyError(field) + try: + delattr(self, field) + except AttributeError: + pass + return value + + def _popitem(self, field, value=_undefined): + return (field, self._pop(field, value=value)) + + def _setdefault(self, field, default=None): + try: + return getattr(self, field) + except AttributeError: + setattr(self, field, default) + return default + + def _update(self, _other=None, **kwargs): + if isinstance(_other, collections.abc.Mapping): + _other = _other.items() + elif _other is None: + _other = [] + chained = chain(_other, kwargs.items()) + for key, value in chained: + setattr(self, key, value) + + def _values(self): + for fname in self.__slots__: + try: + yield getattr(self, fname) + except AttributeError: + pass + + field_names = set(field_names) + for fname in field_names: + if fname.startswith('_'): + raise ValueError( + "Field names cannot start with an underscore: %r" % name) + field_names = tuple(map(sys.intern, field_names)) + type_dict = { + '__slots__': field_names, + '__init__': __init__, + '__getitem__': _getitem, + '__setitem__': _setitem, + '__contains__': _contains, + '_clear': _clear, + '_copy': _copy, + '_get': _get, + '_keys': _keys, + '_items': _items, + '_pop': _pop, + '_popitem': _popitem, + '_setdefault': _setdefault, + '_update': _update, + '_values': _values, + } + return type(name, (), type_dict) diff --git a/trytond/model/modelsql.py b/trytond/model/modelsql.py index 9984dffb21..5c04ca9ea9 100644 --- a/trytond/model/modelsql.py +++ b/trytond/model/modelsql.py @@ -794,10 +794,8 @@ class ModelSQL(ModelStorage): if getter_fields and cachable_fields: cache = transaction.get_cache().setdefault( - cls.__name__, LRUDict(cache_size())) + cls.__name__, LRUDict(cache_size(), cls._record)) for row in result: - if row['id'] not in cache: - cache[row['id']] = {} for fname in cachable_fields: cache[row['id']][fname] = row[fname] @@ -1319,7 +1317,7 @@ class ModelSQL(ModelStorage): rows = list(cursor_dict(cursor, transaction.database.IN_MAX)) cache = transaction.get_cache() if cls.__name__ not in cache: - cache[cls.__name__] = LRUDict(cache_size()) + cache[cls.__name__] = LRUDict(cache_size(), cls._record) delete_records = transaction.delete_records.setdefault(cls.__name__, set()) @@ -1375,7 +1373,6 @@ class ModelSQL(ModelStorage): keys = list(data.keys()) for k in keys[:]: if k in ('_timestamp', '_datetime', '__id'): - keys.remove(k) continue field = cls._fields[k] if not getattr(field, 'datetime_field', None): @@ -1383,7 +1380,7 @@ class ModelSQL(ModelStorage): continue for k in keys: del data[k] - cache[cls.__name__].setdefault(data['id'], {}).update(data) + cache[cls.__name__][data['id']]._update(data) if len(rows) >= transaction.database.IN_MAX: if (cls._history diff --git a/trytond/model/modelstorage.py b/trytond/model/modelstorage.py index a0c169a45c..40f7d28f75 100644 --- a/trytond/model/modelstorage.py +++ b/trytond/model/modelstorage.py @@ -211,16 +211,14 @@ class ModelStorage(Model): # Clean local cache for record in all_records: - local_cache = record._local_cache.get(record.id) - if local_cache: - local_cache.clear() + record._local_cache.pop(record.id, None) # Clean transaction cache for cache in Transaction().cache.values(): if cls.__name__ in cache: + cache_cls = cache[cls.__name__] for record in all_records: - if record.id in cache[cls.__name__]: - cache[cls.__name__][record.id].clear() + cache_cls.pop(record.id, None) @classmethod @without_check_access @@ -597,7 +595,7 @@ class ModelStorage(Model): ''' transaction = Transaction() ids = list(map(int, ids)) - local_cache = LRUDictTransaction(cache_size()) + local_cache = LRUDictTransaction(cache_size(), cls._record) transaction_cache = transaction.get_cache() return [cls(x, _ids=ids, _local_cache=local_cache, @@ -1385,7 +1383,7 @@ class ModelStorage(Model): assert isinstance(_local_cache, LRUDictTransaction) self._local_cache = _local_cache else: - self._local_cache = LRUDictTransaction(cache_size()) + self._local_cache = LRUDictTransaction(cache_size(), self._record) super(ModelStorage, self).__init__(id, **kwargs) @@ -1393,31 +1391,36 @@ class ModelStorage(Model): def _cache(self): cache = self._transaction_cache if self.__name__ not in cache: - cache[self.__name__] = LRUDict(cache_size()) + cache[self.__name__] = LRUDict(cache_size(), self._record) return cache[self.__name__] def __getattr__(self, name): try: return super(ModelStorage, self).__getattr__(name) except AttributeError: - if self.id is None or self.id < 0: + if self.id is None or self.id < 0 or name.startswith('_'): raise self._local_cache.refresh() + try: + return self._local_cache[self.id][name] + except KeyError: + pass + # fetch the definition of the field try: field = self._fields[name] except KeyError: raise AttributeError('"%s" has no attribute "%s"' % (self, name)) - try: - return self._local_cache[self.id][name] - except KeyError: - pass try: if field._type not in ('many2one', 'reference'): - return self._cache[self.id][name] + # fill local cache for quicker access later + value \ + = self._local_cache[self.id][name] \ + = self._cache[self.id][name] + return value else: skip_eager = name in self._cache[self.id] except KeyError: @@ -1437,8 +1440,10 @@ class ModelStorage(Model): def not_cached(item): fname, field = item - return (fname not in self._cache.get(self.id, {}) - and fname not in self._local_cache.get(self.id, {})) + return ((self.id not in self._cache + or fname not in self._cache[self.id]) + and (self.id not in self._local_cache + or fname not in self._local_cache[self.id])) def to_load(item): fname, field = item @@ -1477,8 +1482,11 @@ class ModelStorage(Model): def filter_(id_): return (id_ == self.id # Ensure the value is read - or (name not in self._cache.get(id_, {}) - and name not in self._local_cache.get(id_, {}))) + or (( + id_ not in self._cache + or name not in self._cache[id_]) + and (id_ not in self._local_cache + or name not in self._local_cache[id_]))) def unique(ids): s = set() @@ -1527,7 +1535,7 @@ class ModelStorage(Model): kwargs = {} key = (Model, freeze(ctx)) kwargs['_local_cache'] = model2cache.setdefault(key, - LRUDictTransaction(cache_size())) + LRUDictTransaction(cache_size(), Model._record)) kwargs['_ids'] = ids = model2ids.setdefault(key, []) kwargs['_transaction_cache'] = transaction.get_cache() kwargs['_transaction'] = transaction @@ -1560,28 +1568,23 @@ class ModelStorage(Model): read_data = self.read(list(ids), list(ffields.keys())) # create browse records for 'remote' models for data in read_data: + id_ = data['id'] for fname, field in ffields.items(): fvalue = data[fname] - if field._type in ('many2one', 'one2one', 'one2many', - 'many2many', 'reference'): + if field._type in { + 'many2one', 'one2one', 'one2many', 'many2many', + 'reference'}: fvalue = instantiate(field, data[fname], data) - if data['id'] == self.id and fname == name: + if id_ == self.id and fname == name: value = fvalue - if (field._type not in ('many2one', 'one2one', 'one2many', - 'many2many', 'reference', 'binary') - and not isinstance(field, fields.Function)): - continue - if data['id'] not in self._local_cache: - self._local_cache[data['id']] = {} - self._local_cache[data['id']][fname] = fvalue - if (field._type not in ('many2one', 'reference') + self._local_cache[id_][fname] = fvalue + if (field._type in { + 'one2one', 'one2many', 'many2many', 'binary'} or field.context or getattr(field, 'datetime_field', None) or isinstance(field, fields.Function)): del data[fname] - if data['id'] not in self._cache: - self._cache[data['id']] = {} - self._cache[data['id']].update(data) + self._cache[id_]._update(data) return value @property @@ -1589,7 +1592,7 @@ class ModelStorage(Model): values = {} if not self._values: return values - for fname, value in self._values.items(): + for fname, value in self._values._items(): field = self._fields[fname] if isinstance(field, fields.Function) and not field.setter: continue @@ -1619,9 +1622,9 @@ class ModelStorage(Model): if (field._type == 'one2many' and field.field and target._values): - t_values = target._values.copy() + t_values = target._values._copy() # Don't look at reverse field - target._values.pop(field.field, None) + target._values._pop(field.field, None) else: t_values = None try: diff --git a/trytond/model/modelview.py b/trytond/model/modelview.py index adba097d6a..1e8f92b8d5 100644 --- a/trytond/model/modelview.py +++ b/trytond/model/modelview.py @@ -795,10 +795,10 @@ class ModelView(Model): """ from .modelstorage import ModelStorage changed = {} - init_values = self._init_values or {} + init_values = self._init_values or self._record() if not self._values: return changed - for fname, value in self._values.items(): + for fname, value in self._values._items(): field = self._fields[fname] # Always test key presence in case value is None if (fname in init_values @@ -820,7 +820,7 @@ class ModelView(Model): value = value.id elif field._type in ['one2many', 'many2many']: targets = value - init_targets = list(init_values.get( + init_targets = list(init_values._get( fname, targets if field._type == 'one2many' else [])) value = collections.defaultdict(list) previous = [t.id for t in init_targets if t.id] @@ -828,9 +828,9 @@ class ModelView(Model): if (field._type == 'one2many' and field.field and target._values): - t_values = target._values.copy() + t_values = target._values._copy() # Don't look at reverse field - target._values.pop(field.field, None) + target._values._pop(field.field, None) else: t_values = None try: diff --git a/trytond/modules/__init__.py b/trytond/modules/__init__.py index 5ae97e345b..c3bdcf5326 100644 --- a/trytond/modules/__init__.py +++ b/trytond/modules/__init__.py @@ -274,6 +274,8 @@ def load_module_graph(graph, pool, update=None, lang=None): # Avoid clearing cache to prevent dead lock on ir.cache table Cache.rollback(transaction) transaction.commit() + # Clear transaction cache to update default_factory + transaction.cache.clear() if not update: pool.setup() diff --git a/trytond/res/user.py b/trytond/res/user.py index 80b7628994..b11fa5464b 100644 --- a/trytond/res/user.py +++ b/trytond/res/user.py @@ -384,8 +384,7 @@ class User(DeactivableMixin, ModelSQL, ModelView): for cache in Transaction().cache.values(): if cls.__name__ in cache: for user in all_users: - if user.id in cache[cls.__name__]: - cache[cls.__name__][user.id].clear() + cache[cls.__name__].pop(user.id, None) # Restart the cache for domain_get method pool = Pool() pool.get('ir.rule')._domain_get_cache.clear() diff --git a/trytond/tests/modelsql.py b/trytond/tests/modelsql.py index e532407e24..f5f5552215 100644 --- a/trytond/tests/modelsql.py +++ b/trytond/tests/modelsql.py @@ -76,6 +76,12 @@ class ModelSQLOne2ManyTarget(ModelSQL): origin = fields.Many2One('test.modelsql.one2many', "Origin") +class ModelSQLSearch(ModelSQL): + "ModelSQL Search" + __name__ = 'test.modelsql.search' + name = fields.Char("Name") + + class NullOrder(ModelSQL): "Null Order" __name__ = 'test.modelsql.null_order' @@ -153,6 +159,7 @@ def register(module): ModelSQLFieldSet, ModelSQLOne2Many, ModelSQLOne2ManyTarget, + ModelSQLSearch, NullOrder, ModelTranslation, ModelCheck, diff --git a/trytond/tests/test_cache.py b/trytond/tests/test_cache.py index 85c017e6e2..741eb17509 100644 --- a/trytond/tests/test_cache.py +++ b/trytond/tests/test_cache.py @@ -5,7 +5,7 @@ import time import unittest from trytond import backend, cache as cache_mod -from trytond.cache import freeze, MemoryCache +from trytond.cache import freeze, MemoryCache, LRUDict, LRUDictTransaction from trytond.tests.test_tryton import with_transaction, activate_module from trytond.tests.test_tryton import DB_NAME, USER from trytond.transaction import Transaction @@ -175,12 +175,94 @@ class MemoryCacheChannelTestCase(MemoryCacheTestCase): super().test_memory_cache_sync() +class LRUDictTestCase(unittest.TestCase): + "Test LRUDict" + + def test_setitem(self): + lru_dict = LRUDict(1) + + lru_dict['foo'] = 'foo' + self.assertEqual(len(lru_dict), 1) + + lru_dict['bar'] = 'bar' + self.assertEqual(len(lru_dict), 1) + self.assertEqual(lru_dict, {'bar': 'bar'}) + + def test_update(self): + lru_dict = LRUDict(1) + + lru_dict['foo'] = 'foo' + self.assertEqual(len(lru_dict), 1) + + lru_dict.update(bar='bar', baz='baz') + self.assertEqual(len(lru_dict), 1) + self.assertEqual(lru_dict, {'baz': 'baz'}) + + def test_setdefault(self): + lru_dict = LRUDict(1) + + lru_dict['foo'] = 'foo' + self.assertEqual(len(lru_dict), 1) + + lru_dict.setdefault('bar', 'value') + self.assertEqual(len(lru_dict), 1) + self.assertEqual(lru_dict, {'bar': 'value'}) + + def test_default_factory(self): + lru_dict = LRUDict(1, default_factory=list) + + self.assertEqual(lru_dict['foo'], []) + + lru_dict['bar'].append('bar') + self.assertEqual(lru_dict, {'bar': ['bar']}) + + +class LRUDictTransactionTestCase(unittest.TestCase): + "Test LRUDictTransaction" + + @with_transaction() + def test_init(self): + "Test init set to transaction counter" + lru_dict = LRUDictTransaction(48) + + self.assertEqual(lru_dict.counter, Transaction().counter) + + @with_transaction() + def test_clear(self): + "Test clear reset counter" + lru_dict = LRUDictTransaction(48) + + Transaction().counter += 1 + lru_dict.clear() + + self.assertEqual(lru_dict.counter, Transaction().counter) + + @with_transaction() + def test_refresh(self): + "Test refresh" + lru_dict = LRUDictTransaction(48) + + lru_dict['foo'] = 'foo' + lru_dict.refresh() + + self.assertEqual(lru_dict, {'foo': 'foo'}) + + Transaction().counter += 1 + lru_dict.refresh() + + self.assertEqual(lru_dict, {}) + self.assertEqual(lru_dict.counter, Transaction().counter) + + def suite(): func = unittest.TestLoader().loadTestsFromTestCase suite = unittest.TestSuite() for testcase in [ CacheTestCase, MemoryCacheTestCase, - MemoryCacheChannelTestCase]: + MemoryCacheChannelTestCase, + LRUDictTestCase, + LRUDictTransactionTestCase, + ]: suite.addTests(func(testcase)) return suite diff --git a/trytond/tests/test_model.py b/trytond/tests/test_model.py index 7407a6f63b..1ecec11be9 100644 --- a/trytond/tests/test_model.py +++ b/trytond/tests/test_model.py @@ -3,6 +3,7 @@ import unittest +from trytond.model.model import record from trytond.pool import Pool from trytond.tests.test_tryton import activate_module, with_transaction from trytond.transaction import Transaction @@ -219,9 +220,159 @@ class ModelTranslationTestCase(unittest.TestCase): self.assertEqual(other['string'], "Nom") +class RecordTestCase(unittest.TestCase): + "Test record" + + def test_creation(self): + "Test record creation" + Record = record('model', ['foo', 'bar']) + + self.assertEqual(Record.__name__, 'model') + self.assertEqual(set(Record.__slots__), {'foo', 'bar'}) + + def test_init(self): + Record = record('model', ['foo', 'bar']) + + rec = Record(foo='foo') + + self.assertEqual(rec.foo, 'foo') + + def test_getitem(self): + Record = record('model', ['foo', 'bar']) + + rec = Record(foo='foo') + + self.assertEqual(rec['foo'], 'foo') + + def test_setitem(self): + Record = record('model', ['foo', 'bar']) + + rec = Record(foo='foo') + + rec['foo'] = 'foo' + self.assertEqual(rec['foo'], 'foo') + + def test_contains(self): + "Test record contains" + Record = record('model', ['foo', 'bar']) + + rec = Record(foo='foo') + + self.assertTrue('foo' in rec) + self.assertFalse('bar' in rec) + + def test_clear(self): + "Test record clear" + Record = record('model', ['foo', 'bar']) + + rec = Record(foo='foo', bar='bar') + + rec._clear() + + with self.assertRaises(AttributeError): + rec.foo + with self.assertRaises(AttributeError): + rec.bar + + def test_copy(self): + "Test copy record" + Record = record('model', ['foo', 'bar']) + + rec = Record(foo='baz') + copy = rec._copy() + + self.assertEqual(copy.foo, 'baz') + self.assertNotEqual(rec, copy) + self.assertIsInstance(copy, Record) + + def test_get(self): + "Test get field from record" + Record = record('model', ['foo', 'bar']) + + rec = Record(foo='baz') + + self.assertEqual(rec._get('foo'), 'baz') + self.assertEqual(rec._get('bar'), None) + self.assertEqual(rec._get('bar', 42), 42) + with self.assertRaises(KeyError): + rec._get('baz') + + def test_keys(self): + "Test record keys" + Record = record('model', ['foo', 'bar']) + + rec = Record(foo='baz') + + self.assertEqual(list(rec._keys()), ['foo']) + + def test_items(self): + "Test record items" + Record = record('model', ['foo', 'bar']) + + rec = Record(foo='baz') + + self.assertEqual(list(rec._items()), [('foo', 'baz')]) + + def test_pop(self): + "Test pop field from record" + Record = record('model', ['foo', 'bar']) + + rec = Record(foo='baz') + + value = rec._pop('foo') + self.assertEqual(value, 'baz') + with self.assertRaises(AttributeError): + rec.foo + + with self.assertRaises(KeyError): + rec._pop('baz') + + def test_popitem(self): + "Test popitem field from record" + Record = record('model', ['foo', 'bar']) + + rec = Record(foo='baz') + + value = rec._popitem('foo') + self.assertEqual(value, ('foo', 'baz')) + + def test_setdefault(self): + "Test set default field on record" + Record = record('model', ['foo', 'bar']) + + rec = Record(foo='foo') + + self.assertEqual(rec._setdefault('bar', 'bar'), 'bar') + self.assertEqual(rec.bar, 'bar') + self.assertEqual(rec._setdefault('foo', 'baz'), 'foo') + self.assertEqual(rec.foo, 'foo') + + def test_update(self): + "Test update record" + Record = record('model', ['foo', 'bar']) + + rec = Record(foo='foo') + rec._update(foo='bar', bar='baz') + + self.assertEqual(rec.foo, 'bar') + self.assertEqual(rec.bar, 'baz') + + def test_values(self): + "Test record values" + Record = record('model', ['foo', 'bar']) + + rec = Record(foo='baz') + + self.assertEqual(list(rec._values()), [('baz')]) + + def suite(): + func = unittest.TestLoader().loadTestsFromTestCase suite_ = unittest.TestSuite() - suite_.addTests(unittest.TestLoader().loadTestsFromTestCase(ModelTestCase)) - suite_.addTests(unittest.TestLoader().loadTestsFromTestCase( - ModelTranslationTestCase)) + for testcase in [ + ModelTestCase, + ModelTranslationTestCase, + RecordTestCase, + ]: + suite_.addTests(func(testcase)) return suite_ diff --git a/trytond/tests/test_modelsql.py b/trytond/tests/test_modelsql.py index 8d4f5bd04b..cccc8174e4 100644 --- a/trytond/tests/test_modelsql.py +++ b/trytond/tests/test_modelsql.py @@ -742,6 +742,20 @@ class ModelSQLTranslationTestCase(unittest.TestCase): self.assertEqual(record.name, "Foo") self.assertEqual(other.name, "Baz") + @with_transaction() + def test_search_fill_transaction_cache(self): + "Test search fill the transaction cache" + pool = Pool() + Model = pool.get('test.modelsql.search') + Model.create([{'name': "Foo"}]) + + record, = Model.search([]) + cache = Transaction().get_cache()[Model.__name__] + + self.assertIn(record.id, cache) + self.assertEqual(cache[record.id]['name'], "Foo") + self.assertNotIn('_timestamp', cache[record.id]) + def suite(): suite_ = unittest.TestSuite() diff --git a/trytond/tests/test_modelview.py b/trytond/tests/test_modelview.py index 94e325c853..4dfbd0861d 100644 --- a/trytond/tests/test_modelview.py +++ b/trytond/tests/test_modelview.py @@ -82,7 +82,7 @@ class ModelView(unittest.TestCase): # no initial value record = Model() - record._values = {} + record._values = record._record() target = Target(id=1) record._values['targets'] = [target] target.name = 'foo'