Типы данных¶
Для каждого поля в типах данных компилятор проверяет, подходит ли его тип
Haskell к типу колонки в базе. В этом приложении собраны соответствия типов,
которые pg-schema поддерживает сама, и показано, как добавить свои.
Как устроена проверка¶
Из сгенерированного Sch.hs компилятор знает про каждую колонку три вещи:
имя её типа в PostgreSQL (например, "pg_catalog" ->> "text"), допускает ли
она NULL и описание типа TypDef. Описание типа состоит из трёх частей:
категории типа, типа элемента (только для массивов, иначе 'Nothing) и
списка значений (только для перечислений, иначе пустой). Категория — это
буква из поля typcategory системного каталога pg_type: A — массив,
B — логический, C — составной, D — дата и время, E — перечисление,
G — геометрический, I — сетевой адрес, N — числовой, R — диапазон,
S — строковый, T — интервал, U — пользовательский (например, json,
uuid, bytea), V — битовая строка.
Сначала проверяется Maybe. Значение NULL нельзя прочитать в обычный тип,
поэтому у колонки, которая допускает NULL, тип поля должен быть Maybe t,
а у обязательной Maybe запрещён. Как и всё остальное, это проверяется при
компиляции; сообщения о таких ошибках разобраны в главе
«Ошибки компиляции». Затем для оставшегося типа t
должно найтись уравнение открытого семейства типов CanConvert1:
type family CanConvert1 sch (tab :: NameNSK) (fld :: Symbol) (tn :: NameNSK) (td :: TypDefK) t :: Constraint
Его аргументы — схема, таблица, колонка, имя типа колонки, описание этого
типа и тип Haskell. Колонка здесь — это имя в базе (email, created_at),
то есть уже после ренеймера. Если подходящего уравнения нет, компилятор
сообщит Could not solve: CanConvert1 … и перечислит все эти аргументы. В
сообщении они выглядят чуть иначе, чем в коде: "tut" ->> "users" GHC
печатает как NameNS "tut" "users", а 'TypDef — без апострофа. Увидев такое
сообщение, проверьте тип поля по таблице ниже и, если тип выбран сознательно,
добавьте для него уравнение, как описано в разделе «Свои типы».
Массивы и перечисления библиотека обрабатывает отдельно. Для массива
проверяется тип его элемента. Значение массива строится функцией pgArr'
из списка, а unPgArr возвращает список с элементами Maybe, потому что в
массиве PostgreSQL может оказаться NULL (unPgArr' возвращает список без
пустых элементов). Для перечисления генератор создаёт
в Sch.hs тип с конструкторами вида Project_status_active (см. главу
«Рабочая модель pg-schema»), и их можно использовать
прямо во входных данных и в условиях.
Поддерживаемые типы¶
Если колонка допускает NULL, тип из таблицы оборачивается в Maybe.
| Тип в PostgreSQL | Тип в Haskell |
|---|---|
boolean |
Bool |
smallint (int2) |
Int16 или Scientific |
integer (int4) |
Int32 или Scientific |
bigint (int8) |
Int64 или Scientific |
real, double precision |
Double или Scientific |
numeric |
Scientific, а также Fixed k: соответствие его точности колонке не проверяется |
text, varchar, name |
Text |
"char" |
PgChar |
citext |
CI Text |
date |
Day |
time |
TimeOfDay |
timestamp |
LocalTime |
timestamptz |
UTCTime или ZonedTime |
uuid |
UUID |
bytea |
Binary ByteString (строгий или ленивый) |
json, jsonb |
любой тип с FromJSON и ToJSON |
| enum | PGEnum Sch ("схема" ->> "тип"), сгенерированный в Sch.hs |
| массив | PgArr t, где t — тип элемента |
Обычный Int для целочисленных колонок не поддерживается: используйте тип
нужного размера (Int16, Int32, Int64). PgArr, PgChar и
PGEnum экспортирует PgSchema.DML. Scientific берётся из пакета
scientific, CI — из case-insensitive,
UUID — из uuid. Типы даты и времени, uuid и citext поддерживаются,
пока включены флаги пакета pg-schema time, uuid и case-insensitive;
по умолчанию все три включены.
Свои типы¶
Поскольку CanConvert1 — открытое семейство, к нему можно добавить свои
уравнения. Так можно, например, хранить адрес электронной почты в отдельном
типе-обёртке, а не в голом Text. Для этого нужны две вещи.
Во-первых, сам тип должен уметь передаваться в базу и обратно. Какие инстансы для этого нужны, зависит от того, где он используется:
| Где используется тип | Нужные инстансы |
|---|---|
корень selectSch |
FromField |
входные данные и условия любых операций, а также insertSch, updateByKey и другие операции с одной таблицей |
ToField (и FromField для returning) |
вложенные ветки результата selectSch |
FromJSON |
insertJSON, upsertJSON, updateJSON |
ToJSON и FromJSON |
Проще всего дать типу сразу все четыре инстанса. Для обёртки над типом,
который их уже имеет, они выводятся через deriving newtype.
Во-вторых, нужно уравнение CanConvert1, которое разрешает этот тип для
нужных колонок. Оно может быть общим, для всех колонок типа text во всех
таблицах (CanConvert1 sch tab fld ("pg_catalog" ->> "text") ('TypDef "S" x y) Email),
а может быть и узким, только для одной колонки одной таблицы. Узкое уравнение
полезно тем, что компилятор не даст по ошибке записать Email в какое-нибудь
другое текстовое поле. Переменные x и y в шаблоне 'TypDef "S" x y
означают «любой тип элемента» и «любые значения» — для скалярного типа это
'Nothing и пустой список. (Уравнения открытого семейства не должны
противоречить друг другу, но общее и узкое уравнение для одного типа могут
жить вместе, потому что их правые части совпадают.)
Уравнение пишется в своём модуле, а не в Sch.hs, который перезаписывается
при генерации. Если оно стоит в том же модуле, что и сам тип, оно не будет
«сиротским» (orphan), и предупреждений не будет. Понадобятся расширения
DataKinds и TypeFamilies (остальные в примере нужны для самих запросов)
и импорт PgSchema.DML, который экспортирует CanConvert1 и TypDef':
{-# LANGUAGE BlockArguments #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TypeFamilies #-}
import Data.Aeson (FromJSON, ToJSON)
import Data.Int (Int64)
import Data.Text (Text)
import Database.PostgreSQL.Simple (Connection)
import Data.Text.Encoding (decodeUtf8)
import Database.PostgreSQL.Simple.FromField (FromField (..), ResultError (..), returnError)
import Database.PostgreSQL.Simple.ToField (ToField (..))
import PgSchema.DML
import Sch (Sch)
newtype Email = Email Text
deriving newtype (Show, Eq, FromField, ToField, FromJSON, ToJSON)
-- Email may be used only for the email column of tut.users
type instance CanConvert1 sch ("tut" ->> "users") "email" ("pg_catalog" ->> "text") ('TypDef "S" x y) Email = ()
Теперь Email можно использовать везде, где встречается колонка
users.email: во входных данных, в результате и в условиях. Пример
выполняется на данных главы «Select»:
_ <- insertSch_ (MyAnn "users") conn $ named ["id", "name", "email"]
[ (5 :: Int64) :. ("Eve" :: Text) :. Just (Email "eve@example.com") ]
(rows, (sql, _)) <- selectSch (MyAnn "users") conn $ qRoot do
qWhere $ "email" =? Just (Email "eve@example.com")
print (plains @("name" := Text :. "email" := Maybe Email) rows)
["Eve" :. Just "eve@example.com"]
SQL, отформатированный для чтения
select t0.name "name",
t0.email "email"
from tut.users t0
where t0.email = ?;
А для колонки users.name, у которой тот же тип text, Email уже не
подойдёт. Если в этом примере написать "name" := Email вместо
"name" := Text, программа не скомпилируется:
• Could not solve: ‘CanConvert1
Sch.Sch
(NameNS "tut" "users")
"name"
(NameNS "pg_catalog" "text")
(TypDef "S" Nothing '[])
Email’
arising from a use of ‘selectSch’
Тип PostgreSQL без готового соответствия¶
Точно так же поддерживается тип PostgreSQL, которого нет в таблице выше. В
учебной базе такой тип есть: колонка audit.project_log.client_ip имеет тип
inet. Его имя и категорию проще всего посмотреть в сгенерированном
Sch.hs, где для каждого используемого типа есть уравнение TTypDefSch:
TTypDefSch ( "pg_catalog" ->> "inet" ) = 'TypDef "I" 'Nothing '[ ]. То же
относится и к доменам и другим типам, объявленным в вашей базе.
Здесь уже нельзя просто вывести инстансы через deriving newtype: FromField
для Text в postgresql-simple принимает только текстовые типы и отвергнет
inet. Поэтому FromField пишется вручную: значение приходит в текстовом
виде, и его достаточно декодировать. Уравнение CanConvert1 на этот раз
общее, для всех колонок типа inet:
newtype IpAddress = IpAddress Text
deriving newtype (Show, Eq, FromJSON, ToJSON)
-- FromField for Text accepts only text types, so we read
-- the text form of the inet value ourselves
instance FromField IpAddress where
fromField f = \case
Nothing -> returnError UnexpectedNull f ""
Just bs -> pure $ IpAddress (decodeUtf8 bs)
instance ToField IpAddress where
toField (IpAddress t) = toField t
-- IpAddress fits any column of type inet
type instance CanConvert1 sch tab fld ("pg_catalog" ->> "inet") ('TypDef "I" x y) IpAddress = ()
type AuditAnn t = 'Ann MyRenamer Sch 3 ("audit" ->> t)
(_, sqlIns) <- insertSch_ (AuditAnn "project_log") conn $ named ["projectId", "message", "clientIp"]
[ (1 :: Int64) :. ("opened" :: Text) :. Just (IpAddress "192.168.0.10") ]
(rows, (sql, _)) <- selectSch (AuditAnn "project_log") conn $ qRoot do
qWhere $ "clientIp" =? Just (IpAddress "192.168.0.10")
print (plains @("message" := Text :. "clientIp" := Maybe IpAddress) rows)
["opened" :. Just "192.168.0.10"]
SQL, отформатированный для чтения
insert into audit.project_log(project_id,message,client_ip)
values (?,?,?);
select t0.message "message",
t0.client_ip "clientIp"
from audit.project_log t0
where t0.client_ip = ?;