diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fb44b2053f..cdf32505d87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/rfcs/mutations-mssql.md b/rfcs/mutations-mssql.md index 183d5b4f36d..992629d5a46 100644 --- a/rfcs/mutations-mssql.md +++ b/rfcs/mutations-mssql.md @@ -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. values ('rakesh', 25) +INSERT INTO test (name, age) OUTPUT INSERTED., INSERTED. INTO #temp_table values ('rakesh', 25) -WITH some_alias AS (SELECT * FROM test WHERE IN ()) +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. values ('rakesh', 25) +INSERT INTO test (name, age) OUTPUT INSERTED., INSERTED. INTO #temp_table values ('rakesh', 25) -WITH alias AS (SELECT * FROM test where id IN ()) +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 ; ``` diff --git a/server/src-lib/Hasura/Backends/MSSQL/FromIr.hs b/server/src-lib/Hasura/Backends/MSSQL/FromIr.hs index 8f9245e31b8..417c55bf2eb 100644 --- a/server/src-lib/Hasura/Backends/MSSQL/FromIr.hs +++ b/server/src-lib/Hasura/Backends/MSSQL/FromIr.hs @@ -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 diff --git a/server/src-lib/Hasura/Backends/MSSQL/Instances/Execute.hs b/server/src-lib/Hasura/Backends/MSSQL/Instances/Execute.hs index 087580ea148..439be067ce1 100644 --- a/server/src-lib/Hasura/Backends/MSSQL/Instances/Execute.hs +++ b/server/src-lib/Hasura/Backends/MSSQL/Instances/Execute.hs @@ -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 -- -- --- -- := --- -- SELECT * FROM some_table WHERE (pkey_column1 = value1 AND pkey_column2 = value2) OR (pkey_column1 = value3 AND pkey_column2 = value4) +-- -- := SELECT * FROM #inserted -- -- -- -- The above select statement is referred through a common table expression - "WITH [with_alias] AS ()" -- -- @@ -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 () AS [mutation_response], () AS [check_constraint_select] - let mutationOutputCheckConstraintSelect = selectMutationOutputAndCheckCondition withAlias mutationOutputSelect checkBoolExp - + let withSelect = + emptySelect + { selectProjections = [StarProjection], + selectFrom = Just $ FromTempTable $ Aliased tempTableNameInserted "inserted_alias" + } + -- SELECT () AS [mutation_response], () AS [check_constraint_select] + mutationOutputCheckConstraintSelect = selectMutationOutputAndCheckCondition withAlias mutationOutputSelect checkBoolExp -- WITH "with_alias" AS () -- SELECT () AS [mutation_response], () 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. diff --git a/server/src-lib/Hasura/Backends/MSSQL/Instances/Schema.hs b/server/src-lib/Hasura/Backends/MSSQL/Instances/Schema.hs index 5f3ad812d67..b4743e61977 100644 --- a/server/src-lib/Hasura/Backends/MSSQL/Instances/Schema.hs +++ b/server/src-lib/Hasura/Backends/MSSQL/Instances/Schema.hs @@ -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 => diff --git a/server/src-lib/Hasura/Backends/MSSQL/ToQuery.hs b/server/src-lib/Hasura/Backends/MSSQL/ToQuery.hs index 168daf21664..41ee5cb42c2 100644 --- a/server/src-lib/Hasura/Backends/MSSQL/ToQuery.hs +++ b/server/src-lib/Hasura/Backends/MSSQL/ToQuery.hs @@ -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) ] diff --git a/server/src-lib/Hasura/Backends/MSSQL/Types/Insert.hs b/server/src-lib/Hasura/Backends/MSSQL/Types/Insert.hs index e8012c0b79d..3de80d68872 100644 --- a/server/src-lib/Hasura/Backends/MSSQL/Types/Insert.hs +++ b/server/src-lib/Hasura/Backends/MSSQL/Types/Insert.hs @@ -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], diff --git a/server/src-lib/Hasura/Backends/MSSQL/Types/Internal.hs b/server/src-lib/Hasura/Backends/MSSQL/Types/Internal.hs index 37802c2301d..ef39ea20749 100644 --- a/server/src-lib/Hasura/Backends/MSSQL/Types/Internal.hs +++ b/server/src-lib/Hasura/Backends/MSSQL/Types/Internal.hs @@ -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" diff --git a/server/tests-py/queries/graphql_mutation/insert/basic/insert_table_no_pk_fail_mssql.yaml b/server/tests-py/queries/graphql_mutation/insert/basic/insert_table_no_pk_mssql.yaml similarity index 60% rename from server/tests-py/queries/graphql_mutation/insert/basic/insert_table_no_pk_fail_mssql.yaml rename to server/tests-py/queries/graphql_mutation/insert/basic/insert_table_no_pk_mssql.yaml index 05a3930ad43..8178f920e60 100644 --- a/server/tests-py/queries/graphql_mutation/insert/basic/insert_table_no_pk_fail_mssql.yaml +++ b/server/tests-py/queries/graphql_mutation/insert/basic/insert_table_no_pk_mssql.yaml @@ -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 diff --git a/server/tests-py/test_graphql_mutations.py b/server/tests-py/test_graphql_mutations.py index 84b30dd1db8..e2a4d88cbc0 100644 --- a/server/tests-py/test_graphql_mutations.py +++ b/server/tests-py/test_graphql_mutations.py @@ -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