Категории как данные: EAV-каталог для доски объявлений на FastAPI + SQLModel

от автора

Меня зовут Дмитрий Бабчук, я делаю О’Полку — доску объявлений: авто, недвижимость, электроника, одежда, услуги. Проект пишем небольшой командой, стек — FastAPI + SQLModel + PostgreSQL 16 на бэке и Next.js 14 (App Router) на фронте.

В какой-то момент любой каталог-агрегатор упирается в один и тот же вопрос: как хранить характеристики, если у автомобиля это «пробег, коробка, привод», у кроссовок — «размер, сезон», а у сплит-системы — «мощность, площадь охлаждения»? У каждой категории свой набор полей, их сотни, и они постоянно меняются. Ниже — как мы решили это через EAV, где это выстрелило, а где отдаёт болью. Будет код из боевого репозитория.

Почему не колонки

Первое, что приходит в голову — таблица listing с колонками под всё: mileage, shoe_size, power_kw… Это тупик:

  • Колонок станут сотни, 99% из них NULL для любой конкретной строки.

  • Каждая новая категория или атрибут — это ALTER TABLE и миграция на проде.

  • Продуктовая команда не может завести «Тип застёжки» у ботинок, не дёргая бэкендера.

Второй вариант — JSONB-колонка attributes с произвольным словарём. Уже лучше, гибко, но: нет справочника допустимых значений (каждый пишет размер как хочет — «42», «42.0», «42-й»), тяжело строить фасетные фильтры с типами и единицами измерения, и валидация целиком на приложении.

Мы выбрали классический EAV (Entity–Attribute–Value) — но с важной оговоркой, к которой вернусь: сами категории и их поля у нас тоже данные, а не схема.

Три таблицы

Вся модель каталога — это Category, Attribute и ListingAttributeValue. На SQLModel (это тонкая обёртка над SQLAlchemy + Pydantic):

class AttrType(str, Enum):    string = "string"    number = "number"    enum = "enum"    multi_enum = "multi_enum"    boolean = "boolean"class Category(SQLModel, table=True):    id: Optional[int] = Field(default=None, primary_key=True)    parent_id: Optional[int] = Field(default=None, foreign_key="category.id", index=True)    name: str    slug: str = Field(index=True)    sort: int = 0class Attribute(SQLModel, table=True):    id: Optional[int] = Field(default=None, primary_key=True)    category_id: int = Field(foreign_key="category.id", index=True)    code: str                 # машинное имя: "mileage", "shoe_size"    name: str                 # человеческое: "Пробег", "Размер обуви"    type: AttrType = Field(default=AttrType.string)    required: bool = False    options: Optional[list] = Field(default=None, sa_column=Column(JSON))  # для enum    group: Optional[str] = None   # секция формы: "Технические характеристики"    unit: Optional[str] = None    # единица: "км", "л.с."    sort: int = 0class ListingAttributeValue(SQLModel, table=True):    id: Optional[int] = Field(default=None, primary_key=True)    listing_id: int = Field(foreign_key="listing.id", index=True)    attribute_id: int = Field(foreign_key="attribute.id", index=True)    value: str                # ВСЁ хранится строкой — к этому ещё вернёмся
  • Category — дерево через parent_id (self-FK): «Электроника → Телефоны → Мобильные телефоны».

  • Attribute — описание поля: тип, обязательность, для enum — список options, плюс group (в какую секцию формы) и unit (единица измерения). Это и есть метаданные, из которых фронт сам рисует форму подачи и панель фильтров.

  • ListingAttributeValue — собственно значения: по строке на каждый заполненный атрибут объявления. Значение всегда строка — эта деталь потом выйдет и плюсом, и минусом.

Обратите внимание: Attribute.type — не для БД, а для UI и валидации. БД про типы ничего не знает, value для неё всегда text.

Категории — это данные, а не схема

Ключевой инвариант проекта, который мы прямо прописали в правилах репозитория:

Новая категория = записи в seed.py, а не колонки в БД. Новые колонки под категории не добавлять.

