Каталогів «метод ORM — опис» у мережі вистачає. Розглянемо натомість конкретні місця
в odoo/orm/models.py і odoo/orm/fields.py, де поведінка розходиться з тим, що
очікує розробник, який пише цикл for rec in recs: rec.write(...) за звичкою з іншого
фреймворка.
create() — це завжди пакетна операція
Базовий create() в BaseModel задекорований @api.model_create_multi. Декоратор
не косметичний — він фізично перетворює одиночний словник на список з одним елементом
ще до того, як виклик дійде до тіла методу:
# odoo/orm/decorators.py
def model_create_multi(method):
@wraps(method)
def create(self, vals_list):
if isinstance(vals_list, Mapping):
vals_list = [vals_list]
return method(self, vals_list)
create._api_model = True
return create
Тобто self.env['res.partner'].create({'name': 'A'}) і
self.env['res.partner'].create([{'name': 'A'}]) — це буквально один і той самий шлях
виконання з vals_list довжиною 1. Окремого «одиночного» create в ORM просто немає.
Неправильно:
for vals in vals_list:
self.env['res.partner'].create(vals)
Правильно:
self.env['res.partner'].create(vals_list)
Різниця не в тому, що перший варіант «повільніший ORM-виклик» — обидва йдуть через
однакову логіку. Різниця в тому, що цикл N разів проганяє перевірку прав, _inherits-логіку
і обчислення computed-полів окремо для кожного запису, замість одного проходу по всьому
списку.
write() не йде в базу в момент виклику
Ось де більшість переказів помиляється. write() не виконує UPDATE синхронно.
Дивимось у odoo/orm/fields.py, метод Field.write():
def write(self, records, value):
records.env.remove_to_compute(self, records)
cache_value = self.convert_to_cache(value, records)
records = self._filter_not_equal(records, cache_value)
if not records:
return
self._update_cache(records, cache_value, dirty=True)
Жодного SQL. write() лише позначає поле «брудним» у кеші транзакції. Реальний
UPDATE відбувається пізніше, у _flush(), і групування там іде не за одним полем,
а за набором одночасно брудних полів: _filter_not_equal() фільтрує кожне поле
окремо, тож у межах одного виклику recs.write({'a': X, 'b': Y}) різні записи можуть
лишитися «брудними» по різних комбінаціях полів — і кожна унікальна комбінація йде
в окремий UPDATE ... FROM (VALUES ...), партіями по UPDATE_BATCH_SIZE = 100:
self.env.execute_query(SQL(
""" UPDATE %(table)s
SET %(assignments)s
FROM (VALUES %(values)s) AS "__tmp"("id", %(columns)s)
WHERE %(table)s."id" = "__tmp"."id"
""", ...
))
Практичний наслідок для масового запису: кількість підсумкових UPDATE залежить не
від того, скільки полів у словнику vals, а від того, наскільки однорідні зміни по
записах — чим більша частка записів реально змінює однаковий набір полів, тим менше
окремих UPDATE-запитів піде у флаші.
Важливо! Це означає, що класичний аргумент «цикл write() дає N окремих UPDATE» вже
не зовсім точний — flush однаково збере брудні id в пакетний UPDATE, незалежно від
того, чи вони прийшли з одного виклику recs.write(vals), чи з N окремих
rec.write(vals) у циклі. А от що дійсно повторюється N разів у циклі — це
check_access('write') і _validate_fields() (тобто ваші @api.constrains): вони
виконуються синхронно всередині кожного виклику write(), до будь-якого flush.
Тому цикл write() шкідливий не через SQL, а через N повторів валідації й перевірки
прав замість одного.
Коли потрібно змусити ORM записати брудні поля негайно — flush_model() /
flush_recordset(). Але execute_query() й сам автоматично викликає flush_query()
перед будь-яким SQL, що торкається «брудних» полів. Я б не привчав себе кликати
flush_model() про всяк випадок у звичайному бізнес-коді — це майже завжди сигнал,
що десь поруч виконується сирий SQL (self.env.cr.execute(...)) в обхід ORM, і саме
там ручний flush дійсно потрібен.
search() тягне лише id, не поля
search() — тонка обгортка над search_fetch() з порожнім списком полів:
def search(self, domain, offset=0, limit=None, order=None):
return self.search_fetch(domain, [], offset=offset, limit=limit, order=order)
Порожній field_names=[] означає — жодного поля, крім id, у кеш не потрапляє.
Перше звернення до partner.name після search() викликає _fetch_field(), яка
підвантажує не одне поле для одного запису, а всю групу полів з тим самим
prefetch, і одразу для всього набору prefetch_ids (типово — весь результат
search, до PREFETCH_MAX). Тобто for p in partners: print(p.name) — це один SQL
для всіх імен одразу, а не N окремих. Але якщо в циклі звертатись до різних полів
по черзі без спільної групи prefetch — можна легко отримати той самий N+1, просто
замаскований під «ORM же кешує».
browse() на неіснуючому id не падає — падає пізніше
Ну ок, ось типова помилка з перевіркою існування запису.
def browse(self, ids=()):
...
return self.__class__(self.env, ids, ids)
Жодного звернення до бази. self.env['res.partner'].browse(999999999) завжди
поверне об'єкт recordset — навіть якщо такого id ніколи не існувало. Помилка
підіймається не тут, а в момент, коли ви фактично читаєте значення поля:
# odoo/orm/fields.py, дескриптор поля
value = field_cache.get(record_id, SENTINEL)
if value is SENTINEL:
raise MissingError(
"Record does not exist or has been deleted."
)
Неправильно (перевірка на «є запис чи ні» через try/except навколо browse()):
try:
self.env['res.partner'].browse(pid)
except MissingError:
...
Виключення тут ніколи не спрацює — browse() не звертається до бази.
Правильно:
if not self.env['res.partner'].browse(pid).exists():
...
exists() — це один явний SQL-запит SELECT id ... WHERE id IN (...), який і
перевіряє реальну наявність запису.
sudo(), with_context(), with_company() — не перечитують дані
Усі три повертають новий recordset у новому Environment, а не копію даних:
def sudo(self, flag=True):
if flag == self.env.su:
return self
return self.with_env(self.env(su=flag))
Environment не носить власного кешу — кеш живе на рівні Transaction, спільної
для всіх Environment в межах одного курсора. Тому records.sudo().name не робить
повторний SQL, якщо name вже було прочитане під звичайним користувачем: змінюється
лише пара (uid, context, su), з якою виконуються наступні перевірки доступу, а не
джерело даних. Повторний запит станеться тільки для полів, позначених
@api.depends_context, — вони кешуються окремо під кожен контекст.
Окремо варто знати: метод invalidate_cache() зі старих туторіалів (Odoo 12 і
раніше) в 19-й версії відсутній фізично — його місце зайняли invalidate_model()
і invalidate_recordset().
Тема за матеріалом https://www.cybrosys.com/blog/what-are-the-orm-methods-in-odoo-19. Код і поведінку перевірено на Odoo 19.0.