In this alternative we are going to iterate a bit on what we did before. We are going to keep our functions writen in terms of a generic monad which allows switching the actual implementation for tests. But we are going to move the BookDB value out of the function argument into an implicit state. We are going to do so by implementing the ReaderT design pattern.
The HasConsole type class remains unchanged.
class (Monad m) => HasConsole m where
getStringInput :: String -> m String
printLine :: String -> m ()For performing the book search we are going to define a HasReadOnlyBookDB monad with a findBook that takes only the search query and returns a (monadic) list of books as result.
class (Monad m) => HasReadOnlyBookDB m where
findBook :: String -> m [Book]Where is the BookDB? It will appear soon. Before looking into it, we can implement the rest of the functions.
The main' and loop function takes no argumetns since the BookDB is going to be implicit now. The implemention is mostly like the previous alternative.
main' :: (HasConsole m, HasReadOnlyBookDB m) => m ()
main' = do
printLine "Welcome to the Library"
loop
loop :: (HasConsole m, HasReadOnlyBookDB m) => m ()
loop = do
query <- getStringInput "Search: "
case query of
"" ->
printLine "Bye!"
_ -> do
books <- findBook query
if null books
then
printLine $ "No books found for: " <> query
else
printBookList books
loop
printBookList :: (HasConsole m) => [Book] -> m ()
printBookList books =
forM_ books (\book -> printLine $ " * " <> book.title <> ", " <> book.author)Where things differ from just using type classes and this alternative is that the monad that we are going to use for the production code is no longer IO but something else. It's going to be a ReaderT over and Env that will keep the implicit state (the BookDB) and the IO monad.
We can choose to use ReaderT directly but I prefer a different opaque type to track better where is used, and also to be able to restrict whether we want MonadIO or MonadFail for example.
{-# LANGUAGE GeneralisedNewtypeDeriving #-}
module App (Env (..), App, runApp) where
import Books
import Control.Monad.Reader
newtype Env = Env {db :: BookDB}
newtype App a = App (ReaderT Env IO a)
deriving (Functor, Applicative, Monad, MonadIO, MonadReader Env)
runApp :: Env -> App a -> IO a
runApp env (App f) =
runReaderT f envThanks to GeneralisedNewtypeDeriving we can derive Monad and MonadIO from the wrapped ReaderT.
The exported runApp will effectively run the App a on a given Env where the connection is specified. We can now adapt the main function to initialize the Env to run main'.
main :: IO ()
main = do
withDB
"./books.db"
( \db -> do
runApp Env {db = db} main'
)We still need to implement HasConsole App and HasReadOnlyBookDB App. The asks from MonadReader function gives us a convenient way to get specific values from the Env. The HasConsole App instance does not need access to the Env actually, but there is no need to do something more generic based on MonadIO.
instance HasConsole App where
getStringInput prompt = liftIO $ do
putStr prompt
hFlush stdout
getLine
printLine = liftIO . putStrLn
instance HasReadOnlyBookDB App where
findBook q = do
db' <- asks db
liftIO $ Book.findBook db' qA nice aspect of this alternative is that the tests don't differ much from the production code. We can also use ReaderT design pattern. We actually used it already in the previous alternative, but now with Env we can be a bit more tidy, or at least call it by this name.
data Env = Env {db :: BookDB, tape :: IORef [ConsoleTapeEntry]}
newtype TestApp a = TestApp (ReaderT Env IO a)
deriving (Functor, Applicative, Monad, MonadIO, MonadReader Env, MonadFail)
runTestApp :: Env -> TestApp a -> IO a
runTestApp env (TestApp f) =
runReaderT f envThe Env for TestApp will have the IORef of the expected HasConsole functions to be called and the implicit BookDB. We need to adapt the expectConsoleCalls to use runTestApp.
expectConsoleCalls :: BookDB -> [ConsoleTapeEntry] -> TestApp a -> IO a
expectConsoleCalls db tape' f = do
tape <- newIORef tape'
res <- runTestApp Env {db = db, tape = tape} f
rest <- readIORef tape
unless
(null rest)
(fail $ "Expected more calls to happen " <> show rest)
pure resThe rest of the helper functions for test don't differ much from the previous alternative. popTape needs to get the IORef via asks. The HasConsole TestApp is the same as before.
popTape :: String -> TestApp ConsoleTapeEntry
popTape msg = do
tape' <- asks tape
current <- liftIO $ readIORef tape'
when
(null current)
(fail $ "Unexpected call " <> msg)
liftIO $ writeIORef tape' (tail current)
pure (head current)
instance HasConsole TestApp where
getStringInput arg = do
entry <- popTape $ "GetStringInput " <> show arg
case entry of
GetStringInput arg' res' | arg' == arg -> pure res'
_ -> fail $ "Expected call matching " <> show entry <> " got GetStringInput " <> show arg
printLine arg = do
entry <- popTape $ "PrintLine " <> show arg
case entry of
PrintLine arg' res' | arg' == arg -> pure res'
_ -> fail $ "Expected call matching " <> show entry <> " got PrintLine " <> show argThe HasReadOnlyBookDB Test instance is the same as in the production code.
instance HasReadOnlyBookDB TestApp where
findBook :: String -> TestApp [Book]
findBook q = do
db' <- asks db
liftIO $ Books.findBook db' qReaderT design pattern is a more precise usage of type classes. Having implicit context via Env and working with IO/MonadIO offers better ergonomics than the handler pattern with Task. But the handler pattern is built on simpler concepts.
As a reminder, although our App implements MonadIO, we restrict additional side-effects from happening because the main', loop, printBookList functions don't use App directly but the type classes that don't have MonadIO.
[!note] You can find a working copy of this code in
app5andapp5-testin github:bcardiff/lambda-library