Весь каталог описан в идемпотентном сид-скрипте. Пакет атрибутов для категории выглядит как список кортежей, а функция seed_attr_pack апсертит их при старте — существующие обновляет, новые создаёт:

def seed_attr_pack(session, category_id, attrs):    for i, (code, name, atype, required, options, group, unit) in enumerate(attrs):        a = session.exec(            select(Attribute).where(                Attribute.category_id == category_id,                Attribute.code == code,            )        ).first()        if a:                                   # уже есть — обновляем            a.name, a.type, a.required = name, atype, required            a.options, a.group, a.unit, a.sort = options, group, unit, i        else:                                   # нет — создаём            a = Attribute(category_id=category_id, code=code, name=name,                          type=atype, required=required, options=options,                          group=group, unit=unit, sort=i)        session.add(a)    session.commit()

Практический бонус: чтобы, скажем, обновить список марок телефонов до актуального, я меняю один список опций в seed.py, а при рестарте api сид сам перезаписывает options у существующего атрибута — без миграции и без ручного SQL на проде. Схема БД при этом неизменна: миграции (Alembic) мы гоняем только когда реально меняется таблица, а не когда добавляется категория.

Наследование атрибутов

Атрибуты висят на разных уровнях дерева. «Состояние» логично задать один раз на «Электронике», а «Пробег» — только на «Автомобилях». Чтобы у объявления собрать полный набор полей, нужна вся цепочка от категории до корня:

def _category_chain(session, category_id):    """Категория и все её родители — вверх по дереву до корня."""    chain, cur = [], category_id    while cur:        chain.append(cur)        cur = session.get(Category, cur).parent_id    return chain

Форма подачи запрашивает атрибуты по всей цепочке, группирует по Attribute.group и рисует нужный виджет по Attribute.type: enum → выпадашка из options, boolean → чекбокс, number → поле с unit сбоку, и так далее. Ни строчки хардкода под конкретную категорию — форма целиком data-driven.

Фасетные фильтры: где EAV окупается

Самое приятное в EAV — фильтры. Пользователь на странице категории «Женская обувь» тыкает «размер 38», «сезон: демисезон», «цена до 5000» — и всё это ложится в один построитель запроса. Точечный фильтр по значению — это подзапрос IN:

# attr=code:value, повторяемый: ?attr=shoe_size:38&attr=season:Демисезонgroups: dict[str, list[str]] = {}for item in attr:    code, val = item.split(":", 1)    groups.setdefault(code, []).append(val)for code, vals in groups.items():    ids = attr_ids(code)          # id атрибута по коду в ветке категории    stmt = stmt.where(Listing.id.in_(        select(ListingAttributeValue.listing_id).where(            ListingAttributeValue.attribute_id.in_(ids),            ListingAttributeValue.value.in_(vals),        )    ))

Два нюанса, которые важно не проглядеть.

Резолв кода в ветке, а не глобально. Код size есть и у одежды, и у обуви, и это разные атрибуты с разными id. Поэтому код резолвится в области видимости конкретной категории — объединении цепочки родителей и всего поддерева:

scope = set(_category_chain(session, category_id)) | set(_subtree_ids(session, category_id))def attr_ids(code):    return [a.id for a in session.exec(        select(Attribute).where(            Attribute.code == code,            Attribute.category_id.in_(scope),        )    ).all()]

Числовые диапазоны поверх строк. Раз value — строка (там и «54», и «2.7», и «90 000 км»), для диапазона «год от…до» приходится на лету чистить строку до цифр и точки и кастовать в NUMERIC. И обязательно защищаться от мусора вроде «1.2.3», иначе кривое значение уронит весь запрос — поэтому CASE: некастуемое становится NULL, а не исключением:

