packages feed

orville-postgresql-1.0.0.0: src/Orville/PostgreSQL/Internal/OrvilleState.hs

{-# LANGUAGE RankNTypes #-}

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

@since 1.0.0.0
-}
module Orville.PostgreSQL.Internal.OrvilleState
  ( OrvilleState
  , newOrvilleState
  , resetOrvilleState
  , orvilleConnectionPool
  , orvilleConnectionState
  , orvilleErrorDetailLevel
  , orvilleTransactionCallback
  , orvilleSqlCommenterAttributes
  , addTransactionCallback
  , TransactionEvent (BeginTransaction, NewSavepoint, ReleaseSavepoint, RollbackToSavepoint, CommitTransaction, RollbackTransaction)
  , openTransactionEvent
  , rollbackTransactionEvent
  , transactionSuccessEvent
  , ConnectionState (NotConnected, Connected)
  , ConnectedState (ConnectedState, connectedConnection, connectedTransaction)
  , connectState
  , TransactionState (OutermostTransaction, SavepointTransaction)
  , newTransaction
  , Savepoint
  , savepointNestingLevel
  , initialSavepoint
  , nextSavepoint
  , orvilleSqlExecutionCallback
  , addSqlExecutionCallback
  , orvilleBeginTransactionExpr
  , setBeginTransactionExpr
  , setSqlCommenterAttributes
  , addSqlCommenterAttributes
  )
where

import qualified Data.Map.Strict as Map

import Orville.PostgreSQL.ErrorDetailLevel (ErrorDetailLevel)
import Orville.PostgreSQL.Execution.QueryType (QueryType)
import qualified Orville.PostgreSQL.Expr as Expr
import Orville.PostgreSQL.Raw.Connection (Connection, ConnectionPool)
import qualified Orville.PostgreSQL.Raw.RawSql as RawSql
import qualified Orville.PostgreSQL.Raw.SqlCommenter as SqlCommenter

{- |
  'OrvilleState' is used to manage opening connections to the database,
  transactions, etc. 'newOrvilleState' should be used to create an appopriate
  initial state for your monad's context.

@since 1.0.0.0
-}
data OrvilleState = OrvilleState
  { _orvilleConnectionPool :: ConnectionPool
  , _orvilleConnectionState :: ConnectionState
  , _orvilleErrorDetailLevel :: ErrorDetailLevel
  , _orvilleTransactionCallback :: TransactionEvent -> IO ()
  , _orvilleSqlExecutionCallback :: forall a. QueryType -> RawSql.RawSql -> IO a -> IO a
  , _orvilleBeginTransactionExpr :: Expr.BeginTransactionExpr
  , _orvilleSqlCommenterAttributes :: Maybe SqlCommenter.SqlCommenterAttributes
  }

{- |
  Get the connection pool being used for the 'OrvilleState'.

@since 1.0.0.0
-}
orvilleConnectionPool :: OrvilleState -> ConnectionPool
orvilleConnectionPool =
  _orvilleConnectionPool

{- |
  INTERNAL: The 'ConnectionState' indicates whether Orville currently has a
  connection open, and contains the connection if it does.

@since 1.0.0.0
-}
orvilleConnectionState :: OrvilleState -> ConnectionState
orvilleConnectionState =
  _orvilleConnectionState

{- |
  The 'ErrorDetailLevel' controls how much information Orville includes in
  error messages it generates when data cannot be decoded from rows in the
  database.

@since 1.0.0.0
-}
orvilleErrorDetailLevel :: OrvilleState -> ErrorDetailLevel
orvilleErrorDetailLevel =
  _orvilleErrorDetailLevel

{- |
  Orville will call the transaction callback any time a transaction event
  occurs. You can register a callback with 'addTransactionCallback'.

@since 1.0.0.0
-}
orvilleTransactionCallback :: OrvilleState -> TransactionEvent -> IO ()
orvilleTransactionCallback =
  _orvilleTransactionCallback

