Skip to Content

EAV в Odoo: коли атрибути виправдані, а коли — зайве ускладнення

У будь-якій моделі Odoo нове поле додається однією декларацією fields.Char() і апдейтом модуля — ORM сам зробить ALTER TABLE, і поле одразу фільтрується, групується, бере участь у read_group(). Навіщо тоді городити EAV (entity-attribute-value) — окрему пару таблиць «атрибут»/«значення», де тип визначається рядком Selection, а не колонкою БД? Відповідь конкретна: коли перелік атрибутів визначає не розробник на релізі, а користувач у процесі роботи — без модуля, без апдейту. Розглянемо, як це зроблено у kw_eav і чотирьох модулях-надбудовах, і де саме ціна рішення проявляється.

Як влаштовано зберігання

Атрибут (kw.eav.attribute) — це визначення поля: назва, модель і тип.

class Attribute(models.Model):
    _name = 'kw.eav.attribute'

    name = fields.Char()
    model_id = fields.Many2one(
        comodel_name='ir.model', string='Model', required=True, index=True,
        ondelete='cascade', )
    type = fields.Selection(
        required=True, default='text', selection='_selection_type', )

Значення (kw.eav.value) не посилається на сутність через Many2one — прив'язка поліморфна, як у ir.attachment: рядок model і ціле res_id.

model = fields.Char(string='Model Name', required=True, )
res_id = fields.Integer(string='Record ID', required=True, )

Реального зовнішнього ключа тут немає: Postgres не пов'язує res_id=42 при model='res.partner' із живим контрагентом, і видалення контрагента kw.eav.value не чіпає — коду, який би це прибирав, немає в жодному з п'яти модулів.

Типізація

На кожному рядку kw.eav.value є дев'ять колонок одразу, і заповнена рівно одна — яка саме, вирішує attribute_id.type:

value_text = fields.Text()
value_char = fields.Char()
value_integer = fields.Integer()
value_float = fields.Float()
value_date = fields.Date()
value_datetime = fields.Datetime()
value_time = fields.Float()
value_choice_id = fields.Many2one(comodel_name='kw.eav.attribute.choice', )
value_tag_ids = fields.Many2many(comodel_name='kw.eav.attribute.choice', )

Типізація тут — питання Python, не БД: ніщо в схемі не забороняє записати одночасно value_integer і value_char в один рядок, крім дисципліни коду навколо.

Важливо! Змінити model_id чи type вже створеного атрибута не можна — обидва поля стають readonly="id != False" одразу після збереження. Це не обмеження БД, а UI-заслон: єдиний спосіб не отримати value_integer для атрибута, що вчора був char.

На кожен create()/write() модель перекладає typed-значення у звичайний Char name — текстову проекцію для пошуку:

elif attribute_id.type == 'choice':
    choice_id = self.env['kw.eav.attribute.choice'].browse(
        vals.get('value_choice_id'))
    if choice_id:
        name = choice_id.name
...
if not name:
    name = attribute_id.default_value

Перший компроміс базового модуля: коли значення порожнє, код звертається до attribute_id.default_value — поля, якого в kw_eav немає. Воно з'являється лише з окремим модулем kw_eav_attribute_default_value, не заявленим залежністю в manifest.

Чи можна за цим шукати

Це головне питання для будь-якого EAV: атрибут, за яким не можна відфільтрувати список, наполовину даремний. Кожна сутність з атрибутами отримує це через міксин:

class ValueMixin(models.AbstractModel):
    _name = 'kw.eav.entity.mixin'

    kw_eav_value_ids = fields.Many2many(
        comodel_name='kw.eav.value', compute='_compute_kw_eav_value_ids', )

    def _compute_kw_eav_value_ids(self):
        for obj in self:
            obj.kw_eav_value_ids = self.env['kw.eav.value'].search([
                ('model', '=', self._name), ('res_id', '=', obj.id)]).ids

Поле обчислюване, без store і без search=. Ну ок, тут і ховається пастка: наслідок — не виняток, а тиша. Парсер домену для non-stored поля без search=-методу не кидає помилку — він пише в лог _logger.error(...) і підставляє замість умови порожній лист, тобто просто відкидає її з домену. search() відпрацьовує без збоїв і повертає геть усіх партнерів, ніби умови по value_char у запиті не було взагалі.

Неправильно (виглядає природним способом, компілюється й виконується без жодної помилки — і саме тому небезпечний):

partners = self.env['res.partner'].search([
    ('kw_eav_value_ids.value_char', '=', "Дистриб'ютор")])

Правильно — єдиний робочий шлях лежить не через res.partner, а через власну таблицю значень і денормалізований name:

values = self.env['kw.eav.value'].search([
    ('model', '=', 'res.partner'), ('name', 'ilike', "Дистриб'ютор")])
partner_ids = values.mapped('res_id')

