Учебник по pg-schema¶
pg-schema — это библиотека
для работы с PostgreSQL из Haskell, в которой правильность запросов
проверяет компилятор. Схема базы один раз извлекается из живой базы
PostgreSQL в Haskell-модуль, и дальше select, insert, update и delete
строятся с проверками на этапе компиляции. Опечатка в имени колонки, связь не
с той таблицей, значение не того типа, вложенные данные не той формы — всё
это становится ошибкой компиляции, а не ошибкой при выполнении запроса. А
когда схема базы меняется, достаточно перегенерировать модуль, и компилятор
покажет все места в программе, которые нужно поправить.
pg-schema работает поверх postgresql-simple: операции получают обычное
соединение Connection и выполняются в обычных транзакциях. По описанию схемы
selectSch читает данные вместе со связанными строками, переходя по внешним
ключам, а insertJSON и upsertJSON записывают целое дерево данных —
например, проект вместе с задачами — одной операцией. Поддерживаются
перечисления, json и jsonb, массивы и составные ключи, а для того, что
нельзя выразить средствами библиотеки, есть выход в произвольный SQL.
Вот как выглядит типичный запрос:
type MyAnn tab = 'Ann RenamerId Sch 5 ("tut" ->> tab)
(rows :: ["title" := Text :. "tasks_project_id_fkey" := ["title" := Text :. "priority" := Int32]], (sql, params)) <-
selectSch (MyAnn "projects") conn $ qRoot do
qWhere $ "status" =? Project_status_active
qPath "tasks_project_id_fkey" do
qWhere $ "priority" <? (50 :: Int32)
MyAnn — аннотация, общая для всех операций: Sch — сгенерированное описание
схемы, "tut" ->> tab — таблица в схеме базы tut, RenamerId означает, что
имена в программе совпадают с именами в базе, а 5 ограничивает глубину
вложенности связей. Поле tasks_project_id_fkey названо по внешнему ключу,
который связывает задачи с проектом; с помощью ренеймера связям и колонкам
можно дать и привычные имена в camelCase. Подробно всё это разбирается в
главе «Рабочая модель pg-schema».
Тип результата задаёт, какие поля и связи нужно прочитать: здесь это проекты
со списком их задач (поля описаны через PgTag: "title" := Text, а :.
соединяет поля в строку). qRoot задаёт условия для корневой таблицы
projects, а qPath — для ветки задач. Вот что вернёт этот запрос на
учебных данных:
"title" =: "Website" :. "tasks_project_id_fkey" =: ["title" =: "Design" :. "priority" =: 10,"title" =: "Frontend" :. "priority" =: 30]
"title" =: "API" :. "tasks_project_id_fkey" =: ["title" =: "Schema" :. "priority" =: 20,"title" =: "Endpoints" :. "priority" =: 40]
[SomeToField 50,SomeToField Project_status_active]
Весь результат читается одним SQL-запросом, который pg-schema строит сама:
select t0.title "title",
array_to_json(array(
select jsonb_build_object('title',t1.title,'priority',t1.priority)
from tut.tasks t1
where t1.project_id=t0.id and t1.priority < ?)) "tasks_project_id_fkey"
from tut.projects t0
where t0.status = ?;
А если опечататься в имени колонки и написать "priorty", программа не
скомпилируется, и компилятор перечислит все колонки таблицы:
• In schema Sch
for table "tut" ->> "tasks"
name "priorty" is not defined.
Valid values are:
Fields: project_id, seq, title, priority, payload, watchers.
Foreign key constraints: tasks_project_id_fkey, task_events_project_id_seq_fkey.
Your source or target type or renamer is probably invalid.
Такие запросы легко делать сколь угодно сложными. Форму результата — какие таблицы, через какие связи и с какими полями — задаёт тип, а условия, сортировку и ограничения можно добавить к любой его ветке. По этому описанию строится один SQL-запрос, который читает всё дерево сразу. На практике такие запросы соединяют два десятка таблиц и больше. Написать такой SQL вручную очень трудно, а склеивать его из кусочков в коде трудно поддерживать. Здесь сложность и безопасность работают вместе: чем больше запрос, тем ценнее то, что каждое имя и каждый тип в нём проверены компилятором.
Наконец, для чтения и записи строк данных не нужны никакие инстансы — ни
FromRow/ToRow, ни FromJSON/ToJSON. Достаточно вывести Generic или
описать поля через PgTag. (Инстансы понадобятся только собственным типам
значений внутри колонок, например типу, который хранится в jsonb.) Поэтому
легко использовать параметризованные структуры данных. Большие похожие
структуры в разных местах программы часто требуют разных полей: например, для
списка проектов нужны только названия, а для подробной карточки — ещё
владелец и задачи. Вместо двух почти одинаковых типов можно завести один,
параметризованный контекстом, а ненужным в данном контексте полям дать тип
() (If берётся из Data.Type.Bool, а == — из Data.Type.Equality):
data Context = List | Details
data Task = Task { title :: Text, priority :: Int32 }
deriving (Show, Generic)
data Project (c :: Context) = Project
{ title :: Text
, projects_owner_id_fkey :: If (c == 'List) () ("name" := Text)
, tasks_project_id_fkey :: If (c == 'List) () [Task]
} deriving Generic
whyParam :: Connection -> IO ([Project 'List], [Project 'Details])
whyParam conn = do
(list, _) <- selectSch (MyAnn "projects") conn qpEmpty -- titles only
(details, _) <- selectSch (MyAnn "projects") conn qpEmpty -- with the owner and tasks
pure (list, details)
Оба вызова selectSch в whyParam выглядят одинаково: что именно читать,
pg-schema определяет по типу результата, а он задан сигнатурой. Поля типа
() не читаются вовсе. Для Project 'List запрос сводится к выборке
названий из одной таблицы projects, без соединения с пользователями и без
подзапроса для задач. Для Project 'Details из базы приходят и владелец (это
одна строка: внешний ключ ведёт из проекта к пользователю), и список задач.
Лишнего из базы не вытаскивается, а тип остаётся один.
Производительность¶
О скорости работы pg-schema заботится с самого начала. Главное здесь —
количество обращений к базе: дерево данных читается одним запросом и
записывается одной операцией, вместо множества отдельных запросов к связанным
таблицам, каждый из которых — отдельный обмен с сервером. На практике именно
это даёт основной выигрыш.
Проверки на уровне типов не бесплатны для компилятора, и этому тоже
уделялось много внимания. На схеме примерно из 50 таблиц и запросах,
соединяющих два десятка таблиц и больше, время компиляции остаётся
приемлемым. Большую базу можно описать несколькими схемами Sch. Заметнее
всего нагрузка видна по HLS: он потребляет больше памяти, чем в проектах без
вычислений на уровне типов, но для работы в реальных проектах это приемлемо.
Другие библиотеки¶
Среди библиотек для типобезопасной работы с базой из Haskell есть, например,
opaleye, beam и persistent. В них схема базы описывается в коде на Haskell, а
запросы собираются из выражений встроенного языка, что позволяет записать
почти любой запрос. pg-schema устроена иначе. Описание схемы берётся из
самой базы, поэтому оно не может разойтись с ней незаметно. А запрос не
пишется, а строится по типу результата: вы описываете, какие данные нужны, и
получаете один запрос, который их читает. Для сложных деревьев данных это
короче и надёжнее, а для того, что так не выразить, остаётся выход в
произвольный SQL. Что касается hasql, это альтернатива не pg-schema, а
postgresql-simple, поверх которого pg-schema работает.
Требования¶
- GHC ≥ 9.10: библиотека использует видимые аргументы типов
(
selectSch (MyAnn "users") …). Включать для этого расширениеRequiredTypeArgumentsв своём коде не нужно. Tutorial проверяется на GHC 9.12.4 (см.cabal.projectв корне репозитория). - PostgreSQL и
psql/createdbвPATH. - Расширения GHC. Примеры написаны на
GHC2024, в который уже входятDataKindsиTypeApplications. Дополнительно нужны:OverloadedStrings— для строковых значений (Text) в данных и условиях. Имена таблиц, полей и связей ("title",MyAnn "users") от него не зависят: это литералы уровня типов (Symbol);BlockArguments— дляqRoot do …иqPath "…" do …;TypeFamiliesиUndecidableInstances— для своего ренеймера;DuplicateRecordFields— если у нескольких record-типов есть поля с одинаковыми именами (например,titleу проекта и задачи).
Структура¶
- Быстрый старт — от пустой БД до вставки и чтения дерева данных.
- Рабочая модель pg-schema — генерация схемы, первые insert и select.
- Изменение данных в одной таблице — insert, update, upsert и delete.
- Select — запросы с условиями, связи между таблицами.
- Изменение связанных данных — запись дерева данных одной операцией:
insertJSON,upsertJSON,updateJSON. - Ошибки компиляции — типичные сообщения компилятора и что с ними делать.
- На практике — несколько схем базы, связи без внешних ключей, миграции и проверка в CI.
В справочнике собраны правила, которые в главах показаны на примерах, а в приложении «Типы данных» — соответствие типов PostgreSQL и Haskell и то, как добавить свои типы.
Перед стартом¶
Понадобятся запущенный PostgreSQL, GHC с cabal (см. требования выше) и
клон репозитория pg-schema: в нём лежат примеры учебника. В своём проекте
достаточно подключить пакет pg-schema с Hackage. Всё остальное — создание учебной базы, генерация
схемы и запуск примеров — описано в главе «Быстрый старт».
Примеры лежат в tutorial/app, SQL-скрипты — в tutorial/sql, а главы — в
tutorial/docs.