almdev Технический блог

Пользовательские поля без миграций
EAV с плохой репутацией, который всё-таки пришлось написать — и как он не развалился.

Кастомные поля анкеты, которые заводит заказчик без релиза: две сущности, типизированное хранилище значений, валидация данными и частичные индексы для фильтрации.

Подкатегория: Данные

Чтение
6 мин
Технологии / версии
Doctrine · PostgreSQL · EAV · Формы
19 мая 2026 · 6 мин · Backend
Манипулятор добавляет типизированные модули в конструктор пользовательских полей
Данные 05/2026

Заказчик хочет добавить в анкету кандидата поле «Уровень английского» с шестью вариантами. Правильный ответ — миграция, колонка, релиз. Ответ, который его устраивает, — сделать это самому во вторник вечером.

Я долго сопротивлялся. Потом посчитал: за полгода таких просьб было девятнадцать. Девятнадцать миграций ради полей, три из которых потом удалили.

Что нужно было получить

Требования сформулировались быстро:

  • четырнадцать типов поля: текст, многострочный текст, почта, телефон, число, дата, дата со временем, флажок, список, переключатели, множественный выбор, файл, ссылка, время;
  • свои правила проверки на каждое поле;
  • группировка по разделам и произвольный порядок;
  • фильтрация и поиск по этим полям в списке кандидатов;
  • всё это через интерфейс, без участия разработчика.

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

Две сущности вместо одной таблицы

Определение поля и его значение — разные вещи с разной частотой изменений. Ниже фрагмент сущности без идентификатора и служебных методов:

#[ORM\Entity]
#[ORM\Table(name: 'form_field')]
class FormField
{
    #[ORM\Column(length: 64, unique: true)]
    private string $code;

    #[ORM\Column(enumType: FieldType::class)]
    private FieldType $type;

    #[ORM\Column(length: 255)]
    private string $label;

    #[ORM\Column(type: 'json')]
    private array $validationRules = [];

    #[ORM\Column(type: 'json', nullable: true)]
    private ?array $options = null;

    #[ORM\Column(length: 120, nullable: true)]
    private ?string $section = null;

    #[ORM\Column(type: 'smallint')]
    private int $sortOrder = 0;
}

Определений — десятки. Они меняются редко, читаются на каждый показ формы и прекрасно кешируются целиком.

Значений — сотни тысяч. Они меняются постоянно и читаются выборочно.

Как хранить значение

Здесь ключевое решение всего механизма. Классический EAV кладёт всё в одну текстовую колонку, и дальше начинается ад: дата сравнивается как строка, число сортируется лексикографически, '10' < '9'.

Я развёл значения по типизированным колонкам. Это фрагмент сущности без идентификатора и методов доступа:

#[ORM\Entity]
#[ORM\Table(name: 'form_field_value')]
#[ORM\UniqueConstraint(columns: ['profile_id', 'field_id'])]
class FormFieldValue
{
    #[ORM\ManyToOne] private CandidateProfile $profile;
    #[ORM\ManyToOne] private FormField $field;

    #[ORM\Column(type: 'text', nullable: true)]     private ?string $stringValue = null;
    #[ORM\Column(type: 'decimal', precision: 18, scale: 6, nullable: true)] private ?string $decimalValue = null;
    #[ORM\Column(type: 'date_immutable', nullable: true)] private ?\DateTimeImmutable $dateValue = null;
    #[ORM\Column(type: 'datetime_immutable', nullable: true)] private ?\DateTimeImmutable $dateTimeValue = null;
    #[ORM\Column(type: 'time_immutable', nullable: true)] private ?\DateTimeImmutable $timeValue = null;
    #[ORM\Column(type: 'boolean', nullable: true)]  private ?bool $boolValue = null;
    #[ORM\Column(type: 'json', nullable: true)]     private ?array $jsonValue = null;
}

Семь колонок, из которых заполнена ровно одна. Выглядит расточительно, но в PostgreSQL при наличии хотя бы одного NULL строка хранит общую bitmap — по одному биту на колонку, с округлением и выравниванием. Это небольшая, но не нулевая цена. Зато дата, дата со временем и время не смешаны, а числа сортируются как числа.

Какая колонка соответствует какому типу, знает сам энум типа:

enum FieldType: string
{
    case Text = 'text';
    case Textarea = 'textarea';
    case Email = 'email';
    case Phone = 'phone';
    case Number = 'number';
    case Date = 'date';
    case DateTime = 'date_time';
    case Checkbox = 'checkbox';
    case Select = 'select';
    case Radio = 'radio';
    case MultiSelect = 'multi_select';
    case File = 'file';
    case Url = 'url';
    case Time = 'time';

    public function column(): string
    {
        return match ($this) {
            self::Text, self::Textarea, self::Email, self::Phone,
            self::Url, self::Select, self::Radio, self::File => 'stringValue',
            self::Number => 'decimalValue',
            self::Date => 'dateValue',
            self::DateTime => 'dateTimeValue',
            self::Time => 'timeValue',
            self::Checkbox => 'boolValue',
            self::MultiSelect => 'jsonValue',
        };
    }
}

