packages feed

dep-t (empty) → 0.1.0.0

raw patch · 7 files changed

+608/−0 lines, 7 filesdep +basedep +dep-tdep +mtlsetup-changed

Dependencies added: base, dep-t, mtl, rank2classes, tasty, tasty-hunit, template-haskell, transformers, unliftio-core

Files

+ CHANGELOG.md view
@@ -0,0 +1,5 @@+# Revision history for dep-t++## 0.1.0.0 -- YYYY-mm-dd++* First version. Released on an unsuspecting world.
+ LICENSE view
@@ -0,0 +1,30 @@+Copyright (c) 2021, Daniel Diaz++All rights reserved.++Redistribution and use in source and binary forms, with or without+modification, are permitted provided that the following conditions are met:++    * Redistributions of source code must retain the above copyright+      notice, this list of conditions and the following disclaimer.++    * Redistributions in binary form must reproduce the above+      copyright notice, this list of conditions and the following+      disclaimer in the documentation and/or other materials provided+      with the distribution.++    * Neither the name of Daniel Diaz nor the names of other+      contributors may be used to endorse or promote products derived+      from this software without specific prior written permission.++THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ README.md view
@@ -0,0 +1,194 @@+# dep-t++`DepT` is a+[ReaderT](http://hackage.haskell.org/package/mtl-2.2.2/docs/Control-Monad-Reader.html)-like+monad transformer for dependency injection.++The difference with `ReaderT` is that `DepT` takes an enviroment whose type is+parameterized by `DepT` itself.++## Rationale++To achieve dependency injection in Haskell, a common solution is to build a+record of functions and pass it to the program logic some variant of+[`ReaderT`](http://hackage.haskell.org/package/mtl-2.2.2/docs/Control-Monad-Reader.html).++Let's start by defining some auxiliary typeclasses to extract functions from an+environment record:++    type HasLogger :: Type -> (Type -> Type) -> Constraint+    class HasLogger r m | r -> m where+      logger :: r -> String -> m ()++    type HasRepository :: Type -> (Type -> Type) -> Constraint+    class HasRepository r m | r -> m where+      repository :: r -> Int -> m ()++We see that the type of the record determines the monad in which the effects take place.++Let's define a monomorphic record with effects in `IO`:++    type EnvIO :: Type+    data EnvIO = EnvIO+      { _loggerIO :: String -> IO (),+        _repositoryIO :: Int -> IO ()+      }++    instance HasLogger EnvIO IO where+      logger = _loggerIO++    instance HasRepository EnvIO IO where+      repository = _repositoryIO++Record-of-functions-in-IO is a simple technique which works well in many+situations. There are even [specialized+libraries](http://hackage.haskell.org/package/rio) that support it.++Here's a function which obtains its dependencies from the environment record:++    _mkControllerIO :: (HasLogger e IO, HasRepository e IO) => Int -> ReaderT e IO Int+    _mkControllerIO x = do+      doLog <- asks logger+      liftIO $ doLog "I'm going to insert in the db!"+      insert <- asks repository+      liftIO $ insert x+      return $ x * x++That's all and well, but there are two issues that bug me:++- What if the repository function needs access to the logger, too? The+  repository lives in the environment record, but isn't aware of it. That means+  it can't use the `HasLogger` typeclass for easy and convenient dependency+  injection. Why privilege the controller in such a way?++  In a sufficiently complex app, the diverse functions that comprise it will be+  organized in a big+  [DAG](https://en.wikipedia.org/wiki/Directed_acyclic_graph) of dependencies.+  And it would be nice if all the functions taking part in dependency injection+  were treated uniformly; if all of them had access to (some view of) the+  environment record.++- We might want to write code that is innocent of `IO` and polymorphic over the+  monad, to ensure that the program logic can't do some unexpected missile+  launch, or to allow testing our app in a "pure" way. ++Let's parameterize our environment by a monad: ++    type Env :: (Type -> Type) -> Type+    data Env m = Env+      { _logger :: String -> m (),+        _repository :: Int -> m (),+        _controller :: Int -> m Int+      }+    -- helper from the "rank2classes" package+    $(Rank2.TH.deriveFunctor ''Env)++    instance HasLogger (Env m) m where+      logger = _logger++    instance HasRepository (Env m) m where+      repository = _repository++Notice that the controller function is now part of the environment. No+favorites here!++The following implementation of the logger function has no dependencies besides+`MonadIO`:++    mkStdoutLogger :: MonadIO m => String -> m ()+    mkStdoutLogger msg = liftIO (putStrLn msg)++But look at this implementation of the repository function. It gets hold of the+logger through `HasLogger`:++    mkStdoutRepository :: (MonadReader e m, HasLogger e m, MonadIO m) => Int -> m ()+    mkStdoutRepository entity = do+      doLog <- asks logger+      doLog "I'm going to write the entity!"+      liftIO $ print entity++And here's the controller:++    mkController :: (MonadReader e m, HasLogger e m, HasRepository e m) => Int -> m Int+    mkController x = do+      doLog <- asks logger+      doLog "I'm going to insert in the db!"+      insert <- asks repository+      insert x+      return $ x * x++Now, lets choose `IO` as the base monad and assemble an environment record:++    envIO :: Env (DepT Env IO)+    envIO =+      let _logger = mkStdoutLogger+          _repository = mkStdoutRepository+          _controller = mkController+       in Env {_logger,  _repository, _controller}++Not very complicated, except... what is that weird `DepT Env IO` doing there in+the signature? ++Well, that's the whole reason this library exists. Trying to use a `ReaderT+(Env something) IO` to parameterize `Env` won't fly; you'll get weird "infinite+type" kind of errors because the `Env` needs to be parameterized with the monad+that provides the `Env` environment. So I created the `DepT` newtype over+`ReaderT` to mollify the compiler.++## How to embed environments into other environments?++Sometimes it might be convenient to nest an environment into another one,+basically making it a field of the bigger environment:++    type BiggerEnv :: (Type -> Type) -> Type+    data BiggerEnv m = BiggerEnv+      { _inner :: Env m,+        _extra :: Int -> m Int+      }+    $(Rank2.TH.deriveFunctor ''BiggerEnv)++When constructing the bigger environment, we have to tweak the monad parameter+of the smaller one, to make the types match. This can be done with the+`zoomEnv` function:++    biggerEnvIO :: BiggerEnv (DepT BiggerEnv IO)+    biggerEnvIO =+      let _inner' = zoomEnv (Rank2.<$>) _inner envIO+          _extra = pure+       in BiggerEnv {_inner = _inner', _extra}++We need to pass as the first parameter of `zoomEnv` a function that tweaks the+monad parameter of `Env` using a natural transformation. We can write such a+function ourselves, but here we are using the function generated for us by the+[rank2classes+TH](http://hackage.haskell.org/package/rank2classes-1.4.1/docs/Rank2-TH.html#v:deriveFunctor).++## How to use "pure fakes" during testing?++The [test suite](./test/tests.hs) has an example of using a `Writer` monad for+collecting the outputs of functions working as ["test+doubles"](https://martinfowler.com/bliki/TestDouble.html).++## Caveats++The structure of the `DepT` type might be prone to trigger a [known infelicity+of the GHC+simplifier](https://twitter.com/DiazCarrete/status/1350116413445439493).++## Links++- This library was extracted from my answer to [this Stack Overflow+  question](https://stackoverflow.com/a/61782258/1364288).++- The implementation of `mapDepT` was teased out in [this other SO question](https://stackoverflow.com/questions/65710657/writing-a-zooming-function-for-a-readert-like-monad-transformer).++- I'm unsure of the relationship between `DepT` and the technique described in+  [Adventures assembling records of+  capabilities](https://discourse.haskell.org/t/adventures-assembling-records-of-capabilities/623). ++  It seems that, with `DepT`, functions in the environment obtain their+  dependencies anew every time they are invoked. If we change a function in the+  environment record, all other functions which depend on it will be affected+  in subsequent invocations. I don't think this happens with "Adventures..." at+  least when changing an already "assembled" record.+
+ Setup.hs view
@@ -0,0 +1,2 @@+import Distribution.Simple+main = defaultMain
+ dep-t.cabal view
@@ -0,0 +1,42 @@+cabal-version:       3.0++name:                dep-t+version:             0.1.0.0+synopsis:            Reader-like monad transformer for dependency injection.+description:         Put all your functions in the environment record! Let all+                     your functions read from the environment record! No favorites!+-- bug-reports:+license:             BSD-3-Clause+license-file:        LICENSE+author:              Daniel Diaz+maintainer:          diaz_carrete@yahoo.com+category:            Control+extra-source-files:  CHANGELOG.md, README.md++source-repository    head+  type:     git+  location: https://github.com/danidiaz/dep-t.git++common common+  build-depends:       base >=4.10.0.0 && < 5,+                       transformers ^>= 0.5.0.0,+                       mtl ^>= 2.2,+                       unliftio-core ^>= 0.2.0.0,+  default-language:    Haskell2010++library+  import: common+  exposed-modules:     Control.Monad.Dep+  hs-source-dirs:      lib ++test-suite dep-t-test+  import: common+  type:                exitcode-stdio-1.0+  hs-source-dirs:      test+  main-is:             tests.hs+  build-depends:       +    dep-t, +    rank2classes       ^>= 1.4.1,+    template-haskell,+    tasty              >= 1.3.1,+    tasty-hunit        >= 0.10.0.2,
+ lib/Control/Monad/Dep.hs view
@@ -0,0 +1,146 @@+{-# LANGUAGE DeriveFunctor #-}+{-# LANGUAGE FlexibleInstances #-}+{-# LANGUAGE GeneralizedNewtypeDeriving #-}+{-# LANGUAGE ImportQualifiedPost #-}+{-# LANGUAGE MultiParamTypeClasses #-}+{-# LANGUAGE RankNTypes #-}+{-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE StandaloneDeriving #-}+{-# LANGUAGE StandaloneKindSignatures #-}+{-# LANGUAGE UndecidableInstances #-}++-- |+--    This package provides 'DepT', a monad transformer similar to 'ReaderT'.+--+--    The difference is that the environment of 'DepT' must be parameterized by+--    @DepT@'s own monad stack.+--+--    There's a function 'withDepT' which is analogous to 'withReaderT'.+--+--    There's no analogue of 'mapReaderT' however. This means you can't tweak+--    the monad below the 'DepT' with a natural transformation.+module Control.Monad.Dep+  ( DepT (DepT),+    runDepT,+    toReaderT,+    withDepT,+    zoomEnv,+  )+where++import Control.Applicative+import Control.Monad.Cont.Class+import Control.Monad.Error.Class+import Control.Monad.IO.Unlift+import Control.Monad.Reader+import Control.Monad.State.Class+import Control.Monad.Trans.Class+import Control.Monad.Trans.Identity+import Control.Monad.Writer.Class+import Control.Monad.Zip+import Data.Kind (Type)++-- |+--    A monad transformer which adds a read-only environment to the given monad.+--    The environment type must be parameterized with the transformer's stack.+--+--    The 'return' function ignores the environment, while @>>=@ passes the+--    inherited environment to both subcomputations.+type DepT ::+  ((Type -> Type) -> Type) ->+  (Type -> Type) ->+  Type ->+  Type+newtype DepT env m r = DepT {toReaderT :: ReaderT (env (DepT env m)) m r}+  deriving+    ( Functor,+      Applicative,+      Alternative,+      Monad,+      MonadFix,+      MonadFail,+      MonadZip,+      MonadPlus,+      MonadCont,+      MonadIO,+      MonadUnliftIO,+      MonadReader (env (DepT env m))+    )++instance MonadTrans (DepT env) where+  lift = DepT . lift++deriving instance MonadState s m => MonadState s (DepT env m)++deriving instance MonadWriter w m => MonadWriter w (DepT env m)++deriving instance MonadError e m => MonadError e (DepT env m)++-- |+--    Runs a 'DepT' action in an environment.+runDepT :: DepT env m r -> env (DepT env m) -> m r+runDepT = runReaderT . toReaderT++-- |+--    Changes the environment of a 'DepT', for example making the 'DepT' work in+--    a "bigger" environment than the one in which was defined initially.+--+--    The scary first parameter is a function that, given a natural+--    transformation of monads, changes the monad parameter of the environment+--    record. This function can be defined manually for each environment record,+--    or it can be generated using TH from the "rank2classes" package.+withDepT ::+  forall small big m a.+  Monad m =>+  -- | rank-2 map function+  ( forall p q.+    (forall x. p x -> q x) ->+    small p ->+    small q+  ) ->+  -- | get a small environment from a big one+  (forall t. big t -> small t) ->+  DepT small m a ->+  DepT big m a+withDepT mapEnv inner (DepT (ReaderT f)) =+  DepT+    ( ReaderT+        ( \big ->+            let small :: small (DepT small m)+                -- we have a big environment at hand, so let's extract the+                -- small environment, transform every function in the small+                -- environment by supplying the big environment and, as a+                -- finishing touch, lift from the base monad m so that it+                -- matches the monad expected by f.+                small = mapEnv (lift . flip runDepT big) (inner big)+             in f small+        )+    )++-- |+--    Makes the functions inside a small environment require a bigger environment.+--+--    This can be useful if we are encasing the small environment as a field of+--    the big environment, ir order to make the types match.+--+--    The scary first parameter is a function that, given a natural+--    transformation of monads, changes the monad parameter of the environment+--    record. This function can be defined manually for each environment record,+--    or it can be generated using TH from the "rank2classes" package.++-- For the reason for not inlining, see https://twitter.com/DiazCarrete/status/1350116413445439493+{-# NOINLINE zoomEnv #-}+zoomEnv ::+  forall small big m a.+  Monad m =>+  -- | rank-2 map function+  ( forall p q.+    (forall x. p x -> q x) ->+    small p ->+    small q+  ) ->+  -- | get a small environment from a big one+  (forall t. big t -> small t) ->+  small (DepT small m) ->+  small (DepT big m)+zoomEnv mapEnv inner = mapEnv (withDepT mapEnv inner)
+ test/tests.hs view
@@ -0,0 +1,189 @@+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE FlexibleInstances #-}+{-# LANGUAGE FunctionalDependencies #-}+{-# LANGUAGE ImportQualifiedPost #-}+{-# LANGUAGE MultiParamTypeClasses #-}+{-# LANGUAGE NamedFieldPuns #-}+{-# LANGUAGE RankNTypes #-}+{-# LANGUAGE StandaloneKindSignatures #-}+{-# LANGUAGE TemplateHaskell #-}++module Main (main) where++import Control.Monad.Dep+import Control.Monad.Reader+import Control.Monad.Writer+import Data.Kind+import Rank2 qualified+import Rank2.TH qualified+import Test.Tasty+import Test.Tasty.HUnit+import Prelude hiding (log)++-- Some helper typeclasses.+--+-- Has-style typeclasses can be provided to avoid depending on concrete+-- environments.+-- Note that the environment determines the monad.+type HasLogger :: Type -> (Type -> Type) -> Constraint+class HasLogger r m | r -> m where+  logger :: r -> String -> m ()++type HasRepository :: Type -> (Type -> Type) -> Constraint+class HasRepository r m | r -> m where+  repository :: r -> Int -> m ()++-- Some possible implementations.+--+-- An implementation of the controller, done programming against interfaces+-- (well, against typeclasses).+-- Polymorphic on the monad.+mkController :: (MonadReader e m, HasLogger e m, HasRepository e m) => Int -> m Int+mkController x = do+  doLog <- asks logger+  doLog "I'm going to insert in the db!"+  insert <- asks repository+  insert x+  return $ x * x++-- A "real" logger implementation that interacts with the external world.+mkStdoutLogger :: MonadIO m => String -> m ()+mkStdoutLogger msg = liftIO (putStrLn msg)++-- A "real" repository implementation +mkStdoutRepository :: (MonadReader e m, HasLogger e m, MonadIO m) => Int -> m ()+mkStdoutRepository entity = do+  doLog <- asks logger+  doLog "I'm going to write the entity!"+  liftIO $ print entity++-- The traces we accumulate from the fakes during tests+type TestTrace = ([String], [Int])++-- A "fake". A pure implementation for tests.+mkFakeLogger :: MonadWriter TestTrace m => String -> m ()+mkFakeLogger msg = tell ([msg], [])++-- Ditto.+mkFakeRepository :: (MonadReader e m, HasLogger e m, MonadWriter TestTrace m) => Int -> m ()+mkFakeRepository entity = do+  doLog <- asks logger+  doLog "I'm going to write the entity!"+  tell ([], [entity])+++--+--+-- Here we define a monomorphic environment working on IO+type EnvIO :: Type+data EnvIO = EnvIO+  { _loggerIO :: String -> IO (),+    _repositoryIO :: Int -> IO ()+  }++instance HasLogger EnvIO IO where+  logger = _loggerIO++instance HasRepository EnvIO IO where+  repository = _repositoryIO++-- In the monomorphic environment, the controller function lives "separate",+-- having access to the logger and the repository through the ReaderT+-- environment.+--+-- The question is: the repository function *also* needs to know about the+-- logger!  Shouldn't it be aware of the ReaderT environment as well? Why+-- privilege the controller function in such a manner?+--+-- In a sufficiently complex app, the diverse functions will form a DAG of+-- dependencies between each other. So it would be nice if the functions were+-- treated uniformly, all having access to (views of) the environment record.+_mkControllerIO :: (HasLogger e IO, HasRepository e IO) => Int -> ReaderT e IO Int+_mkControllerIO x = do+  doLog <- asks logger+  liftIO $ doLog "I'm going to insert in the db!"+  insert <- asks repository+  liftIO $ insert x+  return $ x * x++--+--+-- Here we define some polymorphic environments, which are basically+-- records-of-functions parameterized by an effect monad.+type Env :: (Type -> Type) -> Type+data Env m = Env+  { _logger :: String -> m (),+    _repository :: Int -> m (),+    _controller :: Int -> m Int+  }+$(Rank2.TH.deriveFunctor ''Env)++-- If our environment is parmeterized by the monad m, then logging is done in+-- m.+instance HasLogger (Env m) m where+  logger = _logger++instance HasRepository (Env m) m where+  repository = _repository++-- This bigger environment is for demonstrating how to "nest" environments.+type BiggerEnv :: (Type -> Type) -> Type+data BiggerEnv m = BiggerEnv+  { _inner :: Env m,+    _extra :: Int -> m Int+  }+$(Rank2.TH.deriveFunctor ''BiggerEnv)++--+--+-- Creating environment values and commiting to a concrete monad.+--+-- This is the first time DepT is used in this module.+-- Note that it is only here where we settle for a concrete monad for the+-- polymorphic environments.+env :: Env (DepT Env (Writer TestTrace))+env =+  let _logger = mkFakeLogger+      _repository = mkFakeRepository+      _controller = mkController+   in Env {_logger,  _repository, _controller}++-- An IO variant+envIO :: Env (DepT Env IO)+envIO =+  let _logger = mkStdoutLogger+      _repository = mkStdoutRepository+      _controller = mkController+   in Env {_logger,  _repository, _controller}++biggerEnv :: BiggerEnv (DepT BiggerEnv (Writer TestTrace))+biggerEnv =+  let -- We embed the small environment into the bigger one using "zoomEnv"+      -- and the rank-2 fmap that allows us to change the monad which+      -- parameterized the environment.+      --+      -- _inner' = (Rank2.<$>) (withDepT (Rank2.<$>) inner) env,+      _inner' = zoomEnv (Rank2.<$>) _inner env+      _extra = pure+   in BiggerEnv {_inner = _inner', _extra}++biggerEnvIO :: BiggerEnv (DepT BiggerEnv IO)+biggerEnvIO =+  let _inner' = zoomEnv (Rank2.<$>) _inner envIO+      _extra = pure+   in BiggerEnv {_inner = _inner', _extra}++expected :: TestTrace+expected = (["I'm going to insert in the db!", "I'm going to write the entity!"], [7])++tests :: TestTree+tests =+  testGroup+    "All"+    [ testCase "hopeThisWorks" $+        assertEqual "" expected $+          execWriter $ runDepT ((_controller . _inner $ biggerEnv) 7) biggerEnv+    ]++main :: IO ()+main = defaultMain tests