Генерируемые столбцы и домены
В Brew сумму позиции (qty × unit_price) считали в коде приложения. Считали в трёх местах: на странице корзины, в письме-чеке и в ночном отчёте о выручке. И в какой-то момент цифры разошлись — корзина показывала одно, чек другое, отчёт третье. Причина обычная: формулу скопировали трижды, а потом в одном месте добавили округление, в другом забыли про скидку. Источник истины расползся по кодовой базе. Тем временем правило «цена в центах строго положительна» жило CHECK-ом сразу на пяти таблицах — пять копий одного инварианта, которые надо помнить и держать синхронными.
Цель юнита — два инструмента, убирающих обе копипасты в схему. Генерируемый столбец — колонка, значение которой БД вычисляет сама из других колонок этой же строки; формула одна, в DDL, и разойтись уже не может. PG18 даёт два вида: STORED (значение считается при записи и лежит на диске) и новый VIRTUAL (считается на лету при чтении, места не занимает). Домен (DOMAIN) — переиспользуемый тип со встроенным CHECK: объявил positive_cents один раз — и все колонки этого типа автоматически носят его проверку.
Это escape-hatch-юнит, и причина точечная: VIRTUAL настолько свежий, что его ещё не понимает парсер sqlc (v1.30.0 падает на syntax error at or near "VIRTUAL"). А урок именно про него — поэтому ведём его psql-скриптом напрямую, сервер PG18 фичу знает.
Генерируемый столбец: одна формула вместо трёх копий
total_stored BIGINT GENERATED ALWAYS AS (qty * unit_price) STORED — это обещание: значение total_stored всегда равно qty * unit_price, и поддерживает его БД, а не приложение. Вписать в такой столбец своё значение нельзя (SQLSTATE 428C9 — ровно как в GENERATED ALWAYS id из 02-01): формулу не обойти. Три отчёта теперь читают одну и ту же колонку — разойтись неоткуда.
Разница STORED против VIRTUAL — про то, когда считается значение:
STORED— считается приINSERT/UPDATEи физически хранится на диске. Чтение бесплатно (значение уже лежит), запись чуть дороже, занимает место. Только такой столбец можно проиндексировать.VIRTUAL(PG18, и это дефолт по стандарту SQL) — на диске не хранится вообще, вычисляется каждый раз при чтении. Запись бесплатна, места ноль, но чтение тратит CPU, и проиндексировать его нельзя.
Выбор простой: значение, по которому фильтруешь/сортируешь и которое читаешь часто → STORED (платим диском за индекс и быстрый доступ); дешёвая производная, которую читаешь изредка → VIRTUAL (не раздуваем таблицу).
Домен: инвариант, объявленный один раз
CREATE DOMAIN positive_cents AS BIGINT CHECK (VALUE > 0) — это новый тип «BIGINT, но всегда положительный». Колонка price positive_cents автоматически отвергает 0 и отрицательные значения тем же SQLSTATE 23514, что и обычный CHECK из 02-04 — но правило записано в одном месте и переиспользуется. Поменялось правило — правишь домен, а не пять таблиц.
STORED против VIRTUAL: оси выбора
Формула одна — различается только момент вычисления и цена:
| Ось | STORED | VIRTUAL (PG18, дефолт по стандарту) |
|---|---|---|
| Когда считается | при INSERT/UPDATE | при чтении (SELECT) |
| На диске | хранится (занимает место) | не хранится (0 места) |
| Цена записи | чуть дороже | бесплатна |
| Цена чтения | бесплатно (значение готово) | тратит CPU |
| Индексация | можно | нельзя |
| PK/FK, доменные типы | можно | нельзя |
| Когда брать | значение в WHERE/ORDER BY/индексе | дешёвая редко-читаемая производная |
Что показывает наш код
Урок — в demo.sql на лабораторном столе (базовые таблицы не трогаем). Таблица с двумя генерируемыми столбцами на одной формуле и домен:
CREATE TABLE gen_lab (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
qty INT NOT NULL,
unit_price BIGINT NOT NULL,
total_stored BIGINT GENERATED ALWAYS AS (qty * unit_price) STORED,
total_virtual BIGINT GENERATED ALWAYS AS (qty * unit_price) VIRTUAL
);
CREATE DOMAIN positive_cents AS BIGINT CHECK (VALUE > 0);Демо вставляет одну строку (оба столбца дают одинаковые 1350), читает pg_attribute.attgenerated (s против v — наблюдаемое доказательство, что хранятся они по-разному), пробует записать в генерируемый столбец напрямую и проверяет домен на 0 и 300. Ошибки (428C9, 23514) показаны как есть — это нормальный вывод psql escape-hatch-юнита.
Запуск
docker compose up -d
make lecture L=02-schema-and-constraints/02-05-generated-columns-and-domains T=db-reset
make lecture L=02-schema-and-constraints/02-05-generated-columns-and-domainsВывод:
== 1) Генерируемый столбец считается из других колонок (qty * unit_price) ==
qty | unit_price | total_stored | total_virtual
-----+------------+--------------+---------------
3 | 450 | 1350 | 1350
== 2) Как столбец хранится (pg_attribute.attgenerated): s = STORED, v = VIRTUAL ==
col | gen
---------------+-----
total_stored | s
total_virtual | v
== 3) Писать в генерируемый столбец напрямую нельзя (как и в GENERATED ALWAYS id) ==
psql:demo.sql:47: ERROR: cannot insert a non-DEFAULT value into column "total_stored"
== 4) DOMAIN positive_cents = BIGINT + встроенный CHECK (VALUE > 0) ==
-- price = 0 (нарушает CHECK домена):
psql:demo.sql:57: ERROR: value for domain positive_cents violates check constraint "positive_cents_check"
-- price = 300 (валидно):
price
-------
300Оба генерируемых столбца дали 1350 — формула одна. attgenerated показывает s и v: значения равны, но total_stored лежит на диске, а total_virtual посчитан при этом самом SELECT. Прямая запись в генерируемый столбец отбита (428C9), а домен отверг 0 (23514) и принял 300. Три копии формулы и пять копий CHECK ужались до одной строки DDL каждая.
Заборчик
Что мы упростили и где это кусается в проде (за чем следит твой DBA):
VIRTUALв PG18 — заманчиво «бесплатный», но у него есть жёсткие границы. Его нельзя проиндексировать, он не может входить в первичный/внешний ключ и (как мы видели при разработке этого юнита) не работает с пользовательскими типами вроде доменов. ПоэтомуVIRTUAL— для дешёвых производных, которые ты читаешь, но по которым не ищешь; всё, что попадает вWHERE/ORDER BY/индекс, должно бытьSTORED.STORED, в свою очередь, раздувает таблицу и удорожает запись — для тяжёлой формулы на горячей таблице это заметно.- Домены удобны, но менять
CHECKуже существующего домена на проде — это валидация всех зависимых колонок под блокировкой (та же история, что сALTERв 02-06), а часть ORM/инструментов плохо интроспектит домены и видит простоbigint. - Главное правило байт-совместимости: генерируемые столбцы и домены — только на новых таблицах (
gen_labздесь,shops/order_itemsв базовой схеме), а шесть CDC-таблиц Brew держим дословно — иначе сломается handoff 10-05.
Что забрать с собой
- Генерируемый столбец (
GENERATED ALWAYS AS (expr)) — БД сама вычисляет значение из других колонок; одна формула вместо копий в коде, запись напрямую отбита (428C9). STORED— считается при записи, лежит на диске, можно индексировать (для значений вWHERE/ORDER BY);VIRTUAL(PG18) — считается при чтении, места не занимает, но индексировать нельзя (для дешёвых редко-читаемых производных).DOMAIN— переиспользуемый тип со встроеннымCHECK(positive_cents): инвариант объявлен один раз, нарушение →23514.- Современные идиомы (
VIRTUAL, домены) — на новых таблицах; базовые таблицы Brew байт-совместимы и неприкосновенны. - Почему escape-hatch:
VIRTUALещё не парсит sqlc — урок ведётся psql напрямую.
Дальше — юнит 02-06 «ALTER TABLE: мышление миграций»: какие изменения схемы мгновенны (только метаданные), а какие переписывают таблицу целиком под блокировкой записи — и как добавлять констрейнты двухфазно (NOT VALID → VALIDATE), не вешая прод.