hasql-transaction 1.1.1.3 → 1.2.3.1
raw patch · 29 files changed
Files
- CHANGELOG.md +19/−1
- conflicts-test/Main.hs +0/−112
- conflicts-test/Main/Statements.hs +0/−45
- conflicts-test/Main/Transactions.hs +0/−27
- hasql-transaction.cabal +23/−14
- library/Hasql/Transaction.hs +0/−12
- library/Hasql/Transaction/Config.hs +0/−21
- library/Hasql/Transaction/Private/Prelude.hs +0/−80
- library/Hasql/Transaction/Private/SQL.hs +0/−30
- library/Hasql/Transaction/Private/Sessions.hs +0/−51
- library/Hasql/Transaction/Private/Statements.hs +0/−44
- library/Hasql/Transaction/Private/Transaction.hs +0/−53
- library/Hasql/Transaction/Sessions.hs +0/−31
- src/integration-tests/Helpers/Adapters.hs +30/−0
- src/integration-tests/Helpers/Hooks.hs +19/−0
- src/integration-tests/Helpers/Scripts.hs +61/−0
- src/integration-tests/Helpers/Statements.hs +41/−0
- src/integration-tests/Helpers/Transactions.hs +25/−0
- src/integration-tests/Main.hs +1/−0
- src/integration-tests/Specs/ConflictsSpec.hs +62/−0
- src/integration-tests/Specs/SpecHook.hs +15/−0
- src/library/Hasql/Transaction.hs +12/−0
- src/library/Hasql/Transaction/Config.hs +45/−0
- src/library/Hasql/Transaction/Private/Prelude.hs +80/−0
- src/library/Hasql/Transaction/Private/SQL.hs +22/−0
- src/library/Hasql/Transaction/Private/Sessions.hs +55/−0
- src/library/Hasql/Transaction/Private/Statements.hs +20/−0
- src/library/Hasql/Transaction/Private/Transaction.hs +54/−0
- src/library/Hasql/Transaction/Sessions.hs +28/−0
CHANGELOG.md view
@@ -1,3 +1,21 @@-# 1.1+# v1.2.3.1++- Update test dependencies++# v1.2.3.0++## Non-breaking++- Add `Semigroup`/`Monoid` instances for `Mode` and `IsolationLevel`, combining via `max` with `mempty` as the weakest value, so composed transaction requirements escalate to the strictest one explicitly requested.++# v1.2.2.1++- Conform to the new `hasql` API (v2.0)++# v1.2++- Removed the `unpreparedTransaction` session because the same effects can now be achieved via the connection settings in Hasql++# v1.1 - Add automatic retry on deadlock errors (code 40P01)
− conflicts-test/Main.hs
@@ -1,112 +0,0 @@-module Main where--import Control.Concurrent.Async qualified as F-import Hasql.Connection qualified as A-import Hasql.Connection.Setting qualified as H-import Hasql.Connection.Setting.Connection qualified as I-import Hasql.Connection.Setting.Connection.Param qualified as J-import Hasql.Session qualified as B-import Hasql.Transaction qualified as C-import Hasql.Transaction.Sessions qualified as G-import Main.Statements qualified as D-import Main.Transactions qualified as E-import Prelude--main :: IO ()-main =- bracket acquire release use- where- acquire =- (,) <$> acquire <*> acquire- where- acquire =- join- $ fmap (either (fail . show) return)- $ A.acquire connectionSettings- where- connectionSettings =- [ H.connection- ( I.params- [ J.host "localhost",- J.port 5432,- J.user "postgres",- J.password "postgres",- J.dbname "postgres"- ]- )- ]- release (connection1, connection2) =- do- transaction connection1 E.dropSchema- A.release connection1- A.release connection2- use (connection1, connection2) =- do- try (transaction connection1 E.dropSchema) :: IO (Either SomeException ())- transaction connection1 E.createSchema- success <- fmap and (traverse runTest tests)- if success- then exitSuccess- else exitFailure- where- runTest test =- test connection1 connection2- tests =- [readAndWriteTransactionsTest, transactionsTest, transactionAndQueryTest]--session :: A.Connection -> B.Session a -> IO a-session connection session =- B.run session connection- >>= either (fail . show) return--transaction :: A.Connection -> C.Transaction a -> IO a-transaction connection transaction =- session connection (G.transaction G.RepeatableRead G.Write transaction)--type Test =- A.Connection -> A.Connection -> IO Bool--transactionsTest :: Test-transactionsTest connection1 connection2 =- do- id1 <- session connection1 (B.statement 0 D.createAccount)- id2 <- session connection1 (B.statement 0 D.createAccount)- async1 <- F.async (replicateM_ 1000 (transaction connection1 (E.transfer id1 id2 1)))- async2 <- F.async (replicateM_ 1000 (transaction connection2 (E.transfer id1 id2 1)))- F.wait async1- F.wait async2- balance1 <- session connection1 (B.statement id1 D.getBalance)- balance2 <- session connection1 (B.statement id2 D.getBalance)- traceShowM balance1- traceShowM balance2- return (balance1 == Just 2000 && balance2 == Just (-2000))--readAndWriteTransactionsTest :: Test-readAndWriteTransactionsTest connection1 connection2 =- do- id1 <- session connection1 (B.statement 0 D.createAccount)- id2 <- session connection1 (B.statement 0 D.createAccount)- async1 <- F.async (replicateM_ 1000 (transaction connection1 (E.transfer id1 id2 1)))- async2 <- F.async (replicateM_ 1000 (transaction connection2 (C.statement id1 D.getBalance)))- F.wait async1- F.wait async2- balance1 <- session connection1 (B.statement id1 D.getBalance)- balance2 <- session connection1 (B.statement id2 D.getBalance)- traceShowM balance1- traceShowM balance2- return (balance1 == Just 1000 && balance2 == Just (-1000))--transactionAndQueryTest :: Test-transactionAndQueryTest connection1 connection2 =- do- id1 <- session connection1 (B.statement 0 D.createAccount)- id2 <- session connection1 (B.statement 0 D.createAccount)- async1 <- F.async (transaction connection1 (E.transferTimes 200 id1 id2 1))- async2 <- F.async (session connection2 (replicateM_ 200 (B.statement (id1, 1) D.modifyBalance)))- F.wait async1- F.wait async2- balance1 <- session connection1 (B.statement id1 D.getBalance)- balance2 <- session connection1 (B.statement id2 D.getBalance)- traceShowM balance1- traceShowM balance2- return (balance1 == Just 400 && balance2 == Just (-200))
− conflicts-test/Main/Statements.hs
@@ -1,45 +0,0 @@-module Main.Statements where--import Hasql.Decoders qualified as D-import Hasql.Encoders qualified as E-import Hasql.Statement-import Prelude--createAccountTable :: Statement () ()-createAccountTable =- Statement sql E.noParams D.noResult False- where- sql =- "create table account (id serial not null, balance numeric not null, primary key (id))"--dropAccountTable :: Statement () ()-dropAccountTable =- Statement- "drop table account"- E.noParams- D.noResult- False--createAccount :: Statement Scientific Int64-createAccount =- Statement- "insert into account (balance) values ($1) returning id"- ((E.param . E.nonNullable) E.numeric)- (D.singleRow ((D.column . D.nonNullable) D.int8))- True--modifyBalance :: Statement (Int64, Scientific) Bool-modifyBalance =- Statement- "update account set balance = balance + $2 where id = $1"- ((fst >$< (E.param . E.nonNullable) E.int8) <> (snd >$< (E.param . E.nonNullable) E.numeric))- (fmap (> 0) D.rowsAffected)- True--getBalance :: Statement Int64 (Maybe Scientific)-getBalance =- Statement- "select balance from account where id = $1"- ((E.param . E.nonNullable) E.int8)- (D.rowMaybe ((D.column . D.nonNullable) D.numeric))- True
− conflicts-test/Main/Transactions.hs
@@ -1,27 +0,0 @@-module Main.Transactions where--import Hasql.Transaction-import Main.Statements qualified as A-import Prelude--createSchema :: Transaction ()-createSchema =- do- statement () A.createAccountTable--dropSchema :: Transaction ()-dropSchema =- do- statement () A.dropAccountTable--transfer :: Int64 -> Int64 -> Scientific -> Transaction Bool-transfer id1 id2 amount =- do- success <- statement (id1, amount) A.modifyBalance- if success- then statement (id2, negate amount) A.modifyBalance- else return False--transferTimes :: Int -> Int64 -> Int64 -> Scientific -> Transaction ()-transferTimes times id1 id2 amount =- replicateM_ times (transfer id1 id2 amount)
hasql-transaction.cabal view
@@ -1,6 +1,6 @@ cabal-version: 3.0 name: hasql-transaction-version: 1.1.1.3+version: 1.2.3.1 category: Hasql, Database, PostgreSQL synopsis: Composable abstraction over retryable transactions for Hasql@@ -17,7 +17,7 @@ source-repository head type: git- location: git://github.com/nikita-volkov/hasql-transaction.git+ location: https://github.com/nikita-volkov/hasql-transaction common base default-language: Haskell2010@@ -77,7 +77,7 @@ library import: base- hs-source-dirs: library+ hs-source-dirs: src/library exposed-modules: Hasql.Transaction Hasql.Transaction.Sessions@@ -85,8 +85,8 @@ other-modules: Hasql.Transaction.Config Hasql.Transaction.Private.Prelude- Hasql.Transaction.Private.SQL Hasql.Transaction.Private.Sessions+ Hasql.Transaction.Private.SQL Hasql.Transaction.Private.Statements Hasql.Transaction.Private.Transaction @@ -95,26 +95,35 @@ bytestring >=0.10 && <0.13, bytestring-tree-builder >=0.2.7.8 && <0.3, contravariant >=1.3 && <2,- hasql >=1.7 && <1.10,+ hasql >=1.10 && <1.11 || >=2.0 && <2.1, mtl >=2.2 && <3,+ text >=1 && <3, transformers >=0.5 && <0.7, -test-suite conflicts-test+test-suite test import: test type: exitcode-stdio-1.0- hs-source-dirs: conflicts-test+ hs-source-dirs: src/integration-tests main-is: Main.hs other-modules:- Main.Statements- Main.Transactions+ Helpers.Adapters+ Helpers.Hooks+ Helpers.Scripts+ Helpers.Statements+ Helpers.Transactions+ Specs.ConflictsSpec+ Specs.SpecHook - ghc-options:- -O2- -threaded- -with-rtsopts=-N+ build-tool-depends:+ hspec-discover:hspec-discover ^>=2.11.12 build-depends: async >=2.1 && <3,- hasql >=1.9,+ hasql, hasql-transaction,+ hspec >=2.6 && <3,+ pqi ^>=1.1,+ pqi-ffi ^>=1.0,+ pqi-native ^>=1.0, rerebase >=1.11 && <2,+ testcontainers-postgresql >=0.2 && <0.3,
− library/Hasql/Transaction.hs
@@ -1,12 +0,0 @@--- |--- An API for declaration of transactions.-module Hasql.Transaction- ( -- * Transaction monad- Transaction,- condemn,- sql,- statement,- )-where--import Hasql.Transaction.Private.Transaction
− library/Hasql/Transaction/Config.hs
@@ -1,21 +0,0 @@-module Hasql.Transaction.Config where--import Hasql.Transaction.Private.Prelude--data Mode- = -- |- -- Read-only. No writes possible.- Read- | -- |- -- Write and commit.- Write- deriving (Show, Eq, Ord, Enum, Bounded)---- |--- For reference see--- <http://www.postgresql.org/docs/current/static/transaction-iso.html the Postgres' documentation>.-data IsolationLevel- = ReadCommitted- | RepeatableRead- | Serializable- deriving (Show, Eq, Ord, Enum, Bounded)
− library/Hasql/Transaction/Private/Prelude.hs
@@ -1,80 +0,0 @@-module Hasql.Transaction.Private.Prelude- ( module Exports,- tryError,- )-where--import Control.Applicative as Exports-import Control.Arrow as Exports-import Control.Category as Exports-import Control.Concurrent as Exports-import Control.Exception as Exports-import Control.Monad as Exports hiding (fail, forM, forM_, join, mapM, mapM_, msum, sequence, sequence_)-import Control.Monad.Error.Class as Exports (MonadError (..))-import Control.Monad.Fail as Exports-import Control.Monad.Fix as Exports hiding (fix)-import Control.Monad.IO.Class as Exports-import Control.Monad.ST as Exports-import Control.Monad.Trans.Class as Exports-import Control.Monad.Trans.Maybe as Exports hiding (liftListen, liftPass)-import Control.Monad.Trans.Reader as Exports hiding (liftCallCC, liftCatch)-import Control.Monad.Trans.State.Strict as Exports hiding (liftCallCC, liftCatch, liftListen, liftPass)-import Data.Bits as Exports-import Data.Bool as Exports-import Data.ByteString as Exports (ByteString)-import Data.Char as Exports-import Data.Coerce as Exports-import Data.Complex as Exports-import Data.Data as Exports-import Data.Dynamic as Exports-import Data.Either as Exports-import Data.Fixed as Exports-import Data.Foldable as Exports hiding (toList)-import Data.Function as Exports hiding (id, (.))-import Data.Functor as Exports hiding (unzip)-import Data.Functor.Contravariant as Exports-import Data.Functor.Contravariant.Divisible as Exports-import Data.Functor.Identity as Exports-import Data.IORef as Exports-import Data.Int as Exports-import Data.Ix as Exports-import Data.List as Exports hiding (all, and, any, concat, concatMap, elem, find, foldl, foldl', foldl1, foldr, foldr1, isSubsequenceOf, mapAccumL, mapAccumR, maximum, maximumBy, minimum, minimumBy, notElem, or, product, sortOn, sum, uncons)-import Data.Maybe as Exports-import Data.Monoid as Exports hiding (Alt, First (..), Last (..), (<>))-import Data.Ord as Exports-import Data.Proxy as Exports-import Data.Ratio as Exports-import Data.STRef as Exports-import Data.Semigroup as Exports-import Data.String as Exports-import Data.Traversable as Exports-import Data.Tuple as Exports-import Data.Unique as Exports-import Data.Version as Exports-import Data.Word as Exports-import Debug.Trace as Exports-import Foreign.ForeignPtr as Exports-import Foreign.Ptr as Exports-import Foreign.StablePtr as Exports-import Foreign.Storable as Exports hiding (alignment, sizeOf)-import GHC.Conc as Exports hiding (threadWaitRead, threadWaitReadSTM, threadWaitWrite, threadWaitWriteSTM, withMVar)-import GHC.Exts as Exports (IsList (..), groupWith, inline, lazy, sortWith)-import GHC.Generics as Exports (Generic, Generic1)-import GHC.IO.Exception as Exports-import Numeric as Exports-import System.Environment as Exports-import System.Exit as Exports-import System.IO as Exports-import System.IO.Error as Exports-import System.IO.Unsafe as Exports-import System.Mem as Exports-import System.Mem.StableName as Exports-import System.Timeout as Exports-import Text.Printf as Exports (hPrintf, printf)-import Text.Read as Exports (Read (..), readEither, readMaybe)-import Unsafe.Coerce as Exports-import Prelude as Exports hiding (all, and, any, concat, concatMap, elem, fail, foldl, foldl1, foldr, foldr1, id, mapM, mapM_, maximum, minimum, notElem, or, product, sequence, sequence_, sum, (.))--tryError :: (MonadError e m) => m a -> m (Either e a)-tryError m =- catchError (liftM Right m) (return . Left)
− library/Hasql/Transaction/Private/SQL.hs
@@ -1,30 +0,0 @@-module Hasql.Transaction.Private.SQL where--import ByteString.TreeBuilder qualified as D-import Hasql.Transaction.Config-import Hasql.Transaction.Private.Prelude--beginTransaction :: IsolationLevel -> Mode -> ByteString-beginTransaction isolation mode =- D.toByteString builder- where- builder =- "BEGIN " <> isolationBuilder <> " " <> modeBuilder- where- isolationBuilder =- case isolation of- ReadCommitted -> "ISOLATION LEVEL READ COMMITTED"- RepeatableRead -> "ISOLATION LEVEL REPEATABLE READ"- Serializable -> "ISOLATION LEVEL SERIALIZABLE"- modeBuilder =- case mode of- Write -> "READ WRITE"- Read -> "READ ONLY"--declareCursor :: ByteString -> ByteString -> ByteString-declareCursor name sql =- D.toByteString- $ "DECLARE "- <> D.byteString name- <> " NO SCROLL CURSOR FOR "- <> D.byteString sql
− library/Hasql/Transaction/Private/Sessions.hs
@@ -1,51 +0,0 @@-module Hasql.Transaction.Private.Sessions where--import Hasql.Session-import Hasql.Transaction.Config-import Hasql.Transaction.Private.Prelude-import Hasql.Transaction.Private.Statements qualified as Statements--{--We may want to-do one transaction retry in case of the 23505 error, and fail if an identical-error is seen.--}-inRetryingTransaction :: IsolationLevel -> Mode -> Session (a, Bool) -> Bool -> Session a-inRetryingTransaction level mode session preparable =- fix $ \retry -> do- attemptRes <- tryTransaction level mode session preparable- case attemptRes of- Just a -> return a- Nothing -> retry--tryTransaction :: IsolationLevel -> Mode -> Session (a, Bool) -> Bool -> Session (Maybe a)-tryTransaction level mode body preparable = do- statement () (Statements.beginTransaction level mode preparable)-- bodyRes <- catchError (fmap Just body) $ \error -> do- statement () (Statements.abortTransaction preparable)- handleTransactionError error $ return Nothing-- case bodyRes of- Just (res, commit) -> catchError (commitOrAbort commit preparable $> Just res) $ \error -> do- handleTransactionError error $ return Nothing- Nothing -> return Nothing--commitOrAbort :: Bool -> Bool -> Session ()-commitOrAbort commit preparable =- if commit- then statement () (Statements.commitTransaction preparable)- else statement () (Statements.abortTransaction preparable)--handleTransactionError :: SessionError -> Session a -> Session a-handleTransactionError error onTransactionError = case error of- QueryError _ _ clientError -> onCommandError clientError- PipelineError clientError -> onCommandError clientError- where- onCommandError = \case- ResultError (ServerError code _ _ _ _) ->- case code of- "40001" -> onTransactionError- "40P01" -> onTransactionError- _ -> throwError error- _ -> throwError error
− library/Hasql/Transaction/Private/Statements.hs
@@ -1,44 +0,0 @@-module Hasql.Transaction.Private.Statements where--import Hasql.Decoders qualified as C-import Hasql.Encoders qualified as B-import Hasql.Statement qualified as A-import Hasql.Transaction.Config-import Hasql.Transaction.Private.Prelude-import Hasql.Transaction.Private.SQL qualified as D---- * Transactions--beginTransaction :: IsolationLevel -> Mode -> Bool -> A.Statement () ()-beginTransaction isolation mode preparable =- A.Statement (D.beginTransaction isolation mode) B.noParams C.noResult preparable--commitTransaction :: Bool -> A.Statement () ()-commitTransaction preparable =- A.Statement "COMMIT" B.noParams C.noResult preparable--abortTransaction :: Bool -> A.Statement () ()-abortTransaction preparable =- A.Statement "ABORT" B.noParams C.noResult preparable---- * Streaming--declareCursor :: ByteString -> ByteString -> B.Params a -> A.Statement a ()-declareCursor name sql encoder =- A.Statement (D.declareCursor name sql) encoder C.noResult False--closeCursor :: A.Statement ByteString ()-closeCursor =- A.Statement "CLOSE $1" ((B.param . B.nonNullable) B.bytea) C.noResult True--fetchFromCursor :: (b -> a -> b) -> b -> C.Row a -> A.Statement (Int64, ByteString) b-fetchFromCursor step init rowDec =- A.Statement sql encoder decoder True- where- sql =- "FETCH FORWARD $1 FROM $2"- encoder =- (fst >$< (B.param . B.nonNullable) B.int8)- <> (snd >$< (B.param . B.nonNullable) B.bytea)- decoder =- C.foldlRows step init rowDec
− library/Hasql/Transaction/Private/Transaction.hs
@@ -1,53 +0,0 @@-module Hasql.Transaction.Private.Transaction where--import Hasql.Session qualified as B-import Hasql.Statement qualified as A-import Hasql.Transaction.Config-import Hasql.Transaction.Private.Prelude-import Hasql.Transaction.Private.Sessions qualified as D---- |--- A composable abstraction over the retryable transactions.------ Executes multiple queries under the specified mode and isolation level,--- while automatically retrying the transaction in case of conflicts.--- Thus this abstraction closely reproduces the behaviour of 'STM'.-newtype Transaction a- = Transaction (StateT Bool B.Session a)- deriving (Functor, Applicative, Monad)--instance (Semigroup a) => Semigroup (Transaction a) where- (<>) = liftA2 (<>)--instance (Monoid a) => Monoid (Transaction a) where- mempty = pure mempty---- |--- Execute the transaction using the provided isolation level and mode.-{-# INLINE run #-}-run :: Transaction a -> IsolationLevel -> Mode -> Bool -> B.Session a-run (Transaction session) isolation mode preparable =- D.inRetryingTransaction isolation mode (runStateT session True) preparable---- |--- Possibly a multi-statement query,--- which however cannot be parameterized or prepared,--- nor can any results of it be collected.-{-# INLINE sql #-}-sql :: ByteString -> Transaction ()-sql =- Transaction . lift . B.sql---- |--- Parameters and a specification of the parametric query to apply them to.-{-# INLINE statement #-}-statement :: a -> A.Statement a b -> Transaction b-statement params statement =- Transaction . lift $ B.statement params statement---- |--- Cause transaction to eventually roll back.-{-# INLINE condemn #-}-condemn :: Transaction ()-condemn =- Transaction $ put False
− library/Hasql/Transaction/Sessions.hs
@@ -1,31 +0,0 @@-module Hasql.Transaction.Sessions- ( transaction,- unpreparedTransaction,-- -- * Transaction settings- C.Mode (..),- C.IsolationLevel (..),- )-where--import Data.Bool-import Hasql.Session qualified as B-import Hasql.Transaction.Config qualified as C-import Hasql.Transaction.Private.Transaction qualified as A---- |--- Execute the transaction using the provided isolation level and mode.-{-# INLINE transaction #-}-transaction :: C.IsolationLevel -> C.Mode -> A.Transaction a -> B.Session a-transaction isolation mode transaction =- A.run transaction isolation mode True---- |--- Execute the transaction using the provided isolation level and mode,--- and specifying that the generated BEGIN, COMMIT and ABORT statements should not be prepared.------ Helps with transaction pooling due to its incompatibility with prepared statements.-{-# INLINE unpreparedTransaction #-}-unpreparedTransaction :: C.IsolationLevel -> C.Mode -> A.Transaction a -> B.Session a-unpreparedTransaction isolation mode transaction =- A.run transaction isolation mode False
+ src/integration-tests/Helpers/Adapters.hs view
@@ -0,0 +1,30 @@+module Helpers.Adapters+ ( adapters,+ byAdapter,+ hook,+ )+where++import Pqi qualified+import Pqi.Ffi qualified+import Pqi.Native qualified+import Prelude+import Test.Hspec++adapters :: [Pqi.Adapter]+adapters =+ [ Pqi.Ffi.adapter,+ Pqi.Native.adapter+ ]++-- | Run the given spec-building function once per available Pqi adapter,+-- nesting each run under a @describe@ named after the adapter.+byAdapter :: (Pqi.Adapter -> Spec) -> Spec+byAdapter f =+ for_ adapters \adapter ->+ describe (toList (Pqi.name adapter)) (f adapter)++hook :: SpecWith Pqi.Adapter -> Spec+hook hookedSpec =+ byAdapter \adapter ->+ mapSubject (const adapter) hookedSpec
+ src/integration-tests/Helpers/Hooks.hs view
@@ -0,0 +1,19 @@+-- | Hooks for Hspec.+module Helpers.Hooks where++import Data.Bool+import Prelude hiding (Handler)+import TestcontainersPostgresql qualified++-- | Testing action in the scope of the host name and port of a running fresh isolated postgres server.+type Handler = (Text, Word16) -> IO ()++postgres17 :: Handler -> IO ()+postgres17 handler =+ TestcontainersPostgresql.run+ TestcontainersPostgresql.Config+ { forwardLogs = False,+ tagName = "postgres:17",+ auth = TestcontainersPostgresql.TrustAuth+ }+ (\(host, portInt) -> handler (host, fromIntegral portInt))
+ src/integration-tests/Helpers/Scripts.hs view
@@ -0,0 +1,61 @@+module Helpers.Scripts+ ( ScopeParams,+ onConnectionPair,+ session,+ transaction,+ transactionNoRetry,+ )+where++import Hasql.Connection qualified as Connection+import Hasql.Connection.Settings qualified as Settings+import Hasql.Session qualified as Session+import Hasql.Transaction qualified as Transaction+import Hasql.Transaction.Sessions qualified as Transaction.Sessions+import Helpers.Transactions qualified as Transactions+import Pqi qualified+import Prelude++-- |+-- Adapter, host and port of a running isolated postgres server.+type ScopeParams = (Pqi.Adapter, Text, Word16)++-- |+-- Acquire a pair of connections against a fresh copy of the schema,+-- releasing them and dropping the schema once the action completes.+onConnectionPair :: ScopeParams -> (Connection.Connection -> Connection.Connection -> IO ()) -> IO ()+onConnectionPair (adapter, host, port) action =+ bracket acquire release use+ where+ acquire =+ (,) <$> acquireConnection <*> acquireConnection+ where+ acquireConnection =+ Connection.acquire adapter connectionSettings+ >>= either (fail . show) return+ connectionSettings =+ Settings.hostAndPort host port+ <> Settings.user "postgres"+ <> Settings.password "postgres"+ <> Settings.dbname "postgres"+ release (connection1, connection2) = do+ transaction connection1 Transactions.dropSchema+ Connection.release connection1+ Connection.release connection2+ use (connection1, connection2) = do+ _ <- try (transaction connection1 Transactions.dropSchema) :: IO (Either SomeException ())+ transaction connection1 Transactions.createSchema+ action connection1 connection2++session :: Connection.Connection -> Session.Session a -> IO a+session connection theSession =+ Connection.use connection theSession+ >>= either (fail . show) return++transaction :: Connection.Connection -> Transaction.Transaction a -> IO a+transaction connection theTransaction =+ session connection (Transaction.Sessions.transaction Transaction.Sessions.RepeatableRead Transaction.Sessions.Write theTransaction)++transactionNoRetry :: Connection.Connection -> Transaction.Transaction a -> IO a+transactionNoRetry connection theTransaction =+ session connection (Transaction.Sessions.transactionNoRetry Transaction.Sessions.RepeatableRead Transaction.Sessions.Write theTransaction)
+ src/integration-tests/Helpers/Statements.hs view
@@ -0,0 +1,41 @@+module Helpers.Statements where++import Hasql.Decoders qualified as D+import Hasql.Encoders qualified as E+import Hasql.Statement+import Prelude++createAccountTable :: Statement () ()+createAccountTable =+ unpreparable sql E.noParams D.noResult+ where+ sql =+ "create table account (id bigserial not null, balance numeric not null, primary key (id))"++dropAccountTable :: Statement () ()+dropAccountTable =+ unpreparable+ "drop table account"+ E.noParams+ D.noResult++createAccount :: Statement Scientific Int64+createAccount =+ preparable+ "insert into account (balance) values ($1) returning id"+ ((E.param . E.nonNullable) E.numeric)+ (D.singleRow ((D.column . D.nonNullable) D.int8))++modifyBalance :: Statement (Int64, Scientific) Bool+modifyBalance =+ preparable+ "update account set balance = balance + $2 where id = $1"+ ((fst >$< (E.param . E.nonNullable) E.int8) <> (snd >$< (E.param . E.nonNullable) E.numeric))+ (fmap (> 0) D.rowsAffected)++getBalance :: Statement Int64 (Maybe Scientific)+getBalance =+ preparable+ "select balance from account where id = $1"+ ((E.param . E.nonNullable) E.int8)+ (D.rowMaybe ((D.column . D.nonNullable) D.numeric))
+ src/integration-tests/Helpers/Transactions.hs view
@@ -0,0 +1,25 @@+module Helpers.Transactions where++import Hasql.Transaction+import Helpers.Statements qualified as Statements+import Prelude++createSchema :: Transaction ()+createSchema =+ statement () Statements.createAccountTable++dropSchema :: Transaction ()+dropSchema =+ statement () Statements.dropAccountTable++transfer :: Int64 -> Int64 -> Scientific -> Transaction Bool+transfer id1 id2 amount =+ do+ success <- statement (id1, amount) Statements.modifyBalance+ if success+ then statement (id2, negate amount) Statements.modifyBalance+ else return False++transferTimes :: Int -> Int64 -> Int64 -> Scientific -> Transaction ()+transferTimes times id1 id2 amount =+ replicateM_ times (transfer id1 id2 amount)
+ src/integration-tests/Main.hs view
@@ -0,0 +1,1 @@+{-# OPTIONS_GHC -F -pgmF hspec-discover #-}
+ src/integration-tests/Specs/ConflictsSpec.hs view
@@ -0,0 +1,62 @@+module Specs.ConflictsSpec where++import Control.Concurrent.Async qualified as Async+import Hasql.Session qualified as Session+import Hasql.Transaction qualified as Transaction+import Helpers.Scripts qualified as Scripts+import Helpers.Statements qualified as Statements+import Helpers.Transactions qualified as Transactions+import Prelude+import Test.Hspec++spec :: SpecWith Scripts.ScopeParams+spec = do+ it "Retrying transactions converge to a consistent balance under contention" \scopeParams ->+ Scripts.onConnectionPair scopeParams \connection1 connection2 -> do+ id1 <- Scripts.session connection1 (Session.statement 0 Statements.createAccount)+ id2 <- Scripts.session connection1 (Session.statement 0 Statements.createAccount)+ async1 <- Async.async (replicateM_ 1000 (Scripts.transaction connection1 (Transactions.transfer id1 id2 1)))+ async2 <- Async.async (replicateM_ 1000 (Scripts.transaction connection2 (Transactions.transfer id1 id2 1)))+ Async.wait async1+ Async.wait async2+ balance1 <- Scripts.session connection1 (Session.statement id1 Statements.getBalance)+ balance2 <- Scripts.session connection1 (Session.statement id2 Statements.getBalance)+ balance1 `shouldBe` Just 2000+ balance2 `shouldBe` Just (-2000)++ it "Non-retrying transactions fail with a serialization error under contention" \scopeParams ->+ Scripts.onConnectionPair scopeParams \connection1 connection2 -> do+ id1 <- Scripts.session connection1 (Session.statement 0 Statements.createAccount)+ id2 <- Scripts.session connection1 (Session.statement 0 Statements.createAccount)+ async1 <- Async.async (replicateM_ 1000 (Scripts.transactionNoRetry connection1 (Transactions.transfer id1 id2 1)))+ async2 <- Async.async (replicateM_ 1000 (Scripts.transactionNoRetry connection2 (Transactions.transfer id1 id2 1)))+ result1 <- Async.waitCatch async1+ result2 <- Async.waitCatch async2+ let serialError = sequenceA [result1, result2]+ serialError `shouldSatisfy` either (isInfixOf "40001" . show) (const False)++ it "A concurrent read-only transaction does not lose updates from a writer" \scopeParams ->+ Scripts.onConnectionPair scopeParams \connection1 connection2 -> do+ id1 <- Scripts.session connection1 (Session.statement 0 Statements.createAccount)+ id2 <- Scripts.session connection1 (Session.statement 0 Statements.createAccount)+ async1 <- Async.async (replicateM_ 1000 (Scripts.transaction connection1 (Transactions.transfer id1 id2 1)))+ async2 <- Async.async (replicateM_ 1000 (Scripts.transaction connection2 (Transaction.statement id1 Statements.getBalance)))+ Async.wait async1+ Async.wait async2+ balance1 <- Scripts.session connection1 (Session.statement id1 Statements.getBalance)+ balance2 <- Scripts.session connection1 (Session.statement id2 Statements.getBalance)+ balance1 `shouldBe` Just 1000+ balance2 `shouldBe` Just (-1000)++ it "A transaction converges to a consistent balance alongside a concurrent bare session" \scopeParams ->+ Scripts.onConnectionPair scopeParams \connection1 connection2 -> do+ id1 <- Scripts.session connection1 (Session.statement 0 Statements.createAccount)+ id2 <- Scripts.session connection1 (Session.statement 0 Statements.createAccount)+ async1 <- Async.async (Scripts.transaction connection1 (Transactions.transferTimes 200 id1 id2 1))+ async2 <- Async.async (Scripts.session connection2 (replicateM_ 200 (Session.statement (id1, 1) Statements.modifyBalance)))+ Async.wait async1+ Async.wait async2+ balance1 <- Scripts.session connection1 (Session.statement id1 Statements.getBalance)+ balance2 <- Scripts.session connection1 (Session.statement id2 Statements.getBalance)+ balance1 `shouldBe` Just 400+ balance2 `shouldBe` Just (-200)
+ src/integration-tests/Specs/SpecHook.hs view
@@ -0,0 +1,15 @@+-- Docs: https://hspec.github.io/hspec-discover.html+module Specs.SpecHook where++import Helpers.Adapters qualified as Adapters+import Helpers.Hooks qualified as Hooks+import Helpers.Scripts qualified as Scripts+import Test.Hspec++hook :: SpecWith Scripts.ScopeParams -> Spec+hook hookedSpec =+ Adapters.hook+ ( aroundAllWith+ (\action adapter -> Hooks.postgres17 \(host, port) -> action (adapter, host, port))+ hookedSpec+ )
+ src/library/Hasql/Transaction.hs view
@@ -0,0 +1,12 @@+-- |+-- An API for declaration of transactions.+module Hasql.Transaction+ ( -- * Transaction monad+ Transaction,+ condemn,+ sql,+ statement,+ )+where++import Hasql.Transaction.Private.Transaction
+ src/library/Hasql/Transaction/Config.hs view
@@ -0,0 +1,45 @@+module Hasql.Transaction.Config where++import Hasql.Transaction.Private.Prelude++data Mode+ = -- |+ -- Read-only. No writes possible.+ Read+ | -- |+ -- Write and commit.+ Write+ deriving (Show, Eq, Ord, Enum, Bounded)++-- |+-- Combines two modes by picking the one that grants more capability.+--+-- 'mempty' is 'Read', the identity of 'max': it never overrides an+-- explicit 'Write' requirement, so a piece of a composed transaction+-- that needs to write always wins over pieces that don't care.+instance Semigroup Mode where+ (<>) = max++instance Monoid Mode where+ mempty = minBound++-- |+-- For reference see+-- <http://www.postgresql.org/docs/current/static/transaction-iso.html the Postgres' documentation>.+data IsolationLevel+ = ReadCommitted+ | RepeatableRead+ | Serializable+ deriving (Show, Eq, Ord, Enum, Bounded)++-- |+-- Combines two isolation levels by picking the stricter one.+--+-- 'mempty' is 'ReadCommitted', the identity of 'max': it never overrides+-- an explicit stricter requirement, so a piece of a composed transaction+-- that needs e.g. 'Serializable' always wins over pieces that don't care.+instance Semigroup IsolationLevel where+ (<>) = max++instance Monoid IsolationLevel where+ mempty = minBound
+ src/library/Hasql/Transaction/Private/Prelude.hs view
@@ -0,0 +1,80 @@+module Hasql.Transaction.Private.Prelude+ ( module Exports,+ tryError,+ )+where++import Control.Applicative as Exports+import Control.Arrow as Exports+import Control.Category as Exports+import Control.Concurrent as Exports+import Control.Exception as Exports+import Control.Monad as Exports hiding (fail, forM, forM_, join, mapM, mapM_, msum, sequence, sequence_)+import Control.Monad.Error.Class as Exports (MonadError (..))+import Control.Monad.Fail as Exports+import Control.Monad.Fix as Exports hiding (fix)+import Control.Monad.IO.Class as Exports+import Control.Monad.ST as Exports+import Control.Monad.Trans.Class as Exports+import Control.Monad.Trans.Maybe as Exports hiding (liftListen, liftPass)+import Control.Monad.Trans.Reader as Exports hiding (liftCallCC, liftCatch)+import Control.Monad.Trans.State.Strict as Exports hiding (liftCallCC, liftCatch, liftListen, liftPass)+import Data.Bits as Exports+import Data.Bool as Exports+import Data.ByteString as Exports (ByteString)+import Data.Char as Exports+import Data.Coerce as Exports+import Data.Complex as Exports+import Data.Data as Exports+import Data.Dynamic as Exports+import Data.Either as Exports+import Data.Fixed as Exports+import Data.Foldable as Exports hiding (toList)+import Data.Function as Exports hiding (id, (.))+import Data.Functor as Exports hiding (unzip)+import Data.Functor.Contravariant as Exports+import Data.Functor.Contravariant.Divisible as Exports+import Data.Functor.Identity as Exports+import Data.IORef as Exports+import Data.Int as Exports+import Data.Ix as Exports+import Data.List as Exports hiding (all, and, any, concat, concatMap, elem, find, foldl, foldl', foldl1, foldr, foldr1, isSubsequenceOf, mapAccumL, mapAccumR, maximum, maximumBy, minimum, minimumBy, notElem, or, product, sortOn, sum, uncons)+import Data.Maybe as Exports+import Data.Monoid as Exports hiding (Alt, First (..), Last (..), (<>))+import Data.Ord as Exports+import Data.Proxy as Exports+import Data.Ratio as Exports+import Data.STRef as Exports+import Data.Semigroup as Exports+import Data.String as Exports+import Data.Traversable as Exports+import Data.Tuple as Exports+import Data.Unique as Exports+import Data.Version as Exports+import Data.Word as Exports+import Debug.Trace as Exports+import Foreign.ForeignPtr as Exports+import Foreign.Ptr as Exports+import Foreign.StablePtr as Exports+import Foreign.Storable as Exports hiding (alignment, sizeOf)+import GHC.Conc as Exports hiding (threadWaitRead, threadWaitReadSTM, threadWaitWrite, threadWaitWriteSTM, withMVar)+import GHC.Exts as Exports (IsList (..), groupWith, inline, lazy, sortWith)+import GHC.Generics as Exports (Generic, Generic1)+import GHC.IO.Exception as Exports+import Numeric as Exports+import Prelude as Exports hiding (all, and, any, concat, concatMap, elem, fail, foldl, foldl1, foldr, foldr1, id, mapM, mapM_, maximum, minimum, notElem, or, product, sequence, sequence_, sum, (.))+import System.Environment as Exports+import System.Exit as Exports+import System.IO as Exports+import System.IO.Error as Exports+import System.IO.Unsafe as Exports+import System.Mem as Exports+import System.Mem.StableName as Exports+import System.Timeout as Exports+import Text.Printf as Exports (hPrintf, printf)+import Text.Read as Exports (Read (..), readEither, readMaybe)+import Unsafe.Coerce as Exports++tryError :: (MonadError e m) => m a -> m (Either e a)+tryError m =+ catchError (liftM Right m) (return . Left)
+ src/library/Hasql/Transaction/Private/SQL.hs view
@@ -0,0 +1,22 @@+module Hasql.Transaction.Private.SQL where++import ByteString.TreeBuilder qualified as D+import Hasql.Transaction.Config+import Hasql.Transaction.Private.Prelude++beginTransaction :: IsolationLevel -> Mode -> ByteString+beginTransaction isolation mode =+ D.toByteString builder+ where+ builder =+ "BEGIN " <> isolationBuilder <> " " <> modeBuilder+ where+ isolationBuilder =+ case isolation of+ ReadCommitted -> "ISOLATION LEVEL READ COMMITTED"+ RepeatableRead -> "ISOLATION LEVEL REPEATABLE READ"+ Serializable -> "ISOLATION LEVEL SERIALIZABLE"+ modeBuilder =+ case mode of+ Write -> "READ WRITE"+ Read -> "READ ONLY"
+ src/library/Hasql/Transaction/Private/Sessions.hs view
@@ -0,0 +1,55 @@+module Hasql.Transaction.Private.Sessions where++import Hasql.Errors (ServerError (..), SessionError (..), StatementError (..))+import Hasql.Session+import Hasql.Transaction.Config+import Hasql.Transaction.Private.Prelude+import Hasql.Transaction.Private.Statements qualified as Statements++{-+We may want to+do one transaction retry in case of the 23505 error, and fail if an identical+error is seen.+-}+inRetryingTransaction :: IsolationLevel -> Mode -> Bool -> Session (a, Bool) -> Session a+inRetryingTransaction level mode retryOnError session =+ fix $ \retry -> do+ attemptRes <- tryTransaction level mode retryOnError session+ case attemptRes of+ Just a -> return a+ Nothing -> retry++tryTransaction :: IsolationLevel -> Mode -> Bool -> Session (a, Bool) -> Session (Maybe a)+tryTransaction level mode retryOnError body = do+ statement () (Statements.beginTransaction level mode)++ bodyRes <- catchError (fmap Just body) $ \error -> do+ statement () Statements.abortTransaction+ handleTransactionError error retryOnError $ return Nothing++ case bodyRes of+ Just (res, commit) -> catchError (commitOrAbort commit $> Just res) $ \error -> do+ handleTransactionError error retryOnError $ return Nothing+ Nothing -> return Nothing++commitOrAbort :: Bool -> Session ()+commitOrAbort commit =+ if commit+ then statement () Statements.commitTransaction+ else statement () Statements.abortTransaction++handleTransactionError :: SessionError -> Bool -> Session a -> Session a+handleTransactionError error retryOnError onTransactionError = case error of+ StatementSessionError _ _ _ _ _ statementError -> onStatementError statementError+ ScriptSessionError _ serverError -> onServerError serverError+ _ -> throwError error+ where+ retryOrThrow = if retryOnError then onTransactionError else throwError error+ onStatementError = \case+ ServerStatementError serverError -> onServerError serverError+ _ -> throwError error+ onServerError (ServerError code _ _ _ _) =+ case code of+ "40001" -> retryOrThrow+ "40P01" -> retryOrThrow+ _ -> throwError error
+ src/library/Hasql/Transaction/Private/Statements.hs view
@@ -0,0 +1,20 @@+module Hasql.Transaction.Private.Statements where++import Data.Text.Encoding qualified as Text+import Hasql.Decoders qualified as C+import Hasql.Encoders qualified as B+import Hasql.Statement qualified as A+import Hasql.Transaction.Config+import Hasql.Transaction.Private.SQL qualified as D++beginTransaction :: IsolationLevel -> Mode -> A.Statement () ()+beginTransaction isolation mode =+ A.preparable (Text.decodeUtf8 (D.beginTransaction isolation mode)) B.noParams C.noResult++commitTransaction :: A.Statement () ()+commitTransaction =+ A.preparable "COMMIT" B.noParams C.noResult++abortTransaction :: A.Statement () ()+abortTransaction =+ A.preparable "ABORT" B.noParams C.noResult
+ src/library/Hasql/Transaction/Private/Transaction.hs view
@@ -0,0 +1,54 @@+module Hasql.Transaction.Private.Transaction where++import Data.Text.Encoding qualified as Text+import Hasql.Session qualified as B+import Hasql.Statement qualified as A+import Hasql.Transaction.Config+import Hasql.Transaction.Private.Prelude+import Hasql.Transaction.Private.Sessions qualified as D++-- |+-- A composable abstraction over the retryable transactions.+--+-- Executes multiple queries under the specified mode and isolation level,+-- while automatically retrying the transaction in case of conflicts.+-- Thus this abstraction closely reproduces the behaviour of 'STM'.+newtype Transaction a+ = Transaction (StateT Bool B.Session a)+ deriving (Functor, Applicative, Monad)++instance (Semigroup a) => Semigroup (Transaction a) where+ (<>) = liftA2 (<>)++instance (Monoid a) => Monoid (Transaction a) where+ mempty = pure mempty++-- |+-- Execute the transaction using the provided isolation level and mode.+{-# INLINE run #-}+run :: Transaction a -> IsolationLevel -> Mode -> Bool -> B.Session a+run (Transaction session) isolation mode retryOnError =+ D.inRetryingTransaction isolation mode retryOnError (runStateT session True)++-- |+-- Possibly a multi-statement query,+-- which however cannot be parameterized or prepared,+-- nor can any results of it be collected.+{-# INLINE sql #-}+sql :: ByteString -> Transaction ()+sql =+ Transaction . lift . B.script . Text.decodeUtf8++-- |+-- Parameters and a specification of the parametric query to apply them to.+{-# INLINE statement #-}+statement :: a -> A.Statement a b -> Transaction b+statement params statement =+ Transaction . lift $ B.statement params statement++-- |+-- Cause transaction to eventually roll back.+{-# INLINE condemn #-}+condemn :: Transaction ()+condemn =+ Transaction $ put False
+ src/library/Hasql/Transaction/Sessions.hs view
@@ -0,0 +1,28 @@+module Hasql.Transaction.Sessions+ ( transaction,+ transactionNoRetry,++ -- * Transaction settings+ C.Mode (..),+ C.IsolationLevel (..),+ )+where++import Hasql.Session qualified as B+import Hasql.Transaction.Config qualified as C+import Hasql.Transaction.Private.Prelude+import Hasql.Transaction.Private.Transaction qualified as A++-- |+-- Execute the transaction using the provided isolation level and mode.+{-# INLINE transaction #-}+transaction :: C.IsolationLevel -> C.Mode -> A.Transaction a -> B.Session a+transaction isolation mode transaction =+ A.run transaction isolation mode True++-- |+-- Execute the transaction but do not retry it on errors.+{-# INLINE transactionNoRetry #-}+transactionNoRetry :: C.IsolationLevel -> C.Mode -> A.Transaction a -> B.Session a+transactionNoRetry isolation mode transaction =+ A.run transaction isolation mode False