PostgreSQL CookbookСхемаГенерируемые столбцы и домены
0 / 63 (0%)

Генерируемые столбцы и домены

В 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: оси выбора

Формула одна — различается только момент вычисления и цена:

ОсьSTOREDVIRTUAL (PG18, дефолт по стандарту)
Когда считаетсяпри INSERT/UPDATEпри чтении (SELECT)
На дискехранится (занимает место)не хранится (0 места)
Цена записичуть дорожебесплатна
Цена чтениябесплатно (значение готово)тратит CPU
Индексацияможнонельзя
PK/FK, доменные типыможнонельзя
Когда братьзначение в WHERE/ORDER BY/индекседешёвая редко-читаемая производная

Что показывает наш код

Урок — в demo.sql на лабораторном столе (базовые таблицы не трогаем). Таблица с двумя генерируемыми столбцами на одной формуле и домен:

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-юнита.

Запуск

sh
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

Вывод:

plaintext
== 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 VALIDVALIDATE), не вешая прод.

·Модуль 03

Этот урок ещё впереди

Курс изучается по порядку — чтобы открыть этот шаг, сначала завершите предыдущие. Так контекст накапливается без пропусков.

/ вы пытались открыть
Схема / Генерируемые столбцы и домены