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

Рабочая модель pg-schema

В главе «Быстрый старт» мы прошли весь путь от пустой базы до чтения связанных данных, многое приняв на веру. Теперь разберём, как pg-schema устроена. Работа с ней состоит из двух этапов. Сначала генератор читает метаданные PostgreSQL и строит описание схемы базы на уровне типов Haskell — модуль Sch.hs. Затем, при работе с данными, типы входных данных и результата определяют, какой SQL будет выполнен, а компилятор по описанию схемы проверяет, что эти типы соответствуют базе. В этой главе мы подробно посмотрим на оба этапа и увидим, какие ошибки ловит компилятор.

Генерация описания схемы

Описание схемы создаёт отдельная небольшая программа — генератор. Вот наш генератор целиком:

{-# LANGUAGE OverloadedStrings #-}
module Main (main) where

import Control.Monad (when)
import PgSchema.Generation
import System.Environment (getArgs)
import System.Exit (exitFailure)
import System.IO (hPutStrLn, stderr)

genNames :: GenNames
genNames = GenNames
  { schemas = ["tut", "audit"] -- all tables of these schemas
  , tables = []                -- and single tables of other schemas (none here)
  , addRelations =             -- relations that are missing in the DB
    [ AddRelation
      { name = "task_events_author"
      , from = "tut" ->> "task_events"
      , to = "tut" ->> "users"
      , cols = [("author_id", "id")] } ] }

main :: IO ()
main = do
  check <- elem "--check" <$> getArgs
  changed <- updateSchemaFile False "tutorial/app/app/Sch.hs"
    (Left "PG_CONN") -- connection string from the PG_CONN environment variable
    "Sch" -- name of the generated module
    "Sch" -- name of the schema type
    genNames
  when (check && changed) $ do
    hPutStrLn stderr "Sch.hs did not match the database and has been regenerated"
    exitFailure

Главное в нём — вызов updateSchemaFile. Он подключается к базе, читает метаданные и записывает описание в файл, если оно изменилось. Какие таблицы описать, определяет последний аргумент, значение типа GenNames:

data GenNames = GenNames
  { schemas :: [Text]   -- ^ generate data for all tables in these schemas
  , tables  :: [NameNS] -- ^ generate data for these tables
  , addRelations :: [AddRelation] -- ^ additional relations. Be careful!
  }

В описание можно включить все таблицы нескольких схем базы, отдельные таблицы и, если нужно, связи, которых нет в самой базе. Лучше, конечно, объявлять связи внешними ключами в базе, но это не всегда возможно. В сгенерированный файл попадут выбранные таблицы с их колонками, все внешние ключи между ними и все используемые типы, включая перечисления. Подробно о нескольких схемах, добавленных связях и о том, как встроить генератор в процесс разработки, рассказывает глава «На практике».

На практике pg-schema использовалась со схемами из 30–50 таблиц. Большую базу имеет смысл описать несколькими схемами Sch, и одна таблица может входить сразу в несколько из них.

Что лежит в Sch.hs

🔍 Этот раздел можно пропустить при первом чтении.

Сгенерированный модуль определяет тип Sch без значений, который служит именем схемы на уровне типов, и инстансы нескольких классов для него. Главный из них — CSchema: в нём перечислены все таблицы и все типы данных, которые в них используются, включая наш enum project_status:

module Sch where

import PgSchema.Import

data Sch

instance CSchema Sch where
  type TTabs Sch = '[ ( "audit" ->> "project_log" )
    ,( "tut" ->> "projects" ),( "tut" ->> "task_events" )
    ,( "tut" ->> "tasks" ),( "tut" ->> "users" ) ]

  type TTypes Sch = '[ ( "pg_catalog" ->> "_int8" ),( "pg_catalog" ->> "_text" )
    ,( "pg_catalog" ->> "float8" ),( "pg_catalog" ->> "inet" )
    ,( "pg_catalog" ->> "int4" ),( "pg_catalog" ->> "int8" )
    ,( "pg_catalog" ->> "jsonb" ),( "pg_catalog" ->> "text" )
    ,( "pg_catalog" ->> "timestamptz" ),( "tut" ->> "project_status" ) ]

