packages feed

orville-postgresql-1.1.0.0: src/Orville/PostgreSQL/Execution/Transaction.hs

{- |
Copyright : Flipstone Technology Partners 2023-2025
License   : MIT
Stability : Stable

This module provides the functionality to work with SQL transactions - notably
to ensure some Haskell action occurs within a database transaction.

@since 1.0.0.0
-}
module Orville.PostgreSQL.Execution.Transaction
  ( withTransaction
  , withTransactionInstruction
  , TransactionInstruction (Commit, Rollback)
  , inWithTransaction
  , InWithTransaction (InOutermostTransaction, InSavepointTransaction)
  , UnexpectedTransactionStatusError (..)
  )
where

import Control.Exception (Exception, finally, onException, throwIO, try, uninterruptibleMask)
import Control.Monad.IO.Class (liftIO)
import qualified Database.PostgreSQL.LibPQ as LibPQ
import Numeric.Natural (Natural)

import qualified Orville.PostgreSQL.Execution.Execute as Execute
import qualified Orville.PostgreSQL.Execution.QueryType as QueryType
import qualified Orville.PostgreSQL.Expr as Expr
import qualified Orville.PostgreSQL.Internal.Bracket as Bracket
import qualified Orville.PostgreSQL.Internal.MonadOrville as MonadOrville
import qualified Orville.PostgreSQL.Internal.OrvilleState as OrvilleState
import qualified Orville.PostgreSQL.Monad as Monad
import qualified Orville.PostgreSQL.Raw.Connection as Connection
import qualified Orville.PostgreSQL.Raw.RawSql as RawSql

{- | Performs an action in an Orville monad within a database transaction. The transaction
  is begun before the action is called. If the action completes without raising an exception,
  the transaction will be committed. If the action raises an exception, the transaction will
  rollback. This is equivalent to calling 'withTransactionInstruction' and always returning
  'Commit' as the 'TransactionInstruction'.

  This function is safe to call from within another transaction. When called this way, the
  transaction will establish a new savepoint at the beginning of the nested transaction and
  either release the savepoint or rollback to it as appropriate.

  See also: 'withTransactionInstruction'

  Note: Exceptions are handled using the implementations of 'Monad.catch' and
  'Monad.mask' provided by the 'Monad.MonadOrvilleControl' instance for @m@.

@since 1.0.0.0
-}
withTransaction :: Monad.MonadOrville m => m a -> m a
withTransaction =
  withTransactionInstruction . fmap (\a -> (a, Commit))

{- |
Used with 'withTransactionInstruction' to indicate whether the transaction should be
committed or rolled back.

@since 1.1.0.0
-}
data TransactionInstruction = Commit | Rollback

{- | Performs an action in an Orville monad within a database transaction. The transaction
  is begun before the action is called. If the action completes without raising an exception,
  the transaction will be it will either be commited or rolled back, depending on the
  'TransactionInstruction' returned by the action.. If the action raises an exception, the
  transaction will rollback.

  This function is safe to call from within another transaction. When called this way, the
  transaction will establish a new savepoint at the beginning of the nested transaction and
  either release the savepoint or rollback to it as appropriate.

  See also: 'withTransaction'

  Note: Exceptions are handled using the implementations of 'Monad.catch' and
  'Monad.mask' provided by the 'Monad.MonadOrvilleControl' instance for @m@.

@since 1.1.0.0
-}
withTransactionInstruction :: Monad.MonadOrville m => m (a, TransactionInstruction) -> m a
withTransactionInstruction action =
  MonadOrville.withConnectedState $ \connectedState -> do
    let
      conn = OrvilleState.connectedConnection connectedState
      transaction = OrvilleState.newTransaction (OrvilleState.connectedTransaction connectedState)

      innerConnectedState =
        connectedState
          { OrvilleState.connectedTransaction = Just transaction
          }

    state <- Monad.askOrvilleState

    let
      executeTransactionSql :: RawSql.RawSql -> IO ()
      executeTransactionSql sql =
        Execute.executeVoidIO QueryType.OtherQuery sql state conn

      callback =
        OrvilleState.orvilleTransactionCallback state

      beginTransaction :: IO ()
      beginTransaction = do
        status <- Connection.transactionStatusOrThrow conn
        let
          openEvent = OrvilleState.openTransactionEvent transaction
          beginAction = do
            executeTransactionSql (transactionEventSql state openEvent)
            callback openEvent
          transactionError = UnexpectedTransactionStatusError status openEvent
        case status of
          LibPQ.TransIdle -> case openEvent of
            OrvilleState.BeginTransaction -> do
              let
                execRollback :: (IO () -> IO a) -> IO a
                execRollback restore = do
                  executeTransactionSql (transactionEventSql state OrvilleState.RollbackTransaction)
                  restore $ callback OrvilleState.RollbackTransaction
                rollback = uninterruptibleMask $ \restore -> do
                  postBeginStatus <- Connection.transactionStatusOrThrow conn
                  case postBeginStatus of
                    LibPQ.TransInTrans -> execRollback restore
                    LibPQ.TransInError -> execRollback restore
                    LibPQ.TransActive -> execRollback restore
                    LibPQ.TransIdle -> pure ()
                    LibPQ.TransUnknown -> pure ()
              beginAction `onException` rollback
            _ ->
              throwIO transactionError
          LibPQ.TransInTrans -> case openEvent of
            OrvilleState.NewSavepoint _ ->
              beginAction
            _ ->
              throwIO transactionError
          LibPQ.TransActive ->
            throwIO transactionError
          LibPQ.TransInError ->
            throwIO transactionError
          LibPQ.TransUnknown ->
            throwIO transactionError

      doAction () =
        Monad.localOrvilleState
          (OrvilleState.connectState innerConnectedState)
          action

      finishTransaction :: Bracket.BracketResult (a, TransactionInstruction) -> IO ()
      finishTransaction result = uninterruptibleMask $ \restore -> do
        status <- Connection.transactionStatusOrThrow conn
        let
          successEvent = OrvilleState.transactionSuccessEvent transaction
          rollbackEvent = OrvilleState.rollbackTransactionEvent transaction
          rollback = do
            executeTransactionSql (transactionEventSql state rollbackEvent)
            restore $ callback rollbackEvent
          transactionError = UnexpectedTransactionStatusError status $ case result of
            Bracket.BracketSuccess (_, Commit) -> successEvent
            Bracket.BracketSuccess (_, Rollback) -> rollbackEvent
            Bracket.BracketError -> rollbackEvent
        case status of
          LibPQ.TransInTrans -> case result of
            Bracket.BracketSuccess (_, Commit) -> do
              eSuccess <- try $ executeTransactionSql (transactionEventSql state successEvent)
              case eSuccess of
                Right () ->
                  restore $ callback successEvent
                Left ex -> do
                  restore (callback rollbackEvent) `finally` throwIO (ex :: Connection.SqlExecutionError)
            Bracket.BracketSuccess (_, Rollback) -> do
              rollback
            Bracket.BracketError ->
              rollback
          LibPQ.TransInError ->
            rollback
          LibPQ.TransActive ->
            throwIO transactionError
          LibPQ.TransIdle ->
            throwIO transactionError
          LibPQ.TransUnknown ->
            throwIO transactionError

    fmap fst $
      Bracket.bracketWithResult
        (liftIO beginTransaction)
        (const $ liftIO . finishTransaction)
        doAction

