Core workflow¶
In the chapter Quick start we went all the way from an empty
database to reading related data, taking a lot on faith. Now let us see how
pg-schema works. Working with it has two stages. First, the generator reads
the PostgreSQL metadata and builds a description of the database schema at the
Haskell type level — the module Sch.hs. Then, when you work with data, the
types of the input and the result define which SQL is run, and the compiler uses
the schema description to check that these types match the database. In this
chapter we look at both stages in detail and see which errors the compiler
catches.
Generating the schema description¶
The schema description is created by a separate small program, the generator. Here is our generator in full:
{-# 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
The heart of it is the call of updateSchemaFile. It connects to the
database, reads the metadata and writes the description to the file if it has
changed. Which tables to describe is defined by the last argument, a value of
type 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!
}
The description can include all tables of several database schemas, single tables and, if needed, relations that do not exist in the database itself. It is better, of course, to declare relations as foreign keys in the database, but that is not always possible. The generated file gets the selected tables with their columns, all foreign keys between them and all the types they use, including enums. Several schemas, added relations and how to make the generator part of your development process are covered in the chapter In practice.
In practice pg-schema has been used with schemas of 30–50 tables. A large
database is better described by several Sch schemas, and one table may belong
to several of them.
What is in Sch.hs¶
You can skip this section on first reading.
The generated module defines an empty type Sch, which serves as the
type-level name of the schema, and instances of several classes for it. The
main one is CSchema: it lists all the tables and all the data types they use,
including our 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" ) ]
The operator ->> joins the name of a database schema with the name of a table
or a type, both at the type level and at the value level. The context of the
class CSchema, as of the other classes that describe the schema, lets the
description be brought from the type level to the value level with
Data.Singletons.demote: that is how the library gets the names of tables and
columns to generate SQL.
The description of each table — its columns, primary key and unique keys — is
given by the type family 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
The most important instance for the checks is CDBFieldInfo. It describes
every name that may be used with a table:
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
Each name here is of one of several kinds. 'RFPlain is a column of the table,
'RFFromHere is a foreign key from this table to another one (a reference to
the parent), 'RFToHere is a foreign key from another table to this one (a
reference to child rows), and 'RFSelfRef is a reference of a table to itself,
like users.manager_id. The last equation for each table fires when the name
is missing and produces a compile error with the list of valid names. Since all
these are closed type families, the error messages are detailed, as we will
soon see.
For each enum in the database an instance of the data family PGEnum is
generated. Its constructors are made of the type name and the enum values:
data instance PGEnum Sch ( "tut" ->> "project_status" )
= Project_status_draft | Project_status_active | Project_status_archived
deriving (Show, Read, Ord, Eq, Generic, Bounded, Enum)
The annotation¶
Every data operation takes an annotation Ann as its first argument. It is a
type-level value, and it is passed as a visible type argument:
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.
}
The annotation holds four things. The renamer (annRen) translates the names
we use in the program into database names. The schema (annSch) is the
generated type Sch. The depth (annDepth) limits the nesting of relations in
operations with trees of data and mostly protects against infinite cycles in
types: 3–5 is usually enough. Finally, annTab is the base table of the
operation, the root for operations with trees.
A renamer is a type-level function from a name in the program to a name in the database. Our renamer turns camelCase names into snake_case and gives a few relations handy synonyms:
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
The library provides camelCase-to-snake_case conversion out of the box: it is
the type family CamelToSnake. It leaves names already written in snake_case
unchanged, so with this renamer you can write names either way.
The renamer, the schema and the depth are usually the same for the whole program, so it is convenient to define a synonym that leaves only the table name as a parameter:
type MyAnn t = 'Ann MyRenamer Sch 3 ("tut" ->> t)
First queries¶
Let us start simple: insert a user and read all users. We describe the data with an ordinary record type:
data User = MkUser
{ name :: Text
, email :: Maybe Text
} deriving (Show, Generic)
Here and below conn is an ordinary postgresql-simple connection, and all
examples of this chapter are in 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}]
Formatted SQL
insert into tut.users(name,email)
values (?,?);
select t0.name "name",
t0.email "email"
from tut.users t0;
We got back the same record we inserted, and besides it the query text and its
parameters (there are none here). Note that the type User does not contain
all the columns of the users table: the query writes and reads only the
fields of this type. The kind of operation is defined by the structure of the
input and output data, and a Generic instance is all it takes. The field
types and their optionality match the column types and whether the columns
allow NULL.
What if we make a mistake?¶
Let us see how the compiler reacts to mistakes in this example. If we mistype
the table name and write MyAnn "Users", the message is short:
• In schema Sch.Sch the table NameNS "tut" "Users" is not defined.
The schema name is written as Sch.Sch here because the module with the
example imports Sch without any names (import Sch ()), so GHC prints the
type together with its module. If we rename the field email to mail, the
compiler lists all the columns and relations of the users table:
• 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.
Whether a column is mandatory is checked too. The column name is declared
NOT NULL, so name :: Maybe Text does not compile:
• 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
And the column email allows NULL, so without Maybe it does not work
either:
• 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
Finally, if the field type simply does not fit the column type, for example
email :: Maybe Int, the message is less friendly:
• Could not solve: ‘CanConvert1
Sch.Sch
(NameNS "tut" "users")
"email"
(NameNS "pg_catalog" "text")
(TypDef "S" Nothing '[])
Int’
arising from a use of ‘insertSch_’
But it can be read too: in the schema Sch, the column email of the table
tut.users has the type pg_catalog.text, and there is no conversion to Int
for it. Which Haskell types fit which PostgreSQL types is listed in the
appendix Data types.
PgTag¶
Record types are convenient, but not always: defining a separate type for a
couple of fields is tedious, and the same field names in different types need
the DuplicateRecordFields extension. So pg-schema has a second notation,
PgTag, a field with a type-level name:
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 is similar to the well-known type PgTagged, but has different
instances. For convenience there is the operator (:=) at the type level and
(=:) at the value level. Fields are joined with the operator (:.) from
postgresql-simple, and Generic records can be mixed in. Let us insert two
users and ask for their id, manager_id and the fields of the type 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"]
Formatted SQL
insert into tut.users(name,email)
values (?,?)
returning id,manager_id,name,email;
select t0.name "name"
from tut.users t0;
Here insertSch returns the result of the insert (insert … returning), and
the set of columns in returning is defined by the result type. Note that we
write managerId, not manager_id: field names go through the renamer. If we
write managerId1 instead, the compiler reports that there is no such field and
names it as it is after the renamer, 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.
Summary¶
- The generator builds the module
Sch.hsfrom the database: tables, columns, keys, relations and enums at the type level. - Every operation takes an annotation
Ann: the renamer, the schema, the depth and the base table. - The renamer translates names in the program into database names; error messages show names that are already translated.
- The SQL of an operation is defined by the types of the input and the result, and the compiler checks them against the schema description: names, mandatory columns and column types.
- Data can be described by Generic records, by
PgTagfields, or by a mix of both.
What next¶
In the chapter Single-table DML we look at all the ways to insert, update and delete rows, and in the chapter Select at all the ways to read them.