graphql-engine/server/src-lib/Hasura/Backends/MSSQL/Execute/Insert.hs
Gil Mizrahi 59ab1bbe57 refactor Hasura.Backends.MSSQL.Instances.Execute
PR-URL: https://github.com/hasura/graphql-engine-mono/pull/3265
GitOrigin-RevId: f7faa22be30ecc5a876dc40ad93cb8d2452de65e
2022-01-06 09:49:58 +00:00

287 lines
14 KiB
Haskell

{-# OPTIONS_HADDOCK ignore-exports #-}
-- | Responsible for translating and building an MSSQL execution plan for
-- delete mutations.
--
-- This module is used by "Hasura.Backends.MSSQL.Instances.Execute".
module Hasura.Backends.MSSQL.Execute.Insert
( executeInsert,
)
where
import Control.Monad.Validate qualified as V
import Database.MSSQL.Transaction qualified as Tx
import Hasura.Backends.MSSQL.Connection
import Hasura.Backends.MSSQL.Execute.MutationResponse
import Hasura.Backends.MSSQL.FromIr as TSQL
import Hasura.Backends.MSSQL.Plan
import Hasura.Backends.MSSQL.ToQuery as TQ
import Hasura.Backends.MSSQL.Types.Insert (BackendInsert (..), IfMatched)
import Hasura.Backends.MSSQL.Types.Internal as TSQL
import Hasura.Base.Error
import Hasura.EncJSON
import Hasura.GraphQL.Parser
import Hasura.Prelude
import Hasura.RQL.IR
import Hasura.RQL.Types
import Hasura.Session
-- | Execute and insert/upsert mutation against MS SQL Server.
-- See the documentation for 'buildInsertTx' to see how it's done.
executeInsert ::
MonadError QErr m =>
UserInfo ->
Bool ->
SourceConfig 'MSSQL ->
AnnInsert 'MSSQL Void (UnpreparedValue 'MSSQL) ->
m (ExceptT QErr IO EncJSON)
executeInsert userInfo stringifyNum sourceConfig annInsert = do
-- Convert the leaf values from @'UnpreparedValue' to sql @'Expression'
insert <- traverse (prepareValueQuery sessionVariables) annInsert
let insertTx = buildInsertTx tableName withAlias stringifyNum insert
pure $ mssqlRunReadWrite (_mscExecCtx sourceConfig) $ Tx.runTxE fromMSSQLTxError insertTx
where
sessionVariables = _uiSession userInfo
tableName = _aiTableName $ _aiData annInsert
withAlias = "with_alias"
-- | Translates an IR Insert/upsert mutation description to SQL and
-- builds a corresponding transaction to run against MS SQL Server.
--
-- Execution of a MSSQL insert mutation broadly involves two steps.
--
-- > insert_table(objects: [
-- > {column1: value1, column2: value2},
-- > {column1: value3, column2: value4}
-- > ],
-- > if_matched: {match_columns: [column1], update_columns: [column2]} # Optional field to enable upserting
-- > ){
-- > affected_rows
-- > returning {
-- > column1
-- > column2
-- > }
-- > }
--
-- = Step 1: Inserting rows into the table
--
-- a. Create an empty temporary table with name #inserted to store affected rows (for the response)
--
-- > SELECT column1, column2 INTO #inserted FROM some_table WHERE (1 <> 1)
-- > UNION ALL SELECT column1, column2 FROM some_table;
--
-- c. If 'if_matched' is found: Use MERGE statment to perform upsert
-- c.1 Use #values temporary table to store input object values
--
-- > SELECT column1, column2 INTO #values FROM some_table WHERE (1 <> 1)
--
-- c.2 Before and after the insert, Set IDENTITY_INSERT to ON/OFF if any insert row contains
-- at least one identity column.
--
-- > SET IDENTITY_INSERT #values ON;
-- > <INSERT>
-- > SET IDENTITY_INSERT #values OFF;
--
-- c.3 Insert input object values into the temporary table
--
-- > INSERT INTO #values (column1, column2) VALUES (value1, value2), (value3, value4)
--
-- c.4 Before and after the MERGE, Set IDENTITY_INSERT to ON/OFF if any insert row contains
-- at least one identity column.
--
-- > SET IDENTITY_INSERT some_table ON;
-- > <INSERT>
-- > SET IDENTITY_INSERT some_table OFF;
--
-- c.5 Generate an SQL Merge statement to perform either update or insert (upsert) to the table
--
-- > MERGE some_table AS [target]
-- > USING (SELECT column1, column2 from #values) AS [source](column1, column2) ON ([target].column1 = [source].column1)
-- > WHEN MATCHED THEN UPDATE SET [column2] = [source].[column2]
-- > WHEN NOT MATCHED THEN INSERT (column1, column2) VALUES ([source].column1, [source].column2)
-- > OUTPUT INSERTED.column1, INSERTED.column2 INTO #inserted(column1, column2)
--
-- __NOTE__: In @MERGE@ statement, we use @SELECT query from a temporary table@ as source but not @VALUES@ expression
-- because, we can't use @DEFAULT@ expression (for missing columns in @objects@ field) in @VALUES@ expression.
--
-- __else__: Generate an SQL Insert statement from the GraphQL insert mutation with OUTPUT expression to fill @#inserted@ temporary table with inserted rows
--
-- > INSERT INTO some_table (column1, column2) OUTPUT INSERTED.column1, INSERTED.column2 INTO #inserted(column1, column2) VALUES (value1, value2), (value3, value4);
--
-- = Step 2: Generation of the mutation response
--
-- An SQL statement is generated and when executed it returns the mutation selection set containing 'affected_rows' and 'returning' field values.
-- The statement is generated with multiple sub select queries explained below:
--
-- a. A SQL Select statement to fetch only inserted rows from temporary table
--
-- > <table_select> := SELECT * FROM #inserted
--
-- The above select statement is referred through a common table expression - @WITH [with_alias] AS (<table_select>)@
--
-- b. The @affected_rows@ field value is obtained by using @COUNT@ aggregation and the @returning@ field selection set is translated to
-- a SQL select statement using 'mkSQLSelect'.
--
-- > <mutation_output_select> :=
-- > SELECT (SELECT COUNT(*) FROM [with_alias]) AS [affected_rows], (select_from_returning) AS [returning]
-- > FOR JSON PATH, INCLUDE_NULL_VALUES, WITHOUT_ARRAY_WRAPPER
--
-- c. Evaluate the check constraint using @CASE@ expression. We use @SUM@ aggregation to check if any inserted row has failed the check constraint.
--
-- > <check_constraint_select> :=
-- > SELECT SUM(CASE WHEN <check_boolean_expression> THEN 0 ELSE 1 END)
-- > FROM [with_alias]
--
-- d. The final select statement look like
--
-- > WITH "with_alias" AS (<table_select>)
-- > SELECT (<mutation_output_select>) AS [mutation_response], (<check_constraint_select>) AS [check_constraint_select]
--
-- When executed, the above statement returns a single row with mutation response as a string value and check constraint result as an integer value.
buildInsertTx :: TSQL.TableName -> Text -> Bool -> AnnInsert 'MSSQL Void Expression -> Tx.TxET QErr IO EncJSON
buildInsertTx tableName withAlias stringifyNum insert = do
let tableColumns = _aiTableCols $ _aiData insert
ifMatchedField = _biIfMatched . _aiBackendInsert . _aiData $ insert
-- Create #inserted temporary table
let createInsertedTempTableQuery =
toQueryFlat $
TQ.fromSelectIntoTempTable $
TSQL.toSelectIntoTempTable tempTableNameInserted tableName tableColumns RemoveConstraints
Tx.unitQueryE fromMSSQLTxError createInsertedTempTableQuery
-- Choose between running a regular @INSERT INTO@ statement or a @MERGE@ statement
-- depending on the @if_matched@ field.
--
-- Affected rows will be inserted into the #inserted temporary table regardless.
case ifMatchedField of
Nothing -> buildRegularInsertTx tableName insert
Just ifMatched -> buildUpsertTx tableName insert ifMatched
-- Build a response to the user using the values in the temporary table named #inserted
(responseText, checkConditionInt) <- buildInsertResponseTx stringifyNum withAlias insert
-- Drop the #inserted temp table
Tx.unitQueryE fromMSSQLTxError $ toQueryFlat $ dropTempTableQuery tempTableNameInserted
-- Raise an exception if the check condition is not met
unless (checkConditionInt == 0) $
throw400 PermissionError "check constraint of an insert permission has failed"
pure $ encJFromText responseText
-- | Translate an IR Insert mutation into a simple insert SQL statement,
-- which is surrounded by @SET IDENTITY_INSERT <table> ON/OFF@ if needed.
--
-- Should be used as part of a bigger transaction in 'buildInsertTx'.
buildRegularInsertTx :: TSQL.TableName -> AnnInsert 'MSSQL Void Expression -> Tx.TxET QErr IO ()
buildRegularInsertTx tableName insert = do
let identityColumns = _biIdentityColumns $ _aiBackendInsert $ _aiData insert
insertColumns = concatMap (map fst . getInsertColumns) $ _aiInsObj $ _aiData insert
-- Set identity insert to ON/OFF before/after inserting into the table
-- if insert object contains identity columns
withIdentityInsert identityColumns insertColumns (RegularTableName tableName) $ do
-- Insert values into the table using INSERT query
let insertQuery = toQueryFlat $ TQ.fromInsert $ TSQL.fromInsert insert
Tx.unitQueryE fromMSSQLTxError insertQuery
-- | Translates an IR IfMatched clause to SQL and
-- builds a corresponding transaction to run against MS SQL Server.
--
-- We do this in 2 steps:
--
-- 1. Create a temporary table called @#values@ which will hold the values the user want to insert,
-- and insert the values into it
-- 2. Build an run a @MERGE@ statement to either insert or upsert the values from the temporary table @#values@
-- into the original table, and output the affected rows into another temporary table called @#inserted@
-- which will be used to build a "response" for the user.
--
-- Should be used as part of a bigger transaction in 'buildInsertTx'.
buildUpsertTx :: TSQL.TableName -> AnnInsert 'MSSQL Void Expression -> IfMatched Expression -> Tx.TxET QErr IO ()
buildUpsertTx tableName insert ifMatched = do
let identityColumns = _biIdentityColumns $ _aiBackendInsert $ _aiData insert
insertColumns = concatMap (map fst . getInsertColumns) $ _aiInsObj $ _aiData insert
tableColumns = _aiTableCols $ _aiData insert
createValuesTempTableQuery =
toQueryFlat $
TQ.fromSelectIntoTempTable $
-- We want to KeepConstraints here so the user can omit values for identity columns such as `id`
TSQL.toSelectIntoTempTable tempTableNameValues tableName tableColumns KeepConstraints
-- Create #values temporary table
Tx.unitQueryE fromMSSQLTxError createValuesTempTableQuery
-- Set identity insert to ON if insert object contains identity columns for temporary #values table
withIdentityInsert identityColumns insertColumns (TemporaryTableName tempTableNameValues) $ do
-- Store values in #values temporary table
let insertValuesIntoTempTableQuery =
toQueryFlat $
TQ.fromInsertValuesIntoTempTable $
TSQL.toInsertValuesIntoTempTable tempTableNameValues insert
Tx.unitQueryE fromMSSQLTxError insertValuesIntoTempTableQuery
-- Set identity insert to ON if insert object contains identity columns
-- before inserting into the original table
withIdentityInsert identityColumns insertColumns (RegularTableName tableName) $ do
-- Run the MERGE query and store the mutated rows in #inserted temporary table
merge <-
(V.runValidate . runFromIr)
(toMerge tableName (_aiInsObj $ _aiData insert) identityColumns tableColumns ifMatched)
`onLeft` (throw500 . tshow)
let mergeQuery = toQueryFlat $ TQ.fromMerge merge
Tx.unitQueryE fromMSSQLTxError mergeQuery
-- After @MERGE@ we no longer need this temporary table
Tx.unitQueryE fromMSSQLTxError $ toQueryFlat $ dropTempTableQuery tempTableNameValues
-- | Sets @IDENTITY_INSERT@ to ON before running some statements and afterwards OFF
-- if there are identity columns in the table.
--
-- This is done so we can insert identity columns explicitly.
withIdentityInsert :: [ColumnName] -> [ColumnName] -> SomeTableName -> Tx.TxET QErr IO a -> Tx.TxET QErr IO a
withIdentityInsert identityColumns insertColumns table statements = do
let setIdentityInsertIf mode =
when (any (`elem` identityColumns) insertColumns) $
Tx.unitQueryE fromMSSQLTxError $
toQueryFlat $
TQ.fromSetIdentityInsert $ SetIdentityInsert table mode
-- Set identity insert to ON if insert object contains identity columns
setIdentityInsertIf SetON
-- Run the statements that should run while @IDENTITY_INSERT@ is set to ON
result <- statements
-- Set identity insert to OFF if insert object contains identity columns,
-- because only one table can have @IDENTITY_INSERT@ set to ON in a session :(
-- See https://stackoverflow.com/questions/23832598/identity-insert-is-already-on-for-table-x-cannot-perform-set-operation-for-ta
setIdentityInsertIf SetOFF
pure result
-- | Builds a response to the user using the values in the temporary table named #inserted.
buildInsertResponseTx :: Bool -> Text -> AnnInsert 'MSSQL Void Expression -> Tx.TxET QErr IO (Text, Int)
buildInsertResponseTx stringifyNum withAlias insert = do
-- Generate a SQL SELECT statement which outputs the mutation response using the #inserted
mutationOutputSelect <- mkMutationOutputSelect stringifyNum withAlias $ _aiOutput insert
-- The check constraint is translated to boolean expression
let checkCondition = fst $ _aiCheckCond $ _aiData insert
checkBoolExp <-
V.runValidate (runFromIr $ runReaderT (fromGBoolExp checkCondition) (EntityAlias withAlias))
`onLeft` (throw500 . tshow)
let withSelect =
emptySelect
{ selectProjections = [StarProjection],
selectFrom = Just $ FromTempTable $ Aliased tempTableNameInserted "inserted_alias"
}
-- SELECT (<mutation_output_select>) AS [mutation_response], (<check_constraint_select>) AS [check_constraint_select]
mutationOutputCheckConstraintSelect = selectMutationOutputAndCheckCondition withAlias mutationOutputSelect checkBoolExp
-- WITH "with_alias" AS (<table_select>)
-- SELECT (<mutation_output_select>) AS [mutation_response], (<check_constraint_select>) AS [check_constraint_select]
finalSelect = mutationOutputCheckConstraintSelect {selectWith = Just $ With $ pure $ Aliased withSelect withAlias}
-- Execute SELECT query to fetch mutation response and check constraint result
Tx.singleRowQueryE fromMSSQLTxError (toQueryFlat $ TQ.fromSelect finalSelect)