Оператор ->> соединяет имя схемы базы с именем таблицы или типа, причём как на уровне типов, так и на уровне значений. Контекст класса CSchema, как и других классов, описывающих схему, позволяет перевести описание с уровня типов на уровень значений с помощью Data.Singletons.demote: так библиотека получает имена таблиц и колонок для генерации SQL.

Описание каждой таблицы — её колонки, первичный ключ и уникальные ключи — задаёт семейство типов TTabDef:

type family TTabDefSch (name :: NameNSK) :: TabDef' TL.Symbol where
  TTabDefSch ( "audit" ->> "project_log" ) = 'TabDef '[ "id","project_id","message","client_ip","created_at" ] '[ "id" ] '[  ]
  TTabDefSch ( "tut" ->> "projects" ) = 'TabDef '[ "id","owner_id","title","status","tags","created_at" ] '[ "id" ] '[  ]
  TTabDefSch ( "tut" ->> "task_events" ) = 'TabDef '[ "project_id","seq","event_no","kind","meta","author_id","created_at" ] '[ "project_id","seq","event_no" ] '[  ]
  TTabDefSch ( "tut" ->> "tasks" ) = 'TabDef '[ "project_id","seq","title","priority","payload","watchers" ] '[ "project_id","seq" ] '[  ]
  TTabDefSch ( "tut" ->> "users" ) = 'TabDef '[ "id","name","email","manager_id","created_at" ] '[ "id" ] '[ '[ "email" ] ]
  TTabDefSch name = TE.TypeError (TE.Text "In schema " TE.:<>: TE.ShowType Sch TE.:$$: TE.Text "table " TE.:<>: TE.ShowType name TE.:<>: TE.Text " is not defined."
    TE.:$$: TE.Text ""
    TE.:$$: TE.Text "Valid values are:"
    TE.:$$: TE.Text "  Tables: audit.project_log, tut.projects, tut.task_events, tut.tasks, tut.users."
    TE.:$$: TE.Text "")
instance (ToStar (TTabDef Sch name), ToStar name) => CTabDef Sch name where
  type TTabDef Sch name = TTabDefSch name

Самое важное для проверок — инстанс CDBFieldInfo. Он описывает каждое имя, которое можно использовать вместе с таблицей:

type family TDBFieldInfoSch (t :: NameNSK) (f :: TL.Symbol) :: RecFieldK NameNSK where
  TDBFieldInfoSch ( "tut" ->> "projects" ) "created_at" = 'RFPlain ('FldDef ( "pg_catalog" ->> "timestamptz" ) 'False 'True)
  TDBFieldInfoSch ( "tut" ->> "projects" ) "id" = 'RFPlain ('FldDef ( "pg_catalog" ->> "int8" ) 'False 'True)
  TDBFieldInfoSch ( "tut" ->> "projects" ) "owner_id" = 'RFPlain ('FldDef ( "pg_catalog" ->> "int8" ) 'False 'False)
  TDBFieldInfoSch ( "tut" ->> "projects" ) "status" = 'RFPlain ('FldDef ( "tut" ->> "project_status" ) 'False 'True)
  TDBFieldInfoSch ( "tut" ->> "projects" ) "tags" = 'RFPlain ('FldDef ( "pg_catalog" ->> "_text" ) 'False 'True)
  TDBFieldInfoSch ( "tut" ->> "projects" ) "title" = 'RFPlain ('FldDef ( "pg_catalog" ->> "text" ) 'False 'False)
  TDBFieldInfoSch ( "tut" ->> "projects" ) "project_log_project_id_fkey" = 'RFToHere ( "audit" ->> "project_log" )
      '[ 'Ref "project_id" ('FldDef ( "pg_catalog" ->> "int8" ) 'False 'False) "id" ('FldDef ( "pg_catalog" ->> "int8" ) 'False 'True) ]
  TDBFieldInfoSch ( "tut" ->> "projects" ) "tasks_project_id_fkey" = 'RFToHere ( "tut" ->> "tasks" )
      '[ 'Ref "project_id" ('FldDef ( "pg_catalog" ->> "int8" ) 'False 'False) "id" ('FldDef ( "pg_catalog" ->> "int8" ) 'False 'True) ]
  TDBFieldInfoSch ( "tut" ->> "projects" ) "projects_owner_id_fkey" = 'RFFromHere ( "tut" ->> "users" )
      '[ 'Ref "owner_id" ('FldDef ( "pg_catalog" ->> "int8" ) 'False 'False) "id" ('FldDef ( "pg_catalog" ->> "int8" ) 'False 'True) ]
  TDBFieldInfoSch ( "tut" ->> "projects" ) f = TE.TypeError (TE.Text "In schema " TE.:<>: TE.ShowType Sch
    TE.:$$: TE.Text "for table " TE.:<>: TE.ShowType ( "tut" ->> "projects" )
    TE.:$$: TE.Text "name " TE.:<>: TE.ShowType f TE.:<>: TE.Text " is not defined."
    TE.:$$: TE.Text ""
    TE.:$$: TE.Text "Valid values are:"
    TE.:$$: TE.Text "  Fields: id, owner_id, title, status, tags, created_at."
    TE.:$$: TE.Text "  Foreign key constraints: projects_owner_id_fkey, project_log_project_id_fkey, tasks_project_id_fkey."
    TE.:$$: TE.Text ""
    TE.:$$: TE.Text "Your source or target type or renamer is probably invalid."
    TE.:$$: TE.Text "")