{- |
  The SQL expression that Orville will use to begin a transaction. You can set
  this via 'setBeginTransactionExpr' to have fine-grained control over the
  transaction parameters, such as isolation level.

@since 1.0.0.0
-}
orvilleBeginTransactionExpr :: OrvilleState -> Expr.BeginTransactionExpr
orvilleBeginTransactionExpr =
  _orvilleBeginTransactionExpr

{- |
  The SqlCommenter attributes that Orville will include with queries. These can
  be modified with 'addSqlCommenterAttributes'. See
  https://google.github.io/sqlcommenter/.

@since 1.0.0.0
-}
orvilleSqlCommenterAttributes :: OrvilleState -> Maybe SqlCommenter.SqlCommenterAttributes
orvilleSqlCommenterAttributes =
  _orvilleSqlCommenterAttributes

{- |
  Registers a callback to be invoked during transactions.

  The callback given will be called after the SQL statement corresponding
  to the given event has finished executing. Callbacks will be called
  in the order they are added.

  Note: There is no specialized error handling for these callbacks. This means
  that if a callback raises an exception, no further callbacks will be called
  and the exception will propagate up until it is caught elsewhere. In
  particular, if an exception is raised by a callback upon opening the
  transaction, it will cause the transaction to be rolled-back the same as any
  other exception that might happen during the transaction. In general, we
  recommend only using callbacks that either raise no exceptions or can handle
  their own exceptions cleanly.

@since 1.0.0.0
-}
addTransactionCallback ::
  (TransactionEvent -> IO ()) ->
  OrvilleState ->
  OrvilleState
addTransactionCallback newCallback state =
  let
    originalCallback =
      _orvilleTransactionCallback state

    wrappedCallback event = do
      originalCallback event
      newCallback event
  in
    state {_orvilleTransactionCallback = wrappedCallback}

{- |
  Creates an appropriate initial 'OrvilleState' that will use the connection
  pool given to initiate connections to the database.

@since 1.0.0.0
-}
newOrvilleState :: ErrorDetailLevel -> ConnectionPool -> OrvilleState
newOrvilleState errorDetailLevel pool =
  OrvilleState
    { _orvilleConnectionPool = pool
    , _orvilleConnectionState = NotConnected
    , _orvilleErrorDetailLevel = errorDetailLevel
    , _orvilleTransactionCallback = defaultTransactionCallback
    , _orvilleSqlExecutionCallback = defaultSqlExectionCallback
    , _orvilleBeginTransactionExpr = defaultBeginTransactionExpr
    , _orvilleSqlCommenterAttributes = Nothing
    }

{- |
  Creates a new initial 'OrvilleState' using the connection pool from the
  provided state. You might need to use this if you are spawning one Orville
  monad from another and they should not share the same connection and
  transaction state.

@since 1.0.0.0
-}
resetOrvilleState :: OrvilleState -> OrvilleState
resetOrvilleState =
  newOrvilleState
    <$> _orvilleErrorDetailLevel
    <*> _orvilleConnectionPool

{- |
  INTERNAL: Transitions the 'OrvilleState' into "connected" status, storing the
  given 'Connection' as the database connection to be used to execute all
  queries. This is used by 'Orville.PostgreSQL.Monad.withConnection' to track
  the connection it retrieves from the pool.

@since 1.0.0.0
-}
connectState :: ConnectedState -> OrvilleState -> OrvilleState
connectState connectedState state =
  state
    { _orvilleConnectionState = Connected connectedState
    }

{- |
  INTERNAL: This type is used to signal whether a database connection has
  been retrieved from the pool for the current operation or not. The
  value is tracked in the 'OrvilleState' for the host monad, and is checked
  by 'Orville.PostgreSQL.Monad.withConnection' to avoid checking out two
  separate connections for a multiple operations that needs to be run on the
  same connection (e.g. multiple operations inside a transaction).

@since 1.0.0.0
-}
data ConnectionState
  = NotConnected
  | Connected ConnectedState

