server/mssql: use temporary tables for insert mutation execution

PR-URL: https://github.com/hasura/graphql-engine-mono/pull/3205
GitOrigin-RevId: ff889ce43ed613c283edefa68992eab7c36db18c
This commit is contained in:
Rakesh Emmadi 2021-12-22 16:34:33 +05:30 committed by hasura-bot
parent 02aef27a75
commit 60183df1ea
10 changed files with 69 additions and 99 deletions

View File

@ -8,6 +8,8 @@
### Bug fixes and improvements
(Add entries below in the order of server, console, cli, docs, others)
- server: extend support for insert mutations to tables without primary key constraint in a MSSQL backend
## v2.1.1
- server: implement update mutations for MS SQL Server (closes #7834)

View File

@ -92,12 +92,12 @@ Mutation AST is a data type which can be readily translated to SQL Text. We need
- `data Delete` for Delete
Specimen SQL for reference: Unlike Postgres, we cannot integrate DML statements in [common table expression](https://docs.microsoft.com/en-us/sql/t-sql/queries/with-common-table-expression-transact-sql?view=sql-server-ver15) of MSSQL. Only SELECTs are allowed in a common table expression.
Our proposal is to use [local variables](https://docs.microsoft.com/en-us/sql/t-sql/language-elements/declare-local-variable-transact-sql?view=sql-server-ver15) to capture mutated rows and generated appropriate response using SELECT statement.
Our proposal is to use [temporary tables](https://www.sqlservertutorial.net/sql-server-basics/sql-server-temporary-tables/) to capture mutated rows and generated appropriate response using SELECT statement.
```mssql
INSERT INTO test (name, age) OUTPUT INSERTED.<primarykey-column> values ('rakesh', 25)
INSERT INTO test (name, age) OUTPUT INSERTED.<column1>, INSERTED.<column2> INTO #temp_table values ('rakesh', 25)
WITH some_alias AS (SELECT * FROM test WHERE <primarykey-column> IN (<values fetched from above SQL>))
WITH some_alias AS (SELECT * FROM #temp_table)
SELECT (SELECT * FROM some_alias FOR JSON PATH, INCLUDE_NULL_VALUES) AS [returning], count(*) AS [affected_rows] FROM some_alias FOR JSON PATH, WITHOUT_ARRAY_WRAPPER;
```
@ -109,9 +109,9 @@ Like in Postgres, we need to generate expression to evaluate the check condition
If check constraint is not satisfied we'll raise exception in the Haskell code.
```mssql
INSERT INTO test (name, age) OUTPUT INSERTED.<primarykey-column> values ('rakesh', 25)
INSERT INTO test (name, age) OUTPUT INSERTED.<column1>, INSERTED.<column2> INTO #temp_table values ('rakesh', 25)
WITH alias AS (SELECT * FROM test where id IN (<values-returned-from-above-sql>))
WITH alias AS (SELECT * FROM #temp_table)
SELECT (SELECT (SELECT * FROM alias FOR JSON PATH, INCLUDE_NULL_VALUES) AS [returning], count(*) AS [affected_rows] FROM alias FOR JSON PATH, WITHOUT_ARRAY_WRAPPER) AS [data], SUM(case when (id = 12) then 0 else 1 end) AS [check_constraint] FROM alias ;
```

View File

@ -42,7 +42,7 @@ import Data.Proxy
import Data.Text qualified as T
import Database.ODBC.SQLServer qualified as ODBC
import Hasura.Backends.MSSQL.Instances.Types ()
import Hasura.Backends.MSSQL.Types.Insert as TSQL (BackendInsert (..), ExtraColumnInfo (..))
import Hasura.Backends.MSSQL.Types.Insert as TSQL (BackendInsert (..))
import Hasura.Backends.MSSQL.Types.Internal as TSQL
import Hasura.Backends.MSSQL.Types.Update as TSQL (BackendUpdate (..), Update (..))
import Hasura.Prelude
@ -1073,8 +1073,10 @@ fromInsert IR.AnnInsert {..} =
insertRows = normalizeInsertRows _aiData $ map (IR.getInsertColumns) _aiInsObj
insertColumnNames = maybe [] (map fst) $ listToMaybe insertRows
insertValues = map (Values . map snd) insertRows
primaryKeyColumns = map OutputColumn $ _eciPrimaryKeyColumns $ _biExtraColumnInfo _aiBackendInsert
in Insert _aiTableName insertColumnNames (Output Inserted primaryKeyColumns) insertValues
allColumnNames = map (ColumnName . unName . IR.pgiName) _aiTableCols
insertOutput = Output Inserted $ map OutputColumn allColumnNames
tempTable = TempTable tempTableNameInserted allColumnNames
in Insert _aiTableName insertColumnNames insertOutput tempTable insertValues
-- | Normalize a row by adding missing columns with 'DEFAULT' value and sort by column name to make sure
-- all rows are consistent in column values and order.
@ -1099,7 +1101,7 @@ fromInsert IR.AnnInsert {..} =
normalizeInsertRows :: IR.AnnIns 'MSSQL [] Expression -> [[(Column 'MSSQL, Expression)]] -> [[(Column 'MSSQL, Expression)]]
normalizeInsertRows IR.AnnIns {..} insertRows =
let isIdentityColumn column =
IR.pgiColumn column `elem` _eciIdentityColumns (_biExtraColumnInfo _aiBackendInsert)
IR.pgiColumn column `elem` _biIdentityColumns _aiBackendInsert
allColumnsWithDefaultValue =
-- DEFAULT or NULL are not allowed as explicit identity values.
map ((,DefaultExpression) . IR.pgiColumn) $ filter (not . isIdentityColumn) _aiTableCols

View File

@ -15,14 +15,13 @@ import Data.HashSet qualified as Set
import Data.List.NonEmpty qualified as NE
import Data.Text.Extended qualified as T
import Database.MSSQL.Transaction qualified as Tx
import Database.ODBC.Internal qualified as ODBCI
import Database.ODBC.SQLServer qualified as ODBC
import Hasura.Backends.MSSQL.Connection
import Hasura.Backends.MSSQL.FromIr as TSQL
import Hasura.Backends.MSSQL.Plan
import Hasura.Backends.MSSQL.SQL.Value (txtEncodedColVal)
import Hasura.Backends.MSSQL.ToQuery as TQ
import Hasura.Backends.MSSQL.Types.Insert (BackendInsert (..), ExtraColumnInfo (..))
import Hasura.Backends.MSSQL.Types.Insert (BackendInsert (..))
import Hasura.Backends.MSSQL.Types.Internal as TSQL
import Hasura.Backends.MSSQL.Types.Update
import Hasura.Base.Error
@ -250,23 +249,26 @@ msDBMutationPlan userInfo stringifyNum sourceName sourceConfig mrf = do
-- --
-- Step 1: Inserting rows into the table
-- --
-- -- a. Generate an SQL Insert statement from the GraphQL insert mutation with OUTPUT expression to return
-- -- primary key column values after insertion.
-- -- a. Create an empty temporary table with name #inserted to store inserted rows
-- --
-- -- SELECT column1, column2 INTO #inserted FROM some_table WHERE (1 <> 1)
-- --
-- -- b. Before insert, Set IDENTITY_INSERT to ON if any insert row contains atleast one identity column.
-- --
-- -- SET IDENTITY_INSERT some_table ON;
-- -- INSERT INTO some_table (column1, column2) OUTPUT INSERTED.pkey_column1, INSERTED.pkey_column2 VALUES (value1, value2), (value3, value4);
-- --
-- -- c. Generate an SQL Insert statement from the GraphQL insert mutation with OUTPUT expression to fill 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 the table using primary key column values fetched from
-- -- Step 1 in the WHERE clause
-- -- a. A SQL Select statement to fetch only inserted rows from temporary table
-- --
-- -- <table_select> :=
-- -- SELECT * FROM some_table WHERE (pkey_column1 = value1 AND pkey_column2 = value2) OR (pkey_column1 = value3 AND pkey_column2 = value4)
-- -- <table_select> := SELECT * FROM #inserted
-- --
-- -- The above select statement is referred through a common table expression - "WITH [with_alias] AS (<table_select>)"
-- --
@ -303,13 +305,19 @@ executeInsert userInfo stringifyNum sourceConfig annInsert = do
sessionVariables = _uiSession userInfo
pool = _mscConnectionPool sourceConfig
table = _aiTableName $ _aiData annInsert
withSelectTableAlias = "t_" <> tableName table
withAlias = "with_alias"
buildInsertTx :: AnnInsert 'MSSQL Void Expression -> Tx.TxET QErr IO EncJSON
buildInsertTx insert = do
let identityColumns = _eciIdentityColumns $ _biExtraColumnInfo $ _aiBackendInsert $ _aiData insert
let identityColumns = _biIdentityColumns $ _aiBackendInsert $ _aiData insert
insertColumns = concatMap (map fst . getInsertColumns) $ _aiInsObj $ _aiData insert
createTempTableQuery =
toQueryFlat $
TQ.fromSelectIntoTempTable $
TSQL.toSelectIntoTempTable tempTableNameInserted table (_aiTableCols $ _aiData insert)
-- Create #inserted temporary table
Tx.unitQueryE fromMSSQLTxError createTempTableQuery
-- Set identity insert to ON if insert object contains identity columns
when (any (`elem` identityColumns) insertColumns) $
@ -320,53 +328,37 @@ executeInsert userInfo stringifyNum sourceConfig annInsert = do
-- Generate the INSERT query
let insertQuery = toQueryFlat $ TQ.fromInsert $ TSQL.fromInsert insert
fromODBCException e =
(err400 MSSQLError "insert query exception") {qeInternal = Just (ExtraInternal $ odbcExceptionToJSONValue e)}
-- Execute the INSERT query and fetch the primary key values
primaryKeyValues <- Tx.buildGenericTxE fromODBCException $ \conn -> ODBCI.query conn (ODBC.renderQuery insertQuery)
let withSelect = generateWithSelect primaryKeyValues
-- WITH [with_alias] AS (select_query)
withExpression = With $ pure $ Aliased withSelect withAlias
-- Execute the INSERT query
Tx.unitQueryE fromMSSQLTxError insertQuery
mutationOutputSelect <- mkMutationOutputSelect stringifyNum withAlias $ _aiOutput insert
let (checkCondition, _) = _aiCheckCond $ _aiData 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)
-- SELECT (<mutation_output_select>) AS [mutation_response], (<check_constraint_select>) AS [check_constraint_select]
let mutationOutputCheckConstraintSelect = selectMutationOutputAndCheckCondition withAlias mutationOutputSelect checkBoolExp
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 withExpression}
finalSelect = mutationOutputCheckConstraintSelect {selectWith = Just $ With $ pure $ Aliased withSelect withAlias}
-- Execute SELECT query to fetch mutation response and check constraint result
(responseText, checkConditionInt) <- Tx.singleRowQueryE fromMSSQLTxError (toQueryFlat $ TQ.fromSelect finalSelect)
-- Drop the temp table
Tx.unitQueryE fromMSSQLTxError $ toQueryFlat $ dropTempTableQuery tempTableNameInserted
-- Raise an exception if the check condition is not met
unless (checkConditionInt == (0 :: Int)) $
throw400 PermissionError "check constraint of an insert permission has failed"
pure $ encJFromText responseText
columnFieldExpression :: ODBCI.Column -> Expression
columnFieldExpression column =
ColumnExpression $ TSQL.FieldName (ODBCI.columnName column) withSelectTableAlias
generateWithSelect :: [[(ODBCI.Column, ODBC.Value)]] -> Select
generateWithSelect pkeyValues =
emptySelect
{ selectProjections = [StarProjection],
selectFrom = Just $ FromQualifiedTable $ Aliased table withSelectTableAlias,
selectWhere = whereExpression
}
where
-- WHERE (column1 = value1 AND column2 = value2) OR (column1 = value3 AND column2 = value4)
whereExpression =
let mkColumnEqExpression (column, value) =
OpExpression EQ' (columnFieldExpression column) (ValueExpression value)
in Where $ pure $ OrExpression $ map (AndExpression . map mkColumnEqExpression) pkeyValues
-- Delete
-- | Executes a Delete IR AST and return results as JSON.

View File

@ -9,7 +9,7 @@ import Data.List.NonEmpty qualified as NE
import Data.Text.Encoding (encodeUtf8)
import Data.Text.Extended
import Database.ODBC.SQLServer qualified as ODBC
import Hasura.Backends.MSSQL.Types.Insert (BackendInsert (..), ExtraColumnInfo (..))
import Hasura.Backends.MSSQL.Types.Insert (BackendInsert (..))
import Hasura.Backends.MSSQL.Types.Internal qualified as MSSQL
import Hasura.Backends.MSSQL.Types.Update (BackendUpdate (..), UpdateOperator (..))
import Hasura.Base.Error
@ -65,10 +65,6 @@ instance BackendSchema 'MSSQL where
-- SQL literals
columnDefaultValue = msColumnDefaultValue
-- | MSSQL only supports inserts into tables that have a primary key defined.
supportsInserts :: TableInfo 'MSSQL -> Bool
supportsInserts = isJust . _tciPrimaryKey . _tiCoreInfo
----------------------------------------------------------------
-- Top level parsers
@ -95,25 +91,7 @@ msBuildTableInsertMutationFields ::
Maybe (SelPermInfo 'MSSQL) ->
Maybe (UpdPermInfo 'MSSQL) ->
m [FieldParser n (AnnInsert 'MSSQL (RemoteRelationshipField UnpreparedValue) (UnpreparedValue 'MSSQL))]
msBuildTableInsertMutationFields
sourceName
tableName
tableInfo
gqlName
insPerms
mSelPerms
mUpdPerms
| supportsInserts tableInfo =
GSB.buildTableInsertMutationFields
backendInsertParser
sourceName
tableName
tableInfo
gqlName
insPerms
mSelPerms
mUpdPerms
| otherwise = return []
msBuildTableInsertMutationFields = GSB.buildTableInsertMutationFields backendInsertParser
backendInsertParser ::
forall m r n.
@ -130,13 +108,8 @@ backendInsertParser _sourceName tableInfo _selectPerms _updatePerms = do
pure $ do
-- _biIfMatched <- ifMatched
let _biIfMatched = Nothing
_biIdentityColumns = _tciExtraTableMetadata $ _tiCoreInfo tableInfo
pure $ BackendInsert {..}
where
_biExtraColumnInfo :: ExtraColumnInfo
_biExtraColumnInfo =
let pkeyColumns = fmap (map pgiColumn . toList . _pkColumns) . _tciPrimaryKey . _tiCoreInfo $ tableInfo
identityColumns = _tciExtraTableMetadata $ _tiCoreInfo tableInfo
in ExtraColumnInfo (fromMaybe [] pkeyColumns) identityColumns
msBuildTableUpdateMutationFields ::
MonadBuildSchema 'MSSQL r m n =>

View File

@ -205,6 +205,7 @@ fromInsert Insert {..} =
[ "INSERT INTO " <+> fromTableName insertTable,
"(" <+> SepByPrinter ", " (map (fromNameText . columnNameText) insertColumns) <+> ")",
fromInsertOutput insertOutput,
"INTO " <+> fromTempTable insertTempTable,
"VALUES " <+> SepByPrinter ", " (map fromValues insertValues)
]

View File

@ -3,7 +3,6 @@
-- | Types for MSSQL Insert IR.
module Hasura.Backends.MSSQL.Types.Insert
( BackendInsert (..),
ExtraColumnInfo (..),
IfMatched (..),
)
where
@ -16,7 +15,7 @@ import Hasura.SQL.Backend (BackendType (MSSQL))
data BackendInsert v = BackendInsert
{ _biIfMatched :: Maybe (IfMatched v),
_biExtraColumnInfo :: ExtraColumnInfo
_biIdentityColumns :: [ColumnName]
}
deriving instance Backend 'MSSQL => Functor BackendInsert
@ -25,11 +24,6 @@ deriving instance Backend 'MSSQL => Foldable BackendInsert
deriving instance Backend 'MSSQL => Traversable BackendInsert
data ExtraColumnInfo = ExtraColumnInfo
{ _eciPrimaryKeyColumns :: ![ColumnName],
_eciIdentityColumns :: ![ColumnName]
}
-- | The IR data representing an @if_matched@ clause, which handles upserts.
data IfMatched v = IfMatched
{ _imMatchColumns :: [Column 'MSSQL],

View File

@ -70,6 +70,7 @@ module Hasura.Backends.MSSQL.Types.Internal
scalarTypeDBName,
snakeCaseTableName,
stringTypes,
tempTableNameInserted,
tempTableNameDeleted,
tempTableNameUpdated,
)
@ -180,10 +181,11 @@ type InsertOutput = Output Inserted
newtype Values = Values [Expression]
data Insert = Insert
{ insertTable :: !TableName,
insertColumns :: ![ColumnName],
insertOutput :: !InsertOutput,
insertValues :: ![Values]
{ insertTable :: TableName,
insertColumns :: [ColumnName],
insertOutput :: InsertOutput,
insertTempTable :: TempTable,
insertValues :: [Values]
}
data SetValue
@ -215,6 +217,9 @@ data SelectIntoTempTable = SelectIntoTempTable
-- | A temporary table name is prepended by a hash-sign
newtype TempTableName = TempTableName Text
tempTableNameInserted :: TempTableName
tempTableNameInserted = TempTableName "inserted"
tempTableNameDeleted :: TempTableName
tempTableNameDeleted = TempTableName "deleted"

View File

@ -13,8 +13,9 @@ query:
}
}
response:
errors:
- extensions:
path: $.selectionSet.insert_table_no_pk
code: validation-failed
message: "field \"insert_table_no_pk\" not found in type: 'mutation_root'"
data:
insert_table_no_pk:
affected_rows: 1
returning:
- id: 1
name: Foo

View File

@ -867,8 +867,8 @@ class TestGraphQLInsertMSSQL:
def test_insert_multiple_objects(self, hge_ctx):
check_query_f(hge_ctx, self.dir() + "/insert_multiple_objects_mssql.yaml")
def test_insert_table_no_pk_fail(self, hge_ctx):
check_query_f(hge_ctx, self.dir() + "/insert_table_no_pk_fail_mssql.yaml")
def test_insert_table_no_pk(self, hge_ctx):
check_query_f(hge_ctx, self.dir() + "/insert_table_no_pk_mssql.yaml")
@pytest.mark.parametrize("backend", ['mssql'])
@use_mutation_fixtures