Глава 5. Ограничения в документах
Главы
- Введение в документы
- Базовые возможности JSON
- JSON в таблицах
- Индексирование JSON
- Ограничения в документах
- Язык путей JSONPath
- Отчеты и функции
- Функции на языке Python
- Версионирование и архивация документов
- Релевантный поиск
Содержание
- Главы
- Огриничение CHECK
- Ограничение UNIQUE и уникальный индекс
- Ограничение FOREIGN KEY (ссылки)
- Прочие сведения
В этой главе мы поговорим о том, как добавить документам ограничения: проверку поля предикатом, гарантию уникальности в рамках таблицы или ее части. Мы рассмотрим ссылочную целостность, познакомимся с вычисляемыми столбцами и другими возможностями Postgres.
В начале книги мы упоминали разницу между реляционными и документными базами данных. В первом случае данные хранятся в виде плоских таблиц; им нельзя придать произвольную форму. Документ, напротив, принимает любую структуру и вложенность в зависимости от ситуации.
Плоские таблицы сильны в отношениях между ними. Легко сделать так, чтобы одна таблица ссылалась на другую, соединить их как по горизонтали (оператор JOIN), так и вертикали (UNION). Таблице можно назначить ограничения и проверки, которые база выполняет за нас. Независимо от того, из какой платформы мы обращаемся к базе, она не позволит записать неверные данные.
Документы, напротив, слабы в связях и ограничениях. При работе с ними предполагается, что приложение само следит за целостностью данных. Например, перед тем как добавить документ А со ссылкой на В, мы проверяем, что последний существует.
Postgres – реляционная база данных, и многие ее ограничения можно применить к документам. Ниже мы рассмотрим основные случаи и их особенности.
Огриничение CHECK
Первый вид ограничений называется CHECK – простая проверка. В ней указывают выражение, и если при вставке или изменении записи оно ложно, операция отменяется.
Добавим проверку на то, что номер заявки не пуст. Для этого сперва удалим индекс на поле application_id, который мы создали в прошлой главе:
drop index if exists idx_applications_application_id;
Код ниже создаст ограничение с типом CHECK:
alter table applications add constraint ctr_doc_application_id_nn
check (doc->'application_id' is not null);
За выражением ...add constraint следует машинное имя ограничения. Убедитесь,
что имя хотя бы примерно сообщает о том, что и как оно проверяет. В нашем случае
doc и application_id указывают на колонку и поле документа, а часть nn означает
not null. За именем следует тип ограничения (CHECK), а дальше – код, специфичный
для конкретного типа. По ходу главы мы рассмотрим и другие виды ограничений.
Убедимся, что не получится добавить документ без номера:
insert into applications (doc) values (
'{"test": "abc"}'
);
-- ERROR: new row for relation "applications" violates check constraint "ctr_doc_application_id_nn"
-- DETAIL: Failing row contains (b6edce9d-285f-4df0-9bba-5f65d390608a, {"test": "abc"}, 2026-06-05 17:49:33.757553+03, null).
Подчеркнем, что проверка не зависит от языка или платформы, из которой мы обращаемся. Она задана на уровне базы и поэтому сработает всегда.
Улучшим нашу проверку так, чтобы она проверяла, что в поле application_id
записано именно число. Однако в прошлых экспериментах мы добавили документы, где
поле application_id не было числом. Чтобы удалить их, выполните запрос с
выражением на языке JSON Path (мы изучим его в следующей главе):
delete from applications
where doc @@ '$.application_id.type() != "number" ';
Удалите прежнее ограничение:
alter table applications
drop constraint ctr_doc_application_id_nn;
и добавьте новое:
alter table applications add constraint ctr_doc_application_id_int
check ((doc->>'application_id')::int is not null);
Теперь при попытке записать документ с неверным номером получим ошибку:
insert into applications (doc) values (
'{"application_id": "abc"}'
);
-- ERROR: cannot cast jsonb string to type integer
Вероятно, вам захочется добавить как можно больше проверок CHECK, чтобы защититься от ошибок. Увы, это сомнительная практика. Множественные проверки замедляют вставку и изменение данных, поэтому за безопасность вы платите быстродействием. В документе могут быть сотни полей, в том числе опциональных, и проверка их всех ни к чему хорошему не приведет.
Более удачное решение – проверять документ в приложении до записи в базу. Обычно это делают при помощи JSON-схемы. Библиотеки для работы со схемами есть для всех популярных языков. Храните схемы в отдельном репозитории, чтобы ими могли пользоваться клиенты на Python, Java и других языках. Приведем фрагмент схемы нашей заявки:
{
"type": "object",
"properties": {
"id": {"type": "string", "format": "uuid"},
"status": {"enum": ["active", "deleted", "pending"]},
"created_at": {"type": "string", "format": "date-time"},
"application_id": {"type": "integer"},
"organization": {...},
...
},
"required": ["id", "status", "created_at", "..."]
}
Резюмируюя, можно сказать, что ограничения CHECK малополезны для документов. Если можно вынести проверку в схему, так и следует поступить. В редких случаях CHECK может опираться на глобальные настройки или параметры сеанса. Однако такие примеры не будем рассматривать ввиду их надуманности.
Ограничение UNIQUE и уникальный индекс
Гораздо интересней ограничения, которые нельзя вынести в JSON-схему. Это может быть проверка поля на уникальность: гарантия, что в таблице нет другой заявки с таким номером. В этом случае JSON-схема не поможет: ей доступен только документ, но не содержимое базы. Технически возможно написать валидатор схемы, который обращается к базе в поисках дубликата, но такое решение грубо и неуклюже.
При создании таблицы можно задать поле или их комбинацию уникальными. Первый случай – одно уникальное поле – подходит для различных кодов, артикулов, адресов электронной почты и других атрибутов, которые однозначно определяют сущность. В таблице ниже поле email задано уникальным, чтобы не допустить двух пользователей с одной почтой (проблему регистра и мусорных символов мы не рассматриваем).
create table users_demo(
id integer primary key,
full_name text not null,
email text not null,
unique(email)
);
Уникальная комбинация полезна в таблицах-мостах; такие таблицы связывают другие
сущности, например публикацию и тег. Чтобы не допустить случая, когда у статьи
два одинаковых тега, комбинацию post_id и tag_id объявляют уникальной:
create table post_tags(
post_id integer not null,
tag_id integer not null,
created_at timestamptz not null default current_timestamp,
unique (post_id, tag_id)
);
Если вы забыли часть unique в объявлении таблицы, легко добавить ограничение
командой:
alter table post_tags
add constraint ctx_post_id_tag_id_u
unique (post_id, tag_id);
Если мы выполним следующий запрос дважды:
insert into post_tags (post_id, tag_id)
values (100, 10);
то во второй раз получим ошибку уникальности:
ERROR: duplicate key value violates unique constraint "ctx_post_id_tag_id_u"
DETAIL: Key (post_id, tag_id)=(100, 10) already exists.
Особенность ограничения UNIQUE в том, что оно принимает столбцы, а не произвольные выражения. Не получится создать уникальное ограничение на поле документа. С точки зрения Postgres это неверный синтаксис:
alter table applications
add constraint ctx_doc_application_id_u
unique (((doc->>'application_id')::int));
-- ERROR: syntax error at or near "("
-- LINE 3: unique (((doc->>'application_id')::int));
И все же есть способ добиться желаемого. Postgres предлагает индекс btree с гарантией уникальности. Он проверяет, что значение встречается в индексе не больше одного раза, а иначе получим ошибку. Поскольку это индекс, в выражении может быть не только столбец, но и сложная конструкция.
Удалите из таблицы applications ограничения, что мы создали ранее. Посмотреть их
имена можно командой \d+ applications:
Indexes:
"applications_pkey" PRIMARY KEY, btree (id)
...
Check constraints:
"ctr_doc_application_id_int" CHECK (((doc ->> 'application_id'::text)::integer) IS NOT NULL)
Затем создайте уникальный индекс командой:
create unique index idx_doc_application_id_u
on applications (((doc->>'application_id')::int));
Свойство unique поддерживает только индекс btree. Применить его к индексам GIN
или GIST не получится.
В прошлой главе мы уже создали индекс btree для поля application_id под
названием idx_applications_application_id. С новым уникальным индексом в нем
нет нужды, поэтому удалите его командой drop index.
Убедимся, что база воспротивится документу с номером, который уже есть в таблице:
insert into applications (doc) values (
'{"application_id": "12345"}'
);
-- ERROR: duplicate key value violates unique constraint "idx_doc_application_id_u"
-- DETAIL: Key (((doc ->> 'application_id'::text)::integer))=(12345) already exists.
Что произойдет, если в документе вообще нет номера? Оказывается, их можно добавить сколько угодно:
insert into applications (doc) values (
'{"test": "abc"}'
);
-- INSERT 0 1
Почему Postgres пропускает документы без номера? Дело в том, что когда поля
application_id нет, выражение doc ->> 'application_id' возвращает
NULL. Уникальный индекс устроен так, что каждый NULL не равен другому NULL. Это
общая идиома Postgres: NULL считается неизвестностью, а неизвестности нельзя
сравнивать.
На поведение NULL можно повлиять, указав опцию индекса nulls not
distinct. Удалите прежний индекс и создайте новый:
drop index idx_doc_application_id_u;
create unique index idx_doc_application_id_u
on applications (((doc->>'application_id')::int))
nulls not distinct;
Теперь индекс трактует значения NULL как равные. Получится добавить только один документ без номера, но не больше:
insert into applications (doc) values (
'{"test": "abc"}'
);
-- OK
insert into applications (doc) values (
'{"test": "abc"}'
);
-- ERROR: duplicate key value violates unique constraint "idx_doc_application_id_u"
-- DETAIL: Key (((doc->>'application_id'::text)::integer))=(null) already exists.
Подчеркнем: проверку заполненности полей лучше поручить приложению. Уникальный индекс должен делать только ту работу, которая не под силу схеме: проверять данные на уровне базы.
Выше мы сделали номер уникальным глобально, то есть в рамках всей таблицы. Иногда уникальность требуется в рамках периода. Скажем, бухгалтерия обнуляет счетчик заявок каждый год. Номер 12345 может встречаться в заявках за 2025 и 2024 годы, но не одновременно в том или другом.
Чтобы построить подобный индекс, сперва удалим прошлый:
drop index idx_doc_application_id_u;
Подготовим неизменяемую функцию created_at_year. Она принимает документ и
возвращает дату из поля created_at, срезанную на начало года:
create or replace function created_at_year(doc jsonb)
returns timestamptz
language sql immutable strict parallel safe
return date_trunc('year', (doc->>'created_at')::timestamp);
Объявим индекс, уникальный по двум критериям:
create unique index idx_doc_app_id_date_u
on applications (
((doc->>'application_id')::int),
(created_at_year(doc))
);
Убедимся, что он работает: добавим две заявки с одинаковыми номерами за разные годы:
insert into applications (doc) values (
'{"application_id": "10001111", "created_at": "2026-06-05T15:33:55Z"}'
);
insert into applications (doc) values (
'{"application_id": "10001111", "created_at": "2027-06-05T15:33:55Z"}'
);
Обе вставки сработали без ошибок. Но если повторить один из запросов, операция не пройдет:
insert into applications (doc) values (
'{"application_id": "10001111", "created_at": "2026-12-05T15:33:55Z"}'
);
-- ERROR: duplicate key value violates unique constraint "idx_doc_app_id_date_u"
-- DETAIL: Key (((doc ->> 'application_id'::text)::integer), get_year(doc))=(10001111, 2026-01-01 00:00:00+03) already exists.
Напомним: даже если индекс составной, можно пользоватся его лидирующими
элементами, в нашем случае – критерием ((doc->>'application_id')::int). Это
значит, что быстрый поиск по номеру по-прежнему в нашем распоряжении. Возможно,
вам понадобится другой доступ: сначала за указанный год, а затем по номеру. В
этом случае переставьте компоненты индекса местами.
Уникальный индекс дает еще одну возможность: можно указать его в выражении ON
CONFLICT при вставке документа. Напишем запрос UPSERT, который реагирует именно
на конфликт номера и года, а не другие ограничения:
insert into applications (doc) values (
'{"application_id": "10001111", "created_at": "2026-12-05T15:33:55Z"}'
) on conflict (
((doc->>'application_id')::int),
(created_at_year(doc))
)
do nothing;
-- INSERT 0 0
Что именно поместить после DO – зависит от конкретной задачи. Выше мы проигнорировали операцию, но возможно, вам потребуется обновить какие-то поля документа.
Обратите внимание, что мы указали выражение индекса, а не его имя. Форма ON CONFLICT ожидает от нас либо имя уникального столбца, либо выражение, по которому построен уникальный индекс. В результате запрос выглядит громоздко. Если вы строете запросы при помощи ORM, поместите выражение индекса в константу.
Создайте уникальный индекс для другого поля или их комбинации. Напишите UPSERT с разными действиями в случае конфликта.
Ограничение FOREIGN KEY (ссылки)
Рассмотрим еще одно ограничение Postgres для внешний ключей (ссылок) между таблицами. Проблема, которую мы пытаемся решить, звучит так: если первый документ ссылается на второй, то второй обязательно существует. Из этого следует, что нельзя добавить документ, который ссылается на то, чего нет в базе. Вдобавок нельзя удалить документ, на который кто-то ссылается.
Вместе эти ограничения называются целостностью данных. Целостность уберегает базу от многих ошибок; ее отключают только в особых случаях, всегда осознанно и принимая риски.
Как правило, документные базы слабы в плане целостности ссылок. Предполагается, что эту работу выполняет приложение: перед вставкой документа делается запрос, чтобы убедиться, что документы по ссылкам существуют. В распределенных системах целостность не проверяется намеренно. Время от времени запускают скрипт, который находит ссылки, ведущие в никуда, и как-то их исправляет.
Поскольку мы храним документы в Postgres, нам доступны ссылочные ограничения. Ниже мы покажем техническую возможность, а пользоваться ей или нет – остается на ваше усмотрение.
Ссылка между таблицами подразумевает, что по крайней мере таблиц две. На текущий момент у нас только один вид документа – заявка. Чтобы это исправить, заведем второй тип документа – организацию, на которую ссылаются заявки.
Добавим организации за несколько шагов. Первый – подготовим для них таблицу. Ее структура в точности повторяет таблицу заявок, отличается только название:
create table organizations (
id uuid primary key default uuid_generate_v4(),
doc jsonb compression lz4 not null,
created_at timestamptz not null default current_timestamp,
updated_at timestamptz
);
Второй шаг – добавим в таблицу организации. Очевидно, их первичные ключи должны
быть взяты из заявок, а не сгенерированы случайно. Прежде всего удалим мусорные
заявки, которые мы создали во время прошлых экспериментов. Для этого выполним
DELETE с условием, что поле organization→id пустое:
delete from applications
where (doc #>> '{organization,id}') is null;
Выберем из заявок уникальные коды организаций:
select distinct doc #>> '{organization,id}' as org_id
from applications
limit 100;
┌──────────────────────────────────────┐
│ org_id │
├──────────────────────────────────────┤
│ 00000000-0000-0000-0000-000000000000 │
│ 00000000-0000-0000-0000-000000000001 │
│ 00000000-0000-0000-0000-000000000002 │
│ 00000000-0000-0000-0000-000000000003 │
│ 00000000-0000-0000-0000-000000000004 │
│ 00000000-0000-0000-0000-000000000005 │
│ 00000000-0000-0000-0000-000000000006 │
│ 00000000-0000-0000-0000-000000000007 │
│ 00000000-0000-0000-0000-000000000008 │
│ 00000000-0000-0000-0000-000000000009 │
│ 00000000-0000-0000-0000-000000000010 │
│ 00000000-0000-0000-0000-000000000011 │
│ 00000000-0000-0000-0000-000000000012 │
Доработаем запрос, чтобы вместе с кодом организации формировался документ с минимальным набором полей:
select distinct on (doc #>> '{organization,id}')
(doc #>> '{organization,id}')::uuid as id,
jsonb_build_object(
'id', (doc #>> '{organization,id}'),
'status', 'active',
'code', (doc #>> '{organization,code}'),
'short_name', (doc #>> '{organization,short_name}')
) as doc
from
applications
limit
100;
Частичный результат:
┌──────────────────────────────────────┬───────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ id │ doc │
├──────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ 00000000-0000-0000-0000-000000000000 │ {"id": "00000000-0000-0000-0000-000000000000", "code": "0", "status": "active", "short_name": "Organization 0"} │
│ 00000000-0000-0000-0000-000000000001 │ {"id": "00000000-0000-0000-0000-000000000001", "code": "1", "status": "active", "short_name": "Organization 1"} │
│ 00000000-0000-0000-0000-000000000002 │ {"id": "00000000-0000-0000-0000-000000000002", "code": "2", "status": "active", "short_name": "Organization 2"} │
│ 00000000-0000-0000-0000-000000000003 │ {"id": "00000000-0000-0000-0000-000000000003", "code": "3", "status": "active", "short_name": "Organization 3"} │
│ 00000000-0000-0000-0000-000000000004 │ {"id": "00000000-0000-0000-0000-000000000004", "code": "4", "status": "active", "short_name": "Organization 4"} │
│ 00000000-0000-0000-0000-000000000005 │ {"id": "00000000-0000-0000-0000-000000000005", "code": "5", "status": "active", "short_name": "Organization 5"} │
│ 00000000-0000-0000-0000-000000000006 │ {"id": "00000000-0000-0000-0000-000000000006", "code": "6", "status": "active", "short_name": "Organization 6"} │
│ 00000000-0000-0000-0000-000000000007 │ {"id": "00000000-0000-0000-0000-000000000007", "code": "7", "status": "active", "short_name": "Organization 7"} │
│ 00000000-0000-0000-0000-000000000008 │ {"id": "00000000-0000-0000-0000-000000000008", "code": "8", "status": "active", "short_name": "Organization 8"} │
│ 00000000-0000-0000-0000-000000000009 │ {"id": "00000000-0000-0000-0000-000000000009", "code": "9", "status": "active", "short_name": "Organization 9"} │
│ 00000000-0000-0000-0000-000000000010 │ {"id": "00000000-0000-0000-0000-000000000010", "code": "10", "status": "active", "short_name": "Organization 10"} │
│ 00000000-0000-0000-0000-000000000011 │ {"id": "00000000-0000-0000-0000-000000000011", "code": "11", "status": "active", "short_name": "Organization 11"} │
│ 00000000-0000-0000-0000-000000000012 │ {"id": "00000000-0000-0000-0000-000000000012", "code": "12", "status": "active", "short_name": "Organization 12"} │
Предварите запрос командой insert into organizations, удалите limit – и
перенос организаций готов. Вот что получилось у автора:
insert into organizations (id, doc)
select distinct on (doc #>> '{organization,id}')
(doc #>> '{organization,id}')::uuid as id,
jsonb_build_object(
'id', (doc #>> '{organization,id}'),
'status', 'active',
'code', (doc #>> '{organization,code}'),
'short_name', (doc #>> '{organization,short_name}')
) as doc
from
applications;
Проверьте число организаций – их будет тысяча:
select count(*) from organizations;
┌───────┐
│ count │
├───────┤
│ 1000 │
└───────┘
Итак, заявки и организации согласованы: первые указывают на вторые. Это легко проверить оператором JOIN. Присоединим к заявкам организации и выберем несколько полей из каждого документа:
select
app.id as app_id,
org.doc->>'code' as org_code,
org.doc->>'short_name' as org_name
from
applications app
join
organizations org
on (app.doc #>> '{organization,id}')::uuid = org.id
limit
100;
┌──────────────────────────────────────┬──────────┬──────────────────┐
│ app_id │ org_code │ org_name │
├──────────────────────────────────────┼──────────┼──────────────────┤
│ 00000000-0000-0000-0000-000000635585 │ 585 │ Organization 585 │
│ 00000000-0000-0000-0000-000000635586 │ 586 │ Organization 586 │
│ 00000000-0000-0000-0000-000000635587 │ 587 │ Organization 587 │
│ 00000000-0000-0000-0000-000000635588 │ 588 │ Organization 588 │
│ 00000000-0000-0000-0000-000000635589 │ 589 │ Organization 589 │
│ 00000000-0000-0000-0000-000000635590 │ 590 │ Organization 590 │
Обратите внимание, как легко соединить документы по горизонтали – не все документные базы предлагают такую возможность. В примере выше соединение JOIN сработает быстро: оно происходит по первичному ключу организаций, а первичный ключ всегда индексирован.
Мы добились того, что ссылки на организации верны (не указывают в никуда), однако сейчас это ничем не зафиксировано. Если добавить заявку или удалить организацию, нарушится целостность ссылок. Следующий шаг – сделать так, чтобы база не позволила этого сделать.
Postgres предлагает ограничение FOREIGN KEY – внешний ключ. Оно проверяет, что строка, на которую ссылается поле таблицы, действительно существует. Особенность внешних ключей в том, что они работают только с колонками, а не выражениями. Команда ниже вызовет ошибку синтаксиса, потому что после слов foreign key ожидается колонка или их комбинация, а не выражение:
alter table applications add constraint fk_org_id
foreign key ((doc #>> '{organization,id}')::uuid) references organizations(id);
-- ERROR: syntax error at or near "("
-- LINE 2: foreign key ((doc #>> '{organization,id}')::uuid) references...
Как и в случае с уникальностью, есть способ добиться желаемого. C версии 13 Postgres поддерживает вычисляемые колонки. От обычных они отличаются тем, что хранят значения, рассчитанные из других колонок. При изменении вторых первые автоматически пересчитываются.
Приведем пример: в таблице demo две обычные колонки x и y, а третья sum –
вычисляемая, которая складывает x и y. При вставке не нужно беспокоиться о
поведении колонки sum – ее согласованность обеспечит Postgres:
create table demo(
x integer not null default 0,
y integer not null default 0,
sum integer generated always as (x + y) stored
);
insert into demo values (1, 2), (3, 4)
returning x, y, sum;
┌───┬───┬─────┐
│ x │ y │ sum │
├───┼───┼─────┤
│ 1 │ 2 │ 3 │
│ 3 │ 4 │ 7 │
└───┴───┴─────┘
До версии 18 Postgres поддерживал только один вид вычисляемой колонки – хранимый. Выше это свойство задано словом stored. Хранимая колонка вычисляет значение и записывает на диск. При чтении поля sum не происходит обращения к x или y.
С версии 18 доступен второй тип – виртуальный, когда слово stored опущено. Такая колонка не хранит значение, а вычисляет при каждом обращении. Разница подходов очевидна: первый снижает затраты на вычисления, но потребляет диск. Второй не требует диска, но вынуждает Postgres вычислять поле.
В нашем случае мы выбрали первый вариант с хранением на диске. Мы исходим из предположения, что ссылка на организацию никогда не меняется – передача заявок между организациями не вписывается ни в какие процессы. Напротив, обращаться к этому полю мы будем часто, так что имеет смысл рассчитать его заранее. Также мы считаем, что дисковое хранение в целом дешевле, чем процессорное время. Наконец, хотелось бы, чтобы примеры из книги работали на многих версиях Postgres, а не только начиная с 18.
Независимо от типа – хранимая или виртуальная – вычисляемая колонка доступна только для чтения. Записать в нее данные невозможно.
Вычисляемые колонки предъявляют некоторые требования. Их выражение должно быть чистым – не иметь побочных эффектов (случайных чисел, текущей даты и так далее). Одна вычисляемая колонка не может зависеть от другой: в противном случае пришлось бы строить граф зависимостей, что весьма накладно. Есть и другие, более тонкие условия, которые в данный момент нас не интересуют.
Вычисляемые колонки доступны в индексах и ограничениях. Вынесем поле заявки
((doc #>> '{organization,id}')::uuid) в вычисляемую колонку и наложим на нее
ограничение FOREIGN KEY. Первый шаг – подготовка поля:
alter table applications
add column _org_id uuid generated always
as ((doc #>> '{organization,id}')::uuid) stored;
Обратите внимание на два факта. Во-первых, поле начинается с подчеркивания, что говорит о его приватном характере. Во-вторых, выражение приводится к uuid, чтобы его тип совпадал с типом первичного ключа организаций. Приведение типа служит дополнительной проверкой: если добавить заявку с неправильной ссылкой, получим ошибку.
Выберем виртуальную колонку в запросе – она ничем не отличается от обычной:
select id, _org_id from applications limit 10;
┌──────────────────────────────────────┬──────────────────────────────────────┐
│ id │ _org_id │
├──────────────────────────────────────┼──────────────────────────────────────┤
│ 00000000-0000-0000-0000-000000635649 │ 00000000-0000-0000-0000-000000000649 │
│ 00000000-0000-0000-0000-000000635650 │ 00000000-0000-0000-0000-000000000650 │
│ 00000000-0000-0000-0000-000000635651 │ 00000000-0000-0000-0000-000000000651 │
│ 00000000-0000-0000-0000-000000635652 │ 00000000-0000-0000-0000-000000000652 │
│ 00000000-0000-0000-0000-000000635653 │ 00000000-0000-0000-0000-000000000653 │
Наложим ограничение, что колонка ссылается на организацию:
alter table applications add constraint fk_org_id
foreign key (_org_id) references organizations(id);
Поскольку мы подготовили данные правильно, ошибки не произошло. Теперь мы уверены, что целостность ссылок под защитой. Чтобы это проверить, добавим заявку с организацией, которой не существует:
insert into applications (doc) values ($$
{
"application_id": 1999111,
"organization": {
"id": "967849e5-9c59-4fa2-bc57-0a4a84bbd41c"
}
}
$$::jsonb);
-- ERROR: insert or update on table "applications" violates foreign key constraint "fk_org_id"
-- DETAIL: Key (_org_id)=(967849e5-9c59-4fa2-bc57-0a4a84bbd41c) is not present in table "organizations".
В обратную сторону: удалим организацию, на которую ссылается хотя бы одна заявка.
delete from organizations
where id = '00000000-0000-0000-0000-000000000649';
-- ERROR: update or delete on table "organizations" violates foreign key constraint "fk_org_id" on table "applications"
-- DETAIL: Key (id)=(00000000-0000-0000-0000-000000000649) is still referenced from table "applications".
В обоих случаях база не выполнила то, что нарушает ограничение.
В теле FOREIGN KEY можно указать реакцию на удаление. Выражение on delete
cascade означает, что документы, которые ссылаются на удаляемый, будут тоже
удалены. Чтобы проверить это в действии, удалим прошлое ограничение и наложим
новое:
alter table applications add constraint fk_org_id
foreign key (_org_id) references organizations(id)
on delete cascade;
Если теперь удалить организацию, исчезнут заявки, которые на нее ссылались. Проверим это:
delete from organizations
where id = '00000000-0000-0000-0000-000000000649';
select id from applications
where (doc #>> '{organization,id}') = '00000000-0000-0000-0000-000000000649';
-- (0 rows)
Каскадное удаление слишком радикально для документов. Иногда на одну сущность ссылаются десятки других, и удаление затронет нужные документы. Маловероятно, каскадное удаление понадобится в боевом запуске, но знать о нем не помешает.
FOREIGN KEY предлагает другой вариант удаления: on delete set null. С ним
удаляется только та запись, что мы указали, а ссылкам в других таблицах
присваивается NULL. Вспомним, однако, что поле _org_id – вычисляемое, поэтому
ему нельзя ничего присвоить. Postgres учитывает этот случай и не позволит
создать ограничение:
alter table applications add constraint fk_org_id
foreign key (_org_id) references organizations(id)
on delete set null;
-- ERROR: invalid ON DELETE action for foreign key constraint containing generated column
Можно решить проблему триггером. Перед удалением организации вызывается функция,
которая находит заявки и удаляет в них поле organization. Таким образом DELETE
сводится к UPDATE; колонка _org_id будет вычислена повторно и получит значение
NULL. Подготовим функцию триггера:
create or replace function fn_applications_org_ref_null()
returns trigger as $$
begin
update applications
set doc['organization'] = null
where (doc #>> '{organization,id}')::uuid = OLD.id;
return OLD;
end;
$$ language plpgsql;
А вот и сам триггер: он запускает функцию перед удалением организации.
create trigger trg_organizations_before_delete
before delete on organizations
for each row execute function fn_applications_org_ref_null();
Удалим организацию:
delete from organizations
where id = '00000000-0000-0000-0000-000000000651';
Проверим заявку, которая раньше на нее ссылалась. Поля организации пусты, триггер сработал:
select id, _org_id, doc -> 'organization' as org
from applications
where id = '00000000-0000-0000-0000-000000635651';
┌──────────────────────────────────────┬─────────┬──────┐
│ id │ _org_id │ org │
├──────────────────────────────────────┼─────────┼──────┤
│ 00000000-0000-0000-0000-000000635651 │ <null> │ null │
└──────────────────────────────────────┴─────────┴──────┘
Прочие сведения
Вычисляемые столбцы можно использовать и для других ограничений. Когда мы рассматривали уникальность, то столкнулись с похожей проблемой: выражение UNIQUE принимает столбец или их кортеж, но не выражение. Тогда мы выкрутились с помощью с уникального индекса, однако был и другой путь: создать вычисляемый столбец и назначить его уникальным. Предлагаем читателю проделать это самостоятельно.
Как и многие другие техники, вычисляемые колонки следует использовать осторожно. Не допускайте их большого количества. Не опирайтесь на них в запросах сверх меры, потому что в случае рефакторинга потребуется много исправлений. Каждая колонка должна быть обоснована тем или иным требованием.
То же самое относится к ограничениям CHECK, UNIQUE и FOREIGN KEY. В реляционной модели их преимущество бесспорно, однако в случае с документами это не всегда так. На долгой дистанции некоторые ограничения оказываются неудобны. Перед их внедрением убедитесь, что они действительно нужны – иными словами, такие требования диктует бизнес.
У вычисляемых колонок есть еще одно свойство: индекс по ним работает немного
быстрее, чем по выражениям. Как вы помните, индекс btree сопоставляет значение с
позицией строки, где оно находится. В редких случаях Postgres проверяет условие
повторно, даже если строка найдена. Скажем, мы создали индекс на выражение
(((doc #>> '{organization,id}')::uuid)) и выполнили запрос с условием по нему:
select ... where (((doc #>> '{organization,id}')::uuid)) = <UUID>
Чтобы выполнить условие повторно (это случается при нехватке памяти), Postgres
читает колонку doc и берет и из нее поле organization→id. Напомним, что
при определенном пороге значения jsonb сбрасываются в TOAST-таблицу, где они
хранятся в виде фрагментов. Чтобы получить документ, требуется прочитать и
соединить все фрагменты. Для больших документов это влечет дисковые операции и
время процессора.
Если же индекс создан по полю _org_id, обращение к документу требуется. Тип
uuid использует хранение PLAIN (без тостов и сжатия, см. главу 4), поэтому
значение находится в основной таблице, а не TOAST. Проверка _org_id = <UUID>
выполняется быстрее и потому предпочтительней.
Итак, мы рассмотрели ограничения документов на уровне базы данных: проверки CHECK, уникальные индексы, внешние ключи и вычисляемые столбцы. Что именно из этого применять – зависит от задачи и в целом остается на усмотрение читателя.
В следующей главе мы вернемся к теме, с которой временно переключились: как извлекать данные из JSON-документов, но на этот раз – более мощными средствами.
Нашли ошибку? Выделите мышкой и нажмите Ctrl/⌘+Enter