Swappable DB backend
So today, someone in #haskell-beginners
asked a question about switching the database for an application
based on a config file. Let's see a fairly simple solution for this problem. This is meant to be an intermediate tutorial. You should be comfortable with newtypes, typeclasses, monads and perhaps a little bit of json handling but not much more.
Step 1: define an interface for DB operations
First, let's define a typeclass for all database operations. The application will only use these operations and this typeclass. We don't want to put anything specific to a database there since it should be swappable.
class MonadDB m where
-- | count the number of item for the given table/collection
countItems :: String -> m Int
Only one method there to keep things simple, but you can add many more.
Step 2: Create a simple application using this interface
Let's create a very simple application using that.
compareSweets :: (MonadDB m, MonadIO m) => m ()
compareSweets = do
numberOfCakes <- countItems "cake"
numberOfCookies <- countItems "cookies"
if numberOfCakes > numberOfCookies
then putStrLn "There are more cakes than cookies in store"
else putStrLn "Oh noooes, bake more cakes !"
Step 3: Run the application with a postgresql backend
The basic application will be using the readerT pattern.
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
import Control.Monad.Reader as Rdr
-- from the package postgresql-simple
import qualified Database.PostgreSQL.Simple as PG
-- from the package safe-exceptions
import qualified Control.Exception.Safe as Exc
data PGEnv = PGEnv
{ pgConn :: PG.Connection
}
newtype PostgresApp m a = PostgresApp { runPostgresApp :: ReaderT PGEnv m a }
deriving (Functor, Applicative, Monad, MonadReader PGEnv, MonadIO, Exc.MonadThrow)
-- ^ we get all of that for free thanks to GeneralizedNewtypeDeriving
A simple wrapper to get access to a persistent connection to the database. This could also be a connection pool, or whatever is needed.
Now, we need to declare an instance of MonadDB
for this application.
instance (MonadIO m, Exc.MonadThrow m) => MonadDB (PostgresApp m) where
countItems tableName = do
conn <- Rdr.asks pgConn
result <- liftIO $ PG.query conn "select * from ?" (PG.Only tableName)
case result of
[PG.Only count] -> pure count
_ -> Exc.throwString $ "countItems blew up for table: " <> tableName
And now, let's run all of that:
main :: IO ()
main = do
-- connectToPostgresql is easy to write, we'll see later how to improve it
connection <- connectToPostgresql
let pgEnv = PGEnv { pgConn = connection }
Rdr.runReaderT (runPostgresApp compareSweets) pgEnv
Step 4: Get a config file in place
So far, everything was hardcoded. Let's introduce a simple config file in json format.
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DeriveGeneric #-}
import Data.Aeson as Aeson
import GHC.Generics
import GHC.Word
-- the basic config to read from a file
data PGConfig = PGConfig
{ host :: String
, port :: Word16
, user :: String
, password :: String
, database :: String
}
deriving (Show, Generic, Aeson.FromJSON)
The two extensions DeriveAnyClass
and DeriveGeneric
allow us to get a free FromJSON
instance.
Here's an example of a simple json file:
{
"host": "localhost",
"port": 5432,
"user": "postgres",
"password": "password123",
"database": "cake-factory"
}
Let's write some simple code to make sure everything is working as expected:
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE ScopedTypeVariables #-}
main :: IO ()
main = do
(config :: PGConfig) <- Aeson.eitherDecodeFileStrict "./config.json" >>= \case
Left err -> Exc.throwString $ "Cannot decode config file: " <> err
Right x -> pure x
print config
LambdaCase
is my favorite extension, it allows to do a case ... of
without having to conjure a name
just for the sake of matching on it. ScopedTypeVariables
is required there to let GHC know what
is the type of the thing we're trying to decode.
Now, let's plug the configuration in the application:
mkPGEnv :: PGConfig -> IO PGEnv
mkPGEnv conf = do
conn <- PG.connect $ PG.ConnectInfo
{ PG.connectHost = host conf
, PG.connectPort = port conf
, PG.connectUser = user conf
, PG.connectPassword = password conf
, PG.connectDatabase = database conf
}
pure $ PGEnv conn
main :: IO ()
main = do
config <- Aeson.eitherDecodeFileStrict "./config.json" >>= \case
Left err -> Exc.throwString $ "Cannot decode config file: " <> err
Right x -> pure x
env <- mkPGEnv config
Rdr.runReaderT (runPostgresApp compareSweets) env
Step 5: Extend the configuration
Now, to connect to another DB based on the configuration, we need to extend the config a little bit:
data DBConfig
= DBConfigPostgres PGConfig
| DBConfigMem -- imaginary in memory config
instance Aeson.FromJSON DBConfig where
parseJSON raw = Aeson.withObject "DBConfig" (\o -> do
dbType <- o .: "type" -- (.:) is coming from Aeson
case dbType of
"postgres" -> do
conf <- parseJSON raw
pure $ DBConfigPostgres conf
"memory" -> pure DBConfigMem
_ -> fail $ "Unknown DB type: " <> dbType
) raw
The config file now must have a field type
to specify the database to use. The other fields are the one
required for the specific DB.
Step 6: choose the implementation based on the config
main :: IO ()
main = do
config <- Aeson.eitherDecodeFileStrict "./config.json" >>= \case
Left err -> Exc.throwString $ "Cannot decode config file: " <> err
Right x -> pure x
case config of
DBConfigPostgres pgConf -> do
env <- mkPGEnv pgConf
Rdr.runReaderT (runPostgresApp compareSweets) env
DBConfigMem -> error "no backend for in memory db yet"
Conclusion
This is a fairly straightforward way to have multiple swappable DB for an haskell application. It's also pretty easy to test as a result, just use an in-memory DB.