Запрос к PostgreSQL через связи:
SELECT item.name item_name, client.name client_name, manager.name manager_name, document_item.quantityFROM document_item, document(document_item), item(document_item), client(document), manager(document)
А вот он же классикой:
SELECT i.name item_name, p.name client_name, m.name manager_name, di.quantityFROM document_item diJOIN document d ON d.id = di.document_idJOIN item i ON i.id = di.item_idJOIN profile p ON p.client_id = d.client_idJOIN profile m ON m.manager_id = d.manager_id
(profile здесь — общая таблица персон с уникальными client_id и manager_id; вся демо-схема — ниже на диаграмме.)
Это ванильный Postgres — без расширений на C, без форка, без нового синтаксиса. Под катом — почему стандарт SQL так и не научился этому за сорок лет (соединения старше самих foreign keys), кто пытался его дожать — от черновиков девяностых до патча, лежащего в pgsql-hackers прямо сейчас, — и как включить такую навигацию в Postgres уже сегодня.
Схема знает связи. SQL заставляет их повторять
FOREIGN KEY — машинно-проверяемое объявление связи: колонки, целевая таблица, кардинальность. Всё это лежит в каталоге, и по этим объявлениям идёт подавляющее большинство реальных соединений — один из практиков в обсуждении на HN оценил эту долю в «95%+ моих джойнов». Но JOIN каждый раз начинается с чистого листа. Отсюда три беды:
-
Многословие. Пять связей — пять
ON, слово в слово пересказывающих схему. -
Тихие ошибки.
ONпримет любое равенство:ON d.id = di.item_idвместоdocument_idвыполнится молча — типы совпали. Ошибка всплывёт данными, а не компиляцией. -
Потеря смысла. По
ON a.x = b.yне видно: связь это или совпадение, кто родитель, размножатся ли строки в агрегате.
Боль не новая: язык начался с SEQUEL в 1974-м — соединения в нём были с первого дня, запятой во FROM. Первый стандарт SQL-86 обошёлся без ссылочной целостности, foreign keys добавила только ревизия SQL-89: синтаксис соединений старше самих связей. А явный JOIN ... ON появился в SQL-92 — комитет проектировал новый синтаксис соединений, уже имея FK перед глазами, и всё равно их не связал. До навигации по связям в запросах стандарт добрался лишь в SQL:2023 — и то графовыми паттернами, а не джойнами.
Пока комитет думал, задачу решили снаружи: любой ORM первым делом даёт навигацию по связям — @relation в Prisma, navigation properties в Entity Framework, select_related в Django. Прикладной код ушёл в ORM во многом именно за этим, стерпев и N+1, и утечки абстракций. Ирония: источник правды о связях всё это время лежал в базе. ORM его дублируют, GraphQL-слои интроспектируют — и только сам SQL делает вид, что ничего не знает.
SQL-сахар, который не помог
Сокращать условия соединения SQL пытался не раз. Все четыре попытки — мимо.
Запятая + WHERE. Досахарная эпоха: таблицы списком, условия в WHERE вперемешку с фильтрами. Забыл условие — получил декартово произведение, молча.
JOIN ... ON. Сегодняшний мейнстрим. Явно, гибко — и ровно с теми тремя бедами, что выше: пересказ, любое равенство, нулевая семантика.
JOIN ... USING (col). Короче, но соединяет по совпадению имён: обе таблицы обязаны называть колонку одинаково. Конвенция supplier_id = supplier_id проходит, самый частый паттерн document_item.document_id → document.id — уже нет. FK-констрейнты в решении не участвуют вовсе.
NATURAL JOIN. Апофеоз: соединить по всем одноимённым колонкам сразу. Добавили во вторую таблицу колонку updated_at, которая была в первой, — запрос молча начал соединять и по ней. Именно NATURAL JOIN дискредитировал саму идею «автоматических» соединений — хотя порок не в автоматике: порок в том, что фундаментом взяли имена колонок (случайность), а не объявленные связи (намерение).
Итог сорока лет стандарта: сказать «соедини по объявленной связи» он так и не разрешил. Либо пересказ схемы руками, либо угадывание по именам.
Кто пытался улучшить синтаксис SQL
Идея «соединять по объявленной связи» витает давно — и у неё богатый некрополь.
Драфты SQL, 1990-е. В черновиках стандарта существовал синтаксис JOIN ... USING PRIMARY KEY | USING FOREIGN KEY | USING CONSTRAINT. По свидетельству Питера Айзентраута, «these ideas just faded away because of other priorities» — идеи просто растворились.
Sybase SQL Anywhere: KEY JOIN. Единственная СУБД, которая это отгрузила — и держит десятилетиями (родословная: Watcom SQL → Sybase → с 2010 года SAP). FROM Products KEY JOIN SalesOrderItems соединяет по объявленному FK; больше того, KEY JOIN там дефолт — голый JOIN без ON означает именно его. Неоднозначность нескольких FK решена конвенцией: role name констрейнта должен совпасть с алиасом. Работает, но за пределы одной нишевой СУБД идея не вышла.
PostgreSQL, волна 2021. Джоэл Якобсон дважды приносил идею в pgsql-hackers: сначала path expressions по FK, затем «Foreign key joins revisited» — синтаксис мутировал девять дней (WITH p->fk = r → ON KEY p.fk → USING KEY) и утонул в байкшеддинге. Приговор Тома Лейна из того же треда стал классикой жанра: «NATURAL JOIN is widely regarded as a foot-gun that the SQL committee should never have invented. Why would we want to create another one?» — NATURAL JOIN повсеместно считают фут-ганом, который комитету не стоило изобретать; зачем создавать ещё один? Параллельный гист JOIN FOREIGN собрал ~200 комментариев на Hacker News — и все возражения, которые будут повторяться потом везде:
-
несколько FK между теми же таблицами — неоднозначность;
-
новый FK в схеме молча меняет (или ломает) старые запросы;
-
имена констрейнтов — плохой интерфейс: ORM генерируют их нечитаемыми;
-
запрос не должен зависеть от существования констрейнта — FK дропают под bulk load и шардинг.
jOOQ — единственный workaround, добравшийся до массового продакшена. Java-билдер реализовал .onKey() и implicit path joins (BOOK.author().name() сам синтезирует LEFT JOIN по FK). Показательно, что главное возражение jOOQ сформулировал сам, в собственном мануале: «The ON KEY clause can quickly produce ambiguities … queries that have worked in the past … will stop working» — ON KEY быстро порождает неоднозначности, работавшие запросы перестают работать — и увёл пользователей на path joins, где конкретный FK пришпилен кодогенерацией.
PostgreSQL, волна 2026 — прямо сейчас. В мае Якобсон вернулся с командой соавторов, рабочим патчем и change proposal в ISO-комитет: LEFT JOIN order_items oi FOR KEY (order_id) -> o (id) (интерактивные примеры — на keyjoin.org). Акцент сместился с «меньше печатать» на корректность: key join — это объявление, что соединение идёт по ссылочному пути, и база обязана это доказать (главный кейс — тихий fan-out: джойн 1:N незаметно размножает строки, и SUM() честно суммирует дубли). Томаш Вондра встретил патч уважительно, но жёстко: ~5200 строк патча («шанс, что я назову это committable — около нуля»), просадка планирования 30–40%. Тред жив, исход неясен, таймлайн стандартизации — годы.
Каждая волна разбивается об одно и то же: новый синтаксис — это грамматика, парсер, стандарт, обратная совместимость и консенсус комитета.
2026: идея победила везде — кроме SQL
Пока FK-джойны буксуют в комитетах, сама идея «объяви связь один раз — ходи по ней» победила везде вокруг SQL:
-
Графовые стандарты. SQL:2023 SQL/PGQ: объявляешь property graph поверх таблиц, запрашиваешь паттернами
MATCH (c IS customer)-[IS has_placed]->(o)— уже в Oracle 23ai, закоммичен в PostgreSQL 19 (март 2026, GA ожидается осенью), в DuckDB — расширением DuckPGQ; рядом отдельный ISO-язык GQL (2024) — Neo4j, Spanner Graph. Заметьте: даже стандарт не решился тронуть самJOIN— связи вынесены в отдельный графовый слой со своим синтаксисом. -
Семантические модели. Snowflake Semantic Views (GA 2025):
RELATIONSHIPS (orders (customer_id) REFERENCES customers (customer_id))объявляется в объекте схемы, запрос называет измерения и метрики — джойны генерирует движок. Malloy ту же мысль довёл до языка:join_one: users with user_idв модели, дальше просто путьusers.name. -
Слои над базой. Здесь FK-навигация — давно норма. PostgREST собирает вложенные ответы прямо из FK (
?select=title,directors(last_name)). PostGraphile строит из FK целую GraphQL-схему — пара полей на связь (personByAuthorId/postsByAuthorId), 1:1 по UNIQUE — и компилирует запрос в один SQL сjson_agg. А pg_graphql от Supabase исполняет GraphQL одной SQL-функциейgraphql.resolve(...)прямо внутри базы.
Многие независимые системы сошлись на одной и той же модели: FK → пара навигаций в обе стороны, 1:1 — по уникальности. Это конвергентная эволюция: метаданных foreign key достаточно для полной навигационной модели, вопрос лишь в том, на каком языке её отдать. Пока что ответ индустрии — «на любом, кроме самого SQL». pg_graphql доводит иронию до предела: FK-навигация уже живёт внутри вашего Postgres — но говорит на GraphQL.
И контрольный факт: pipe syntax — самая громкая реформа SQL последних лет (BigQuery, Spark 4.0) — переставила всё, кроме джойнов: |> JOIN требует тот же ON.
Решение: связь — это функция
А теперь фокус: для навигации по связям Postgres не нужен ни новый синтаксис, ни патч — не нужна даже свежая версия. Функции от строки таблицы и attribute notation (вызов функции через точку: document.client вместо client(document)) — наследие объектно-реляционных корней Postgres, а инлайн табличных SQL-функций планировщик умеет с 8.4, то есть с 2009 года, — приём должен работать даже на версиях, давно снятых с поддержки. Достаточно превратить каждый FK в пару функций: lookup — перейти по ссылке, и list — собрать ссылающиеся строки. Вот демо-схема, где каждое ребро подписано своей парой:
Эта же схема в SQL — example/init.sql в репозитории, а вместе с готовыми relation-функциями — в песочнице db<>fiddle.
Для одной связи это выглядит так. Lookup — клиент документа:
CREATE FUNCTION client(document) RETURNS SETOF profile LANGUAGE sql STABLE PARALLEL SAFEAS $$ SELECT * FROM public.profile WHERE (client_id) = (($1).client_id) $$;
List — все документы клиента:
CREATE FUNCTION client_document_list(profile) RETURNS SETOF document LANGUAGE sql STABLE PARALLEL SAFEAS $$ SELECT * FROM public.document WHERE (client_id) = (($1).client_id) $$;
Почему функции, а не вьюхи? VIEW фиксирует один готовый джойн, а не связь: вьюха не принимает строку, не компонуется в цепочки вида client(document(di)), не перегружается по типу аргумента — и внутри неё всё равно живёт тот самый рукописный ON. Функция — это связь, её аргумент — модель: ребро графа, по которому можно шагнуть из любого места запроса.
Во FROM такая функция ведёт себя как обычная таблица. LATERAL писать не нужно: функции и так видят соседей по FROM — документация прямо называет его noise word. Обязательная связь (FK-колонка NOT NULL) соединяется запятой. Опциональная (nullable) — через LEFT JOIN ... ON true: условие соединения уже зашито в функцию, а ON true стоит только потому, что LEFT JOIN без ON не бывает:
SELECT d.doc_number, p.name, m.name manager_nameFROM document d, client(d) pLEFT JOIN manager(d) m ON true
Как зовут связь
Здесь победила простота: функция называется тем, что связь значит, — а смысл уже записан в имени FK-колонки её автором. client_id даёт client(document); обратная функция зовётся по своей таблице — document_item_list(document). Несколько связей к одной цели расходятся по ролям: client / manager. Никакой теории именования: имена не вспоминают, а угадывают. Полный свод правил — в Naming rules; это дефолты, собранные в одном месте.
Это бесплатно
Ключевой вопрос к любой «магии» — цена. Ответ: неотличимо от ручного джойна. Когда relation-функция стоит во FROM, планировщик полностью инлайнит её: EXPLAIN совпадает с классикой узел в узел — те же Nested Loop / Hash Join, те же индексы, те же параллельные воркеры. На тестовой базе в 300k документов и 1.35M строк тяжёлый агрегат по цепочке из четырёх таблиц: медианы пяти прогонов — 576 мс классикой против 571 мс функциями; по отдельным прогонам «лидер» меняется, при совпадающих планах остаётся только межпрогонный шум. Разборы планов пар «классика vs связи» — в EXPLAIN.md.
И это не возврат к навигационным СУБД, от которых Кодд уводил индустрию в 1970-м: под сахаром та же реляционная алгебра — план идентичен ручному джойну, а навигация свободно смешивается с классикой в одном запросе.
Условия инлайна генератор гарантирует сам: LANGUAGE sql, STABLE, не STRICT, не SECURITY DEFINER и — неочевидное, добытое замером — PARALLEL SAFE: параллельность запроса решается до инлайна, и дефолтный PARALLEL UNSAFE молча отключает воркеров всему запросу (у нас это стоило ~1280 мс против 571 мс). Дрейф ловится штатно: генератор (о нём — ниже) сверяет тело и флаги каждой функции с эталоном, и функция, пересозданная руками или потерявшая PARALLEL SAFE, подсветится в его отчёте.
Генератор: одна функция на всё
Писать такие функции руками — тот же пересказ схемы, только в профиль. Поэтому их пишет сама база. Всё вместе называется pg_relation_sql и представляет собой один самоустанавливающийся SQL-файл relation_sql.sql — без расширений, без доступа к файловой системе сервера, достаточно PostgreSQL 11+. Установка — одна команда:
curl -sf https://raw.githubusercontent.com/asmgit/pg_relation_sql/main/relation_sql.sql | psql postgresql://postgres:postgres@localhost:5432/postgres
Загрузка сама создаёт функции и вешает event trigger, а в ответ печатает дашборд.
Внутри файла — единственная функция relation_sql(mode): она читает pg_constraint и приводит набор relation-функций в соответствие со схемой.
SELECT status, command FROM relation_sql();
pg_relation_sql 0.1.0 — relation functions generated from foreign keys | SELECT status, command FROM relation_sql() event trigger: installed | SELECT status, command FROM relation_sql('uninstall') relation functions: 16 ok, 0 to sync, 0 foreign, 0 duplicate | SELECT status, command FROM relation_sql('drop') details | SELECT * FROM relation_sql('show')
'show' — дифф по каждому FK с готовым SQL; 'sync' — применить разово (шаг миграций); 'install' — event trigger, после которого функции следуют за DDL сами: CREATE TABLE с FK рождает пару функций прямо внутри команды, DROP CONSTRAINT — убирает. Сделано аккуратно: сбой синхронизации никогда не ломает ваш DDL, чужую функцию с тем же именем генератор не тронет, а неразрешимую коллизию имён отдаст человеку вместо тихой перезаписи. Подробности режимов и статусов — в README.
Команде с CI-миграциями больше подходит другой путь — не триггер, а relation_sql('sync') шагом миграции: функции рождаются в том же pipeline, что и весь DDL, попадают в ревью и детерминированно воспроизводятся в любом окружении. Триггер — режим песочниц, соло-проектов и баз, где DDL и так делает один человек. pg_dump/restore при этом проходят чисто в обоих режимах: функции едут как обычные объекты, event trigger восстанавливается последним и посреди рестора не стреляет (проверено).
Что это даёт в запросах
Помимо исчезнувших ON — идиомы, которых у классики нет; вот пять любимых (цена анти-джойна на полнотабличных проходах — в ограничениях):
-- revenue per client: three hops, zero ONSELECT p.name, sum(di.quantity * i.price) revenueFROM profile p, client_document_list(p) d, document_item_list(d) di, item(di) iGROUP BY p.id;-- deliveries to Berlin: the composite FK stays hiddenSELECT d.doc_number, a.streetFROM document d, delivery_address(d) aWHERE a.city = 'Berlin';-- the whole related row as a fieldSELECT doc_number, (document.client).* FROM document;-- profiles without documentsSELECT name FROM profileWHERE NOT EXISTS (SELECT FROM client_document_list(profile));-- recursive tree walk: the row travels as a value, the relation makes the stepWITH RECURSIVE tree AS ( SELECT item node FROM item WHERE parent_item_id IS NULL UNION ALL SELECT c node FROM tree t, item_list(t.node) c)SELECT (node).name FROM tree;
Рекурсивный обход дерева, цепочки, JSON-вложенность, top-N, DML, INTERSECT/EXCEPT — все 19 пар «классика vs связи» в example/query.sql.
Честные ограничения
Инструменту можно верить, только если он знает свои границы. Наши:
-
NOT EXISTSпо функции не становится anti-join. Планировщик превращаетEXISTS-сублинки в semi/anti-join до инлайна функций, поэтомуNOT EXISTS (SELECT FROM client_document_list(profile))навсегда остаётся коррелированным SubPlan — индексная проба на каждую внешнюю строку. При селективном фильтре разницы нет; полнотабличный анти-джойн пишите классикой (наш замер: ~96 мс против ~40 мс на 100k×300k). -
Attribute notation в select-list не инлайнится — в плане
ProjectSet, вызов на строку; и семантика INNER: строка с пустой связью исчезает. Это сахар для точечных выборок; тяжёлые запросы — навигацией воFROM. (И это не N+1 из ORM: round-trip один, всё происходит внутри одного запроса — разница только в форме плана.) -
DROP TABLEтребуетCASCADE— relation-функции зависят от типа строки таблицы; остатки с другой стороны связи приберёт триггер или ближайшийsync. Автогенерённые миграции фреймворков, привыкшие к голомуDROP TABLE, здесь споткнутся — перед сносом таблицы дропните её функции точечно (готовый SQL даёт'show'). -
Новый FK переименовывает соседей. Второй FK на ту же таблицу добавляет ролевые префиксы:
document_listпревращается вclient_document_list, и старые запросы падают ошибкой компиляции — то самое возражение к KEY JOIN из 2021-го, только здесь поломка громкая, а'show'сразу показывает новые имена. -
Нет констрейнта — нет навигации. Как у всех FK-подходов: дроп FK убирает функцию, и зависящие запросы падают ошибкой компиляции — что честнее, чем тихо изменившийся результат.
-
Тело функции —
SELECT *, поэтому колоночные привилегии (GRANT SELECT (id, name)) с relation-функциями не уживутся: после инлайна запросу нужна вся строка. RLS, напротив, компонуется штатно — политики применяются к таблице после инлайна. -
Функций становится вдвое больше, чем FK, и живут они в схемах своих таблиц.
\dfшумнее, автокомплит длиннее; фильтровать и в тулинге, и в schema-diff (migra, atlas) можно поCOMMENT-маркеруpg_relation_sql— а лучше вести их обычнымsync-шагом миграций, тогда для инструментов это управляемый DDL. -
Event trigger требует суперюзера. Без прав
installне падает: шаг триггера пропускается сWARNING, аsyncвыполняется — функции появятся, автослежения за DDL не будет. -
Это диалект. Запрос с
client(d)исполним только там, где функции созданы, — как с вьюхами. IDE дополняет их как обычные функции каталога, но семантической подсказки «это связь» не даст; читателю нужна одна новая идея — «функция от строки = связь». Это цена любого сахара; взамен — связь, проверенная компиляцией, а не глазами ревьюера. -
Это Postgres-only. Зато без патчей: чистый SQL поверх ванильной базы.
Сахар, которого чуть-чуть не хватило
Три мелочи вдогонку к ограничениям: всё уже работает, но могло бы быть приятнее. В отличие от «больших» синтаксических волн из истории выше, это маленькие кандидаты для pgsql-hackers:
-
LEFT JOIN manager(document)безON true. Условие уже внутри функции,ON true— чистый грамматический шум, но опустить его нельзя. -
Attribute notation во
FROM. Было бы ещё выразительнее:FROM document_item, document_item.document, document.client— сейчас это syntax error. -
Инлайнить функции до трансформации
EXISTS. Поменяй планировщик порядок фаз — и первый пункт ограничений исчез бы вместе с разницей ~96/~40 мс.
Есть ирония в том, что статья о «решении без изменений ядра» упирается в список пожеланий к ядру. Но таков честный итог: девяносто процентов задачи закрывается тем, что в Postgres уже есть, — а оставшиеся десять стоят три маленьких патча, а не ~5200 строк key-join-патча.
SQL становится дружелюбнее
Эта история — часть волны побольше. Стандарт не торопится — диалекты не ждут: DuckDB friendly SQL — это FROM tbl перед SELECT, GROUP BY ALL, переиспользуемые алиасы выражений и ещё дюжина упрощений.
Отдельного поклона заслуживает SELECT * EXCLUDE (...). Признание: наш собственный генератор трижды перечисляет двадцать одну колонку — только потому, что в Postgres нельзя взять * и исключить пару лишних колонок. Перечисление колонок — та же болезнь, что перечисление join-условий: пересказ того, что схема уже знает. DuckDB вылечил её для колонок. Мы — для связей. Postgres для колонок пока не лечится.
Краткость как физика: довод для эпохи ИИ
Финальный довод — уже не про людей. SQL сегодня массово пишут и читают языковые модели, и для них краткость — не эстетика, а физика: меньше токенов контекста, меньше поверхности для галлюцинаций, проще ревью человеком. Join-условия — главный источник тихих ошибок генерации: модель напишет ON d.id = di.item_id вместо document_id, запрос выполнится, отчёт сойдётся почти всегда. document(di) исключает этот класс ошибок структурно: либо связь есть и условие верно по построению, либо ошибка компиляции. (Кардинальность — обязательная связь или опциональная — остаётся на авторе, как и в классике: функция проверяет путь, а не NOT NULL.) А словарь навигации агент получает одним запросом — SELECT * FROM relation_sql('show') возвращает все связи схемы с именами и направлениями, вплоть до карты вида document → client с готовым SELECT * FROM document, client(document) на каждую связь.
Десятилетиями SQL сжимали ради людей — диалектами, ORM, обвязкой. Теперь то же сжатие стало инфраструктурой для машин.
Попробовать за минуту
Всё решение — pg_relation_sql: один самоустанавливающийся SQL-файл для PostgreSQL 11+, который превращает каждый foreign key в пару relation-функций и держит их в синхроне со схемой.
Совсем без установки — песочница на db<>fiddle: демо-схема, готовые relation-функции и пять примеров навигации — пощупать можно прямо в браузере.
Хочется по-настоящему — в репозитории есть докер-стенд: демо-схема со всеми видами связей (example/init.sql), те самые 19 пар запросов (example/query.sql) и массовка в 300k документов и 1.35M строк (test/bigdata.sql) для честных планов (разборы — EXPLAIN.md):
git clone https://github.com/asmgit/pg_relation_sql.gitcd pg_relation_sql/test && docker compose up -d
Схема знает свои связи. Осталось начать её спрашивать.
Буду рад вопросам, возражениям и кейсам из реальных схем: если на каком-то именование споткнётся — докручу; правила дефолтные, и чем больше схем они переживут, тем лучше станут.
ссылка на оригинал статьи https://habr.com/ru/articles/1065684/