...
  TDBFieldInfoSch ( "tut" ->> "users" ) "manager_id" = 'RFPlain ('FldDef ( "pg_catalog" ->> "int8" ) 'True 'False)
  TDBFieldInfoSch ( "tut" ->> "users" ) "users_manager_id_fkey" = 'RFSelfRef ( "tut" ->> "users" )
      '[ 'Ref "manager_id" ('FldDef ( "pg_catalog" ->> "int8" ) 'True 'False) "id" ('FldDef ( "pg_catalog" ->> "int8" ) 'False 'True) ]
...

instance (ToStar (TDBFieldInfo Sch t f), ToStar t, ToStar f) => CDBFieldInfo Sch t f where
  type TDBFieldInfo Sch t f = TDBFieldInfoSch t f

Каждое имя здесь отнесено к одному из видов. 'RFPlain — это колонка таблицы, 'RFFromHere — внешний ключ из этой таблицы в другую (ссылка на родителя), 'RFToHere — внешний ключ из другой таблицы в эту (ссылка на дочерние строки), а 'RFSelfRef — ссылка таблицы на саму себя, как users.manager_id. Последнее уравнение для каждой таблицы срабатывает, если имени нет, и выдаёт ошибку компиляции со списком допустимых имён. Поскольку всё это закрытые семейства типов, сообщения об ошибках получаются подробными, и мы скоро это увидим.

Для каждого перечисления в базе генерируется инстанс семейства данных PGEnum. Его конструкторы получаются из имени типа и значений перечисления:

data instance PGEnum Sch ( "tut" ->> "project_status" )
  = Project_status_draft | Project_status_active | Project_status_archived
  deriving (Show, Read, Ord, Eq, Generic, Bounded, Enum)

Аннотация

Каждая операция с данными получает первым аргументом аннотацию Ann. Это значение на уровне типов, и передаётся оно видимым аргументом типа:

data Ann = Ann
  { annRen  :: Renamer -- ^ Renamer to convert Haskell names to database names.
  , annSch  :: Type    -- ^ Schema with tables, relations and types.
  , annDepth :: Nat
  -- ^ Depth of the nested relations. It is mostly used to prevent cycles in types.
  , annTab  :: NameNSK -- ^ Name of the root table.
  }

Аннотация содержит четыре вещи. Ренеймер (annRen) переводит имена, которые мы используем в программе, в имена базы. Схема (annSch) — это сгенерированный тип Sch. Глубина (annDepth) ограничивает вложенность связей в операциях с деревьями данных и в основном защищает от бесконечных циклов в типах: обычно хватает 3–5. Наконец, annTab — базовая таблица операции, для операций с деревьями — его корень.

Ренеймер — это функция на уровне типов из имени в программе в имя в базе. Наш ренеймер переводит имена из camelCase в snake_case, а нескольким именам связей даёт удобные синонимы:

data MyRenamer :: Renamer

