Coverage for adhoc-cicd-odoo-odoo / odoo / orm / environments.py: 61%
482 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-09 18:05 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-09 18:05 +0000
1# Part of Odoo. See LICENSE file for full copyright and licensing details.
3"""The Odoo API module defines Odoo Environments.
4"""
5from __future__ import annotations
7import functools
8import logging
9import pytz
10import typing
11import warnings
12from collections import defaultdict
13from collections.abc import Mapping
14from contextlib import contextmanager, suppress
15from pprint import pformat
16from weakref import WeakSet
18from odoo.exceptions import AccessError, UserError, CacheMiss
19from odoo.sql_db import BaseCursor
20from odoo.tools import clean_context, frozendict, reset_cached_properties, OrderedSet, Query, SQL
21from odoo.tools.translate import get_translation, get_translated_module, LazyGettext
22from odoo.tools.misc import StackMap, SENTINEL
24from .registry import Registry
25from .utils import SUPERUSER_ID
27if typing.TYPE_CHECKING:
28 from collections.abc import Collection, Iterable, Iterator, MutableMapping
29 from datetime import tzinfo
30 from .identifiers import IdType, NewId
31 from .types import BaseModel, Field
33 M = typing.TypeVar('M', bound=BaseModel)
35_logger = logging.getLogger('odoo.api')
37MAX_FIXPOINT_ITERATIONS = 10
40class Environment(Mapping[str, "BaseModel"]):
41 """ The environment stores various contextual data used by the ORM:
43 - :attr:`cr`: the current database cursor (for database queries);
44 - :attr:`uid`: the current user id (for access rights checks);
45 - :attr:`context`: the current context dictionary (arbitrary metadata);
46 - :attr:`su`: whether in superuser mode.
48 It provides access to the registry by implementing a mapping from model
49 names to models. It also holds a cache for records, and a data
50 structure to manage recomputations.
51 """
53 cr: BaseCursor
54 uid: int
55 context: frozendict
56 su: bool
57 transaction: Transaction
59 def reset(self) -> None:
60 """ Reset the transaction, see :meth:`Transaction.reset`. """
61 warnings.warn("Since 19.0, use directly `transaction.reset()`", DeprecationWarning)
62 self.transaction.reset()
64 def __new__(cls, cr: BaseCursor, uid: int, context: dict, su: bool = False):
65 assert isinstance(cr, BaseCursor)
66 if uid == SUPERUSER_ID:
67 su = True
69 # determine transaction object
70 transaction = cr.transaction
71 if transaction is None:
72 transaction = cr.transaction = Transaction(Registry(cr.dbname))
74 # if env already exists, return it
75 for env in transaction.envs:
76 if env.cr is cr and env.uid == uid and env.su == su and env.context == context:
77 return env
79 # otherwise create environment, and add it in the set
80 self = object.__new__(cls)
81 self.cr, self.uid, self.su = cr, uid, su
82 self.context = frozendict(context)
83 self.transaction = transaction
85 transaction.envs.add(self)
86 # the default transaction's environment is the first one with a valid uid
87 if transaction.default_env is None and uid and isinstance(uid, int):
88 transaction.default_env = self
89 return self
91 def __setattr__(self, name: str, value: typing.Any) -> None:
92 # once initialized, attributes are read-only
93 if name in vars(self): 93 ↛ 94line 93 didn't jump to line 94 because the condition on line 93 was never true
94 raise AttributeError(f"Attribute {name!r} is read-only, call `env()` instead")
95 return super().__setattr__(name, value)
97 #
98 # Mapping methods
99 #
101 def __contains__(self, model_name) -> bool:
102 """ Test whether the given model exists. """
103 return model_name in self.registry
105 def __getitem__(self, model_name: str) -> BaseModel:
106 """ Return an empty recordset from the given model. """
107 return self.registry[model_name](self, (), ())
109 def __iter__(self):
110 """ Return an iterator on model names. """
111 return iter(self.registry)
113 def __len__(self):
114 """ Return the size of the model registry. """
115 return len(self.registry)
117 def __eq__(self, other):
118 return self is other
120 def __ne__(self, other):
121 return self is not other
123 def __hash__(self):
124 return object.__hash__(self)
126 def __call__(
127 self,
128 cr: BaseCursor | None = None,
129 user: IdType | BaseModel | None = None,
130 context: dict | None = None,
131 su: bool | None = None,
132 ) -> Environment:
133 """ Return an environment based on ``self`` with modified parameters.
135 :param cr: optional database cursor to change the current cursor
136 :type cursor: :class:`~odoo.sql_db.Cursor`
137 :param user: optional user/user id to change the current user
138 :type user: int or :class:`res.users record<~odoo.addons.base.models.res_users.ResUsers>`
139 :param dict context: optional context dictionary to change the current context
140 :param bool su: optional boolean to change the superuser mode
141 :returns: environment with specified args (new or existing one)
142 """
143 cr = self.cr if cr is None else cr
144 uid = self.uid if user is None else int(user) # type: ignore
145 if context is None:
146 context = clean_context(self.context) if su and not self.su else self.context
147 su = (user is None and self.su) if su is None else su
148 return Environment(cr, uid, context, su)
150 @typing.overload
151 def ref(self, xml_id: str, raise_if_not_found: typing.Literal[True] = True) -> BaseModel:
152 ...
154 @typing.overload
155 def ref(self, xml_id: str, raise_if_not_found: typing.Literal[False]) -> BaseModel | None:
156 ...
158 def ref(self, xml_id: str, raise_if_not_found: bool = True) -> BaseModel | None:
159 """ Return the record corresponding to the given ``xml_id``.
161 :param str xml_id: record xml_id, under the format ``<module.id>``
162 :param bool raise_if_not_found: whether the method should raise if record is not found
163 :returns: Found record or None
164 :raise ValueError: if record wasn't found and ``raise_if_not_found`` is True
165 """
166 res_model, res_id = self['ir.model.data']._xmlid_to_res_model_res_id(
167 xml_id, raise_if_not_found=raise_if_not_found
168 )
170 if res_model and res_id:
171 record = self[res_model].browse(res_id)
172 if record.exists(): 172 ↛ 174line 172 didn't jump to line 174 because the condition on line 172 was always true
173 return record
174 if raise_if_not_found:
175 raise ValueError('No record found for unique ID %s. It may have been deleted.' % (xml_id))
176 return None
178 def is_superuser(self) -> bool:
179 """ Return whether the environment is in superuser mode. """
180 return self.su
182 def is_admin(self) -> bool:
183 """ Return whether the current user has group "Access Rights", or is in
184 superuser mode. """
185 return self.su or self.user._is_admin()
187 def is_system(self) -> bool:
188 """ Return whether the current user has group "Settings", or is in
189 superuser mode. """
190 return self.su or self.user._is_system()
192 @functools.cached_property
193 def registry(self) -> Registry:
194 """Return the registry associated with the transaction."""
195 return self.transaction.registry
197 @functools.cached_property
198 def _protected(self):
199 """Return the protected map of the transaction."""
200 return self.transaction.protected
202 @functools.cached_property
203 def cache(self):
204 """Return the cache object of the transaction."""
205 return self.transaction.cache
207 @functools.cached_property
208 def user(self) -> BaseModel:
209 """Return the current user (as an instance).
211 :returns: current user - sudoed
212 :rtype: :class:`res.users record<~odoo.addons.base.models.res_users.ResUsers>`"""
213 return self(su=True)['res.users'].browse(self.uid)
215 @functools.cached_property
216 def company(self) -> BaseModel:
217 """Return the current company (as an instance).
219 If not specified in the context (`allowed_company_ids`),
220 fallback on current user main company.
222 :raise AccessError: invalid or unauthorized `allowed_company_ids` context key content.
223 :return: current company (default=`self.user.company_id`), with the current environment
224 :rtype: :class:`res.company record<~odoo.addons.base.models.res_company.Company>`
226 .. warning::
228 No sanity checks applied in sudo mode!
229 When in sudo mode, a user can access any company,
230 even if not in his allowed companies.
232 This allows to trigger inter-company modifications,
233 even if the current user doesn't have access to
234 the targeted company.
235 """
236 company_ids = self.context.get('allowed_company_ids', [])
237 if company_ids:
238 if not self.su: 238 ↛ 239line 238 didn't jump to line 239 because the condition on line 238 was never true
239 user_company_ids = self.user._get_company_ids()
240 if set(company_ids) - set(user_company_ids):
241 raise AccessError(self._("Access to unauthorized or invalid companies."))
242 return self['res.company'].browse(company_ids[0])
243 return self.user.company_id.with_env(self)
245 @functools.cached_property
246 def companies(self) -> BaseModel:
247 """Return a recordset of the enabled companies by the user.
249 If not specified in the context(`allowed_company_ids`),
250 fallback on current user companies.
252 :raise AccessError: invalid or unauthorized `allowed_company_ids` context key content.
253 :return: current companies (default=`self.user.company_ids`), with the current environment
254 :rtype: :class:`res.company recordset<~odoo.addons.base.models.res_company.Company>`
256 .. warning::
258 No sanity checks applied in sudo mode !
259 When in sudo mode, a user can access any company,
260 even if not in his allowed companies.
262 This allows to trigger inter-company modifications,
263 even if the current user doesn't have access to
264 the targeted company.
265 """
266 company_ids = self.context.get('allowed_company_ids', [])
267 user_company_ids = self.user._get_company_ids()
268 if company_ids:
269 if not self.su: 269 ↛ 270line 269 didn't jump to line 270 because the condition on line 269 was never true
270 if set(company_ids) - set(user_company_ids):
271 raise AccessError(self._("Access to unauthorized or invalid companies."))
272 return self['res.company'].browse(company_ids)
273 # By setting the default companies to all user companies instead of the main one
274 # we save a lot of potential trouble in all "out of context" calls, such as
275 # /mail/redirect or /web/image, etc. And it is not unsafe because the user does
276 # have access to these other companies. The risk of exposing foreign records
277 # (wrt to the context) is low because all normal RPCs will have a proper
278 # allowed_company_ids.
279 # Examples:
280 # - when printing a report for several records from several companies
281 # - when accessing to a record from the notification email template
282 # - when loading an binary image on a template
283 return self['res.company'].browse(user_company_ids)
285 @functools.cached_property
286 def tz(self) -> tzinfo:
287 """Return the current timezone info, defaults to UTC."""
288 timezone = self.context.get('tz') or self.user.tz
289 if timezone: 289 ↛ 294line 289 didn't jump to line 294 because the condition on line 289 was always true
290 try:
291 return pytz.timezone(timezone)
292 except Exception: # noqa: BLE001
293 _logger.debug("Invalid timezone %r", timezone, exc_info=True)
294 return pytz.utc
296 @functools.cached_property
297 def lang(self) -> str | None:
298 """Return the current language code."""
299 lang = self.context.get('lang')
300 if lang and lang != 'en_US' and not self['res.lang']._get_data(code=lang): 300 ↛ 302line 300 didn't jump to line 302 because the condition on line 300 was never true
301 # cannot translate here because we do not have a valid language
302 raise UserError(f'Invalid language code: {lang}') # pylint: disable=missing-gettext
303 return lang or None
305 @functools.cached_property
306 def _lang(self) -> str:
307 """Return the technical language code of the current context for **model_terms** translated field
308 """
309 context = self.context
310 lang = self.lang or 'en_US'
311 if context.get('edit_translations') or context.get('check_translations'): 311 ↛ 312line 311 didn't jump to line 312 because the condition on line 311 was never true
312 lang = '_' + lang
313 return lang
315 def _(self, source: str | LazyGettext, *args, **kwargs) -> str:
316 """Translate the term using current environment's language.
318 Usage:
320 ```
321 self.env._("hello world") # dynamically get module name
322 self.env._("hello %s", "test")
323 self.env._(LAZY_TRANSLATION)
324 ```
326 :param source: String to translate or lazy translation
327 :param ...: args or kwargs for templating
328 :return: The transalted string
329 """
330 lang = self.lang or 'en_US'
331 if isinstance(source, str): 331 ↛ 334line 331 didn't jump to line 334 because the condition on line 331 was always true
332 assert not (args and kwargs), "Use args or kwargs, not both"
333 format_args = args or kwargs
334 elif isinstance(source, LazyGettext):
335 # translate a lazy text evaluation
336 assert not args and not kwargs, "All args should come from the lazy text"
337 return source._translate(lang)
338 else:
339 raise TypeError(f"Cannot translate {source!r}")
340 if lang == 'en_US': 340 ↛ 343line 340 didn't jump to line 343 because the condition on line 340 was always true
341 # we ignore the module as en_US is not translated
342 return get_translation('base', 'en_US', source, format_args)
343 try:
344 module = get_translated_module(2)
345 return get_translation(module, lang, source, format_args)
346 except Exception: # noqa: BLE001
347 _logger.debug('translation went wrong for "%r", skipped', source, exc_info=True)
348 return source
350 def clear(self) -> None:
351 """ Clear all record caches, and discard all fields to recompute.
352 This may be useful when recovering from a failed ORM operation.
353 """
354 reset_cached_properties(self)
355 self.transaction.clear()
357 def invalidate_all(self, flush: bool = True) -> None:
358 """ Invalidate the cache of all records.
360 :param flush: whether pending updates should be flushed before invalidation.
361 It is ``True`` by default, which ensures cache consistency.
362 Do not use this parameter unless you know what you are doing.
363 """
364 if flush:
365 self.flush_all()
366 self.transaction.invalidate_field_data()
368 def _recompute_all(self) -> None:
369 """ Process all pending computations. """
370 for _ in range(MAX_FIXPOINT_ITERATIONS): 370 ↛ 378line 370 didn't jump to line 378 because the loop on line 370 didn't complete
371 # fields to compute on real records (new records are not recomputed)
372 fields_ = [field for field, ids in self.transaction.tocompute.items() if any(ids)]
373 if not fields_:
374 break
375 for field in fields_:
376 self[field.model_name]._recompute_field(field)
377 else:
378 _logger.warning("Too many iterations for recomputing fields!")
380 def flush_all(self) -> None:
381 """ Flush all pending computations and updates to the database. """
382 for _ in range(MAX_FIXPOINT_ITERATIONS): 382 ↛ 390line 382 didn't jump to line 390 because the loop on line 382 didn't complete
383 self._recompute_all()
384 model_names = OrderedSet(field.model_name for field in self._field_dirty)
385 if not model_names:
386 break
387 for model_name in model_names:
388 self[model_name].flush_model()
389 else:
390 _logger.warning("Too many iterations for flushing fields!")
392 def is_protected(self, field: Field, record: BaseModel) -> bool:
393 """ Return whether `record` is protected against invalidation or
394 recomputation for `field`.
395 """
396 return record.id in self._protected.get(field, ())
398 def protected(self, field: Field) -> BaseModel:
399 """ Return the recordset for which ``field`` should not be invalidated or recomputed. """
400 return self[field.model_name].browse(self._protected.get(field, ()))
402 @typing.overload
403 def protecting(self, what: Collection[Field], records: BaseModel) -> typing.ContextManager[None]:
404 ...
406 @typing.overload
407 def protecting(self, what: Collection[tuple[Collection[Field], BaseModel]]) -> typing.ContextManager[None]:
408 ...
410 @contextmanager
411 def protecting(self, what, records=None) -> Iterator[None]:
412 """ Prevent the invalidation or recomputation of fields on records.
413 The parameters are either:
415 - ``what`` a collection of fields and ``records`` a recordset, or
416 - ``what`` a collection of pairs ``(fields, records)``.
417 """
418 protected = self._protected
419 try:
420 protected.pushmap()
421 if records is not None: # convert first signature to second one
422 what = [(what, records)]
423 ids_by_field = defaultdict(list)
424 for fields, what_records in what:
425 for field in fields:
426 ids_by_field[field].extend(what_records._ids)
428 for field, rec_ids in ids_by_field.items():
429 ids = protected.get(field)
430 protected[field] = ids.union(rec_ids) if ids else frozenset(rec_ids)
431 yield
432 finally:
433 protected.popmap()
435 def fields_to_compute(self) -> Collection[Field]:
436 """ Return a view on the field to compute. """
437 return self.transaction.tocompute.keys()
439 def records_to_compute(self, field: Field) -> BaseModel:
440 """ Return the records to compute for ``field``. """
441 ids = self.transaction.tocompute.get(field, ())
442 return self[field.model_name].browse(ids)
444 def is_to_compute(self, field: Field, record: BaseModel) -> bool:
445 """ Return whether ``field`` must be computed on ``record``. """
446 return record.id in self.transaction.tocompute.get(field, ())
448 def not_to_compute(self, field: Field, records: BaseModel) -> BaseModel:
449 """ Return the subset of ``records`` for which ``field`` must not be computed. """
450 ids = self.transaction.tocompute.get(field, ())
451 return records.browse(id_ for id_ in records._ids if id_ not in ids)
453 def add_to_compute(self, field: Field, records: BaseModel) -> None:
454 """ Mark ``field`` to be computed on ``records``. """
455 if not records:
456 return
457 assert field.store and field.compute, "Cannot add to recompute no-store or no-computed field"
458 self.transaction.tocompute[field].update(records._ids)
460 def remove_to_compute(self, field: Field, records: BaseModel) -> None:
461 """ Mark ``field`` as computed on ``records``. """
462 if not records:
463 return
464 ids = self.transaction.tocompute.get(field, None)
465 if ids is None:
466 return
467 ids.difference_update(records._ids)
468 if not ids:
469 del self.transaction.tocompute[field]
471 def cache_key(self, field: Field) -> typing.Any:
472 """ Return the cache key of the given ``field``. """
473 def get(key, get_context=self.context.get):
474 if key == 'company':
475 return self.company.id
476 elif key == 'uid':
477 return self.uid if field.compute_sudo else (self.uid, self.su)
478 elif key == 'lang':
479 return get_context('lang') or None
480 elif key == 'active_test': 480 ↛ 481line 480 didn't jump to line 481 because the condition on line 480 was never true
481 return get_context('active_test', field.context.get('active_test', True))
482 elif key.startswith('bin_size'):
483 return bool(get_context(key))
484 else:
485 val = get_context(key)
486 if type(val) is list:
487 val = tuple(val)
488 try:
489 hash(val)
490 except TypeError:
491 raise TypeError(
492 "Can only create cache keys from hashable values, "
493 f"got non-hashable value {val!r} at context key {key!r} "
494 f"(dependency of field {field})"
495 ) from None # we don't need to chain the exception created 2 lines above
496 else:
497 return val
499 return tuple(get(key) for key in self.registry.field_depends_context[field])
501 @functools.cached_property
502 def _field_cache_memo(self) -> dict[Field, MutableMapping[IdType, typing.Any]]:
503 """Memo for `Field._get_cache(env)`. Do not use it."""
504 return {}
506 @functools.cached_property
507 def _field_dirty(self):
508 """ Map fields to set of dirty ids. """
509 return self.transaction.field_dirty
511 @functools.cached_property
512 def _field_depends_context(self):
513 return self.registry.field_depends_context
515 def flush_query(self, query: SQL) -> None:
516 """ Flush all the fields in the metadata of ``query``. """
517 fields_to_flush = tuple(query.to_flush)
518 if not fields_to_flush:
519 return
521 fnames_to_flush = defaultdict[str, OrderedSet[str]](OrderedSet)
522 for field in fields_to_flush:
523 fnames_to_flush[field.model_name].add(field.name)
524 for model_name, field_names in fnames_to_flush.items():
525 self[model_name].flush_model(field_names)
527 def execute_query(self, query: SQL) -> list[tuple]:
528 """ Execute the given query, fetch its result and it as a list of tuples
529 (or an empty list if no result to fetch). The method automatically
530 flushes all the fields in the metadata of the query.
531 """
532 assert isinstance(query, SQL)
533 self.flush_query(query)
534 self.cr.execute(query)
535 return [] if self.cr.description is None else self.cr.fetchall()
537 def execute_query_dict(self, query: SQL) -> list[dict]:
538 """ Execute the given query, fetch its results as a list of dicts.
539 The method automatically flushes fields in the metadata of the query.
540 """
541 rows = self.execute_query(query)
542 if not rows:
543 return []
544 description = self.cr.description
545 assert description is not None, "No cr.description, the executed query does not return a table."
546 return [
547 {column.name: row[index] for index, column in enumerate(description)}
548 for row in rows
549 ]
552class Transaction:
553 """ A object holding ORM data structures for a transaction. """
554 __slots__ = (
555 '_Transaction__file_open_tmp_paths', 'cache',
556 'default_env', 'envs', 'field_data', 'field_data_patches', 'field_dirty',
557 'protected', 'registry', 'tocompute',
558 )
560 def __init__(self, registry: Registry):
561 self.registry = registry
562 # weak OrderedSet of environments
563 self.envs = WeakSet[Environment]()
564 self.envs.data = OrderedSet() # type: ignore[attr-defined]
565 # default environment (for flushing)
566 self.default_env: Environment | None = None
568 # cache data {field: cache_data_managed_by_field} often uses a dict
569 # to store a mapping from id to a value, but fields may use this field
570 # however they need
571 self.field_data = defaultdict["Field", typing.Any](dict)
572 # {field: set[id]} stores the fields and ids that are changed in the
573 # cache, but not yet written in the database; their changed values are
574 # in `data`
575 self.field_dirty = defaultdict["Field", OrderedSet["IdType"]](OrderedSet)
576 # {field: {record_id: ids}} record ids to be added to the values of
577 # x2many fields if they are not in cache yet
578 self.field_data_patches = defaultdict["Field", defaultdict["IdType", list["IdType"]]](lambda: defaultdict(list))
579 # fields to protect {field: ids}
580 self.protected = StackMap["Field", OrderedSet["IdType"]]()
581 # pending computations {field: ids}
582 self.tocompute = defaultdict["Field", OrderedSet["IdType"]](OrderedSet)
583 # backward-compatible view of the cache
584 self.cache = Cache(self)
586 # temporary directories (managed in odoo.tools.file_open_temporary_directory)
587 self.__file_open_tmp_paths = [] # type: ignore # noqa: PLE0237
589 def flush(self) -> None:
590 """ Flush pending computations and updates in the transaction. """
591 if self.default_env is not None: 591 ↛ 594line 591 didn't jump to line 594 because the condition on line 591 was always true
592 self.default_env.flush_all()
593 else:
594 for env in self.envs:
595 _logger.warning("Missing default_env, flushing as public user")
596 public_user = env.ref('base.public_user')
597 Environment(env.cr, public_user.id, {}).flush_all()
598 break
600 def clear(self):
601 """ Clear the caches and pending computations and updates in the transactions. """
602 self.invalidate_field_data()
603 self.field_data_patches.clear()
604 self.field_dirty.clear()
605 self.tocompute.clear()
606 for env in self.envs: 606 ↛ exitline 606 didn't return from function 'clear' because the loop on line 606 didn't complete
607 env.cr.cache.clear()
608 break # all envs of the transaction share the same cursor
610 def reset(self) -> None:
611 """ Reset the transaction. This clears the transaction, and reassigns
612 the registry on all its environments. This operation is strongly
613 recommended after reloading the registry.
614 """
615 self.registry = Registry(self.registry.db_name)
616 for env in self.envs:
617 reset_cached_properties(env)
618 self.clear()
620 def invalidate_field_data(self) -> None:
621 """ Invalidate the cache of all the fields.
623 This operation is unsafe by default, and must be used with care.
624 Indeed, invalidating a dirty field on a record may lead to an error,
625 because doing so drops the value to be written in database.
626 """
627 self.field_data.clear()
628 # reset Field._get_cache()
629 for env in self.envs:
630 with suppress(AttributeError):
631 del env._field_cache_memo
634# sentinel value for optional parameters
635EMPTY_DICT = frozendict() # type: ignore
638class Cache:
639 """ Implementation of the cache of records.
641 For most fields, the cache is simply a mapping from a record and a field to
642 a value. In the case of context-dependent fields, the mapping also depends
643 on the environment of the given record. For the sake of performance, the
644 cache is first partitioned by field, then by record. This makes some
645 common ORM operations pretty fast, like determining which records have a
646 value for a given field, or invalidating a given field on all possible
647 records.
649 The cache can also mark some entries as "dirty". Dirty entries essentially
650 marks values that are different from the database. They represent database
651 updates that haven't been done yet. Note that dirty entries only make
652 sense for stored fields. Note also that if a field is dirty on a given
653 record, and the field is context-dependent, then all the values of the
654 record for that field are considered dirty. For the sake of consistency,
655 the values that should be in the database must be in a context where all
656 the field's context keys are ``None``.
657 """
658 __slots__ = ('transaction',)
660 def __init__(self, transaction: Transaction):
661 self.transaction = transaction
663 def __repr__(self) -> str:
664 # for debugging: show the cache content and dirty flags as stars
665 data: dict[Field, dict] = {}
666 for field, field_cache in sorted(self.transaction.field_data.items(), key=lambda item: str(item[0])):
667 dirty_ids = self.transaction.field_dirty.get(field, ())
668 if field in self.transaction.registry.field_depends_context:
669 data[field] = {
670 key: {
671 Starred(id_) if id_ in dirty_ids else id_: val if field.type != 'binary' else '<binary>'
672 for id_, val in key_cache.items()
673 }
674 for key, key_cache in field_cache.items()
675 }
676 else:
677 data[field] = {
678 Starred(id_) if id_ in dirty_ids else id_: val if field.type != 'binary' else '<binary>'
679 for id_, val in field_cache.items()
680 }
681 return repr(data)
683 def _get_field_cache(self, model: BaseModel, field: Field) -> Mapping[IdType, typing.Any]:
684 """ Return the field cache of the given field, but not for modifying it. """
685 return self._set_field_cache(model, field)
687 def _set_field_cache(self, model: BaseModel, field: Field) -> dict[IdType, typing.Any]:
688 """ Return the field cache of the given field for modifying it. """
689 return field._get_cache(model.env)
691 def contains(self, record: BaseModel, field: Field) -> bool:
692 """ Return whether ``record`` has a value for ``field``. """
693 return record.id in self._get_field_cache(record, field)
695 def contains_field(self, field: Field) -> bool:
696 """ Return whether ``field`` has a value for at least one record. """
697 cache = self.transaction.field_data.get(field)
698 if not cache:
699 return False
700 # 'cache' keys are tuples if 'field' is context-dependent, record ids otherwise
701 if field in self.transaction.registry.field_depends_context:
702 return any(value for value in cache.values())
703 return True
705 def get(self, record: BaseModel, field: Field, default=SENTINEL):
706 """ Return the value of ``field`` for ``record``. """
707 try:
708 field_cache = self._get_field_cache(record, field)
709 return field_cache[record._ids[0]]
710 except KeyError:
711 if default is SENTINEL:
712 raise CacheMiss(record, field) from None
713 return default
715 def set(self, record: BaseModel, field: Field, value: typing.Any, dirty: bool = False) -> None:
716 """ Set the value of ``field`` for ``record``.
717 One can normally make a clean field dirty but not the other way around.
718 Updating a dirty field without ``dirty=True`` is a programming error and
719 raises an exception.
721 :param dirty: whether ``field`` must be made dirty on ``record`` after
722 the update
723 """
724 field._update_cache(record, value, dirty=dirty)
726 def update(self, records: BaseModel, field: Field, values: Iterable, dirty: bool = False) -> None:
727 """ Set the values of ``field`` for several ``records``.
728 One can normally make a clean field dirty but not the other way around.
729 Updating a dirty field without ``dirty=True`` is a programming error and
730 raises an exception.
732 :param dirty: whether ``field`` must be made dirty on ``record`` after
733 the update
734 """
735 for record, value in zip(records, values):
736 field._update_cache(record, value, dirty=dirty)
738 def update_raw(self, records: BaseModel, field: Field, values: Iterable, dirty: bool = False) -> None:
739 """ This is a variant of method :meth:`~update` without the logic for
740 translated fields.
741 """
742 if field.translate:
743 records = records.with_context(prefetch_langs=True)
744 for record, value in zip(records, values):
745 field._update_cache(record, value, dirty=dirty)
747 def insert_missing(self, records: BaseModel, field: Field, values: Iterable) -> None:
748 """ Set the values of ``field`` for the records in ``records`` that
749 don't have a value yet. In other words, this does not overwrite
750 existing values in cache.
751 """
752 warnings.warn("Since 19.0, use Field._insert_cache", DeprecationWarning)
753 field._insert_cache(records, values)
755 def patch(self, records: BaseModel, field: Field, new_id: NewId):
756 """ Apply a patch to an x2many field on new records. The patch consists
757 in adding new_id to its value in cache. If the value is not in cache
758 yet, it will be applied once the value is put in cache with method
759 :meth:`patch_and_set`.
760 """
761 warnings.warn("Since 19.0, this method is internal", DeprecationWarning)
762 from .fields_relational import _RelationalMulti # noqa: PLC0415
763 assert isinstance(field, _RelationalMulti)
764 value = records.env[field.comodel_name].browse((new_id,))
765 field._update_inverse(records, value)
767 def patch_and_set(self, record: BaseModel, field: Field, value: typing.Any) -> typing.Any:
768 """ Set the value of ``field`` for ``record``, like :meth:`set`, but
769 apply pending patches to ``value`` and return the value actually put
770 in cache.
771 """
772 warnings.warn("Since 19.0, this method is internal", DeprecationWarning)
773 field._update_cache(record, value)
774 return self.get(record, field)
776 def remove(self, record: BaseModel, field: Field) -> None:
777 """ Remove the value of ``field`` for ``record``. """
778 assert record.id not in self.transaction.field_dirty.get(field, ())
779 try:
780 field_cache = self._set_field_cache(record, field)
781 del field_cache[record._ids[0]]
782 except KeyError:
783 pass
785 def get_values(self, records: BaseModel, field: Field) -> Iterator[typing.Any]:
786 """ Return the cached values of ``field`` for ``records``. """
787 field_cache = self._get_field_cache(records, field)
788 for record_id in records._ids:
789 try:
790 yield field_cache[record_id]
791 except KeyError:
792 pass
794 def get_until_miss(self, records: BaseModel, field: Field) -> list[typing.Any]:
795 """ Return the cached values of ``field`` for ``records`` until a value is not found. """
796 warnings.warn("Since 19.0, this is managed directly by Field")
797 field_cache = self._get_field_cache(records, field)
798 vals = []
799 for record_id in records._ids:
800 try:
801 vals.append(field_cache[record_id])
802 except KeyError:
803 break
804 return vals
806 def get_records_different_from(self, records: M, field: Field, value: typing.Any) -> M:
807 """ Return the subset of ``records`` that has not ``value`` for ``field``. """
808 warnings.warn("Since 19.0, becomes internal function of fields", DeprecationWarning)
809 return field._filter_not_equal(records, value)
811 def get_fields(self, record: BaseModel) -> Iterator[Field]:
812 """ Return the fields with a value for ``record``. """
813 for name, field in record._fields.items():
814 if name != 'id' and record.id in self._get_field_cache(record, field):
815 yield field
817 def get_records(self, model: BaseModel, field: Field, all_contexts: bool = False) -> BaseModel:
818 """ Return the records of ``model`` that have a value for ``field``.
819 By default the method checks for values in the current context of ``model``.
820 But when ``all_contexts`` is true, it checks for values *in all contexts*.
821 """
822 ids: Iterable
823 if all_contexts and field in model.pool.field_depends_context: 823 ↛ 824line 823 didn't jump to line 824 because the condition on line 823 was never true
824 field_cache = self.transaction.field_data.get(field, EMPTY_DICT)
825 ids = OrderedSet(id_ for sub_cache in field_cache.values() for id_ in sub_cache)
826 else:
827 ids = self._get_field_cache(model, field)
828 return model.browse(ids)
830 def get_missing_ids(self, records: BaseModel, field: Field) -> Iterator[IdType]:
831 """ Return the ids of ``records`` that have no value for ``field``. """
832 return field._cache_missing_ids(records)
834 def get_dirty_fields(self) -> Collection[Field]:
835 """ Return the fields that have dirty records in cache. """
836 warnings.warn("Since 19.0, don't use Cache to manipulate dirty fields")
837 return self.transaction.field_dirty.keys()
839 def filtered_dirty_records(self, records: BaseModel, field: Field) -> BaseModel:
840 """ Filtered ``records`` where ``field`` is dirty. """
841 warnings.warn("Since 19.0, don't use Cache to manipulate dirty fields")
842 dirties = self.transaction.field_dirty.get(field, ())
843 return records.browse(id_ for id_ in records._ids if id_ in dirties)
845 def filtered_clean_records(self, records: BaseModel, field: Field) -> BaseModel:
846 """ Filtered ``records`` where ``field`` is not dirty. """
847 warnings.warn("Since 19.0, don't use Cache to manipulate dirty fields")
848 dirties = self.transaction.field_dirty.get(field, ())
849 return records.browse(id_ for id_ in records._ids if id_ not in dirties)
851 def has_dirty_fields(self, records: BaseModel, fields: Collection[Field] | None = None) -> bool:
852 """ Return whether any of the given records has dirty fields.
854 :param fields: a collection of fields or ``None``; the value ``None`` is
855 interpreted as any field on ``records``
856 """
857 warnings.warn("Since 19.0, don't use Cache to manipulate dirty fields")
858 if fields is None:
859 return any(
860 not ids.isdisjoint(records._ids)
861 for field, ids in self.transaction.field_dirty.items()
862 if field.model_name == records._name
863 )
864 else:
865 return any(
866 field in self.transaction.field_dirty and not self.transaction.field_dirty[field].isdisjoint(records._ids)
867 for field in fields
868 )
870 def clear_dirty_field(self, field: Field) -> Collection[IdType]:
871 """ Make the given field clean on all records, and return the ids of the
872 formerly dirty records for the field.
873 """
874 warnings.warn("Since 19.0, don't use Cache to manipulate dirty fields")
875 return self.transaction.field_dirty.pop(field, ())
877 def invalidate(self, spec: Collection[tuple[Field, Collection[IdType] | None]] | None = None) -> None:
878 """ Invalidate the cache, partially or totally depending on ``spec``.
880 If a field is context-dependent, invalidating it for a given record
881 actually invalidates all the values of that field on the record. In
882 other words, the field is invalidated for the record in all
883 environments.
885 This operation is unsafe by default, and must be used with care.
886 Indeed, invalidating a dirty field on a record may lead to an error,
887 because doing so drops the value to be written in database.
889 spec = [(field, ids), (field, None), ...]
890 """
891 if spec is None:
892 self.transaction.invalidate_field_data()
893 return
894 env = next(iter(self.transaction.envs))
895 for field, ids in spec:
896 field._invalidate_cache(env, ids)
898 def clear(self):
899 """ Invalidate the cache and its dirty flags. """
900 self.transaction.invalidate_field_data()
901 self.transaction.field_dirty.clear()
902 self.transaction.field_data_patches.clear()
904 def check(self, env: Environment) -> None:
905 """ Check the consistency of the cache for the given environment. """
906 depends_context = env.registry.field_depends_context
907 invalids = []
909 def process(model: BaseModel, field: Field, field_cache):
910 # ignore new records and records to flush
911 dirty_ids = self.transaction.field_dirty.get(field, ())
912 ids = [id_ for id_ in field_cache if id_ and id_ not in dirty_ids]
913 if not ids:
914 return
916 # select the column for the given ids
917 query = Query(env, model._table, model._table_sql)
918 sql_id = SQL.identifier(model._table, 'id')
919 sql_field = model._field_to_sql(model._table, field.name, query)
920 if field.type == 'binary' and (
921 model.env.context.get('bin_size') or model.env.context.get('bin_size_' + field.name)
922 ):
923 sql_field = SQL('pg_size_pretty(length(%s)::bigint)', sql_field)
924 query.add_where(SQL("%s IN %s", sql_id, tuple(ids)))
925 env.cr.execute(query.select(sql_id, sql_field))
927 # compare returned values with corresponding values in cache
928 for id_, value in env.cr.fetchall():
929 cached = field_cache[id_]
930 if value == cached or (not value and not cached):
931 continue
932 invalids.append((model.browse((id_,)), field, {'cached': cached, 'fetched': value}))
934 for field, field_cache in self.transaction.field_data.items():
935 # check column fields only
936 if not field.store or not field.column_type or field.translate or field.company_dependent:
937 continue
939 model = env[field.model_name]
940 if field in depends_context:
941 for context_keys, inner_cache in field_cache.items():
942 context = dict[str, typing.Any](zip(depends_context[field], context_keys))
943 if 'company' in context:
944 # the cache key 'company' actually comes from context
945 # key 'allowed_company_ids' (see property env.company
946 # and method env.cache_key())
947 context['allowed_company_ids'] = [context.pop('company')]
948 process(model.with_context(context), field, inner_cache)
949 else:
950 process(model, field, field_cache)
952 if invalids:
953 _logger.warning("Invalid cache: %s", pformat(invalids))
956class Starred:
957 """ Simple helper class to ``repr`` a value with a star suffix. """
958 __slots__ = ['value']
960 def __init__(self, value):
961 self.value = value
963 def __repr__(self):
964 return f"{self.value!r}*"