Рабочая модель 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» — все способы их читать.