type family MyRenamerImpl (s :: Symbol) :: Symbol where
  MyRenamerImpl "projectOwner" = "projects_owner_id_fkey"
  MyRenamerImpl "manager" = "users_manager_id_fkey"
  MyRenamerImpl "subordinates" = "users_manager_id_fkey"
  MyRenamerImpl s = CamelToSnake s

type instance ApplyRenamer MyRenamer s = MyRenamerImpl s

Перевод из camelCase в snake_case библиотека даёт готовым: это семейство типов CamelToSnake. Имена, которые уже записаны в snake_case, оно не меняет, поэтому с таким ренеймером можно писать имена и так, и так.

Ренеймер, схема и глубина обычно одни и те же для всей программы, поэтому удобно завести синоним, в котором остаётся только имя таблицы:

type MyAnn t = 'Ann MyRenamer Sch 3 ("tut" ->> t)

Первые запросы

Начнём с простого: вставим пользователя и прочитаем всех пользователей. Данные опишем обычным record-типом:

data User = MkUser
  { name :: Text
  , email :: Maybe Text
  } deriving (Show, Generic)

Здесь и дальше conn — обычное соединение postgresql-simple, а все примеры этой главы лежат в tutorial/app/app/Tut/Basics.hs:

(cnt, tIns) <- insertSch_ (MyAnn "users") conn [MkUser "Benjy" Nothing]
(res, (tSel, selParams)) <- selectSch (MyAnn "users") @User conn qpEmpty
putStrLn $ "insert text: " <> T.unpack tIns
putStrLn $ "inserted " <> show cnt <> " rows"
putStrLn $ "select text: " <> T.unpack tSel
putStrLn $ "select params: " <> show selParams
putStrLn $ "selected rows: " <> show res
insert text: insert into tut.users(name,email) values (?,?)
inserted 1 rows
select text: select t0.name "name",t0.email "email" from tut.users t0 
select params: []
selected rows: [MkUser {name = "Benjy", email = Nothing}]
SQL, отформатированный для чтения
insert into tut.users(name,email)
values (?,?);

select t0.name "name",
       t0.email "email"
from tut.users t0;

Мы получили ту же запись, которую добавили, а кроме неё — текст запроса и его параметры (здесь их нет). Обратите внимание, что тип User содержит не все колонки таблицы users: запрос записывает и читает только поля этого типа. Вид операции определяется структурой входных и выходных данных, и для этого достаточно инстанса Generic. Типы полей и их необязательность при этом соответствуют типам колонок и тому, допускают ли они NULL.

🤔 Что будет, если ошибиться?

Посмотрим, как компилятор реагирует на ошибки в этом примере. Если опечататься в имени таблицы и написать MyAnn "Users", сообщение будет коротким:

• In schema Sch.Sch the table NameNS "tut" "Users" is not defined.

Имя схемы здесь записано как Sch.Sch, потому что модуль с примером импортирует Sch без единого имени (import Sch ()), и GHC указывает тип вместе с модулем. Если же переименовать поле email в mail, компилятор перечислит все колонки и связи таблицы users:

• In schema Sch.Sch
  for table "tut" ->> "users"
  name "mail" is not defined.

  Valid values are:
    Fields: id, name, email, manager_id, created_at.
    Foreign key constraints: users_manager_id_fkey, task_events_author, projects_owner_id_fkey.

  Your source or target type or renamer is probably invalid.

Проверяется и обязательность колонок. Колонка name объявлена NOT NULL, поэтому name :: Maybe Text не скомпилируется:

• You can't use Maybe for mandatory fields

  Table: NameNS "tut" "users"
  DB Field name: "name"
  DB Field type: NameNS "pg_catalog" "text"
  Haskell Field type: Maybe Text

А колонка email допускает NULL, поэтому без Maybe тоже ничего не выйдет:

• You have to use Maybe for nullable fields

  Table: NameNS "tut" "users"
  DB Field name: "email"
  DB Field type: NameNS "pg_catalog" "text"
  Haskell Field type: Text

Наконец, если тип поля просто не подходит к типу колонки, например email :: Maybe Int, сообщение получается менее дружелюбным:

