Advanced DB Mappings
Warning
Danger
This tutorial is for opayele-0.6 and earlier. If you try these examples with the latest version of Opaleye they won't work. I am working on updating (and expanding these tutorials), and have already completed Instant Gratification.
If you'd like to support my work please subscribe to my newsletter and let me know at hello@haskelltutorials.com if these tutorials helped you in any way!
Overview
In this chapter we'll build upon what we did in the last chapter:
- We'll modify the
tenants
table, to be a little more typesafe by changing the type of thestatus
column to a PostgresENUM
(rather than atext
) and mapping it to a Haskell ADT. - We'll add a new table called
products
that will be used to store information of various products in our hypothetical ecommerce store - We'll change the
id
andcreatedAt
columns to be read-only, for greater type-safety while inserting records. - We'll change the primary keys,
tenants.id
andproducts.id
toTenantId
andProductId
respecively. Again, for greater type-safety.
SQL for table creation
-- -- Tenants -- create type tenant_status as enum('active', 'inactive', 'new'); create table tenants( id serial primary key timestamp with time zone not null default current_timestamp ,created_at timestamp with time zone not null default current_timestamp ,updated_at not null ,name text not null ,first_name text not null ,last_name text not null ,email text not null ,phone text not null default 'inactive' ,status tenant_status integer ,owner_id not null ,backoffice_domain text constraint ensure_not_null_owner_id check (status!='active' or owner_id is not null) );create unique index idx_index_owner_id on tenants(owner_id); create index idx_status on tenants(status); create index idx_tenants_created_at on tenants(created_at); create index idx_tenants_updated_at on tenants(updated_at); create unique index idx_unique_tenants_backoffice_domain on tenants(lower(backoffice_domain)); --- --- Products --- create type product_type as enum('physical', 'digital'); create table products( id serial primary key timestamp with time zone not null default current_timestamp ,created_at timestamp with time zone not null default current_timestamp ,updated_at integer not null references tenants(id) ,tenant_id not null ,name text ,description textnot null ,url_slug text not null default '{}' ,tags text[] char(3) not null ,currency numeric not null ,advertised_price numeric not null ,comparison_price numeric ,cost_price type product_type not null ,boolean not null default false ,is_published ,properties jsonb );create unique index idx_products_name on products(tenant_id, lower(name)); create unique index idx_products_url_sluf on products(tenant_id, lower(url_slug)); create index idx_products_created_at on products(created_at); create index idx_products_updated_at on products(updated_at); create index idx_products_comparison_price on products(comparison_price); create index idx_products_tags on products using gin(tags); create index idx_product_type on products(type); create index idx_product_is_published on products(is_published);
Code that we'll run through
code/opaleye-tenants-and-products.hs
Warning
In the code given above, we are using PGFloat8
to represent monetary values. This is a bad idea and absolutely not recommended. We are forced to do this because Opaleye's support for Postgres NUMERIC
datatype is not really complete.
Core mechanism for mapping custom Haskell types to PG types
There are three typeclasses at play in converting values between Haskell types (like Int, Text and other user defined types) and PG types (like PGInt4, PGText etc). These are:
FromField
QueryRunnerColumnDefault
Data.Profunctor.Product.Default
(notData.Default
)
FromField
This is a typeclass defined by the postgresql-simple library <https://www.stackage.org/haddock/lts-7.14/postgresql-simple-0.5.2.1/Database-PostgreSQL-Simple-FromField.html>_. This typeclass decides how values read from database are converted to their Haskell counterparts. It is defined as:
class FromField a where
fromField :: FieldParser a
type FieldParser a = Field -> Maybe ByteString -> Conversion a
The basic idea of this typeclass is simple. It wants you to define a function fromField
which will be passed the following:
Field
- a record holding a lot of metadata about the underlying Postgres columnMaybe ByteString
- the raw value of that column
You are expected to return a Conversion a
which is conceptually an action, which when evaluated will do the conversion from Maybe ByteString
to your desired type a
.
Diligent readers will immediately have the following questions:
What kind of metadata does Field have?
name :: Field -> Maybe ByteString
tableOid :: Field -> Maybe Oid
tableColumn :: Field -> Int
format :: Field -> Format
typeOid :: Field -> Oid
-- and more
How does one write a (Conversion a) action?
Good question! The answer is that we (the authors of this tutorial) don't know! And we didn't feel the need to find out as well. Because you already have the fromField
functions for a lot of pre-defined Haskell types. In practice, you usually compose them to obtain your desired Conversion
action. Read the other sections in this chapter to find examples of how to do this.
Note
Please submit a PR if you know how to build a Conversion a
action from scratch without using other fromField
instance functions.
QueryRunnerColumnDefault
This typeclass is used by Opaleye to do the conversion from PG types, to Haskell types (eg. PGText
to Text
). It is defined as:
- class QueryRunnerColumnDefault pgType haskellType where
queryRunnerColumnDefault :: QueryRunnerColumn pgType haskellType
Opaleye provides us with a fieldQueryRunnerColumn <>_ function:
fieldQueryRunnerColumn:: FromField haskell => QueryRunnerColumn pgType haskell
As the type signature shows, fieldQueryRunnerColumn can return a value of type QueryRunnerColumn a b as long as b is an instance of FromField typeclass. So once we define an instance of FromField for our type, all we have to do is the following.
For the data type TenantStatus that we saw earlier, :
instance QueryRunnerColumnDefault PGText TenantStatus where
queryRunnerColumnDefault = fieldQueryRunnerColumn
Default
Note
This is not the Data.Default
that you may be familiar with. This is Data.Profunctor.Product.Default
This is a typeclass that Opaleye uses to convert Haskell values to Postgres values while writing to the database. It is defined as:
class Default (p :: * -> * -> *) a b where
def :: p a b
You see a type variable p
, that this definition required. Opaleye provided with a type Constant that can be used here. It is defined as :
newtype Constant haskells columns
= Constant {constantExplicit :: haskells -> columns}
So if we are defining a Default instance for the TenantStatus we saw earlier, it would be something like this. :
instance Default Constant TenantStatus (Column PGText) where
def = Constant def'
where
def' :: TenantStatus -> (Column PGText)
def' TenantStatusActive = pgStrictText "active"
def' TenantStatusInActive = pgStrictText "inactive"
def' TenantStatusNew = pgStrictText "new"
Newtypes for primary keys
Ideally, we would like to represent our primary keys using newtypes that wrap around an Int
. For example:
newtype TenantId = TenantId Int
newtype ProductId = ProductId Int
This is generally done to extract greater type-safety out of the system. For instance, doing this would prevent the following class of errors:
- Comparing a
TenantId
to aProductId
, which would rarely make sense. - Passing a
TenantId
to a function which is expecting aProductId
- At an SQL level, joining the
tenantTable
with theproductTable
by matchingtenants.id
toproducts.id
But it seems that Opaleye's support for this feature is not really ready. So we will skip it for now.
Mapping ENUMs to Haskell ADTs
Here's what our ADT for TenantStatus
looks like:
data TenantStatus = TenantStatusActive | TenantStatusInActive | TenantStatusNew
deriving (Show)
Here's how we would setup the DB => Haskell conversion. If you notice, we didn't really need to bother with how to build Conversion TenantStatus
because once we know what the incoming ByteString is, we know exactly which ADT value it should map to. We simply return
that value, since Conversion
is a Monad.
instance FromField TenantStatus where
= makeTenantStatus mb_bytestring
fromField field mb_bytestring where
makeTenantStatus :: Maybe ByteString -> Conversion TenantStatus
Just "active") = return TenantStatusActive
makeTenantStatus (Just "inactive") = return TenantStatusInActive
makeTenantStatus (Just "new") = return TenantStatusNew
makeTenantStatus (Just _) = returnError ConversionFailed field "Unrecognized tenant status"
makeTenantStatus (Nothing = returnError UnexpectedNull field "Empty tenant status"
makeTenantStatus
instance QueryRunnerColumnDefault PGText TenantStatus where
= fieldQueryRunnerColumn queryRunnerColumnDefault
TODO: As we saw in the Typeclasses section, Opaleye requires the QueryRunnerColumnDefault typeclass instances for converting from data read from Database to Haskell values. the function fieldQueryRunnerColumn can return the value of the required type as long as there is a FromField instance for the required type.
Now, let's look at how to setup the Haskell => DB conversion.
instance Default Constant TenantStatus (Column PGText) where
= Constant def'
def where
def' :: TenantStatus -> (Column PGText)
TenantStatusActive = pgStrictText "active"
def' TenantStatusInActive = pgStrictText "inactive"
def' TenantStatusNew = pgStrictText "new" def'
Handing Postgres Arrays
Postgresql Array column are represented by the PGArray type. It can take an additional type to represent the kind of the array. So if the column is text[]
, the type needs to be PGArray PGText
.
If you look at the earlier code, you can see that the output contains a list for the tag
fields.
Handling JSONB
The type that represents jsonb
postgresql columns in Opaleye is PGJsonb
. It will support any type that has a ToJSON/FromJSON instances defined for it.
ToJSON/FromJSON typeclasses are exported by the Aeson json library.
This is how it is done. Let us change the properties field of the Product type we saw earlier into a record in see how we can store it in a jsonb field.
code/opaleye-products-with-json-properties.hs
In the emphasized lines in code above, we are defining instances to support json conversion. The binary operators .: and .= that you see are stuff exported by the Aeson json library. The basis of Json decoding/encoding is the aeson's Value type. This type can represent any json value. It is defined as :
data Value
= Object !Object
| Array !Array
| String !Text
| Number !Scientific
| Bool !Bool
| Null
The Object type is an alias for a HashMap, and Array for a Vector and so on.
The instances are our usual type conversion instances. The Value type has the instances built in, so we will use them for defining instances for ProductProperties. So when we define a FromField instance for ProductProperties, we use the fromField instance of the Value type. We are also handling errors that might occur while parsing and reporting via postgresql's error reporting functions.
In the last instance, we are using the Default instance of the aforementioned Value type to implement instance for ProductProperties. The toJSON converts our ProductProperties to Value type, and since there are already built in Default instance for Value type, we were able to call the constant function on it, to return the appropriate opaleye's column type.
Making columns read-only
Sometimes we will want to make a certain column read only, accepting only values generated from the database. Here is how we can do it.
We have to define a new function readOnly, which will make the required field of type (), in the write types so we won't be able to provide a value for writing.
code/opaleye-readonly.hs
The type Conversion is a functor, so you can define instances for custom types in terms of existing FromField instances. For example, if you have a type that wraps an Int, like
data ProductId = ProductId Int
You can make a field parser instance for ProductId as follows :
instance FromField ProductId where
fromField field mb_bytestring = ProductId <$> fromField field mb_bytestring
While doing the above method, you have to make sure that the FromField instance that you are depending on can actually accept data from the underlying database column. This is relavant if you want to do this for enum types.
If you depend on the FromField instance of a String to read the data coming from an Enum field, it will error out because the FromField instance of String checks if the data is coming from a Varchar or Char field (using the first argument to the fromField function), and errors out if it is not.
Since the second argument to the fromField functon is a Maybe Bytestring, for a data type TenantStatus defined as :
data TenantStatus = TenantStatusActive | TenantStatusInActive | TenantStatusNew
we could do the following :
instance FromField TenantStatus where
fromField field mb_bytestring = makeTenantStatus mb_bytestring
where
makeTenantStatus :: Maybe ByteString -> Conversion TenantStatus
makeTenantStatus (Just "active") = return TenantStatusActive
makeTenantStatus (Just "inactive") = return TenantStatusInActive
makeTenantStatus (Just "new") = return TenantStatusNew
makeTenantStatus (Just _) = returnError ConversionFailed field "Unrecognized tenant status"
makeTenantStatus Nothing = returnError UnexpectedNull field "Empty tenant status"
With OverloadedStrings extension enabled, we could pattern match on Bystrings using normal String literals, and return the proper value. You can also see how we are handling unexpected values or a null coming from the column.