У будь-якій моделі 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 брати не треба»: якщо користувачам знадобиться фільтрувати за атрибутом
з інтерфейсу — не треба.