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

Типы данных

Для каждого поля в типах данных компилятор проверяет, подходит ли его тип 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 = ?;