{- |
  INTERNAL: This type is used hold the connection while it is open and
  track the state of open transactions and savepoints on the connection.

@since 1.0.0.0
-}
data ConnectedState = ConnectedState
  { connectedConnection :: Connection
  , connectedTransaction :: Maybe TransactionState
  }

{- |
  INTERNAL: This type is use to track the state of open transactions and
  savepoints on an open connection.

@since 1.0.0.0
-}
data TransactionState
  = OutermostTransaction
  | SavepointTransaction Savepoint

{- |
  INTERNAL: Constructs a new 'TransactionState' to represent beginning a
  new transaction in SQL.

@since 1.0.0.0
-}
newTransaction :: Maybe TransactionState -> TransactionState
newTransaction maybeTransactionState =
  case maybeTransactionState of
    Nothing ->
      OutermostTransaction
    Just OutermostTransaction ->
      SavepointTransaction initialSavepoint
    Just (SavepointTransaction savepoint) ->
      SavepointTransaction (nextSavepoint savepoint)

{- |
  An internal Orville identifier for a savepoint in a PostgreSQL transaction.

@since 1.0.0.0
-}
newtype Savepoint
  = Savepoint Int
  deriving (Eq, Show)

{- |
  The initial identifier Orville uses to track the first savepoint within
  a transaction.

@since 1.0.0.0
-}
initialSavepoint :: Savepoint
initialSavepoint =
  Savepoint 1

{- |
  Determines the identifier for the next savepoint in a transaction after the
  given savepoint.

@since 1.0.0.0
-}
nextSavepoint :: Savepoint -> Savepoint
nextSavepoint (Savepoint n) =
  Savepoint (n + 1)

{- |
  Indicates how many levels of nested savepoints the given 'Savepoint'
  identifier represents.

@since 1.0.0.0
-}
savepointNestingLevel :: Savepoint -> Int
savepointNestingLevel (Savepoint n) = n

{- |
  Describes an event in the lifecycle of a database transaction. You can use
  'addTransactionCallback' to register a callback to respond to these events.
  The callback will be called after the event in question has been successfully
  executed.

@since 1.0.0.0
-}
data TransactionEvent
  = -- | Indicates a new transaction has been started.
    BeginTransaction
  | -- | Indicates that a new savepoint has been saved within a transaction.
    NewSavepoint Savepoint
  | -- | Indicates that a previous savepoint has been released. It can no
    -- longer be rolled back to.
    ReleaseSavepoint Savepoint
  | -- | Indicates that rollback was performed to a prior savepoint.
    --
    -- Note: It is possible to rollback to a savepoint prior to the most recent
    -- one without releasing or rolling back to intermediate savepoints. Doing
    -- so destroys any savepoints created after the given savepoint. Although
    -- Orville currently always matches 'NewSavepoint' with either
    -- 'ReleaseSavepoint' or 'RollbackToSavepoint', it is recommended that you
    -- do not rely on this behavior.
    RollbackToSavepoint Savepoint
  | -- | Indicates that the transaction has been committed.
    CommitTransaction
  | -- | Indicates that the transaction has been rolled back.
    RollbackTransaction
  deriving (Eq, Show)

{- |
  The default transaction callback is simply a no-op.

@since 1.0.0.0
-}
defaultTransactionCallback :: TransactionEvent -> IO ()
defaultTransactionCallback = const (pure ())

{- |
  Constructs the appropriate 'TransactionEvent' for opening a new transaction
  based on the current 'TransactionState'.

@since 1.0.0.0
-}
openTransactionEvent :: TransactionState -> TransactionEvent
openTransactionEvent txnState =
  case txnState of
    OutermostTransaction -> BeginTransaction
    SavepointTransaction savepoint -> NewSavepoint savepoint

{- |
  Constructs the appropriate 'TransactionEvent' for rolling back the innermost
  transaction based on the current 'TransactionState'.

@since 1.0.0.0
-}
rollbackTransactionEvent :: TransactionState -> TransactionEvent
rollbackTransactionEvent txnState =
  case txnState of
    OutermostTransaction -> RollbackTransaction
    SavepointTransaction savepoint -> RollbackToSavepoint savepoint

