Graphula Core
Graphula is a simple interface for generating persistent data and linking its dependencies. We use this interface to generate fixtures for automated testing. The interface is extensible and supports pluggable front-ends.
Arbitrary Data
Graphula utilizes QuickCheck
to generate random data. We need to declare Arbitrary
instances for our types.
share [mkPersist sqlSettings, mkMigrate "migrateAll"] [persistLowerCase|
A
a String
b Int
deriving Show Eq Generic
B
a AId
b String
deriving Show Eq Generic
C
a AId
b BId
c String
deriving Show Eq Generic
D
Id UUIDKey
a Int
b String
deriving Show Eq Generic
E
Id DId sqltype=uuid
a String
deriving Show Eq Generic
F
a Bool
UniqueFA a
deriving Show Eq Generic
|]
instance Arbitrary A where
arbitrary = A <$> arbitrary <*> arbitrary
instance Arbitrary B where
arbitrary = B <$> arbitrary <*> arbitrary
instance Arbitrary C where
arbitrary = C <$> arbitrary <*> arbitrary <*> arbitrary
instance Arbitrary D where
arbitrary = D <$> arbitrary <*> arbitrary
instance Arbitrary E where
arbitrary = E <$> arbitrary
instance Arbitrary F where
arbitrary = F <$> arbitrary
Dependencies
We declare dependencies via the HasDependencies
typeclass and its associated type Dependencies
.
By default a type does not have any dependencies. We only need to declare an empty instance.
instance HasDependencies A
instance HasDependencies F
For single dependencies we use the Only
type.
instance HasDependencies B where
type Dependencies B = Only AId
Groups of dependencies use tuples. Declare these dependencies in the order they appear in the type. HasDependencies
leverages generic programming to inject dependencies for you.
instance HasDependencies C where
type Dependencies C = (AId, BId)
Non Sequential Keys
Graphula supports non-sequential keys with the KeySource
associated type. To generate a key using
its Arbitrary
instance, use 'SourceArbitrary
. Non-serial keys will need to also derive
an overlapping Arbitrary
instance.
instance HasDependencies D where
type KeySource D = 'SourceArbitrary
deriving newtype instance {-# OVERLAPPING #-} Arbitrary (Key D)
You can also elect to always specify an external key using 'SourceExternal
. This means that
this type cannot be constructed with node
; use nodeKeyed
instead.
instance HasDependencies E where
type KeySource E = 'SourceExternal
By default, HasDependencies
instances use type KeySource _ = 'SourceDefault
, which means
that graphula will expect the database to provide a key.
Serialization
Graphula allows logging of graphs via runGraphulaLogged
. Graphula dumps graphs
to a temp file on test failure.
loggingSpec :: IO ()
loggingSpec = do
let
logFile :: FilePath
logFile = "test.graphula"
-- We'd typically use `runGraphulaLogged` which utilizes a temp file.
failingGraph :: IO ()
failingGraph = runGraphulaT Nothing runDB . runGraphulaLoggedWithFileT logFile $ do
Entity _ a <- node @A () $ edit $ \n ->
n {aA = "success"}
liftIO $ aA a `shouldBe` "failed"
failingGraph
`shouldThrow` anyException
n <- lines <$> readFile "test.graphula"
n `shouldSatisfy` (not . null)
Running It
simpleSpec :: IO ()
simpleSpec =
runGraphulaT Nothing runDB $ do
-- Type application is not necessary, but recommended for clarity.
Entity aId _ <- node @A () mempty
Entity bId b <- node @B (only aId) mempty
Entity _ c <- node @C (aId, bId) $ edit $ \n -> n { cC = "edited" }
Entity dId _ <- node @D () mempty
Entity eId _ <- nodeKeyed @E (EKey dId) () mempty
-- Do something with your data
liftIO $ do
cC c `shouldBe` "edited"
cA c `shouldBe` bA b
unEKey eId `shouldBe` dId
runGraphulaT
carries frontend instructions. If we'd like to override them we need to declare our own frontend.
For example, a front-end that always fails to insert.
newtype GraphulaFailT m a = GraphulaFailT { runGraphulaFailT :: m a }
deriving newtype (Functor, Applicative, Monad, MonadIO, MonadGraphulaBackend)
instance MonadGraphulaFrontend (GraphulaFailT m) where
insert _ _ = pure Nothing
remove = const (pure ())
insertionFailureSpec :: IO ()
insertionFailureSpec = do
let
failingGraph :: IO ()
failingGraph = runGraphulaT Nothing runDB . runGraphulaFailT $ do
Entity _ _ <- node @A () mempty
pure ()
failingGraph
`shouldThrow` (== (GenerationFailureMaxAttemptsToInsert (typeRep $ Proxy @A)))
Note that graphula can fail naturally if we define a graph that violates unique constraints in the database:
constraintFailureSpec :: IO ()
constraintFailureSpec = do
let
failingGraph :: IO ()
failingGraph = runGraphulaT Nothing runDB $
replicateM_ 3 $ node @F () mempty
failingGraph
`shouldThrow` (== (GenerationFailureMaxAttemptsToInsert (typeRep $ Proxy @F)))
or if we define a graph with an unsatisfiable predicates:
ensureFailureSpec :: IO ()
ensureFailureSpec = do
let
failingGraph :: IO ()
failingGraph = runGraphulaT Nothing runDB $ do
Entity _ _ <- node @A () $ ensure $ \a -> a /= a
pure ()
failingGraph
`shouldThrow` (== (GenerationFailureMaxAttemptsToConstrain (typeRep $ Proxy @A)))