Skip to Content

ORM Odoo 19: де create(), write() і search() працюють не так, як здається

Каталогів «метод 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.

ORM Odoo 19: де create(), write() і search() працюють не так, як здається
KitWorks, Volodymyr Karabanov 1 серпня 2026 р.
Поділитися цією публікацією
Теги
Архів