Типизированные свойства
Назначение
Типизированные свойства позволяют пользователям или администраторам определять новые атрибуты без изменения схемы: таблица определений хранит имена, типы данных и ограничения, а таблица значений ссылается и на сущность, и на определение. Это спроектированная форма «сущность — атрибут — значение»: значения хранятся в столбцах нужных типов, обязательность и диапазоны проверяются, определения развиваются. Глава показывает построение модели, запросы сущностей одновременно по нескольким свойствам и ситуации, где столбец JSON дешевле решает ту же задачу.
Также известен как
Typed dynamic properties; определённые динамические свойства с типами значений. Dealing with Properties Фаулера различает свойства со строковыми именами, определённые свойства, типизированные свойства и правила, определяющие допустимые свойства типов объектов. Схема ниже — разработанная реализация идей, а не SQL, воспроизведённый из статьи.
Проблема
Администраторы каталога определяют атрибуты после развёртывания. Профилю доставки нужен weight_kg с числовым диапазоном; color — необязательный текст. Товары должны редактироваться незавершёнными, но активный товар обязан иметь все обязательные значения. Следующий профиль может сузить диапазон веса без молчаливого изменения контракта товаров старого профиля.
Таблица (product_id, name, text_value) упрощает добавление поля, но не связывает имя с объявленным типом или обязательностью. NOT NULL у text_value запрещает null внутри существующей строки, но не требует существования отсутствующей строки веса. Это разные ограничения с разными механизмами обеспечения.
Решение
Моделируйте версию профиля, определения её свойств, товары с этим профилем и типизированные значения. Публикуйте полный профиль до его использования товарами, затем замораживайте определения. Развивайте определения созданием и публикацией следующего профиля вместо изменения смысла существующих значений на месте.
Первичным ключом обеспечьте одно значение на товар и свойство. Составными FK сопоставляйте и профиль товара, и объявленный тип определения. Проверкой строки выбирайте нужный столбец значения, триггером проверяйте границы из метаданных, отложенными триггерами ограничений — существование обязательных значений. Проверки PostgreSQL не могут безопасно обеспечивать правила по строкам других таблиц; скрытый в функции CHECK поиск метаданных не решает изменения определений и конкурентную запись.
Правило обязательности действует в активном состоянии жизненного цикла. Транзакция может временно удалить и заменить обязательное значение, но не может зафиксировать активный товар без него.
Альтернативы и выбор
| Соседний паттерн | Связь | Определяющее требование |
|---|---|---|
| Обычные типизированные столбцы | Замена стабильных полей; дополнение идентичности товара | Имена полей известны разработчикам и заслуживают прямых ограничений и простых запросов |
| Столбец JSON | Замена | Обычно вы читаете и редактируете один документ без полного процесса определений свойств |
| Единая таблица / Таблицы классов | Замена | Вариативность следует известному семейству подтипов, а не полям администраторов |
| Наблюдение | Замена для другого жизненного цикла факта | Измерение имеет собственные время, метод или происхождение вместо одного текущего значения |
| Величина | Дополнение | Числовым свойствам нужны явные единицы и совместимые сравнения |
| Нетипизированные свойства | Заменяемый антипаттерн | Имена и значения — неограниченные строки без контракта определения |
Полная таблица — в Атласе решений. Типизированные свойства оправдывают сложность лишь тогда, когда определения свойств — часть создаваемого продукта. Это не общий способ избежать миграций обычных столбцов.
Структура
Повторённые профиль и тип — проверяемые компоненты ссылок, а не независимые редактируемые факты. Метки UK обозначают компоненты составных уникальных целей DDL. Ребро от товара к значениям допускает отсутствие; отложенное правило жизненного цикла задаёт обязательность активных товаров.
Таблицы и инварианты
property_profileпредставляет одну версию определений. Опубликованный профиль нельзя вернуть в черновик, а изменения определений блокируют его строку до проверки публикации.property_definitionидентифицирует поле парой(profile_id, property_key). Пример поддерживает ограниченные числа и текст, а не произвольные выражения типов, реляционные ссылки или многозначные поля.productссылается лишь на опубликованные профили через(profile_id, profile_state). Черновики могут быть неполными, активные товары — нет.property_valueимеет одно типизированное значение безNULLна пару «товар/свойство». Составные FK обеспечивают принадлежность профилю и объявленный вид значения;property_value_shapeвыбирает ровно один столбец значения.- Межстрочная проверка явная: триггер значения проверяет числовые границы, отложенные триггеры товара/значений — каждое обязательное определение. Изменения значений сначала блокируют товар, сериализуясь с активацией при read committed.
Когда применять
- Конечные пользователи или администраторы действительно создают поля во время работы.
- Нужны управляемые имена, типы, диапазоны и обязательность, а не непрозрачный набор настроек.
- Товарам нужны разные версионируемые контракты полей, а переходы между контрактами — видимые бизнес-операции.
- Поиск по нескольким свойствам оправдывает типизированные индексы и более сложные запросы.
Последствия и компромиссы
- ➕ Новым определениям поддерживаемого семейства типов нужны изменения данных, а не столбцы.
- ➕ Числовые условия используют числа, а объявленное число нельзя заменить произвольным текстом.
- ➕ Опубликованные профили сохраняют трактовку существующих товаров при развитии определений.
- ➖ Чтение товара восстанавливает множество строк значений; каждое поисковое поле добавляет условие или соединение.
- ➖ Обязательности, изменениям метаданных и публикации нужны жизненный цикл и протокол конкурентности.
- ➖ Новый примитивный тип, ссылка на сущность или повторяющиеся значения всё ещё меняют схему и логику проверки.
- ➖ Индексы смешанных свойств требуют анализа нагрузки. Не считайте их эквивалентом обычного составного индекса фиксированных столбцов товара.
Реализация
Полный скрипт проверен на PostgreSQL 18.6. Он включает все защиты, индексы, ожидаемые ошибки и миграцию профиля. Таблицы определяют правила ссылок и формы значения:
CREATE TABLE property_profile (
profile_id bigint PRIMARY KEY,
state text NOT NULL DEFAULT 'draft' CHECK (state IN ('draft', 'published')),
UNIQUE (profile_id, state)
);
CREATE TABLE property_definition (
profile_id bigint NOT NULL REFERENCES property_profile (profile_id),
property_key text NOT NULL,
value_kind text NOT NULL CHECK (value_kind IN ('number', 'text')),
required boolean NOT NULL DEFAULT false,
minimum numeric,
maximum numeric,
PRIMARY KEY (profile_id, property_key),
UNIQUE (profile_id, property_key, value_kind),
CHECK (
(value_kind = 'number' AND minimum IS NOT NULL AND maximum IS NOT NULL
AND minimum <= maximum AND minimum > '-Infinity'::numeric
AND maximum < 'Infinity'::numeric)
OR (value_kind = 'text' AND minimum IS NULL AND maximum IS NULL)
)
);
CREATE TABLE product (
product_id bigint PRIMARY KEY,
profile_id bigint NOT NULL,
profile_state text NOT NULL DEFAULT 'published' CHECK (profile_state = 'published'),
state text NOT NULL DEFAULT 'draft' CHECK (state IN ('draft', 'active')),
UNIQUE (product_id, profile_id),
FOREIGN KEY (profile_id, profile_state) REFERENCES property_profile (profile_id, state)
);
CREATE TABLE property_value (
product_id bigint NOT NULL,
profile_id bigint NOT NULL,
property_key text NOT NULL,
value_kind text NOT NULL,
number_value numeric,
text_value text,
PRIMARY KEY (product_id, property_key),
FOREIGN KEY (product_id, profile_id) REFERENCES product (product_id, profile_id)
ON DELETE CASCADE,
FOREIGN KEY (profile_id, property_key, value_kind)
REFERENCES property_definition (profile_id, property_key, value_kind),
CONSTRAINT property_value_shape CHECK (
(value_kind = 'number' AND number_value IS NOT NULL AND text_value IS NULL)
OR (value_kind = 'text' AND number_value IS NULL AND text_value IS NOT NULL)
)
);
CREATE INDEX property_value_number_idx
ON property_value (profile_id, property_key, number_value, product_id)
WHERE value_kind = 'number';
CREATE INDEX property_value_text_idx
ON property_value (profile_id, property_key, text_value, product_id)
WHERE value_kind = 'text';
Числовые определения требуют конечных нижней и верхней границ; реальному продукту может понадобиться более богатый словарь типов. Единицы в примере явно закодированы в weight_kg. Если пользователи выбирают единицы, моделируйте их, а не сравнивайте килограммы и фунты как голые числа; см. Величина.
Публикация должна замораживать метаданные, читаемые проверками диапазонов. Эти защиты блокируют профиль до редактирования определений и запрещают изменения опубликованных определений. Блокировки строк удерживаются до конца транзакции по правилам блокировок PostgreSQL:
CREATE FUNCTION guard_published_profile() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF OLD.state = 'published' THEN
RAISE EXCEPTION USING ERRCODE = '23514', MESSAGE = 'published profile is immutable';
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER profile_immutable BEFORE UPDATE ON property_profile
FOR EACH ROW EXECUTE FUNCTION guard_published_profile();
CREATE FUNCTION guard_definition() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE
target_profile bigint;
profile_status text;
BEGIN
IF TG_OP = 'UPDATE' AND NEW.profile_id <> OLD.profile_id THEN
RAISE EXCEPTION 'create a new definition instead of moving it';
END IF;
target_profile := CASE WHEN TG_OP = 'DELETE' THEN OLD.profile_id ELSE NEW.profile_id END;
SELECT state INTO profile_status FROM property_profile
WHERE profile_id = target_profile FOR UPDATE;
IF profile_status = 'published' THEN
RAISE EXCEPTION USING ERRCODE = '23514', MESSAGE = 'published definitions are immutable';
END IF;
IF TG_OP = 'DELETE' THEN RETURN OLD; END IF;
RETURN NEW;
END $$;
CREATE TRIGGER definition_guard BEFORE INSERT OR UPDATE OR DELETE ON property_definition
FOR EACH ROW EXECUTE FUNCTION guard_definition();
Запись значения блокирует товар и проверяет объявленный диапазон. И активация, и удаление обязательной строки должны назначать проверку полноты. Триггеры ограничений PostgreSQL поддерживают такой отложенный момент:
CREATE FUNCTION guard_property_value() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE
target_product bigint;
definition property_definition%ROWTYPE;
BEGIN
IF TG_OP = 'UPDATE' AND NEW.product_id <> OLD.product_id THEN
RAISE EXCEPTION 'delete and insert instead of moving a value';
END IF;
target_product := CASE WHEN TG_OP = 'DELETE' THEN OLD.product_id ELSE NEW.product_id END;
PERFORM 1 FROM product WHERE product_id = target_product FOR UPDATE;
IF TG_OP = 'DELETE' THEN RETURN OLD; END IF;
SELECT * INTO definition FROM property_definition
WHERE profile_id = NEW.profile_id AND property_key = NEW.property_key;
IF NEW.value_kind = 'number' AND NOT
(NEW.number_value BETWEEN definition.minimum AND definition.maximum) THEN
RAISE EXCEPTION USING ERRCODE = '23514', MESSAGE = 'property value outside allowed range';
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER value_guard BEFORE INSERT OR UPDATE OR DELETE ON property_value
FOR EACH ROW EXECUTE FUNCTION guard_property_value();
CREATE FUNCTION check_required_properties() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE
target_product bigint;
BEGIN
target_product := CASE WHEN TG_OP = 'DELETE' THEN OLD.product_id ELSE NEW.product_id END;
IF EXISTS (
SELECT 1 FROM product AS p
JOIN property_definition AS d ON d.profile_id = p.profile_id
WHERE p.product_id = target_product AND p.state = 'active' AND d.required
AND NOT EXISTS (
SELECT 1 FROM property_value AS v
WHERE v.product_id = p.product_id AND v.property_key = d.property_key
)
) THEN
RAISE EXCEPTION USING ERRCODE = '23514', MESSAGE = 'active product lacks required property';
END IF;
RETURN NULL;
END $$;
CREATE CONSTRAINT TRIGGER product_complete AFTER INSERT OR UPDATE ON product
DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION check_required_properties();
CREATE CONSTRAINT TRIGGER value_complete AFTER INSERT OR UPDATE OR DELETE ON property_value
DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION check_required_properties();
Используйте таблицы через роль приложения, которая не может заменять функции, отключать триггеры или очищать метаданные через TRUNCATE. Считайте ID стабильными и используйте одну транзакцию на редактирование товара. Скрипт работает при read committed; операция значений нескольких товаров должна получать их блокировки в одном порядке для снижения взаимоблокировок.
Опубликуйте профиль, создайте товар-черновик и активируйте со значениями в одной транзакции:
BEGIN;
INSERT INTO property_profile (profile_id) VALUES (1);
INSERT INTO property_definition VALUES
(1, 'weight_kg', 'number', true, 0.01, 1000),
(1, 'color', 'text', false, NULL, NULL);
UPDATE property_profile SET state = 'published' WHERE profile_id = 1;
INSERT INTO product (product_id, profile_id) VALUES (10, 1);
INSERT INTO property_value VALUES
(10, 1, 'weight_kg', 'number', 2.5, NULL),
(10, 1, 'color', 'text', NULL, 'red');
UPDATE product SET state = 'active' WHERE product_id = 10;
COMMIT;
SELECT p.product_id FROM product AS p
JOIN property_value AS w ON w.product_id = p.product_id
AND w.profile_id = p.profile_id AND w.property_key = 'weight_kg'
JOIN property_value AS c ON c.product_id = p.product_id
AND c.profile_id = p.profile_id AND c.property_key = 'color'
WHERE p.profile_id = 1 AND p.state = 'active'
AND w.value_kind = 'number' AND w.number_value < 5
AND c.value_kind = 'text' AND c.text_value = 'red';
UPDATE property_value SET number_value = 3 WHERE product_id = 10 AND property_key = 'weight_kg';
Поиск по двум свойствам возвращает 10. Недопустимое изменение диапазона отклоняется немедленно:
UPDATE property_value SET number_value = 2000
WHERE product_id = 10 AND property_key = 'weight_kg';
Триггер вызывает SQLSTATE 23514: ERROR: property value outside allowed range. Удаление обязательной строки отклоняется в конце транзакции:
BEGIN;
DELETE FROM property_value WHERE product_id = 10 AND property_key = 'weight_kg';
COMMIT;
Ошибка — SQLSTATE 23514: ERROR: active product lacks required property. Неудачная фиксация сохраняет исходную строку. Вставка активного товара без значений нарушает то же правило. Полный скрипт также проверяет, что текстовое значение weight_kg нарушает составной FK типа с SQLSTATE 23503, а редактирование опубликованного определения вызывает published definitions are immutable.
Поддержка в движках
- PostgreSQL 18: схему реализуют обычные составные ключи, защиты PL/pgSQL и отложенные триггеры ограничений. Одна проверка с чтением метаданных не является равноценной заменой.
- MySQL 8.4 / InnoDB:
CHECKзапрещает подзапросы и хранимые функции, а проверки внешних ключей немедленны. Осознанно переносите проверку жизненного цикла; одна процедура сохранения приложения не защищает обходящую её запись. - SQL Server 2025 (17.x): DML-триггеры обрабатывают множества строк. Буквальный перенос построчной PL/pgSQL-функции пропустил бы многострочные операции. Синтаксис
CREATE TABLEтакже не даёт отложенного триггера ограничений в стиле PostgreSQL. - Oracle AI Database 26ai: поддерживает откладываемые ограничения, но это не переносит напрямую тела PL/pgSQL и пользовательскую отложенную проверку полноты. Спроектируйте и проверьте границу валидации реализации Oracle.
- SQLite 3: таблицы
STRICTограничивают типы скаляров, но не принимают объявление PostgreSQLnumeric. Осознанно выберите числовое представление; триггеры SQLite построчные и не предоставляют такую отложенную возможность.
Сбои и конкурентность
При read committed блокируйте товар до изменения значений. value_guard делает это даже при удалениях; изменение состояния уже блокирует строку товара. Если активация побеждает, последующее удаление обязательного значения видит активный товар и отклоняется при фиксации. Если удаление побеждает, последующая активация обнаруживает отсутствие и отклоняется. PostgreSQL перепроверяет строки после ожидающей блокировки или обновления; проверки намеренно выполняются после родительской блокировки и повторно при отложенной валидации.
У публикации отдельная точка сериализации: изменение определения блокирует профиль, а публикация обновляет тот же профиль. Затем опубликованные определения не меняются под проверкой диапазона. Повтор после неопределённой фиксации означает перечитать состояние товара и значения; не считайте успешную вставку доказательством прохождения отложенных проверок. Взаимоблокировки и ошибки сериализации требуют повтора всей транзакции, а не лишь последнего оператора значения.
Миграция
Из Нетипизированных свойств сначала согласуйте имена, дубликаты значений, единицы и неверный числовой текст. Создайте профиль-черновик из принятых определений и определите обязательные поля. Опубликуйте, перенесите товары в черновом состоянии и сохраните отклонённые исходные значения для исправления вместо молчаливого преобразования. Проверьте каждый товар до активации.
Запускаемый скрипт показывает переход: удаление старых строк значений, изменение профиля товара и вставка преобразованных значений одной транзакцией. Отложенная проверка допускает временный пробел. Более строгий диапазон или новое обязательное поле должны соблюдаться до фиксации. Сохраняйте старый профиль и представление в период Расширения и свёртывания; откат к старому профилю возможен лишь при сохранении соответствия значений. Новый примитивный вид значения требует изменения схемы, хотя новые определения существующих видов — нет.
Антипаттерны и частые ошибки
- Делать строки значений обязательными по
NULLи считать, что из этого следует существование обязательных строк. - Менять опубликованное определение, придавая существующим значениям новый смысл без миграции.
- Проверять полноту лишь при активации, разрешая последующее удаление обязательных значений.
- Проверять диапазоны в приложении, когда импорт пишет прямо в таблицу значений.
- Пропускать компоненты профиля/типа в FK и допускать значения другого контракта.
- Использовать динамические свойства для небольшого стабильного набора полей разработчиков, яснее выражаемого обычными столбцами.
Известные применения
- Typed Dynamic Property Фаулера на странице 9 прикрепляет объявленный тип значения к определению свойства; статья также различает правила допустимых свойств разных видов объектов. Протокол публикации и полноты здесь — разработка этой главы, а не гарантия фреймворка поставщика.
Связанные паттерны
- Столбец JSON предлагает более простую границу документа, если управление определениями не нужно.
- Нетипизированные свойства описывают исправляемую модель неограниченных строковых значений.
- Наблюдение даёт независимо записываемым измерениям собственный жизненный цикл.
- Величина определяет числовые значения с явными единицами.
- Таблицы классов обрабатывают известные формы подтипов без метаданных полей времени выполнения.