Важливо! Різниця тут не в тому, що перший приклад падає, а другий працює — не падає жоден. Виняток ви побачите одразу: тест почервоніє або в консолі одразу з'явиться traceback. Домен на kw_eav_value_ids, натомість, не падає ніколи — він мовчки повертає всіх партнерів, і зовні це виглядає як робочий фільтр із цілком правдоподібним лічильником. Помилку, яку видно одразу, полагодити легко. Помилку, яка тихо повертає невідфільтрований список, можна не помітити місяцями.

Пошукова search-view kw.eav.value це підтверджує — єдине поле name, ilike, без прив'язки до res.partner. Ще одна вада — вже продуктивність: _compute_kw_eav_value_ids це search() у циклі по кожному запису: непомітно для форми одного контрагента, але для списку в 10 000 — 10 000 окремих SQL-запитів, без індексу на парі (model, res_id).

kw_eav_category: навіщо групування

Сам атрибут не знає, кому він потрібен. kw_eav_category прив'язує набір атрибутів до категорії моделі:

class Category(models.Model):
    _name = 'kw.eav.category'

    model_id = fields.Many2one(comodel_name='ir.model', ...)
    attribute_ids = fields.Many2many(comodel_name='kw.eav.attribute', )

Атрибут із прапорцем is_available_wo_category=True доступний завжди, незалежно від категорій запису. Кнопка kw_eav_fill_up_attributes_by_category створює порожні kw.eav.value одразу на всі такі атрибути:

attr_ids = self.env['kw.eav.attribute'].search([
    ('model_id.model', '=', self._name), ('id', 'not in', ids),
    '|', ('id', 'in', cat_ids),
    ('is_available_wo_category', '=', True), ])

Важливо! Тут і проявляється компроміс з default_value: create() викликається без жодного value_* у vals, тож для шести типів із дев'яти — text/char/date/datetime/choice/tags — compute_name() завжди йде у гілку фолбека: без kw_eav_attribute_default_value кнопка «Fill attribute» ламається на першому ж такому атрибуті.

kw_eav_partner: реальне застосування

Уся Python-частина застосування EAV до контрагента — два рядки:

class Partner(models.Model):
    _name = 'res.partner'
    _inherit = ['kw.eav.entity.mixin', 'res.partner', ]

Решта — XML: вкладка «Attributes» у формі контрагента зі списком kw_eav_value_ids і кнопкою виклику візарда. Ні список, ні kanban, ні пошукова панель контрагентів модулем не чіпаються — атрибути видно тільки з картки одного контрагента, відкритої вручну.

Коли EAV не варто чіпати

EAV виправданий рівно для одного сценарію: коли перелік атрибутів визначає бізнес, а не розробник, і змінюється частіше, ніж команда випускає релізи. kw_eav_partner саме такий приклад — довільні характеристики контрагента, яких не було в ТЗ на старті і які завтра додасть адміністратор, не програміст.

Якщо атрибут відомий на етапі розробки, звичайне поле дешевше назавжди: воно фільтрується, групується і бере участь у звітах без жодного додаткового рядка коду. У kw.eav.value за конструкцією модуля відсутні три речі:

  • фільтрація за атрибутом з інтерфейсу — не працює взагалі, а не «незручно». Прямий домен на res.partner (див. вище) не фільтрує нічого, і в UI немає жодного filter- чи search-поля за значенням атрибута. Робочий шлях є, але лише з коду, через власну таблицю значень: kw.eav.value.search([('model', '='), ('name', 'ilike', ...)]) і .mapped('res_id') — підрядковий пошук по денормалізованому name, без типізованого порівняння;
  • типізація на рівні БД — Postgres не бачить різниці між value_integer, заповненим для integer-атрибута, і тим самим стовпцем, випадково заповненим для char-атрибута;
  • референційна цілісність — видалення контрагента не видаляє його kw.eav.value, сирітські рядки нічим не прибираються.

Є ще одна пастка, і вона не про архітектуру, а про порядок встановлення модулів: compute_name() звертається до attribute_id.default_value — поля, якого в базовому kw_eav немає. Залежність спрямована в інший бік: kw_eav_attribute_default_value залежить від kw_eav, а не навпаки, тож без нього виклик падає на звичайному AttributeError. Відтворюється навіть у самому kw_eav, без жодної надбудови — досить викликати kw.eav.value.wizard на атрибуті без введеного значення.

У коді KitWorks цей механізм застосований рівно там, де задумано: до res.partner, де записів сотні-тисячі й атрибут заповнений не на кожному. Я б не переносив той самий міксин на product.template чи sale.order.line без переробки фільтрації — там оборот на порядки більший, і кожен «непомітний» search() в циклі вже не дрібниця. Це і є чесна відповідь на питання «коли EAV брати не треба»: якщо користувачам знадобиться фільтрувати за атрибутом з інтерфейсу — не треба.

EAV в Odoo: коли атрибути виправдані, а коли — зайве ускладнення
KitWorks, Volodymyr Karabanov 20 серпня 2026 р.
Поділитися цією публікацією
Архів