Перейти к содержанию

Учебник по 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 у проекта и задачи).

Структура

  1. Быстрый старт — от пустой БД до вставки и чтения дерева данных.
  2. Рабочая модель pg-schema — генерация схемы, первые insert и select.
  3. Изменение данных в одной таблице — insert, update, upsert и delete.
  4. Select — запросы с условиями, связи между таблицами.
  5. Изменение связанных данных — запись дерева данных одной операцией: insertJSON, upsertJSON, updateJSON.
  6. Ошибки компиляции — типичные сообщения компилятора и что с ними делать.
  7. На практике — несколько схем базы, связи без внешних ключей, миграции и проверка в CI.

В справочнике собраны правила, которые в главах показаны на примерах, а в приложении «Типы данных» — соответствие типов PostgreSQL и Haskell и то, как добавить свои типы.

Перед стартом

Понадобятся запущенный PostgreSQL, GHC с cabal (см. требования выше) и клон репозитория pg-schema: в нём лежат примеры учебника. В своём проекте достаточно подключить пакет pg-schema с Hackage. Всё остальное — создание учебной базы, генерация схемы и запуск примеров — описано в главе «Быстрый старт». Примеры лежат в tutorial/app, SQL-скрипты — в tutorial/sql, а главы — в tutorial/docs.