Один метод, к которому обращаются и запись, и чтение, и фильтр. Пока он один — механизм держится.

Валидация, которая живёт данными

Правила хранятся в JSON рядом с определением поля и превращаются в ограничения на лету:

final class FieldConstraintFactory
{
    /** @return list<Constraint> */
    public function build(FormField $field): array
    {
        $constraints = [];

        if ($field->isRequired()) {
            $constraints[] = new Assert\NotBlank();
        }

        foreach ($field->getValidationRules() as $rule => $value) {
            $constraints[] = match ($rule) {
                'min_length' => new Assert\Length(min: $value),
                'max_length' => new Assert\Length(max: $value),
                'min' => new Assert\GreaterThanOrEqual($value),
                'max' => new Assert\LessThanOrEqual($value),
                default => throw new \DomainException("Неизвестное правило: {$rule}"),
            };
        }

        return $constraints;
    }
}

Важное решение — белый список правил без произвольных регулярных выражений. Пользовательский шаблон, попадающий прямо в движок регулярок, может устроить отказ в обслуживании одной строкой. Если понадобится сложная проверка, в данных будет храниться не regex, а имя заранее проверенного правила из кода.

N+1, который случился обязательно

Первая версия списка кандидатов работала 11 секунд на двухстах записях. Каждый профиль тянул свои значения отдельным запросом, каждое значение тянуло определение поля.

Лечится одной выборкой:

public function loadValuesFor(array $profileIds): array
{
    $rows = $this->createQueryBuilder('v')
        ->select('IDENTITY(v.profile) AS profile_id', 'f.code', 'f.type',
                 'v.stringValue', 'v.decimalValue', 'v.dateValue', 'v.dateTimeValue',
                 'v.timeValue', 'v.boolValue', 'v.jsonValue')
        ->join('v.field', 'f')
        ->where('IDENTITY(v.profile) IN (:ids)')->setParameter('ids', $profileIds)
        ->getQuery()
        ->getArrayResult();

    $byProfile = [];
    foreach ($rows as $row) {
        $byProfile[$row['profile_id']][$row['code']] = $row;
    }

    return $byProfile;
}

Два принципиальных момента. Выборка идёт массивом, а не объектами: гидрировать сорок тысяч сущностей FormFieldValue ради показа таблицы — чистая трата памяти. И IDENTITY() вместо джойна на профиль — не нужен второй раз тот же объект.

После этого страница стала открываться за 180 миллисекунд.

Фильтрация с частичным индексом

Запрос «показать кандидатов с уровнем английского не ниже B2» превращается в подзапрос:

SELECT p.* FROM candidate_profile p
WHERE EXISTS (
    SELECT 1 FROM form_field_value v
    JOIN form_field f ON f.id = v.field_id
    WHERE v.profile_id = p.id
      AND f.code = 'english_level'
      AND v.string_value IN ('b2', 'c1', 'c2')
)

Чтобы это не читало всю таблицу значений, нужен составной индекс, и порядок колонок в нём важен:

CREATE INDEX idx_ffv_field_string ON form_field_value (field_id, string_value)
    WHERE string_value IS NOT NULL;

Частичный индекс меньше полного: строковое значение заполнено от силы у трети записей. PostgreSQL умеет хранить и искать NULL в B-tree, но этому запросу такие записи не нужны. Такой же индекс есть на числовую и на дату. На JSON-значения индекса нет вовсе: множественный выбор фильтруется редко, и я решил не платить за это записью.

Нужность такого индекса надо проверять на данных проекта через EXPLAIN (ANALYZE, BUFFERS). Само наличие индекса ещё не гарантирует, что планировщик выберет его на конкретном распределении значений.

Где механизм заканчивается

Границу я формулирую так: пользовательское поле — это то, по чему не строится бизнес-логика.

Хранить в кастомном поле хобби кандидата — нормально. Хранить статус, от которого зависит переход по этапам найма, — нет, это колонка и миграция. Как только код начинает читать значение по коду поля и что-то на его основании решать, механизм превращается в способ обойти схему данных, и всё преимущество исчезает.

Второй признак: если полем пользуются все клиенты и оно есть в каждой анкете — оно заслужило колонку. Дважды я такие поля переносил, оба раза с миграцией данных из универсального хранилища в нормальную колонку. Занимает вечер, читается потом гораздо лучше.

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

Итог

Девятнадцать просьб за полгода превратились в ноль обращений ко мне. За это я заплатил примерно неделей работы, семью колонками там, где хватило бы одной, и постоянной необходимостью помнить про индексы.

Плохая репутация EAV заслужена, но заслужена конкретной реализацией — одна текстовая колонка на всё. Типизированные колонки и частичные индексы снимают большую часть претензий. Остаётся главное ограничение: это хранилище для данных, а не для логики.

#doctrine #postgresql #eav #formy