trek-db-0.1.0.0: test/Tests/Database/Trek/DbSpec.hs
module Tests.Database.Trek.DbSpec (spec) where
import Database.Trek.Db
import Data.Time.QQ
import qualified Database.Postgres.Temp as Temp
import qualified Database.PostgreSQL.Transact as T
import qualified Database.PostgreSQL.Simple as Psql
import Database.PostgreSQL.Simple.SqlQQ
import Control.Exception
import Control.Monad (void)
import Data.Pool
import qualified Data.List.NonEmpty as NonEmpty
import Test.Hspec hiding (shouldReturn, shouldBe)
import Data.Foldable
import Control.Monad ((<=<))
import Test.Hspec.Expectations.Lifted (shouldReturn, shouldBe)
import Data.List.NonEmpty (NonEmpty(..), fromList)
import Control.Concurrent
import Control.Concurrent.Async
import Data.IORef
import Data.Function
type WorldState = String
-------------------------------------------------------------------------------
-- Functions that should live somewhere else
-------------------------------------------------------------------------------
-- This is sequential subsets!
nonEmptyPartitionsOf :: NonEmpty a -> NonEmpty (NonEmpty (NonEmpty a))
nonEmptyPartitionsOf = fromList . fmap fromList . fmap (fmap fromList) . partitions . toList
partitions :: [a] -> [[[a]]]
partitions [] = [[]]
partitions (x:xs) = [[x]:p | p <- partitions xs]
++ [(x:ys):yss | (ys:yss) <- partitions xs]
-------------------------------------------------------------------------------
-- Hspec helper
-------------------------------------------------------------------------------
aroundAll :: forall a. ((a -> IO ()) -> IO ()) -> SpecWith a -> Spec
aroundAll withFunc specWith = do
(var, stopper, asyncer) <- runIO $
(,,) <$> newEmptyMVar <*> newEmptyMVar <*> newIORef Nothing
let theStart :: IO a
theStart = do
thread <- async $ do
withFunc $ \x -> do
putMVar var x
takeMVar stopper
pure $ error "Don't evaluate this"
writeIORef asyncer $ Just thread
either pure pure =<< (wait thread `race` takeMVar var)
theStop :: a -> IO ()
theStop _ = do
putMVar stopper ()
traverse_ cancel =<< readIORef asyncer
beforeAll theStart $ afterAll theStop $ specWith
clear :: DB ()
clear = void $ T.execute_ [sql|
DROP SCHEMA IF EXISTS meta CASCADE;
DROP SCHEMA IF EXISTS test CASCADE;
|]
worldState :: DB WorldState
worldState = do
xs :: [String] <- fmap Psql.fromOnly <$> T.query_
"SELECT CAST(table_name AS varchar) FROM information_schema.tables where table_schema = 'test' ORDER BY table_name"
pure $ concat xs
createFoo :: DB ()
createFoo = void $ T.execute_ [sql| CREATE SCHEMA IF NOT EXISTS test; CREATE TABLE test.foo (id SERIAL PRIMARY KEY)|]
createBar :: DB ()
createBar = void $ T.execute_ [sql| CREATE SCHEMA IF NOT EXISTS test; CREATE TABLE test.bar (id SERIAL PRIMARY KEY)|]
createQuux :: DB ()
createQuux = void $ T.execute_ [sql| CREATE SCHEMA IF NOT EXISTS test; CREATE TABLE test.quux (id SERIAL PRIMARY KEY)|]
foo :: InputMigration
foo = InputMigration createFoo [utcIso8601| 2022-12-01 |] (Psql.Binary "extra")
bar :: InputMigration
bar = InputMigration createBar [utcIso8601| 2025-12-01 |] (Psql.Binary "migration-2025-12-01")
quux :: InputMigration
quux = InputMigration createQuux [utcIso8601| 2025-12-02 |] (Psql.Binary "migration-2025-12-02")
toOutputMigration :: InputMigration -> OutputMigration
toOutputMigration InputMigration {..} = OutputMigration
{ omVersion = inputVersion
, omHash = inputHash
}
toOutput :: InputGroup -> DB OutputGroup
toOutput InputGroup {..} = pure OutputGroup
{ ogId = GroupId $ Psql.Binary $ makeGroupHash inputGroupCreateAd $ NonEmpty.toList inputGroupMigrations
, ogCreatedAt = inputGroupCreateAd
, ogMigrations = fmap toOutputMigration $ NonEmpty.sortBy (compare `on` inputHash) inputGroupMigrations
}
rollback :: DB a -> DB a
rollback = T.rollback
--
withSetup :: (Pool Psql.Connection -> IO a) -> IO a
withSetup f = do
-- Helper to throw exceptions
let throwE x = either throwIO pure =<< x
throwE $ Temp.withDbCache $ \dbCache -> do
let combinedConfig = Temp.defaultConfig <> Temp.cacheConfig dbCache
Temp.withConfig combinedConfig $ \db ->
f =<< createPool (Psql.connectPostgreSQL $ Temp.toConnectionString db) Psql.close 2 60 10
withPool :: DB a -> Pool Psql.Connection -> IO a
withPool action pool = withResource pool $ \conn -> T.runDBTSerializable action conn
spec :: Spec
spec = do
let rollbackIt msg action = it msg $ \pool -> flip withPool pool $ rollback action
aroundAll withSetup $ describe "Tests.Database.Trek.Db.Interface" $ do
rollbackIt "input migration on clean setup give output group" $ do
initial <- inputGroup $ pure foo
expected <- toOutput initial
apply initial `shouldReturn` Just expected
it "for s ⊆ x. apply x >> apply s = Nothing" $ withPool $ rollback $ do
twoMigrations <- inputGroup (foo NonEmpty.:| [bar])
expectedTwoOutput <- toOutput twoMigrations
apply twoMigrations `shouldReturn` Just expectedTwoOutput
rollback $ (apply =<< inputGroup (pure foo)) `shouldReturn` Nothing
rollback $ (apply =<< inputGroup (pure bar)) `shouldReturn` Nothing
rollbackIt "for s ⊆ x and y st. z = y / x and z ≠ ∅. apply s >> apply y = Just z" $ do
twoMigrations <- inputGroup (foo NonEmpty.:| [bar])
expectedTwoOutput <- toOutput twoMigrations
apply twoMigrations `shouldReturn` Just expectedTwoOutput
someAlreadyApplied <- inputGroup (foo NonEmpty.:| [quux])
onlyQuux <- toOutput =<< inputGroup (quux NonEmpty.:| [])
apply someAlreadyApplied `shouldReturn` Just onlyQuux
-- TODO make exception safe
it "actions are preserved during migration : all partitions apply the same effects" $ \pool -> do
let migrations = quux NonEmpty.:| [foo, bar]
forM_ (nonEmptyPartitionsOf migrations) $ \parts -> do
_ <- flip withPool pool clear
expectedWorldState <- mapM_ (flip withPool pool . sequenceA_ . fmap inputAction) parts
>> flip withPool pool worldState
_ <- flip withPool pool clear
actual <- flip withPool pool
(mapM_ (apply <=< inputGroup) parts >> worldState)
actual `shouldBe` expectedWorldState