Skip to content

Data types

For every field in data types the compiler checks whether its Haskell type fits the type of the column in the database. This appendix lists the type mappings that pg-schema supports itself and shows how to add your own.

How the check works

From the generated Sch.hs the compiler knows three things about each column: the name of its PostgreSQL type (for example, "pg_catalog" ->> "text"), whether it allows NULL, and the type description TypDef. A type description has three parts: the type category, the element type (only for arrays, otherwise 'Nothing) and the list of values (only for enums, otherwise empty). The category is the letter from the typcategory field of the system catalog pg_type: A — array, B — boolean, C — composite, D — date and time, E — enum, G — geometric, I — network address, N — numeric, R — range, S — string, T — interval, U — user-defined (for example, json, uuid, bytea), V — bit string.

Maybe is checked first. A NULL value cannot be read into a plain type, so for a column that allows NULL the field type must be Maybe t, and for a mandatory column Maybe is forbidden. Like everything else, this is checked at compile time; the messages about such errors are covered in the chapter Compiler errors. Then, for the remaining type t, there must be an equation of the open type family CanConvert1:

type family CanConvert1 sch (tab :: NameNSK) (fld :: Symbol) (tn :: NameNSK) (td :: TypDefK) t :: Constraint

Its arguments are the schema, the table, the column, the name of the column type, the description of that type and the Haskell type. The column here is the database name (email, created_at), that is, already after the renamer. If there is no suitable equation, the compiler reports Could not solve: CanConvert1 … and lists all these arguments. In the message they look slightly different than in code: GHC prints "tut" ->> "users" as NameNS "tut" "users", and 'TypDef without the apostrophe. When you see such a message, check the field type against the table below and, if the type was chosen on purpose, add an equation for it as described in the section "Custom types".

Arrays and enums are handled by the library separately. For an array the type of its element is checked. An array value is built with the function pgArr' from a list, and unPgArr returns a list with Maybe elements, because a PostgreSQL array may contain NULL (unPgArr' returns a list without the empty elements). For an enum the generator creates in Sch.hs a type with constructors like Project_status_active (see the chapter Core workflow), and they can be used directly in input data and in conditions.

Supported types

If a column allows NULL, the type from the table is wrapped in Maybe.

PostgreSQL type Haskell type
boolean Bool
smallint (int2) Int16 or Scientific
integer (int4) Int32 or Scientific
bigint (int8) Int64 or Scientific
real, double precision Double or Scientific
numeric Scientific, and also Fixed k: whether its precision matches the column is not checked
text, varchar, name Text
"char" PgChar
citext CI Text
date Day
time TimeOfDay
timestamp LocalTime
timestamptz UTCTime or ZonedTime
uuid UUID
bytea Binary ByteString (strict or lazy)
json, jsonb any type with FromJSON and ToJSON
enum PGEnum Sch ("schema" ->> "type"), generated in Sch.hs
array PgArr t, where t is the element type

Plain Int is not supported for integer columns: use a type of the right size (Int16, Int32, Int64). PgArr, PgChar and PGEnum are exported by PgSchema.DML. Scientific comes from the package scientific, CI from case-insensitive, UUID from uuid. Date and time types, uuid and citext are supported as long as the pg-schema package flags time, uuid and case-insensitive are on; all three are on by default.

Custom types

Since CanConvert1 is an open family, you can add your own equations to it. This way you can, for example, keep an e-mail address in a separate wrapper type rather than in a bare Text. Two things are needed for this.

First, the type itself must be able to travel to the database and back. Which instances are needed depends on where it is used:

Where the type is used Required instances
the root of selectSch FromField
input data and conditions of any operation, as well as insertSch, updateByKey and other single-table operations ToField (and FromField for returning)
nested branches of a selectSch result FromJSON
insertJSON, upsertJSON, updateJSON ToJSON and FromJSON

The simplest way is to give the type all four instances at once. For a wrapper over a type that already has them, they are derived with deriving newtype.

Second, you need an equation of CanConvert1 that allows this type for the right columns. It can be general, for all columns of type text in all tables (CanConvert1 sch tab fld ("pg_catalog" ->> "text") ('TypDef "S" x y) Email), or narrow, only for one column of one table. A narrow equation is useful because the compiler will not let you write Email into some other text field by mistake. The variables x and y in the pattern 'TypDef "S" x y mean "any element type" and "any values"; for a scalar type these are 'Nothing and the empty list. (Equations of an open family must not contradict each other, but a general and a narrow equation for the same type can live together, because their right-hand sides are the same.)

The equation is written in your own module, not in Sch.hs, which is overwritten on generation. If it is in the same module as the type itself, it is not an orphan, and there are no warnings. You need the extensions DataKinds and TypeFamilies (the others in the example are needed for the queries themselves) and the import of PgSchema.DML, which exports CanConvert1 and 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 = ()

Now Email can be used wherever the column users.email appears: in input data, in the result and in conditions. The example runs on the data of the chapter 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"]
Formatted SQL
select t0.name "name",
       t0.email "email"
from tut.users t0
where t0.email = ?;

But for the column users.name, which has the same type text, Email no longer fits. If in this example we write "name" := Email instead of "name" := Text, the program does not compile:

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

A PostgreSQL type without a ready mapping

A PostgreSQL type that is not in the table above is supported the same way. The tutorial database has such a type: the column audit.project_log.client_ip has the type inet. Its name and category are easiest to look up in the generated Sch.hs, which has a TTypDefSch equation for each type used: TTypDefSch ( "pg_catalog" ->> "inet" ) = 'TypDef "I" 'Nothing '[ ]. The same applies to domains and other types declared in your database.

Here you can no longer simply derive the instances with deriving newtype: FromField for Text in postgresql-simple accepts only text types and rejects inet. So FromField is written by hand: the value arrives in text form, and it is enough to decode it. The CanConvert1 equation is general this time, for all columns of type 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"]
Formatted 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 = ?;