graphql-engine/server/src-lib/Database/MSSQL/Transaction.hs

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

375 lines
12 KiB
Haskell
Raw Normal View History

module Database.MSSQL.Transaction
( TxET (..),
MSSQLTxError (..),
TxIsolation (..),
TxT,
TxE,
runTx,
runTxE,
unitQuery,
unitQueryE,
singleRowQuery,
singleRowQueryE,
multiRowQuery,
multiRowQueryE,
forJsonQueryE,
buildGenericQueryTxE,
withTxET,
)
where
import Autodocodec (HasCodec (codec), bimapCodec, textCodec, (<?>))
import Autodocodec.Aeson qualified as AC
import Control.Exception (try)
import Control.Monad.Morph (MFunctor (hoist))
import Control.Monad.Trans.Control (MonadBaseControl)
import Data.Aeson qualified as J
import Data.Text qualified as T
import Database.MSSQL.Pool
import Database.ODBC.SQLServer (FromRow)
import Database.ODBC.SQLServer qualified as ODBC
import Hasura.Prelude
-- | The transaction command to run, parameterised over:
-- e - the exception type (usually 'MSSQLTxError')
-- m - some Monad, (usually some 'MonadIO')
-- a - the successful result type
newtype TxET e m a = TxET
{ txHandler :: ReaderT ODBC.Connection (ExceptT e m) a
}
deriving
( Functor,
Applicative,
Monad,
MonadError e,
MonadIO,
MonadReader ODBC.Connection,
MonadFix
)
instance MFunctor (TxET e) where
hoist f = TxET . hoist (hoist f) . txHandler
server/mssql: add cascade to mssql_run_sql <!-- Thank you for ss in the Title above ^ --> ## Description <!-- Please fill thier. --> <!-- Describe the changes from a user's perspective --> We don't have dependency reporting mechanism for `mssql_run_sql` API i.e when a database object (table, column etc.) is dropped through the API we should raise an exception if any dependencies (relationships, permissions etc.) with the database object exists in the metadata. This PR addresses the above mentioned problem by -> Integrating transaction to the API to rollback the SQL query execution if dependencies exists and exception is thrown -> Accepting `cascade` optional field in the API payload to drop the dependencies, if any -> Accepting `check_metadata_consistency` optional field to bypass (if value set to `false`) the dependency check ### Related Issues <!-- Please make surt title --> <!-- Add the issue number below (e.g. #234) --> Close #1853 ### Solution and Design <!-- How is this iss --> <!-- It's better if we elaborate --> The design/solution follows the `run_sql` API implementation for Postgres backend. ### Steps to test and verify <!-- If this is a fehis is a bug-fix, how do we verify the fix? --> - Create author - article tables and track them - Defined object and array relationships - Try to drop the article table without cascade or cascade set to `false` - The server should raise the relationship dependency exists exception ## Changelog - ✅ `CHANGELOG.md` is updated with user-facing content relevant to this PR. If no changelog is required, then add the `no-changelog-required` label. ## Affected components <!-- Remove non-affected components from the list --> - ✅ Server - ❎ Console - ❎ CLI - ❎ Docs - ❎ Community Content - ❎ Build System - ✅ Tests - ❎ Other (list it) PR-URL: https://github.com/hasura/graphql-engine-mono/pull/2636 GitOrigin-RevId: 0ab152295394056c4ca6f02923142a1658ad25dc
2021-10-22 17:49:15 +03:00
instance MonadTrans (TxET e) where
lift = TxET . lift . lift
-- | Error type generally used in 'TxET'.
data MSSQLTxError
= MSSQLQueryError !ODBC.Query !ODBC.ODBCException
| MSSQLConnError !ODBC.ODBCException
| MSSQLInternal !Text
deriving (Eq, Show)
type TxE e a = TxET e IO a
-- | The transaction command to run, returning an MSSQLTxError or the result.
type TxT m a = TxET MSSQLTxError m a
-- | Run a command on the given connection wrapped in a transaction.
--
-- See 'runTxE' if you need to map the error type as well.
runTx ::
(MonadIO m, MonadBaseControl IO m) =>
TxIsolation ->
TxT m a ->
MSSQLPool ->
ExceptT MSSQLTxError m a
runTx = runTxE id
-- | Run a command on the given connection wrapped in a transaction.
runTxE ::
(MonadIO m, MonadBaseControl IO m) =>
(MSSQLTxError -> e) ->
TxIsolation ->
TxET e m a ->
MSSQLPool ->
ExceptT e m a
runTxE ef txIsolation tx pool = do
withMSSQLPool pool (asTransaction ef txIsolation (`execTx` tx))
>>= hoistEither
. mapLeft (ef . MSSQLConnError)
-- | Useful for building transactions which return no data.
--
-- @
-- insertId :: TxT m ()
-- insertId = unitQuery "INSERT INTO some_table VALUES (1, \"hello\")"
-- @
--
-- See 'unitQueryE' if you need to map the error type as well.
unitQuery :: (MonadIO m) => ODBC.Query -> TxT m ()
unitQuery = unitQueryE id
-- | Useful for building transactions which return no data.
unitQueryE :: (MonadIO m) => (MSSQLTxError -> e) -> ODBC.Query -> TxET e m ()
unitQueryE ef = rawQueryE ef emptyResult
where
emptyResult :: MSSQLResult -> Either String ()
emptyResult (MSSQLResult []) = Right ()
emptyResult (MSSQLResult _) = Left "expecting no data for ()"
-- | Useful for building query transactions which return a single one row.
--
-- @
-- returnOne :: TxT m Int
-- returnOne = singleRowQuery "SELECT 1"
-- @
--
-- See 'singleRowQueryE' if you need to map the error type as well.
singleRowQuery :: forall a m. (MonadIO m, FromRow a) => ODBC.Query -> TxT m a
singleRowQuery = singleRowQueryE id
-- | Useful for building query transactions which return a single one row.
singleRowQueryE ::
forall m a e.
(MonadIO m, FromRow a) =>
(MSSQLTxError -> e) ->
ODBC.Query ->
TxET e m a
singleRowQueryE ef = rawQueryE ef singleRowResult
where
singleRowResult :: MSSQLResult -> Either String a
singleRowResult (MSSQLResult [row]) = ODBC.fromRow row
singleRowResult (MSSQLResult _) = Left "expecting single row"
-- | MSSQL splits up results that have a @SELECT .. FOR JSON@ at the top-level
-- into multiple rows with a single column, see
-- https://docs.microsoft.com/en-us/sql/relational-databases/json/format-query-results-as-json-with-for-json-sql-server?view=sql-server-ver15#output-of-the-for-json-clause
--
-- This function simply concatenates each single-column row into one long 'Text' string.
forJsonQueryE ::
forall m e.
(MonadIO m) =>
(MSSQLTxError -> e) ->
ODBC.Query ->
TxET e m Text
forJsonQueryE ef = rawQueryE ef concatRowResult
where
concatRowResult :: MSSQLResult -> Either String Text
concatRowResult (MSSQLResult []) = pure mempty
concatRowResult (MSSQLResult rows@(r1 : _)) | length r1 == 1 = mconcat <$> mapM ODBC.fromRow rows
concatRowResult (MSSQLResult (r1 : _)) = Left $ "forJsonQueryE: Expected single-column results, but got " <> show (length r1) <> " columns"
-- | Useful for building query transactions which return multiple rows.
--
-- @
-- selectIds :: TxT m [Int]
-- selectIds = multiRowQuery "SELECT id FROM author"
-- @
--
-- See 'multiRowQueryE' if you need to map the error type as well.
multiRowQuery :: forall a m. (MonadIO m, FromRow a) => ODBC.Query -> TxT m [a]
multiRowQuery = multiRowQueryE id
-- | Useful for building query transactions which return multiple rows.
multiRowQueryE ::
forall m a e.
(MonadIO m, FromRow a) =>
(MSSQLTxError -> e) ->
ODBC.Query ->
TxET e m [a]
multiRowQueryE ef = rawQueryE ef multiRowResult
where
multiRowResult :: MSSQLResult -> Either String [a]
multiRowResult (MSSQLResult rows) = traverse ODBC.fromRow rows
-- | Build a generic transaction out of an IO action.
buildGenericQueryTxE ::
(MonadIO m) =>
-- | map 'MSSQLTxError' to some other type
(MSSQLTxError -> e) ->
-- | query to run
query ->
-- | how to map a query to a 'ODBC.Query'
(query -> ODBC.Query) ->
-- | run the query on a provided 'ODBC.Connection'
(ODBC.Connection -> query -> IO a) ->
TxET e m a
buildGenericQueryTxE errorF query convertQ runQuery =
TxET $ ReaderT $ withExceptT errorF . execQuery query convertQ . runQuery
-- | Map the error type for a 'TxET'.
withTxET :: (Monad m) => (e1 -> e2) -> TxET e1 m a -> TxET e2 m a
withTxET f (TxET m) = TxET $ hoist (withExceptT f) m
-- | A successful result from a query is a list of rows where each row contains
-- list of column values
newtype MSSQLResult = MSSQLResult [[ODBC.Value]]
deriving (Eq, Show)
-- | Packs a query, along with result and error converters into a 'TxET'.
--
-- Used by 'unitQueryE', 'singleRowQueryE', and 'multiRowQueryE'.
rawQueryE ::
(MonadIO m) =>
-- | Error modifier
(MSSQLTxError -> e) ->
-- | Result modifier with a failure
(MSSQLResult -> Either String a) ->
-- | Query to run
ODBC.Query ->
TxET e m a
rawQueryE ef rf q = do
rows <- buildGenericQueryTxE ef q id ODBC.query
liftEither
$ mapLeft (ef . MSSQLQueryError q . ODBC.DataRetrievalError)
$ rf (MSSQLResult rows)
-- | Combinator for abstracting over the query type and ensuring we catch exceptions.
--
-- Used by 'buildGenericQueryTxE'.
execQuery ::
forall m a query.
(MonadIO m) =>
query ->
(query -> ODBC.Query) ->
(query -> IO a) ->
ExceptT MSSQLTxError m a
execQuery query toODBCQuery runQuery = do
result :: Either ODBC.ODBCException a <- liftIO $ try $ runQuery query
withExceptT (MSSQLQueryError $ toODBCQuery query) $ hoistEither result
-- | Run a 'TxET' with the given connection.
--
-- Used by 'runTxE' and 'asTransaction'.
execTx :: ODBC.Connection -> TxET e m a -> ExceptT e m a
execTx conn tx = runReaderT (txHandler tx) conn
{-# INLINE execTx #-}
-- | The transaction state of the current connection
data TransactionState
= -- | Has an active transaction.
TSActive
| -- | Has no active transaction.
TSNoActive
| -- | An error occurred that caused the transaction to be uncommittable.
-- We cannot commit or rollback to a savepoint; we can only do a full
-- rollback of the transaction.
TSUncommittable
-- | <https://learn.microsoft.com/en-us/sql/t-sql/statements/set-transaction-isolation-level-transact-sql>
data TxIsolation
= ReadUncommitted
| ReadCommitted
| RepeatableRead
| Snapshot
| Serializable
deriving (Eq, Generic)
instance Show TxIsolation where
show = \case
ReadUncommitted -> "READ UNCOMMITTED"
ReadCommitted -> "READ COMMITTED"
RepeatableRead -> "REPEATABLE READ"
Snapshot -> "SNAPSHOT"
Serializable -> "SERIALIZABLE"
instance Hashable TxIsolation
instance NFData TxIsolation
instance HasCodec TxIsolation where
codec =
bimapCodec
decode
encode
textCodec
<?> "Isolation level"
where
decode :: Text -> Either String TxIsolation
decode = \case
"read-uncommitted" -> Right ReadUncommitted
"read-committed" -> Right ReadCommitted
"repeatable-read" -> Right RepeatableRead
"snapshot" -> Right Snapshot
"serializable" -> Right Serializable
_ ->
Left
$ T.unpack
$ "Unexpected options for isolation_level. Expected "
<> "'read-uncommited' | 'read-committed' | 'repeatable-read' | 'snapshot' | 'serializable'"
encode :: TxIsolation -> Text
encode = \case
ReadUncommitted -> "read-uncommitted"
ReadCommitted -> "read-committed"
RepeatableRead -> "repeatable-read"
Snapshot -> "snapshot"
Serializable -> "serializable"
instance J.ToJSON TxIsolation where
toJSON = AC.toJSONViaCodec
toEncoding = AC.toEncodingViaCodec
instance J.FromJSON TxIsolation where
parseJSON = AC.parseJSONViaCodec
-- | Wraps an action in a transaction. Rolls back on errors.
asTransaction ::
forall e a m.
(MonadIO m) =>
(MSSQLTxError -> e) ->
TxIsolation ->
(ODBC.Connection -> ExceptT e m a) ->
ODBC.Connection ->
ExceptT e m a
asTransaction ef txIsolation action conn = do
-- Begin the transaction. If there is an error, do not rollback.
withExceptT ef $ execTx conn $ setTxIsoLevelTx txIsolation >> beginTx
-- Run the transaction and commit. If there is an error, rollback.
flip catchError rollbackAndThrow do
result <- action conn
-- After running the transaction, set the transaction isolation level
-- to the default isolation level i.e. Read Committed
withExceptT ef $ execTx conn $ commitTx >> setTxIsoLevelTx ReadCommitted
pure result
where
-- Rollback and throw error.
rollbackAndThrow :: e -> ExceptT e m b
rollbackAndThrow err = do
withExceptT ef $ execTx conn rollbackTx
throwError err
beginTx :: (MonadIO m) => TxT m ()
beginTx = unitQuery "BEGIN TRANSACTION"
setTxIsoLevelTx :: (MonadIO m) => TxIsolation -> TxT m ()
setTxIsoLevelTx txIso =
unitQuery $ ODBC.rawUnescapedText $ "SET TRANSACTION ISOLATION LEVEL " <> tshow txIso <> ";"
commitTx :: (MonadIO m) => TxT m ()
commitTx =
getTransactionState >>= \case
TSActive ->
unitQuery "COMMIT TRANSACTION"
TSUncommittable ->
throwError $ MSSQLInternal "Transaction is uncommittable"
TSNoActive ->
throwError $ MSSQLInternal "No active transaction exist; cannot commit"
rollbackTx :: (MonadIO m) => TxT m ()
rollbackTx =
let rollback = unitQuery "ROLLBACK TRANSACTION"
in getTransactionState >>= \case
TSActive -> rollback
TSUncommittable -> rollback
TSNoActive ->
-- Some query exceptions result in an auto-rollback of the transaction.
-- For eg. Creating a table with already existing table name (See https://github.com/hasura/graphql-engine-mono/issues/3046)
-- In such cases, we shouldn't rollback the transaction again.
pure ()
-- | Get the @'TransactionState' of current connection
-- For more details, refer to https://docs.microsoft.com/en-us/sql/t-sql/functions/xact-state-transact-sql?view=sql-server-ver15
getTransactionState :: (MonadIO m) => TxT m TransactionState
getTransactionState =
let query = "SELECT XACT_STATE()"
in singleRowQuery @Int query
>>= \case
1 -> pure TSActive
0 -> pure TSNoActive
-1 -> pure TSUncommittable
_ ->
throwError
$ MSSQLQueryError query
$ ODBC.DataRetrievalError "Unexpected value for XACT_STATE"