{- | 'withTransaction' will throw this exception if libpq reports a transaction status on the underlying
  connection that is incompatible with the current transaction event.

@since 1.1.0.0
-}
data UnexpectedTransactionStatusError = UnexpectedTransactionStatusError
  { unexpectedTransactionStatusErrorTransactionStatus :: LibPQ.TransactionStatus
  , unexpectedTransactionStatusErrorTransactionEvent :: OrvilleState.TransactionEvent
  }

{- |

@since 1.1.0.0
-}
instance Show UnexpectedTransactionStatusError where
  show (UnexpectedTransactionStatusError status event) =
    "Unexpected transaction status during event " <> show event <> ": " <> show status

{- |

@since 1.1.0.0
-}
instance Exception UnexpectedTransactionStatusError

transactionEventSql ::
  OrvilleState.OrvilleState ->
  OrvilleState.TransactionEvent ->
  RawSql.RawSql
transactionEventSql state event =
  case event of
    OrvilleState.BeginTransaction ->
      RawSql.toRawSql $ OrvilleState.orvilleBeginTransactionExpr state
    OrvilleState.NewSavepoint savepoint ->
      RawSql.toRawSql $ Expr.savepoint (savepointName savepoint)
    OrvilleState.RollbackTransaction ->
      RawSql.toRawSql Expr.rollback
    OrvilleState.RollbackToSavepoint savepoint ->
      RawSql.toRawSql $ Expr.rollbackTo (savepointName savepoint)
    OrvilleState.CommitTransaction ->
      RawSql.toRawSql Expr.commit
    OrvilleState.ReleaseSavepoint savepoint ->
      RawSql.toRawSql $ Expr.releaseSavepoint (savepointName savepoint)

{- | INTERNAL: Constructs a savepoint name based on the current nesting level of
  transactions, as tracked by the `OrvilleState.Savepoint` type. Strictly
  speaking this is not necessary for PostgreSQL because it supports shadowing
  savepoint names. The SQL standard doesn't allow for savepoint name shadowing,
  however. Re-using this same name in other databases would overwrite the
  savepoint rather than shadow it. This function constructs savepoint names
  that will work on any database that implements savepoints accordings to the
  SQL standard even though Orville only supports PostgreSQL currently.

@since 1.0.0.0
-}
savepointName :: OrvilleState.Savepoint -> Expr.SavepointName
savepointName savepoint =
  let
    n = OrvilleState.savepointNestingLevel savepoint
  in
    Expr.savepointName ("orville_savepoint_level_" <> show n)

{- | Information about the current transaction state of an action passed to 'withTransaction'.

@since 1.1.0.0
-}
data InWithTransaction
  = InOutermostTransaction
  | -- | The 'Natural' indicates the savepoint depth, where @1@ is the first savepoint.
    InSavepointTransaction Natural
  deriving
    ( -- | @since 1.1.0.0
      Eq
    , -- | @since 1.1.0.0
      Ord
    , -- | @since 1.1.0.0
      Show
    )

{- |  Returns 'Just' an 'InWithTransaction' value when called inside of the action passed to
  'withTransaction', and 'Nothing' otherwise.

@since 1.1.0.0
-}
inWithTransaction :: MonadOrville.MonadOrville m => m (Maybe InWithTransaction)
inWithTransaction =
  fmap
    ( \state -> case OrvilleState.orvilleConnectionState state of
        OrvilleState.Connected connectedState ->
          fmap
            ( \transactionState -> case transactionState of
                OrvilleState.OutermostTransaction ->
                  InOutermostTransaction
                OrvilleState.SavepointTransaction i ->
                  InSavepointTransaction . fromIntegral $ OrvilleState.savepointNestingLevel i
            )
            (OrvilleState.connectedTransaction connectedState)
        OrvilleState.NotConnected ->
          Nothing
    )
    Monad.askOrvilleState