module Database.PostgreSQL.TransactSpec where import Control.Monad (void) import Control.Monad.Catch import Data.Typeable import qualified Database.PostgreSQL.Simple as PS import Database.PostgreSQL.Simple ( Connection , Only (..) , SqlError (..) ) import Database.PostgreSQL.Simple.SqlQQ import Database.PostgreSQL.Transact import qualified Database.Postgres.Temp as Temp import Test.Hspec (Spec, SpecWith, describe, beforeAll, afterAll, it, runIO, shouldThrow) import Test.Hspec.Expectations.Lifted (shouldReturn) import Data.IORef import Control.Concurrent import Control.Concurrent.Async import Data.Foldable import qualified Control.Exception as E import Control.Monad ((<=<)) 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 ------------------------- Test DB Creation ------------------------- withConn :: Temp.DB -> (Connection -> IO a) -> IO a withConn db f = do let connStr = Temp.toConnectionString db bracket (PS.connectPostgreSQL connStr) PS.close f withSetup :: (Connection -> IO ()) -> IO () withSetup f = either E.throwIO pure <=< Temp.withDbCache $ \dbCache -> Temp.withConfig (Temp.defaultConfig <> Temp.cacheConfig dbCache) $ \db -> withConn db $ \conn -> do void $ PS.execute_ conn $ [sql| CREATE TABLE fruit (name VARCHAR(100) PRIMARY KEY ) |] f conn withDb :: DB a -> Connection -> IO a withDb action conn = runDB conn action runDB :: Connection -> DB a -> IO a runDB = flip runDBTSerializable ------------------------- Test Utilities ------------------------- insertFruit :: String -> DB () insertFruit fruit = void $ execute [sql| INSERT INTO fruit (name) VALUES (?) |] (Only fruit) getFruits :: DB [String] getFruits = fmap (map fromOnly) $ query_ [sql|SELECT name FROM fruit ORDER BY name|] fruits :: Connection -> IO [String] fruits conn = fmap (map fromOnly) $ PS.query_ conn [sql|SELECT name FROM fruit ORDER BY name|] -- Simple exception type for testing data Forbidden = Forbidden deriving (Show, Eq, Typeable) instance Exception Forbidden ------------------------- Tests Start ------------------------- spec :: Spec spec = describe "TransactionSpec" $ do aroundAll withSetup $ do it "execute_ happen path succeeds" $ \conn -> do let apple = "apple" runDB conn $ insertFruit apple fruits conn `shouldReturn` [apple] it "execute_ rollbacks on exception" $ \conn -> do flip shouldThrow (\(SqlError {}) -> True) $ runDB conn $ do insertFruit "orange" -- This should cause an exception because of the UNIQUE -- constraint on 'name' insertFruit "apple" fruits conn `shouldReturn` ["apple"] aroundAll withSetup $ do it "multiple execute_'s succeed" $ \conn -> do runDB conn $ do insertFruit "grapes" insertFruit "orange" fruits conn `shouldReturn` ["grapes", "orange"] aroundAll withSetup $ do it "throwM causes a rollback" $ \conn -> do flip shouldThrow (\Forbidden -> True) $ runDB conn $ do insertFruit "salak" () <- throwM Forbidden insertFruit "banana" fruits conn `shouldReturn` [] it "query recovers when exception is caught" $ \conn -> do runDB conn $ do -- This should always happen because of the handle below insertFruit "banana" handle (\Forbidden -> insertFruit "tomato") $ do insertFruit "salak" throwM Forbidden fruits conn `shouldReturn` ["banana", "tomato"] aroundAll withSetup $ do it "multiple catch statements work correctly" $ \conn -> do runDB conn $ do insertFruit "banana" handle (\Forbidden -> insertFruit "tomato") $ do -- This will happen ... even if there is an exception below -- if we catch it insertFruit "blueberry" handle (\Forbidden -> insertFruit "frankenberry") $ do insertFruit "salak" throwM Forbidden fruits conn `shouldReturn` ["banana", "blueberry", "frankenberry"] aroundAll withSetup $ do it "alternate branches can also have savepoints" $ \conn -> do runDB conn $ do insertFruit "banana" catch (insertFruit "tomato" >> throwM Forbidden) $ \Forbidden -> do insertFruit "blueberry" handle (\Forbidden -> insertFruit "frankenberry") $ do insertFruit "salak" throwM Forbidden fruits conn `shouldReturn` ["banana", "blueberry", "frankenberry"] aroundAll withSetup $ do it "releasing silently fails if the transaction errors" $ \conn -> do runDB conn $ do insertFruit "banana" catchAll (void $ execute_ [sql| ABORT |]) $ \_ -> insertFruit "tomato" fruits conn `shouldReturn` [] it "rollback ... rollbacks effects on expected finish" $ withDb $ do insertFruit "grapes" rollback $ do insertFruit "oranges" getFruits `shouldReturn` ["grapes", "oranges"] getFruits `shouldReturn` ["grapes"] aroundAll withSetup $ do it "rollback ... rollbacks effects on exception" $ withDb $ do insertFruit "grapes" _ :: Either Forbidden () <- try $ rollback $ do insertFruit "oranges" getFruits `shouldReturn` ["grapes", "oranges"] throwM Forbidden getFruits `shouldReturn` ["grapes"] aroundAll withSetup $ do it "abort ... abort effects on expected finish" $ \conn -> runDB conn (do insertFruit "grapes" abort $ do insertFruit "oranges" getFruits `shouldReturn` ["grapes", "oranges"] getFruits `shouldReturn` [] ) `shouldThrow` (\Abort -> True) aroundAll withSetup $ do it "abort ... abort effects on exception" $ \conn -> runDB conn ( do insertFruit "grapes" _ :: Either Forbidden () <- try $ abort $ do insertFruit "oranges" getFruits `shouldReturn` ["grapes", "oranges"] throwM Forbidden getFruits `shouldReturn` [] ) `shouldThrow` (\Abort -> True) aroundAll withSetup $ do it "abort ... abort throws a warning only when nested" $ \conn -> do runDB conn (abort (abort (pure ()))) `shouldThrow` (\Abort -> True) aroundAll withSetup $ do it "abort ... can't be caught" $ \conn -> do runDB conn (abort (pure ()) `catch` (\Abort -> pure ())) `shouldThrow` (\Abort -> True)