{- |
  Constructs the appropriate 'TransactionEvent' to represent a transaction
  completely successfully based on the current 'TransactionState'.

@since 1.0.0.0
-}
transactionSuccessEvent :: TransactionState -> TransactionEvent
transactionSuccessEvent txnState =
  case txnState of
    OutermostTransaction -> CommitTransaction
    SavepointTransaction savepoint -> ReleaseSavepoint savepoint

{- |
  The callback Orville will call whenever it wants to run SQL. You can
  register a callback using 'addSqlExecutionCallback'.

@since 1.0.0.0
-}
orvilleSqlExecutionCallback ::
  OrvilleState ->
  forall a.
  QueryType ->
  RawSql.RawSql ->
  IO a ->
  IO a
orvilleSqlExecutionCallback =
  _orvilleSqlExecutionCallback

{- |
  The default SQL execption callback simply runs the IO action given without
  doing anything else.

@since 1.0.0.0
-}
defaultSqlExectionCallback :: QueryType -> RawSql.RawSql -> IO a -> IO a
defaultSqlExectionCallback _ _ io = io

{- |
  Adds a callback to be called when an Orville operation executes a SQL
  statement. The callback is given the IO action that will perform the
  query execution and must call that action for the query to be run.
  In particular, you can use this to time queries and log any that are slow.

  Calls to any previously added callbacks will also be executed as part of
  the IO action passed to the new callback. Thus the newly added callback
  happens "around" the previously added callback.

  There is no special exception handling done for these callbacks beyond what
  they implement themselves. Any callbacks should allow for the possibility
  that the IO action they are given may raise an exception.

@since 1.0.0.0
-}
addSqlExecutionCallback ::
  (forall a. QueryType -> RawSql.RawSql -> IO a -> IO a) ->
  OrvilleState ->
  OrvilleState
addSqlExecutionCallback outerCallback state =
  let
    layeredCallback, innerCallback :: QueryType -> RawSql.RawSql -> IO a -> IO a
    layeredCallback queryType sql action =
      outerCallback queryType sql (innerCallback queryType sql action)
    innerCallback = _orvilleSqlExecutionCallback state
  in
    state {_orvilleSqlExecutionCallback = layeredCallback}

{- |
  The default begin transaction expression is simply @BEGIN TRANSACTION@
  with no options specified.

@since 1.0.0.0
-}
defaultBeginTransactionExpr :: Expr.BeginTransactionExpr
defaultBeginTransactionExpr =
  Expr.beginTransaction Nothing

{- |
  Sets the SQL expression that Orville will use to begin transactions. You can
  control the transaction isolation level by building your own
  'Expr.BeginTransactionExpr' with the desired isolation level.

@since 1.0.0.0
-}
setBeginTransactionExpr ::
  Expr.BeginTransactionExpr ->
  OrvilleState ->
  OrvilleState
setBeginTransactionExpr expr state =
  state
    { _orvilleBeginTransactionExpr = expr
    }

{- |
  Sets the SqlCommenterAttributes that Orville will then add to any following
  statement executions.

@since 1.0.0.0
-}
setSqlCommenterAttributes ::
  SqlCommenter.SqlCommenterAttributes ->
  OrvilleState ->
  OrvilleState
setSqlCommenterAttributes comments state =
  state
    { _orvilleSqlCommenterAttributes = Just comments
    }

{- |
  Adds the SqlCommenterAttributes to the already existing attributes that
  Orville will then add to any following statement executions.

@since 1.0.0.0
-}
addSqlCommenterAttributes ::
  SqlCommenter.SqlCommenterAttributes ->
  OrvilleState ->
  OrvilleState
addSqlCommenterAttributes comments state =
  case orvilleSqlCommenterAttributes state of
    Nothing ->
      state
        { _orvilleSqlCommenterAttributes = Just comments
        }
    Just existingAttrs ->
      state
        { _orvilleSqlCommenterAttributes = Just $ Map.union comments existingAttrs
        }