cleaned = func.regexp_replace(ListingAttributeValue.value, r"[^0-9.]", "", "g")numexpr = case(    (cleaned.op("~")(r"^\d+(\.\d+)?$"), cast(cleaned, Numeric)),    else_=None,)sub = select(ListingAttributeValue.listing_id).where(    ListingAttributeValue.attribute_id.in_(ids))if lo is not None:    sub = sub.where(numexpr >= lo)if hi is not None:    sub = sub.where(numexpr <= hi)stmt = stmt.where(Listing.id.in_(sub))

Некрасиво? Да. Но это цена за схему, в которой БД не знает типов. И один и тот же билдер обслуживает и ленту каталога, и счётчик результатов, и точки на карте.

Побочный суперспособность: «много значений у одного атрибута»

Недавно мы добавляли варианты товара — когда бизнес продаёт кроссовки в размерах 36–41 одной карточкой (как размерная сетка на маркетплейсах). Возник вопрос: как сделать так, чтобы такая карточка находилась по фильтру «размер 38»?

И тут выяснилось, что ничего доделывать в фильтре не надо. Схема ListingAttributeValue — это строка-на-значение, и ничто не мешает объявлению иметь несколько строк одного атрибута. Мы просто пишем каждый размер-вариант отдельной строкой в атрибут shoe_size:

def _index_variant_sizes(session, listing, variants, written):    attr = _size_attr(session, listing.category_id)  # shoe_size / size    if not attr:        return    for v in variants:        pair = (attr.id, v["label"])        if pair in written:      # без дублей            continue        written.add(pair)        session.add(ListingAttributeValue(            listing_id=listing.id, attribute_id=attr.id, value=v["label"],        ))

Фильтр из прошлой главы — attribute_id IN (...) AND value IN ('38') — матчит объявление, если у него есть хоть одна подходящая строка. Multi-value заработал бесплатно, ровно потому что модель изначально «одна строка = одно значение». Приятный случай, когда старое архитектурное решение окупается спустя полгода.

Где EAV честно болит

Чтобы статья не выглядела рекламой EAV, вот цена, которую мы платим:

  • Всё — строка. Отсюда канонизация чисел при сохранении («03» → «3», «3.0» → «3», «2,5» → «2.5»), иначе точечные фильтры-чекбоксы не совпадут. Отсюда же регэкспы и CAST в диапазонах. Типобезопасности на уровне БД нет — вся ответственность на приложении.

  • Сортировка enum-опций. В БД options лежат в порядке вставки. Ходовые размеры хочется показать первыми, а в развёрнутом списке — по возрастанию, включая половинки (37.5, 38.5). Эту сортировку делает фронт, потому что «41 и больше» строкой не отсортируешь.

  • JOIN-ы вместо колонок. Каждый фильтр — это подзапрос по ListingAttributeValue. На наших объёмах индексов по (attribute_id, value) и listing_id хватает с запасом, но на «Авито-масштабе» здесь пришлось бы думать про денормализацию или вынос фасетов в поисковый движок (у нас для полнотекста уже стоит OpenSearch, и фасеты — очевидный следующий кандидат туда).

  • Нельзя выразить «значение зависит от другого поля» на уровне схемы — только соглашениями в коде.

Когда это оправдано

EAV — не серебряная пуля, а осознанный размен: гибкость каталога в обмен на сложность запросов и потерю типов в БД. Для доски объявлений, где категорий десятки, поля у них радикально разные и меняются еженедельно силами продукта, а не бэкенда, — размен выгодный. Для сервиса с 3–5 стабильными типами сущностей я бы взял обычные колонки и не выдумывал.

Главный урок для нас оказался даже не про EAV как таковой, а про принцип «категории и их поля — это данные, а не схема». Именно он позволил обновлять каталог сид-скриптом без миграций и получать вот такие бесплатные бонусы вроде multi-value фильтра.

Если интересно посмотреть, как это выглядит живьём — opolka.ru. Вопросы и «а вот у нас было иначе» — очень welcome в комментариях, расскажу подробнее про любой кусок.

Дмитрий Бабчук, О’Полка

ссылка на оригинал статьи https://habr.com/ru/articles/1062104/