• Could not solve: ‘CanConvert1
                      Sch.Sch
                      (NameNS "tut" "users")
                      "email"
                      (NameNS "pg_catalog" "text")
                      (TypDef "S" Nothing '[])
                      Int’
    arising from a use of ‘insertSch_’

Но и его можно прочитать: в схеме Sch у таблицы tut.users колонка email имеет тип pg_catalog.text, и преобразования в Int для неё нет. Какие типы Haskell подходят к каким типам PostgreSQL, сведено в приложении «Типы данных».

PgTag

Record-типы удобны, но не всегда: заводить отдельный тип ради пары полей утомительно, а одинаковые имена полей в разных типах требуют расширения DuplicateRecordFields. Поэтому в pg-schema есть вторая нотация — PgTag, поле с именем на уровне типов:

newtype PgTag s t = PgTag { unPgTag :: t }
  deriving stock (Eq, Ord, Functor, Foldable, Traversable)
  deriving newtype (Semigroup, Monoid)

-- Show / Read: for s :: Symbol use "field" =: value; otherwise
-- PgTag {unPgTag = …} (see PgSchema.Types).

type s := t = PgTag s t
infixr 5 :=

(=:) :: forall b. forall a -> b -> a := b
(=:) _ = coerce
infixr 5 =:

PgTag похож на известный тип PgTagged, но у него другие инстансы. Для удобства есть оператор (:=) на уровне типов и (=:) на уровне значений. Отдельные поля соединяются оператором (:.) из postgresql-simple, а вперемешку с ними можно использовать и Generic-рекорды. Вставим двух пользователей и попросим вернуть их id, manager_id и поля типа User:

(res2 :: ["id" := Int64 :. "managerId" := Maybe Int64 :. User], tIns2) <-
  insertSch (MyAnn "users") conn
    [ MkUser "Quentin" (Just "quentin@example.com")
    , MkUser "Jason" (Just "jason@example.com") ]
(res3 :: ["name" := Text], (tSel3, selParams3)) <- selectSch (MyAnn "users") conn qpEmpty
putStrLn $ "insert text: " <> T.unpack tIns2
putStrLn $ "inserted: " <> show res2
putStrLn $ "select text: " <> T.unpack tSel3
putStrLn $ "select params: " <> show selParams3
putStrLn $ "selected rows: " <> show res3
insert text: insert into tut.users(name,email) values (?,?) returning id,manager_id,name,email
inserted: ["id" =: 2 :. ("managerId" =: Nothing :. MkUser {name = "Quentin", email = Just "quentin@example.com"}),"id" =: 3 :. ("managerId" =: Nothing :. MkUser {name = "Jason", email = Just "jason@example.com"})]
select text: select t0.name "name" from tut.users t0 
select params: []
selected rows: ["name" =: "Benjy","name" =: "Quentin","name" =: "Jason"]
SQL, отформатированный для чтения
insert into tut.users(name,email)
values (?,?)
returning id,manager_id,name,email;

select t0.name "name"
from tut.users t0;

Здесь insertSch возвращает результат вставки (insert … returning), и набор колонок в returning определяется типом результата. Обратите внимание, что мы пишем managerId, а не manager_id: имена полей проходят через ренеймер. Если же написать managerId1, компилятор сообщит, что поля нет, и назовёт его уже после ренеймера, manager_id1:

• In schema Sch.Sch
  for table "tut" ->> "users"
  name "manager_id1" is not defined.

  Valid values are:
    Fields: id, name, email, manager_id, created_at.
    Foreign key constraints: users_manager_id_fkey, task_events_author, projects_owner_id_fkey.

  Your source or target type or renamer is probably invalid.

Резюме

  • Генератор строит по базе модуль Sch.hs: таблицы, колонки, ключи, связи и перечисления на уровне типов.
  • Каждая операция получает аннотацию Ann: ренеймер, схему, глубину и базовую таблицу.
  • Ренеймер переводит имена из программы в имена базы; в сообщениях об ошибках имена уже переведены.
  • SQL операции определяется типами входных данных и результата, а компилятор проверяет их по описанию схемы: имена, обязательность и типы колонок.
  • Данные можно описывать Generic-рекордами, полями PgTag или смешивать обе нотации.

Что дальше

В главе «Изменение данных в одной таблице» мы разберём все способы вставлять, изменять и удалять строки, а в главе «Select» — все способы их читать.