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

1# Part of Odoo. See LICENSE file for full copyright and licensing details. 

2 

3"""The Odoo API module defines Odoo Environments. 

4""" 

5from __future__ import annotations 

6 

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 

17 

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 

23 

24from .registry import Registry 

25from .utils import SUPERUSER_ID 

26 

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 

32 

33 M = typing.TypeVar('M', bound=BaseModel) 

34 

35_logger = logging.getLogger('odoo.api') 

36 

37MAX_FIXPOINT_ITERATIONS = 10 

38 

39 

40class Environment(Mapping[str, "BaseModel"]): 

41 """ The environment stores various contextual data used by the ORM: 

42 

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. 

47 

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 """ 

52 

53 cr: BaseCursor 

54 uid: int 

55 context: frozendict 

56 su: bool 

57 transaction: Transaction 

58 

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() 

63 

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 

68 

69 # determine transaction object 

70 transaction = cr.transaction 

71 if transaction is None: 

72 transaction = cr.transaction = Transaction(Registry(cr.dbname)) 

73 

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 

78 

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 

84 

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 

90 

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) 

96 

97 # 

98 # Mapping methods 

99 # 

100 

101 def __contains__(self, model_name) -> bool: 

102 """ Test whether the given model exists. """ 

103 return model_name in self.registry 

104 

105 def __getitem__(self, model_name: str) -> BaseModel: 

106 """ Return an empty recordset from the given model. """ 

107 return self.registry[model_name](self, (), ()) 

108 

109 def __iter__(self): 

110 """ Return an iterator on model names. """ 

111 return iter(self.registry) 

112 

113 def __len__(self): 

114 """ Return the size of the model registry. """ 

115 return len(self.registry) 

116 

117 def __eq__(self, other): 

118 return self is other 

119 

120 def __ne__(self, other): 

121 return self is not other 

122 

123 def __hash__(self): 

124 return object.__hash__(self) 

125 

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. 

134 

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) 

149 

150 @typing.overload 

151 def ref(self, xml_id: str, raise_if_not_found: typing.Literal[True] = True) -> BaseModel: 

152 ... 

153 

154 @typing.overload 

155 def ref(self, xml_id: str, raise_if_not_found: typing.Literal[False]) -> BaseModel | None: 

156 ... 

157 

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``. 

160 

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 ) 

169 

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 

177 

178 def is_superuser(self) -> bool: 

179 """ Return whether the environment is in superuser mode. """ 

180 return self.su 

181 

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() 

186 

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() 

191 

192 @functools.cached_property 

193 def registry(self) -> Registry: 

194 """Return the registry associated with the transaction.""" 

195 return self.transaction.registry 

196 

197 @functools.cached_property 

198 def _protected(self): 

199 """Return the protected map of the transaction.""" 

200 return self.transaction.protected 

201 

202 @functools.cached_property 

203 def cache(self): 

204 """Return the cache object of the transaction.""" 

205 return self.transaction.cache 

206 

207 @functools.cached_property 

208 def user(self) -> BaseModel: 

209 """Return the current user (as an instance). 

210 

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) 

214 

215 @functools.cached_property 

216 def company(self) -> BaseModel: 

217 """Return the current company (as an instance). 

218 

219 If not specified in the context (`allowed_company_ids`), 

220 fallback on current user main company. 

221 

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>` 

225 

226 .. warning:: 

227 

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. 

231 

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) 

244 

245 @functools.cached_property 

246 def companies(self) -> BaseModel: 

247 """Return a recordset of the enabled companies by the user. 

248 

249 If not specified in the context(`allowed_company_ids`), 

250 fallback on current user companies. 

251 

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>` 

255 

256 .. warning:: 

257 

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. 

261 

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) 

284 

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 

295 

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 

304 

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 

314 

315 def _(self, source: str | LazyGettext, *args, **kwargs) -> str: 

316 """Translate the term using current environment's language. 

317 

318 Usage: 

319 

320 ``` 

321 self.env._("hello world") # dynamically get module name 

322 self.env._("hello %s", "test") 

323 self.env._(LAZY_TRANSLATION) 

324 ``` 

325 

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 

349 

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() 

356 

357 def invalidate_all(self, flush: bool = True) -> None: 

358 """ Invalidate the cache of all records. 

359 

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() 

367 

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!") 

379 

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!") 

391 

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, ()) 

397 

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, ())) 

401 

402 @typing.overload 

403 def protecting(self, what: Collection[Field], records: BaseModel) -> typing.ContextManager[None]: 

404 ... 

405 

406 @typing.overload 

407 def protecting(self, what: Collection[tuple[Collection[Field], BaseModel]]) -> typing.ContextManager[None]: 

408 ... 

409 

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: 

414 

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) 

427 

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() 

434 

435 def fields_to_compute(self) -> Collection[Field]: 

436 """ Return a view on the field to compute. """ 

437 return self.transaction.tocompute.keys() 

438 

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) 

443 

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, ()) 

447 

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) 

452 

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) 

459 

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] 

470 

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 

498 

499 return tuple(get(key) for key in self.registry.field_depends_context[field]) 

500 

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 {} 

505 

506 @functools.cached_property 

507 def _field_dirty(self): 

508 """ Map fields to set of dirty ids. """ 

509 return self.transaction.field_dirty 

510 

511 @functools.cached_property 

512 def _field_depends_context(self): 

513 return self.registry.field_depends_context 

514 

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 

520 

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) 

526 

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() 

536 

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 ] 

550 

551 

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 ) 

559 

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 

567 

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) 

585 

586 # temporary directories (managed in odoo.tools.file_open_temporary_directory) 

587 self.__file_open_tmp_paths = [] # type: ignore # noqa: PLE0237 

588 

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 

599 

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 

609 

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() 

619 

620 def invalidate_field_data(self) -> None: 

621 """ Invalidate the cache of all the fields. 

622 

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 

632 

633 

634# sentinel value for optional parameters 

635EMPTY_DICT = frozendict() # type: ignore 

636 

637 

638class Cache: 

639 """ Implementation of the cache of records. 

640 

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. 

648 

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',) 

659 

660 def __init__(self, transaction: Transaction): 

661 self.transaction = transaction 

662 

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) 

682 

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) 

686 

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) 

690 

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) 

694 

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 

704 

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 

714 

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. 

720 

721 :param dirty: whether ``field`` must be made dirty on ``record`` after 

722 the update 

723 """ 

724 field._update_cache(record, value, dirty=dirty) 

725 

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. 

731 

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) 

737 

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) 

746 

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) 

754 

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) 

766 

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) 

775 

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 

784 

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 

793 

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 

805 

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) 

810 

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 

816 

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) 

829 

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) 

833 

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() 

838 

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) 

844 

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) 

850 

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. 

853 

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 ) 

869 

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, ()) 

876 

877 def invalidate(self, spec: Collection[tuple[Field, Collection[IdType] | None]] | None = None) -> None: 

878 """ Invalidate the cache, partially or totally depending on ``spec``. 

879 

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. 

884 

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. 

888 

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) 

897 

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() 

903 

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 = [] 

908 

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 

915 

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)) 

926 

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})) 

933 

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 

938 

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) 

951 

952 if invalids: 

953 _logger.warning("Invalid cache: %s", pformat(invalids)) 

954 

955 

956class Starred: 

957 """ Simple helper class to ``repr`` a value with a star suffix. """ 

958 __slots__ = ['value'] 

959 

960 def __init__(self, value): 

961 self.value = value 

962 

963 def __repr__(self): 

964 return f"{self.value!r}*"