mirror of
https://github.com/hasura/graphql-engine.git
synced 2024-12-13 19:33:55 +03:00
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:
parent
02aef27a75
commit
60183df1ea
@ -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)
|
||||
|
@ -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 ;
|
||||
|
||||
```
|
||||
|
@ -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
|
||||
|
@ -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.
|
||||
|
@ -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 =>
|
||||
|
@ -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)
|
||||
]
|
||||
|
||||
|
@ -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],
|
||||
|
@ -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"
|
||||
|
||||
|
@ -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
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user