From 0e659323557afbf9074d2b755e5e82e1fe75e16f Mon Sep 17 00:00:00 2001 From: Gil Mizrahi Date: Fri, 19 Nov 2021 19:05:01 +0200 Subject: [PATCH] server/mssql support delete mutations (close hasura/graphql-engine#7626) PR-URL: https://github.com/hasura/graphql-engine-mono/pull/2691 Co-authored-by: Abby Sassel <3883855+sassela@users.noreply.github.com> Co-authored-by: Rakesh Emmadi <12475069+rakeshkky@users.noreply.github.com> GitOrigin-RevId: 8b66cc30e8e036ee56a5267c1d2f308155951ae9 --- CHANGELOG.md | 1 + cabal.project | 4 +- .../core/databases/ms-sql-server/index.rst | 3 +- .../ms-sql-server/mutations/delete.rst | 184 ++++++++++++++++++ .../ms-sql-server/mutations/index.rst | 44 +++++ .../src-lib/Hasura/Backends/MSSQL/FromIr.hs | 33 +++- .../Backends/MSSQL/Instances/Execute.hs | 104 ++++++++-- .../Hasura/Backends/MSSQL/Instances/Schema.hs | 2 +- .../src-lib/Hasura/Backends/MSSQL/ToQuery.hs | 69 ++++++- .../Hasura/Backends/MSSQL/Types/Instances.hs | 8 +- .../Hasura/Backends/MSSQL/Types/Internal.hs | 19 +- server/src-lib/Hasura/RQL/IR/Delete.hs | 4 + server/src-lib/Hasura/RQL/IR/Returning.hs | 6 + server/tests-py/context.py | 9 + .../delete/basic/article_mssql.yaml | 16 ++ ...author_returning_empty_articles_mssql.yaml | 27 +++ .../delete/basic/schema_setup_mssql.yaml | 159 +++++++++++++++ .../delete/basic/schema_teardown_mssql.yaml | 11 ++ .../delete/basic/setup_mssql.yaml | 40 ++++ .../delete/basic/teardown_mssql.yaml | 24 +++ .../delete/basic/test_types_mssql.yaml | 75 +++++++ .../author_foreign_key_violation_mssql.yaml | 6 + .../constraints/schema_setup_mssql.yaml | 58 ++++++ .../constraints/schema_teardown_mssql.yaml | 9 + .../delete/constraints/setup_mssql.yaml | 36 ++++ .../delete/constraints/teardown_mssql.yaml | 24 +++ .../permissions/schema_setup_mssql.yaml | 78 ++++++++ .../permissions/schema_teardown_mssql.yaml | 13 ++ .../delete/permissions/setup_mssql.yaml | 172 ++++++++++++++++ .../delete/permissions/teardown_mssql.yaml | 16 ++ .../permissions/user_delete_author.yaml | 28 --- .../permissions/user_delete_author_by_pk.yaml | 27 +++ server/tests-py/test_graphql_mutations.py | 91 +++++++++ server/tests-py/test_graphql_queries.py | 6 + 34 files changed, 1346 insertions(+), 60 deletions(-) create mode 100644 docs/graphql/core/databases/ms-sql-server/mutations/delete.rst create mode 100644 docs/graphql/core/databases/ms-sql-server/mutations/index.rst create mode 100644 server/tests-py/queries/graphql_mutation/delete/basic/article_mssql.yaml create mode 100644 server/tests-py/queries/graphql_mutation/delete/basic/author_returning_empty_articles_mssql.yaml create mode 100644 server/tests-py/queries/graphql_mutation/delete/basic/schema_setup_mssql.yaml create mode 100644 server/tests-py/queries/graphql_mutation/delete/basic/schema_teardown_mssql.yaml create mode 100644 server/tests-py/queries/graphql_mutation/delete/basic/setup_mssql.yaml create mode 100644 server/tests-py/queries/graphql_mutation/delete/basic/teardown_mssql.yaml create mode 100644 server/tests-py/queries/graphql_mutation/delete/basic/test_types_mssql.yaml create mode 100644 server/tests-py/queries/graphql_mutation/delete/constraints/author_foreign_key_violation_mssql.yaml create mode 100644 server/tests-py/queries/graphql_mutation/delete/constraints/schema_setup_mssql.yaml create mode 100644 server/tests-py/queries/graphql_mutation/delete/constraints/schema_teardown_mssql.yaml create mode 100644 server/tests-py/queries/graphql_mutation/delete/constraints/setup_mssql.yaml create mode 100644 server/tests-py/queries/graphql_mutation/delete/constraints/teardown_mssql.yaml create mode 100644 server/tests-py/queries/graphql_mutation/delete/permissions/schema_setup_mssql.yaml create mode 100644 server/tests-py/queries/graphql_mutation/delete/permissions/schema_teardown_mssql.yaml create mode 100644 server/tests-py/queries/graphql_mutation/delete/permissions/setup_mssql.yaml create mode 100644 server/tests-py/queries/graphql_mutation/delete/permissions/teardown_mssql.yaml create mode 100644 server/tests-py/queries/graphql_mutation/delete/permissions/user_delete_author_by_pk.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md index 20053682b2f..ab89246e5c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Next release (Add highlights/major features below) +- server: implement delete mutations for MS SQL Server (closes #7626) - server: fix JSON path in error when parsing sources in metadata (fix #7769) - server: log locking DB queries during source catalog migration - server: fix to allow remote schema response to contain an "extensions" field (#7143) diff --git a/cabal.project b/cabal.project index 39242dbcbfd..5eb6516088b 100644 --- a/cabal.project +++ b/cabal.project @@ -87,10 +87,10 @@ source-repository-package location: https://github.com/hasura/ekg-json.git tag: 098e3a5951c4991c823815706f1f58f608bb6ec3 --- This is v1.2.3.2 with https://github.com/haskell/text/pull/348 +-- This is v1.2.3.2 with https://github.com/haskell/text/pull/348 -- cherry-picked. When 1.3 is released we can move from this fork. source-repository-package type: git location: https://github.com/hasura/text.git - tag: 874c3164fadf39a83382359d2b6ce941a3e134da + tag: 874c3164fadf39a83382359d2b6ce941a3e134da diff --git a/docs/graphql/core/databases/ms-sql-server/index.rst b/docs/graphql/core/databases/ms-sql-server/index.rst index f3c69a16498..9d750526c90 100644 --- a/docs/graphql/core/databases/ms-sql-server/index.rst +++ b/docs/graphql/core/databases/ms-sql-server/index.rst @@ -40,7 +40,7 @@ Hasura currently supports queries, subscriptions, relationships and permissions Next up on our roadmap for Hasura + SQL Server: - Support for stored procedures & functions (`#7073 `__) -- Mutations: Run inserts, updates, deletes, stored procedures and transactions securely on SQL Server over a GraphQL API (`#7074 `__) +- Mutations: Run inserts, updates, stored procedures and transactions securely on SQL Server over a GraphQL API (`#7074 `__) - Event triggers: Trigger HTTP webhooks with atomic capture and atleast once guarantee whenever data changes inside the database (`#7075 `__) - Remote Joins: Join models in SQL Server to models from other API services (GraphQL or REST) (`#7076 `__) @@ -66,6 +66,7 @@ Know more Getting Started Schema Queries + Mutations Subscriptions .. TODO: DB-COMPATIBILITY diff --git a/docs/graphql/core/databases/ms-sql-server/mutations/delete.rst b/docs/graphql/core/databases/ms-sql-server/mutations/delete.rst new file mode 100644 index 00000000000..3a91fd58f07 --- /dev/null +++ b/docs/graphql/core/databases/ms-sql-server/mutations/delete.rst @@ -0,0 +1,184 @@ +.. meta:: + :description: Delete an object from MS SQL Server using a mutation + :keywords: hasura, docs, ms sql server, mutation, delete + +.. _ms_sql_server_delete: + +MS SQL Server: Delete mutation +============================== + +.. contents:: Table of contents + :backlinks: none + :depth: 1 + :local: + +Auto-generated delete mutation schema +------------------------------------- + +**For example**, the auto-generated schema for the delete mutation field for a table ``article`` looks like the following: + +.. code-block:: graphql + + delete_article ( + where: article_bool_exp! + ): article_mutation_response + + # response of any mutation on the table "article" + type article_mutation_response { + # number of affected rows by the mutation + affected_rows: Int! + # data of the affected rows by the mutation + returning: [article!]! + } + + # single object delete + delete_article_by_pk ( + # all primary key columns args + id: Int + ): article + +As you can see from the schema: + +- The ``where`` argument is compulsory to filter rows to be deleted. See :ref:`Filter queries ` + for filtering options. Objects can be deleted based on filters on their own fields or those in their nested objects. + The ``{}`` expression can be used to delete all rows. +- You can return the number of affected rows and the affected objects (with nested objects) in the response. + +See the :ref:`delete mutation API reference ` for the full specifications. + +.. note:: + + If a table is not in the default ``dbo`` MS SQL Server schema, the delete mutation field will be of the format + ``delete__``. + +Delete an object by its primary key +----------------------------------- + +You can delete a single object in a table using the primary key. +The output type is the nullable table object. The mutation returns the deleted +row object or ``null`` if the row does not exist. + + +**Example:** Delete an article where ``id`` is ``1``: + +.. graphiql:: + :view_only: + :query: + mutation delete_an_object { + delete_article_by_pk ( + id: 1 + ) { + id + title + user_id + } + } + :response: + { + "data": { + "delete_article_by_pk": { + "id": 1, + "title": "Article 1", + "user_id": 1 + } + } + } + +**Example:** Delete a non-existent article: + +.. graphiql:: + :view_only: + :query: + mutation delete_an_object { + delete_article_by_pk ( + id: 100 + ) { + id + title + user_id + } + } + :response: + { + "data": { + "delete_article_by_pk": null + } + } + +.. note:: + + ``delete__by_pk`` will **only** be available if you have select permissions on the table, as it returns the deleted row. + +Delete objects based on their fields +------------------------------------ +**Example:** Delete all articles rated less than 3: + +.. graphiql:: + :view_only: + :query: + mutation delete_low_rated_articles { + delete_article( + where: {rating: {_lt: 3}} + ) { + affected_rows + } + } + :response: + { + "data": { + "delete_low_rated_articles": { + "affected_rows": 8 + } + } + } + + +Delete objects based on nested objects' fields +---------------------------------------------- +**Example:** Delete all articles written by a particular author: + +.. graphiql:: + :view_only: + :query: + mutation delete_authors_articles { + delete_article( + where: {author: {name: {_eq: "Corny"}}} + ) { + affected_rows + } + } + :response: + { + "data": { + "delete_authors_articles": { + "affected_rows": 2 + } + } + } + +Delete all objects +------------------ + +You can delete all objects in a table using the ``{}`` expression as the ``where`` argument. ``{}`` basically +evaluates to ``true`` for all objects. + +**Example:** Delete all articles: + +.. graphiql:: + :view_only: + :query: + mutation delete_all_articles { + delete_article ( + where: {} + ) { + affected_rows + } + } + :response: + { + "data": { + "delete_article": { + "affected_rows": 20 + } + } + } diff --git a/docs/graphql/core/databases/ms-sql-server/mutations/index.rst b/docs/graphql/core/databases/ms-sql-server/mutations/index.rst new file mode 100644 index 00000000000..287645f8a7f --- /dev/null +++ b/docs/graphql/core/databases/ms-sql-server/mutations/index.rst @@ -0,0 +1,44 @@ +.. meta:: + :description: Manage mutations on MS SQL Server with Hasura + :keywords: hasura, docs, ms sql server, mutation + +.. _ms_sql_server_mutations: + +MS SQL Server: Mutations +======================== + +.. contents:: Table of contents + :backlinks: none + :depth: 1 + :local: + +Introduction +------------ + +GraphQL mutations are used to modify data on the server (i.e. write, update or delete data). + +Hasura GraphQL engine auto-generates mutations as part of the GraphQL schema from your MS SQL Server schema model. + +Data of all tables in the database tracked by the GraphQL engine can be modified over the GraphQL endpoint. +If you have a tracked table in your database, its insert/update/delete mutation fields are added as nested +fields under the ``mutation_root`` root level type. + +Types of mutation requests +-------------------------- + +The following types of mutation requests are possible: + +.. toctree:: + :maxdepth: 1 + + Delete + +.. TODO: DBCOMPATIBILITY + + .. toctree:: + :maxdepth: 1 + + Insert + Upsert + Update + multiple-mutations diff --git a/server/src-lib/Hasura/Backends/MSSQL/FromIr.hs b/server/src-lib/Hasura/Backends/MSSQL/FromIr.hs index c4bd158b10a..faaff1c0152 100644 --- a/server/src-lib/Hasura/Backends/MSSQL/FromIr.hs +++ b/server/src-lib/Hasura/Backends/MSSQL/FromIr.hs @@ -29,6 +29,7 @@ module Hasura.Backends.MSSQL.FromIr jsonFieldName, fromInsert, fromDelete, + toSelectIntoTempTable, ) where @@ -47,6 +48,7 @@ import Hasura.RQL.Types.Column qualified as IR import Hasura.RQL.Types.Common qualified as IR import Hasura.RQL.Types.Relationship qualified as IR import Hasura.SQL.Backend +import Language.GraphQL.Draft.Syntax (unName) -------------------------------------------------------------------------------- -- Types @@ -1113,8 +1115,9 @@ normalizeInsertRows IR.AnnIns {..} insertRows = -------------------------------------------------------------------------------- -- Delete +-- | Convert IR AST representing delete into MSSQL AST representing a delete statement fromDelete :: IR.AnnDel 'MSSQL -> FromIr Delete -fromDelete (IR.AnnDel tableName (permFilter, whereClause) _ _) = do +fromDelete (IR.AnnDel tableName (permFilter, whereClause) _ allColumns) = do tableAlias <- fromTableName tableName runReaderT ( do @@ -1127,11 +1130,38 @@ fromDelete (IR.AnnDel tableName (permFilter, whereClause) _ _) = do { aliasedAlias = entityAliasText tableAlias, aliasedThing = tableName }, + deleteColumns = map (ColumnName . unName . IR.pgiName) allColumns, deleteWhere = Where [permissionsFilter, whereExpression] } ) tableAlias +-- | Convert IR AST representing delete into MSSQL AST representing a creation of a temporary table +-- with the same schema as the deleted from table. +toSelectIntoTempTable :: TempTableName -> IR.AnnDel 'MSSQL -> SelectIntoTempTable +toSelectIntoTempTable tempTableName (IR.AnnDel {IR.dqp1Table = fromTable, IR.dqp1AllCols = allColumns}) = do + SelectIntoTempTable + { sittTempTableName = tempTableName, + sittColumns = map columnInfoToUnifiedColumn allColumns, + sittFromTableName = fromTable + } + +-- | Extracts the type and column name of a ColumnInfo +columnInfoToUnifiedColumn :: IR.ColumnInfo 'MSSQL -> UnifiedColumn +columnInfoToUnifiedColumn colInfo = + case IR.pgiType colInfo of + IR.ColumnScalar t -> + UnifiedColumn + { name = unName $ IR.pgiName colInfo, + type' = t + } + -- Enum values are represented as text value so they will always be of type text + IR.ColumnEnumReference {} -> + UnifiedColumn + { name = unName $ IR.pgiName colInfo, + type' = TextType + } + -------------------------------------------------------------------------------- -- Misc combinators @@ -1187,6 +1217,7 @@ fromAlias (FromQualifiedTable Aliased {aliasedAlias}) = EntityAlias aliasedAlias fromAlias (FromOpenJson Aliased {aliasedAlias}) = EntityAlias aliasedAlias fromAlias (FromSelect Aliased {aliasedAlias}) = EntityAlias aliasedAlias fromAlias (FromIdentifier identifier) = EntityAlias identifier +fromAlias (FromTempTable Aliased {aliasedAlias}) = EntityAlias aliasedAlias columnNameToFieldName :: ColumnName -> EntityAlias -> FieldName columnNameToFieldName (ColumnName fieldName) EntityAlias {entityAliasText = fieldNameEntity} = diff --git a/server/src-lib/Hasura/Backends/MSSQL/Instances/Execute.hs b/server/src-lib/Hasura/Backends/MSSQL/Instances/Execute.hs index cd8060002c4..c227a4abd4a 100644 --- a/server/src-lib/Hasura/Backends/MSSQL/Instances/Execute.hs +++ b/server/src-lib/Hasura/Backends/MSSQL/Instances/Execute.hs @@ -226,7 +226,7 @@ msDBMutationPlan :: msDBMutationPlan userInfo stringifyNum sourceName sourceConfig mrf = do go <$> case mrf of MDBInsert annInsert -> executeInsert userInfo stringifyNum sourceConfig annInsert - MDBDelete _annDelete -> throw400 NotSupported "delete mutations are not supported in MSSQL" + MDBDelete annDelete -> executeDelete userInfo stringifyNum sourceConfig annDelete MDBFunction {} -> throw400 NotSupported "function mutations are not supported in MSSQL" where go v = DBStepInfo @'MSSQL sourceName sourceConfig Nothing v @@ -313,7 +313,7 @@ executeInsert userInfo stringifyNum sourceConfig annInsert = do Tx.unitQueryE fromMSSQLTxError $ toQueryFlat $ TQ.fromSetIdentityInsert $ - SetIdenityInsert (_aiTableName $ _aiData insert) SetON + SetIdentityInsert (_aiTableName $ _aiData insert) SetON -- Generate the INSERT query let insertQuery = toQueryFlat $ TQ.fromInsert $ TSQL.fromInsert insert @@ -383,6 +383,62 @@ executeInsert userInfo stringifyNum sourceConfig annInsert = do ExpressionProjection $ Aliased (SelectExpression (generateCheckConstraintSelect checkBoolExp)) "check_constraint_select" in emptySelect {selectProjections = [mutationOutputProjection, checkConstraintProjection]} +-- Delete + +-- | Executes a Delete IR AST and return results as JSON. +executeDelete :: + MonadError QErr m => + UserInfo -> + Bool -> + SourceConfig 'MSSQL -> + AnnDelG 'MSSQL (Const Void) (UnpreparedValue 'MSSQL) -> + m (ExceptT QErr IO EncJSON) +executeDelete userInfo stringifyNum sourceConfig deleteOperation = do + preparedDelete <- traverse (prepareValueQuery $ _uiSession userInfo) deleteOperation + let pool = _mscConnectionPool sourceConfig + pure $ withMSSQLPool pool $ Tx.runTxE fromMSSQLTxError (buildDeleteTx preparedDelete stringifyNum) + +-- | Converts a Delete IR AST to a transaction of three delete sql statements. +-- +-- A GraphQL delete mutation does two things: +-- +-- 1. Deletes rows in a table according to some predicate +-- 2. (Potentially) returns the deleted rows (including relationships) as JSON +-- +-- In order to complete these 2 things we need 3 SQL statements: +-- +-- 1. SELECT INTO WHERE - creates a temporary table +-- with the same schema as the original table in which we'll store the deleted rows +-- from the table we are deleting +-- 2. DELETE FROM with OUTPUT - deletes the rows from the table and inserts the +-- deleted rows to the temporary table from (1) +-- 3. SELECT - constructs the @returning@ query from the temporary table, including +-- relationships with other tables. +buildDeleteTx :: + AnnDel 'MSSQL -> + Bool -> + Tx.TxET QErr IO EncJSON +buildDeleteTx deleteOperation stringifyNum = do + let tempTableName = TSQL.TempTableName "deleted" + withAlias = "with_alias" + createTempTableQuery = toQueryFlat $ TQ.fromSelectIntoTempTable $ TSQL.toSelectIntoTempTable tempTableName deleteOperation + -- Create a temp table + Tx.unitQueryE fromMSSQLTxError createTempTableQuery + let deleteQuery = TQ.fromDelete tempTableName <$> TSQL.fromDelete deleteOperation + deleteQueryValidated <- toQueryFlat <$> V.runValidate (runFromIr deleteQuery) `onLeft` (throw500 . tshow) + -- Execute DELETE statement + Tx.unitQueryE fromMSSQLTxError deleteQueryValidated + mutationOutputSelect <- mkMutationOutputSelect stringifyNum withAlias $ dqp1Output deleteOperation + let withSelect = + emptySelect + { selectProjections = [StarProjection], + selectFrom = Just $ FromTempTable $ Aliased tempTableName "deleted_alias" + } + finalMutationOutputSelect = mutationOutputSelect {selectWith = Just $ With $ pure $ Aliased withSelect withAlias} + mutationOutputSelectQuery = toQueryFlat $ TQ.fromSelect finalMutationOutputSelect + -- Execute SELECT query and fetch mutation response + encJFromText <$> Tx.singleRowQueryE fromMSSQLTxError mutationOutputSelectQuery + -- | Generate a SQL SELECT statement which outputs the mutation response -- -- For multi row inserts: @@ -400,28 +456,38 @@ mkMutationOutputSelect stringifyNum withAlias = \case projections <- forM multiRowFields $ \(fieldName, field') -> do let mkProjection = ExpressionProjection . flip Aliased (getFieldNameTxt fieldName) . SelectExpression mkProjection <$> case field' of - IR.MCount -> pure countSelect + IR.MCount -> pure $ countSelect withAlias IR.MExp t -> pure $ textSelect t - IR.MRet returningFields -> mkSelect JASMultipleRows returningFields + IR.MRet returningFields -> mkSelect stringifyNum withAlias JASMultipleRows returningFields let forJson = JsonFor $ ForJson JsonSingleton NoRoot pure emptySelect {selectFor = forJson, selectProjections = projections} - IR.MOutSinglerowObject singleRowField -> mkSelect JASSingleObject singleRowField - where - mkSelect jsonAggSelect annFields = do - let annSelect = IR.AnnSelectG annFields (IR.FromIdentifier withAlias) IR.noTablePermissions IR.noSelectArgs stringifyNum - V.runValidate (runFromIr $ mkSQLSelect jsonAggSelect annSelect) `onLeft` (throw500 . tshow) + IR.MOutSinglerowObject singleRowField -> mkSelect stringifyNum withAlias JASSingleObject singleRowField - -- SELECT COUNT(*) FROM [with_alias] - countSelect = - let countProjection = AggregateProjection $ Aliased (CountAggregate StarCountable) "count" - in emptySelect - { selectProjections = [countProjection], - selectFrom = Just $ TSQL.FromIdentifier withAlias - } +mkSelect :: + MonadError QErr m => + Bool -> + Text -> + JsonAggSelect -> + Fields (AnnFieldG 'MSSQL (Const Void) Expression) -> + m Select +mkSelect stringifyNum withAlias jsonAggSelect annFields = do + let annSelect = IR.AnnSelectG annFields (IR.FromIdentifier withAlias) IR.noTablePermissions IR.noSelectArgs stringifyNum + V.runValidate (runFromIr $ mkSQLSelect jsonAggSelect annSelect) `onLeft` (throw500 . tshow) - textSelect t = - let textProjection = ExpressionProjection $ Aliased (ValueExpression (ODBC.TextValue t)) "exp" - in emptySelect {selectProjections = [textProjection]} +-- SELECT COUNT(*) AS "count" FROM [with_alias] +countSelect :: Text -> Select +countSelect withAlias = + let countProjection = AggregateProjection $ Aliased (CountAggregate StarCountable) "count" + in emptySelect + { selectProjections = [countProjection], + selectFrom = Just $ TSQL.FromIdentifier withAlias + } + +-- SELECT '' AS "exp" +textSelect :: Text -> Select +textSelect t = + let textProjection = ExpressionProjection $ Aliased (ValueExpression (ODBC.TextValue t)) "exp" + in emptySelect {selectProjections = [textProjection]} -- subscription diff --git a/server/src-lib/Hasura/Backends/MSSQL/Instances/Schema.hs b/server/src-lib/Hasura/Backends/MSSQL/Instances/Schema.hs index 033c1fc992c..db0d80af7ca 100644 --- a/server/src-lib/Hasura/Backends/MSSQL/Instances/Schema.hs +++ b/server/src-lib/Hasura/Backends/MSSQL/Instances/Schema.hs @@ -35,7 +35,7 @@ instance BackendSchema 'MSSQL where buildTableRelayQueryFields = msBuildTableRelayQueryFields buildTableInsertMutationFields = msBuildTableInsertMutationFields buildTableUpdateMutationFields = msBuildTableUpdateMutationFields - buildTableDeleteMutationFields = msBuildTableDeleteMutationFields + buildTableDeleteMutationFields = GSB.buildTableDeleteMutationFields buildFunctionQueryFields = msBuildFunctionQueryFields buildFunctionRelayQueryFields = msBuildFunctionRelayQueryFields buildFunctionMutationFields = msBuildFunctionMutationFields diff --git a/server/src-lib/Hasura/Backends/MSSQL/ToQuery.hs b/server/src-lib/Hasura/Backends/MSSQL/ToQuery.hs index fd81b97514e..decd4e58c2a 100644 --- a/server/src-lib/Hasura/Backends/MSSQL/ToQuery.hs +++ b/server/src-lib/Hasura/Backends/MSSQL/ToQuery.hs @@ -10,6 +10,7 @@ module Hasura.Backends.MSSQL.ToQuery fromInsert, fromSetIdentityInsert, fromDelete, + fromSelectIntoTempTable, Printer (..), ) where @@ -189,8 +190,8 @@ fromSetValue = \case SetON -> "ON" SetOFF -> "OFF" -fromSetIdentityInsert :: SetIdenityInsert -> Printer -fromSetIdentityInsert SetIdenityInsert {..} = +fromSetIdentityInsert :: SetIdentityInsert -> Printer +fromSetIdentityInsert SetIdentityInsert {..} = SepByPrinter " " [ "SET IDENTITY_INSERT", @@ -198,15 +199,71 @@ fromSetIdentityInsert SetIdenityInsert {..} = fromSetValue setValue ] -fromDelete :: Delete -> Printer -fromDelete Delete {deleteTable, deleteWhere} = +-- | Generate a delete statement +-- +-- > Delete +-- > (Aliased (TableName "table" "schema") "alias") +-- > [ColumnName "id", ColumnName "name"] +-- > (Where [OpExpression EQ' (ValueExpression (IntValue 1)) (ValueExpression (IntValue 1))]) +-- +-- Becomes: +-- +-- > DELETE [alias] OUTPUT DELETED.[id], DELETED.[name] INTO #deleted([id], [name]) FROM [schema].[table] AS [alias] WHERE ((1) = (1)) +fromDelete :: TempTableName -> Delete -> Printer +fromDelete tempTableName Delete {deleteTable, deleteColumns, deleteWhere} = SepByPrinter NewlinePrinter [ "DELETE " <+> fromNameText (aliasedAlias deleteTable), + "OUTPUT " <+> SepByPrinter ", " (map ((<+>) "DELETED." . fromColumnName) deleteColumns), + "INTO " <+> fromTempTableName tempTableName <+> parens (SepByPrinter ", " (map fromColumnName deleteColumns)), "FROM " <+> fromAliased (fmap fromTableName deleteTable), fromWhere deleteWhere ] +-- | Converts `SelectIntoTempTable`. +-- +-- > SelectIntoTempTable (TempTableName "deleted") [UnifiedColumn "id" IntegerType, UnifiedColumn "name" TextType] (TableName "table" "schema") +-- +-- Becomes: +-- +-- > SELECT [id], [name] INTO #deleted([id], [name]) FROM [schema].[table] WHERE (1<>1) UNION ALL SELECT [id], [name] FROM [schema].[table]; +-- +-- We add the `UNION ALL` part to avoid copying identity constraints, and we cast columns with types such as `timestamp` +-- which are non-insertable to a different type. +fromSelectIntoTempTable :: SelectIntoTempTable -> Printer +fromSelectIntoTempTable SelectIntoTempTable {sittTempTableName, sittColumns, sittFromTableName} = + SepByPrinter + NewlinePrinter + [ "SELECT " + <+> columns, + "INTO " <+> fromTempTableName sittTempTableName, + "FROM " <+> fromTableName sittFromTableName, + "WHERE " <+> falsePrinter, + "UNION ALL SELECT " <+> columns, + "FROM " <+> fromTableName sittFromTableName, + "WHERE " <+> falsePrinter + ] + where + -- column names separated by commas + columns = + SepByPrinter + ("," <+> NewlinePrinter) + (map columnNameFromUnifiedColumn sittColumns) + + -- column name with potential modifications of types + columnNameFromUnifiedColumn (UnifiedColumn columnName columnType) = + case columnType of + -- The "timestamp" is type synonym for "rowversion" and it is just an incrementing number and does not preserve a date or a time. + -- So, the "timestamp" type is neither insertable nor explicitly updatable. Its values are unique binary numbers within a database. + -- We're using "binary" type instead so that we can copy a timestamp row value safely into the temporary table. + -- See https://docs.microsoft.com/en-us/sql/t-sql/data-types/rowversion-transact-sql for more details. + TimestampType -> "CAST(" <+> fromNameText columnName <+> " AS binary(8)) AS " <+> fromNameText columnName + _ -> fromNameText columnName + +-- | @TempTableName "deleted"@ becomes @\#deleted@ +fromTempTableName :: TempTableName -> Printer +fromTempTableName (TempTableName v) = QueryPrinter (fromString . T.unpack $ "#" <> v) + fromSelect :: Select -> Printer fromSelect Select {..} = fmap fromWith selectWith ?<+> wrapFor selectFor result where @@ -438,6 +495,7 @@ fromFrom = FromOpenJson openJson -> fromAliased (fmap fromOpenJson openJson) FromSelect select -> fromAliased (fmap (parens . fromSelect) select) FromIdentifier identifier -> fromNameText identifier + FromTempTable aliasedTempTable -> fromAliased (fmap fromTempTableName aliasedTempTable) fromOpenJson :: OpenJson -> Printer fromOpenJson OpenJson {openJsonExpression, openJsonWith} = @@ -482,6 +540,9 @@ fromAliased Aliased {..} = aliasedThing <+> ((" AS " <+>) . fromNameText) aliasedAlias +fromColumnName :: ColumnName -> Printer +fromColumnName (ColumnName colname) = fromNameText colname + fromNameText :: Text -> Printer fromNameText t = QueryPrinter (rawUnescapedText ("[" <> t <> "]")) diff --git a/server/src-lib/Hasura/Backends/MSSQL/Types/Instances.hs b/server/src-lib/Hasura/Backends/MSSQL/Types/Instances.hs index f24faf579e7..5e2a7b4c6e0 100644 --- a/server/src-lib/Hasura/Backends/MSSQL/Types/Instances.hs +++ b/server/src-lib/Hasura/Backends/MSSQL/Types/Instances.hs @@ -41,7 +41,8 @@ $( fmap concat $ for ''UnifiedArrayRelationship, ''UnifiedUsing, ''UnifiedOn, - ''UnifiedColumn + ''UnifiedColumn, + ''TempTableName ] \name -> [d| @@ -57,6 +58,8 @@ $( fmap concat $ for deriving instance Data $(conT name) + instance NFData $(conT name) + instance FromJSON $(conT name) deriving instance Ord $(conT name) @@ -92,7 +95,8 @@ $( fmap concat $ for ''OpenJson, ''JsonFieldSpec, ''Join, - ''JoinSource + ''JoinSource, + ''SelectIntoTempTable ] \name -> [d| diff --git a/server/src-lib/Hasura/Backends/MSSQL/Types/Internal.hs b/server/src-lib/Hasura/Backends/MSSQL/Types/Internal.hs index e7187fa530c..854060dd802 100644 --- a/server/src-lib/Hasura/Backends/MSSQL/Types/Internal.hs +++ b/server/src-lib/Hasura/Backends/MSSQL/Types/Internal.hs @@ -38,8 +38,10 @@ module Hasura.Backends.MSSQL.Types.Internal ScalarType (..), SchemaName (..), Select (..), - SetIdenityInsert (..), + SetIdentityInsert (..), + TempTableName (..), SetValue (..), + SelectIntoTempTable (..), SpatialOp (..), TableName (..), Top (..), @@ -172,16 +174,28 @@ data SetValue = SetON | SetOFF -data SetIdenityInsert = SetIdenityInsert +data SetIdentityInsert = SetIdentityInsert { setTable :: !TableName, setValue :: !SetValue } data Delete = Delete { deleteTable :: !(Aliased TableName), + deleteColumns :: [ColumnName], deleteWhere :: !Where } +-- | SELECT INTO temporary table statement without values. +-- Used to create a temporary table with the same schema as an existing table. +data SelectIntoTempTable = SelectIntoTempTable + { sittTempTableName :: TempTableName, + sittColumns :: [UnifiedColumn], + sittFromTableName :: TableName + } + +-- | A temporary table name is prepended by a hash-sign +newtype TempTableName = TempTableName Text + data Reselect = Reselect { reselectProjections :: ![Projection], reselectFor :: !For, @@ -302,6 +316,7 @@ data From | FromOpenJson (Aliased OpenJson) | FromSelect (Aliased Select) | FromIdentifier Text + | FromTempTable (Aliased TempTableName) data OpenJson = OpenJson { openJsonExpression :: Expression, diff --git a/server/src-lib/Hasura/RQL/IR/Delete.hs b/server/src-lib/Hasura/RQL/IR/Delete.hs index 54bb54d95b9..2b42100afac 100644 --- a/server/src-lib/Hasura/RQL/IR/Delete.hs +++ b/server/src-lib/Hasura/RQL/IR/Delete.hs @@ -1,3 +1,5 @@ +{-# LANGUAGE UndecidableInstances #-} + module Hasura.RQL.IR.Delete ( AnnDel, AnnDelG (..), @@ -21,3 +23,5 @@ data AnnDelG (b :: BackendType) (r :: BackendType -> Type) v = AnnDel deriving (Functor, Foldable, Traversable) type AnnDel b = AnnDelG b (Const Void) (SQLExpression b) + +deriving instance (Show (MutationOutputG b r a), Backend b, Show (BooleanOperators b a), Show a) => Show (AnnDelG b r a) diff --git a/server/src-lib/Hasura/RQL/IR/Returning.hs b/server/src-lib/Hasura/RQL/IR/Returning.hs index 9a12f02a46f..f30a4481852 100644 --- a/server/src-lib/Hasura/RQL/IR/Returning.hs +++ b/server/src-lib/Hasura/RQL/IR/Returning.hs @@ -1,3 +1,5 @@ +{-# LANGUAGE UndecidableInstances #-} + module Hasura.RQL.IR.Returning ( MutFld, MutFldG (..), @@ -25,6 +27,8 @@ data MutFldG (b :: BackendType) (r :: BackendType -> Type) v | MRet !(AnnFieldsG b r v) deriving (Functor, Foldable, Traversable) +deriving instance (Show (r b), Backend b, Show (BooleanOperators b a), Show a) => Show (MutFldG b r a) + type MutFld b = MutFldG b (Const Void) (SQLExpression b) type MutFldsG b r v = Fields (MutFldG b r v) @@ -34,6 +38,8 @@ data MutationOutputG (b :: BackendType) (r :: BackendType -> Type) v | MOutSinglerowObject !(AnnFieldsG b r v) deriving (Functor, Foldable, Traversable) +deriving instance (Show (MutFldsG b r a), Show (r b), Backend b, Show (BooleanOperators b a), Show a) => Show (MutationOutputG b r a) + type MutationOutput b = MutationOutputG b (Const Void) (SQLExpression b) type MutFlds b = MutFldsG b (Const Void) (SQLExpression b) diff --git a/server/tests-py/context.py b/server/tests-py/context.py index 465a73e42b8..d1823c74e08 100644 --- a/server/tests-py/context.py +++ b/server/tests-py/context.py @@ -786,6 +786,15 @@ class HGECtx: yml = yaml.YAML() return self.v1metadataq(yml.load(f)) + def v1graphqlq(self, q, headers = {}): + return self.execute_query(q, "/v1/graphql", headers) + + def v1graphql_f(self, fn): + with open(fn) as f: + # NOTE: preserve ordering with ruamel + yml = yaml.YAML() + return self.v1graphqlq(yml.load(f)) + def teardown(self): self.http.close() self.engine.dispose() diff --git a/server/tests-py/queries/graphql_mutation/delete/basic/article_mssql.yaml b/server/tests-py/queries/graphql_mutation/delete/basic/article_mssql.yaml new file mode 100644 index 00000000000..e23cbe144b7 --- /dev/null +++ b/server/tests-py/queries/graphql_mutation/delete/basic/article_mssql.yaml @@ -0,0 +1,16 @@ +description: Delete mutation on article +url: /v1/graphql +status: 200 +response: + data: + delete_article: + affected_rows: 1 +query: + query: | + mutation delete_article { + delete_article ( + where: {id: {_eq: 2}} + ) { + affected_rows + } + } diff --git a/server/tests-py/queries/graphql_mutation/delete/basic/author_returning_empty_articles_mssql.yaml b/server/tests-py/queries/graphql_mutation/delete/basic/author_returning_empty_articles_mssql.yaml new file mode 100644 index 00000000000..f078630d4d0 --- /dev/null +++ b/server/tests-py/queries/graphql_mutation/delete/basic/author_returning_empty_articles_mssql.yaml @@ -0,0 +1,27 @@ +description: Delete mutation on author with returning articles +url: /v1/graphql +status: 200 +response: + data: + delete_author: + affected_rows: 1 + returning: + - id: 3 + name: Author 3 + articles: [] +query: + query: | + mutation DeleteAuthor3 { + delete_author(where: {id: {_eq: 3}}){ + affected_rows + returning{ + id + name + articles{ + id + title + content + } + } + } + } diff --git a/server/tests-py/queries/graphql_mutation/delete/basic/schema_setup_mssql.yaml b/server/tests-py/queries/graphql_mutation/delete/basic/schema_setup_mssql.yaml new file mode 100644 index 00000000000..0fc6e355422 --- /dev/null +++ b/server/tests-py/queries/graphql_mutation/delete/basic/schema_setup_mssql.yaml @@ -0,0 +1,159 @@ +type: bulk +args: + +- type: mssql_run_sql + args: + source: mssql + sql: | + + CREATE TABLE test_types + ( + c1_smallint smallint, + c2_integer integer, + c3_bigint bigint, + c4_decimal decimal(5, 2), + c5_numeric numeric (4, 3), + c6_real real, + c7_double_precision double precision, + c11_varchar_3 varchar(3), + c12_char_4 char(4), + c13_text text, + c16_date date, + c17_time time, + c44_xml xml, + c45_money money, + c47_smallmoney smallmoney, + c48_bit bit, + c49_tinyint tinyint, + c50_float float, + c51_real real, + c52_datetime datetime, + c53_datetime2 datetime2, + c54_datetimeoffset datetimeoffset, + c55_smalldatetime smalldatetime, + c56_binary binary(4), + c57_varbinary varbinary(4), + c58_hierarchyid hierarchyid, + c59_uniqueidentifier uniqueidentifier + ); + + CREATE TABLE author + ( + id int identity NOT NULL PRIMARY KEY, + name nvarchar(450) UNIQUE, + createdAt datetime + ); + + CREATE TABLE article + ( + id int identity NOT NULL PRIMARY KEY, + title TEXT, + content TEXT, + is_published BIT, + published_on timestamp, + author_id int, + co_author_id int, + FOREIGN KEY (author_id) REFERENCES author(id), + FOREIGN KEY (co_author_id) REFERENCES author(id), + ); + + INSERT INTO test_types + ( + c1_smallint, + c2_integer, + c3_bigint, + c4_decimal, + c5_numeric, + c6_real, + c7_double_precision, + c11_varchar_3, + c12_char_4, + c13_text, + c16_date, + c17_time, + c44_xml, + c45_money, + c47_smallmoney, + c48_bit, + c49_tinyint, + c50_float, + c51_real, + c52_datetime, + c53_datetime2, + c54_datetimeoffset, + c55_smalldatetime, + c56_binary, + c57_varbinary, + c58_hierarchyid, + c59_uniqueidentifier + ) + VALUES + ( 3277 -- c1_smallint + , 2147483647 -- c2_integer + , 9223372036854775807 -- c3_bigint + , 123.45 -- c4_decimal + , 1.234 -- c5_numeric + , 0.00390625 -- c6_real + , 16.0001220703125 -- c7_double_precision + , 'abc' -- c11_varchar_3 + , 'baaz' -- c12_char_4 + , 'foo bar baz' -- c13_text + , '2014-09-14' -- c16_date + , '11:09:23' -- c17_time + , 'bar' -- c44_xml + , 123.45 -- c45_money + , -123.45 -- c47_smallmoney + , 0 -- c48_bit + , 254 -- c49_tinyint + , -305.77 -- c50_float + , -36.82 -- c51_real + , '04-15-96 4am' -- c52_datetime + , '04-15-9999 23:59:59.9999999' -- c53_datetime2 + , '2007-05-08 12:35:29.1234567 +12:15' -- c54_datetimeoffset + , '1955-12-13 12:43:10' -- c55_smalldatetime + , 0x0001e240 -- c56_binary + , 0x0001e240 -- c57_varbinary + , '/0.1/0.2/' -- c58_hierarchyid + , '0E984725-C51C-4BF4-9960-E1C80E27ABA0' -- c59_uniqueidentifier + ); + + INSERT INTO author + (name, createdAt) + VALUES + ('Author 1', '2017-09-21T09:39:44Z'), + ('Author 2', '2017-09-21T09:50:44Z'), + ('Author 3', '2017-09-21T09:55:44Z'); + + INSERT INTO article + (title, content, author_id, is_published) + VALUES + ( + 'Article 1', + 'Sample article content 1', + 1, + 0 + ), + ( + 'Article 2', + 'Sample article content 2', + 1, + 1 + ), + ( + 'Article 3', + 'Sample article content 3', + 1, + 1 + ), + ( + 'Article 4', + 'Sample article content 4', + 2, + 1 + ), + ( + 'Article 5', + 'Sample article content 5', + 2, + 0 + ); diff --git a/server/tests-py/queries/graphql_mutation/delete/basic/schema_teardown_mssql.yaml b/server/tests-py/queries/graphql_mutation/delete/basic/schema_teardown_mssql.yaml new file mode 100644 index 00000000000..22a1e97db9b --- /dev/null +++ b/server/tests-py/queries/graphql_mutation/delete/basic/schema_teardown_mssql.yaml @@ -0,0 +1,11 @@ +type: bulk +args: + +- type: mssql_run_sql + args: + source: mssql + cascade: true + sql: | + drop table test_types; + drop table article; + drop table author; diff --git a/server/tests-py/queries/graphql_mutation/delete/basic/setup_mssql.yaml b/server/tests-py/queries/graphql_mutation/delete/basic/setup_mssql.yaml new file mode 100644 index 00000000000..97d68f32fd9 --- /dev/null +++ b/server/tests-py/queries/graphql_mutation/delete/basic/setup_mssql.yaml @@ -0,0 +1,40 @@ +type: bulk +args: + +# track tables +- type: mssql_track_table + args: + source: mssql + table: + name: test_types + +- type: mssql_track_table + args: + source: mssql + table: + name: author + +- type: mssql_track_table + args: + source: mssql + table: + name: article + +# create relationships +- type: mssql_create_object_relationship + args: + source: mssql + table: article + name: author + using: + foreign_key_constraint_on: author_id + +- type: mssql_create_array_relationship + args: + source: mssql + table: author + name: articles + using: + foreign_key_constraint_on: + table: article + column: author_id diff --git a/server/tests-py/queries/graphql_mutation/delete/basic/teardown_mssql.yaml b/server/tests-py/queries/graphql_mutation/delete/basic/teardown_mssql.yaml new file mode 100644 index 00000000000..b863d00e3fc --- /dev/null +++ b/server/tests-py/queries/graphql_mutation/delete/basic/teardown_mssql.yaml @@ -0,0 +1,24 @@ +type: bulk +args: + +# untrack tables +- type: mssql_untrack_table + args: + source: mssql + table: + name: test_types + cascade: true + +- type: mssql_untrack_table + args: + source: mssql + table: + name: article + cascade: true + +- type: mssql_untrack_table + args: + source: mssql + table: + name: author + cascade: true diff --git a/server/tests-py/queries/graphql_mutation/delete/basic/test_types_mssql.yaml b/server/tests-py/queries/graphql_mutation/delete/basic/test_types_mssql.yaml new file mode 100644 index 00000000000..439b574a82a --- /dev/null +++ b/server/tests-py/queries/graphql_mutation/delete/basic/test_types_mssql.yaml @@ -0,0 +1,75 @@ +description: Delete mutation on test_types +url: /v1/graphql +status: 200 +response: + data: + delete_test_types: + affected_rows: 1 + returning: + - c1_smallint: 3277 + c2_integer: 2147483647 + c3_bigint: 9223372036854775807 + c4_decimal: 123.45 + c5_numeric: 1.234 + c6_real: 0.00390625 + c7_double_precision: 16.0001220703125 + c11_varchar_3: 'abc' + c12_char_4: 'baaz' + c13_text: 'foo bar baz' + c16_date: '2014-09-14' + c17_time: '11:09:23' + c44_xml: 'bar' + c45_money: 123.45 + c47_smallmoney: -123.45 + c48_bit: 0 + c49_tinyint: 254 + c50_float: -305.77 + c51_real: -36.82 + c52_datetime: '1996-04-15T04:00:00' + c53_datetime2: '9999-04-15T23:59:59.9999999' + c54_datetimeoffset: '2007-05-08T12:35:29.1234567+12:15' + c55_smalldatetime: '1955-12-13T12:43:00' + c56_binary: AAHiQA== + c57_varbinary: AAHiQA== + c58_hierarchyid: '/0.1/0.2/' + c59_uniqueidentifier: '0E984725-C51C-4BF4-9960-E1C80E27ABA0' + + +query: + query: | + mutation delete_test_types { + delete_test_types ( + where: {c1_smallint: {_eq: 3277}} + ) { + affected_rows + returning { + c1_smallint + c2_integer + c3_bigint + c4_decimal + c5_numeric + c6_real + c7_double_precision + c11_varchar_3 + c12_char_4 + c13_text + c16_date + c17_time + c44_xml + c45_money + c47_smallmoney + c48_bit + c49_tinyint + c50_float + c51_real + c52_datetime + c53_datetime2 + c54_datetimeoffset + c55_smalldatetime + c56_binary + c57_varbinary + c58_hierarchyid + c59_uniqueidentifier + } + } + } diff --git a/server/tests-py/queries/graphql_mutation/delete/constraints/author_foreign_key_violation_mssql.yaml b/server/tests-py/queries/graphql_mutation/delete/constraints/author_foreign_key_violation_mssql.yaml new file mode 100644 index 00000000000..96450cb150e --- /dev/null +++ b/server/tests-py/queries/graphql_mutation/delete/constraints/author_foreign_key_violation_mssql.yaml @@ -0,0 +1,6 @@ +query: | + mutation { + delete_author(where: {id: {_eq: 2}}){ + affected_rows + } + } diff --git a/server/tests-py/queries/graphql_mutation/delete/constraints/schema_setup_mssql.yaml b/server/tests-py/queries/graphql_mutation/delete/constraints/schema_setup_mssql.yaml new file mode 100644 index 00000000000..1b811b2fc1c --- /dev/null +++ b/server/tests-py/queries/graphql_mutation/delete/constraints/schema_setup_mssql.yaml @@ -0,0 +1,58 @@ +type: bulk +args: + +- type: mssql_run_sql + args: + source: mssql + sql: | + create table author( + id INT IDENTITY NOT NULL PRIMARY KEY, + name NVARCHAR(450) UNIQUE + ); + CREATE TABLE article ( + id INT IDENTITY NOT NULL PRIMARY KEY, + title TEXT, + content TEXT, + is_published BIT, + author_id INTEGER NOT NULL REFERENCES author(id), + published_on TIMESTAMP + ); + + INSERT INTO author(name) + VALUES + ('Author 1'), + ('Author 2'), + ('Author 3'); + + INSERT INTO article(title, content, author_id, is_published) + VALUES + ( + 'Article 1', + 'Sample article content 1', + 1, + 0 + ), + ( + 'Article 2', + 'Sample article content 2', + 1, + 1 + ), + ( + 'Article 3', + 'Sample article content 3', + 1, + 1 + ), + ( + 'Article 4', + 'Sample article content 4', + 2, + 1 + ), + ( + 'Article 5', + 'Sample article content 5', + 2, + 0 + ); diff --git a/server/tests-py/queries/graphql_mutation/delete/constraints/schema_teardown_mssql.yaml b/server/tests-py/queries/graphql_mutation/delete/constraints/schema_teardown_mssql.yaml new file mode 100644 index 00000000000..5a9efa324dc --- /dev/null +++ b/server/tests-py/queries/graphql_mutation/delete/constraints/schema_teardown_mssql.yaml @@ -0,0 +1,9 @@ +type: bulk +args: + +- type: mssql_run_sql + args: + source: mssql + sql: | + drop table article; + drop table author; diff --git a/server/tests-py/queries/graphql_mutation/delete/constraints/setup_mssql.yaml b/server/tests-py/queries/graphql_mutation/delete/constraints/setup_mssql.yaml new file mode 100644 index 00000000000..12d475d3241 --- /dev/null +++ b/server/tests-py/queries/graphql_mutation/delete/constraints/setup_mssql.yaml @@ -0,0 +1,36 @@ +type: bulk +args: + +# Author table +- type: mssql_track_table + args: + source: mssql + table: + name: author + +# Article table +- type: mssql_track_table + args: + source: mssql + table: + name: article + +# Object relationship +- type: mssql_create_object_relationship + args: + source: mssql + table: article + name: author + using: + foreign_key_constraint_on: author_id + +# Array relationship +- type: mssql_create_array_relationship + args: + source: mssql + table: author + name: articles + using: + foreign_key_constraint_on: + table: article + column: author_id diff --git a/server/tests-py/queries/graphql_mutation/delete/constraints/teardown_mssql.yaml b/server/tests-py/queries/graphql_mutation/delete/constraints/teardown_mssql.yaml new file mode 100644 index 00000000000..a8d410406f0 --- /dev/null +++ b/server/tests-py/queries/graphql_mutation/delete/constraints/teardown_mssql.yaml @@ -0,0 +1,24 @@ +type: bulk +args: + +# Drop relationship first +- type: mssql_drop_relationship + args: + source: mssql + relationship: articles + table: + name: author + +- type: mssql_untrack_table + args: + source: mssql + table: + name: article + cascade: true + +- type: mssql_untrack_table + args: + source: mssql + table: + name: author + cascade: true diff --git a/server/tests-py/queries/graphql_mutation/delete/permissions/schema_setup_mssql.yaml b/server/tests-py/queries/graphql_mutation/delete/permissions/schema_setup_mssql.yaml new file mode 100644 index 00000000000..04073bac4f3 --- /dev/null +++ b/server/tests-py/queries/graphql_mutation/delete/permissions/schema_setup_mssql.yaml @@ -0,0 +1,78 @@ +type: bulk +args: + +- type: mssql_run_sql + args: + source: mssql + sql: | + create table author( + id INT IDENTITY NOT NULL PRIMARY KEY, + name NVARCHAR(450) UNIQUE, + payments_done BIT NOT NULL DEFAULT 0, + user_id INT + ); + CREATE TABLE article ( + id INT IDENTITY NOT NULL PRIMARY KEY, + title TEXT, + content TEXT, + author_id INTEGER NOT NULL REFERENCES author(id), + is_published BIT, + published_on TIMESTAMP + ); + CREATE TABLE resident ( + id INT IDENTITY NOT NULL PRIMARY KEY, + name TEXT NOT NULL, + age INTEGER NOT NULL + ); + create table "user" ( + id INT IDENTITY NOT NULL PRIMARY KEY, + name NVARCHAR(450) UNIQUE, + is_admin BIT DEFAULT 0 + ); + + create table account ( + id INT IDENTITY NOT NULL PRIMARY KEY, + account_no INTEGER NOT NULL + ); + + insert into resident (name, age) + values + ('Griffin', 25), + ('Clarke', 26); + + insert into author (name, payments_done) + values + ('Author 1', 0), + ('Author 2', 0), + ('Author 3', 1), + ('Author 4', 1), + ('Author 5', 1); + + insert into article (content, title, author_id) + values + ( 'Sample article content 1', + 'Article 1', + 1 + ), + ( 'Sample article content 2', + 'Article 2', + 1 + ), + ( 'Sample article content 3', + 'Article 3', + 1 + ), + ( 'Sample article content 4', + 'Article 4', + 2 + ), + ( 'Sample article content 5', + 'Article 5', + 2 + ); + + insert into "user" (name, is_admin) + values ('user_1', 0), ('user_2', 1) + ; + + insert into account (account_no) values (1), (2) ; diff --git a/server/tests-py/queries/graphql_mutation/delete/permissions/schema_teardown_mssql.yaml b/server/tests-py/queries/graphql_mutation/delete/permissions/schema_teardown_mssql.yaml new file mode 100644 index 00000000000..247a1c5f3c2 --- /dev/null +++ b/server/tests-py/queries/graphql_mutation/delete/permissions/schema_teardown_mssql.yaml @@ -0,0 +1,13 @@ +type: bulk +args: + +- type: mssql_run_sql + args: + source: mssql + sql: | + drop table article; + drop table author; + drop table resident; + drop table "user"; + drop table account; + cascade: true diff --git a/server/tests-py/queries/graphql_mutation/delete/permissions/setup_mssql.yaml b/server/tests-py/queries/graphql_mutation/delete/permissions/setup_mssql.yaml new file mode 100644 index 00000000000..e7c177e6cb8 --- /dev/null +++ b/server/tests-py/queries/graphql_mutation/delete/permissions/setup_mssql.yaml @@ -0,0 +1,172 @@ +type: bulk +args: + +# Author table +- type: mssql_track_table + args: + source: mssql + table: + name: author + +# Article table +- type: mssql_track_table + args: + source: mssql + table: + name: article + + +# Object relationship +- type: mssql_create_object_relationship + args: + source: mssql + table: article + name: author + using: + foreign_key_constraint_on: author_id + +# Array relationship +- type: mssql_create_array_relationship + args: + source: mssql + table: author + name: articles + using: + foreign_key_constraint_on: + table: article + column: author_id + +# Prevent deletion if payments to the author is not yet done +- type: mssql_create_delete_permission + args: + source: mssql + table: author + role: user + permission: + filter: + $and: + - id: X-HASURA-USER-ID + - payments_done: + _eq: 1 + +# Author select permission for user +- type: mssql_create_select_permission + args: + source: mssql + table: author + role: user + permission: + columns: [id, name, payments_done] + filter: + id: X-HASURA-USER-ID + +# A user can delete only his articles +- type: mssql_create_select_permission + args: + source: mssql + table: article + role: user + permission: + columns: + - id + - title + - content + - author_id + filter: + $and: + - author_id: X-HASURA-USER-ID + +# A user can delete only his articles +- type: mssql_create_delete_permission + args: + source: mssql + table: article + role: user + permission: + filter: + $and: + - author_id: X-HASURA-USER-ID + +# Create resident table +- type: mssql_track_table + args: + source: mssql + table: + name: resident + +- type: mssql_create_delete_permission + args: + source: mssql + table: resident + role: resident + permission: + filter: + id: X-Hasura-Resident-Id + +- type: mssql_create_delete_permission + args: + source: mssql + table: resident + role: agent + permission: + filter: + id: + $in: X-Hasura-Allowed-Resident-Ids + +- type: mssql_create_select_permission + args: + source: mssql + table: resident + role: agent + permission: + columns: + - id + - name + - age + filter: + id: + $in: X-Hasura-Allowed-Resident-Ids + +# Tables to test '_exist' field +- type: mssql_track_table + args: + source: mssql + table: + name: user + +- type: mssql_track_table + args: + source: mssql + table: + name: account + +- type: mssql_create_delete_permission + args: + source: mssql + table: account + role: user + permission: + filter: + _exists: + _table: user + _where: + id: X-Hasura-User-Id + is_admin: + _eq: 1 + +- type: mssql_create_select_permission + args: + source: mssql + table: account + role: user + permission: + columns: + - id + - account_no + filter: + _exists: + _table: user + _where: + id: X-Hasura-User-Id + is_admin: + _eq: 1 diff --git a/server/tests-py/queries/graphql_mutation/delete/permissions/teardown_mssql.yaml b/server/tests-py/queries/graphql_mutation/delete/permissions/teardown_mssql.yaml new file mode 100644 index 00000000000..3a056a02e41 --- /dev/null +++ b/server/tests-py/queries/graphql_mutation/delete/permissions/teardown_mssql.yaml @@ -0,0 +1,16 @@ +type: bulk +args: + +- type: mssql_drop_relationship + args: + source: mssql + relationship: author + table: + name: article + +- type: mssql_drop_relationship + args: + source: mssql + relationship: articles + table: + name: author diff --git a/server/tests-py/queries/graphql_mutation/delete/permissions/user_delete_author.yaml b/server/tests-py/queries/graphql_mutation/delete/permissions/user_delete_author.yaml index 8339bc47b90..bbb59f3dca4 100644 --- a/server/tests-py/queries/graphql_mutation/delete/permissions/user_delete_author.yaml +++ b/server/tests-py/queries/graphql_mutation/delete/permissions/user_delete_author.yaml @@ -30,31 +30,3 @@ } } } - -- description: Delete an author by pk - url: /v1/graphql - status: 200 - headers: - X-Hasura-Role: user - X-Hasura-User-Id: '5' - response: - data: - delete_author_by_pk: - id: 5 - name: Author 5 - articles: [] - query: - query: | - mutation { - delete_author_by_pk( - id: 5 - ){ - id - name - articles { - id - title - content - } - } - } diff --git a/server/tests-py/queries/graphql_mutation/delete/permissions/user_delete_author_by_pk.yaml b/server/tests-py/queries/graphql_mutation/delete/permissions/user_delete_author_by_pk.yaml new file mode 100644 index 00000000000..b4f6c8ae33e --- /dev/null +++ b/server/tests-py/queries/graphql_mutation/delete/permissions/user_delete_author_by_pk.yaml @@ -0,0 +1,27 @@ +- description: Delete an author by pk + url: /v1/graphql + status: 200 + headers: + X-Hasura-Role: user + X-Hasura-User-Id: '5' + response: + data: + delete_author_by_pk: + id: 5 + name: Author 5 + articles: [] + query: + query: | + mutation { + delete_author_by_pk( + id: 5 + ){ + id + name + articles { + id + title + content + } + } + } diff --git a/server/tests-py/test_graphql_mutations.py b/server/tests-py/test_graphql_mutations.py index 10f4d7722f1..b3f9f875d95 100644 --- a/server/tests-py/test_graphql_mutations.py +++ b/server/tests-py/test_graphql_mutations.py @@ -539,6 +539,37 @@ class TestGraphqlDeleteBasic: def dir(cls): return "queries/graphql_mutation/delete/basic" +@pytest.mark.parametrize("backend", ['mssql']) +@pytest.mark.parametrize("transport", ['http', 'websocket']) +@usefixtures('per_method_tests_db_state') +class TestGraphqlDeleteBasicMSSQL: + + def test_article_delete(self, hge_ctx, transport): + check_query_f(hge_ctx, self.dir() + "/article.yaml", transport) + + def test_article_delete_returning(self, hge_ctx, transport): + check_query_f(hge_ctx, self.dir() + "/article_returning.yaml", transport) + + def test_article_delete_returning_author(self, hge_ctx, transport): + check_query_f(hge_ctx, self.dir() + "/article_returning_author.yaml", transport) + + def test_author_returning_empty_articles(self, hge_ctx, transport): + check_query_f(hge_ctx, self.dir() + "/author_returning_empty_articles_mssql.yaml", transport) + + def test_article_by_pk(self, hge_ctx, transport): + check_query_f(hge_ctx, self.dir() + "/article_by_pk.yaml", transport) + + def test_article_by_pk_null(self, hge_ctx, transport): + check_query_f(hge_ctx, self.dir() + "/article_by_pk_null.yaml", transport) + + def test_test_types_delete(self, hge_ctx, transport): + check_query_f(hge_ctx, self.dir() + "/test_types_mssql.yaml", transport) + + + @classmethod + def dir(cls): + return "queries/graphql_mutation/delete/basic" + @pytest.mark.parametrize("transport", ['http', 'websocket']) @use_mutation_fixtures class TestGraphqlDeleteConstraints: @@ -550,6 +581,24 @@ class TestGraphqlDeleteConstraints: def dir(cls): return "queries/graphql_mutation/delete/constraints" +@pytest.mark.parametrize("backend", ['mssql']) +@pytest.mark.parametrize("transport", ['http', 'websocket']) +@usefixtures('per_method_tests_db_state') +class TestGraphqlDeleteConstraintsMSSQL: + + # The response from this query is non-determinstic. It looks something like: + # `conflicted with the REFERENCE constraint "FK__article__author___1B29035F"` + # where 1B29035F changes with each call. + # This makes it hard to write an equality-based test for it, so we just check the error code. + def test_author_delete_foreign_key_violation(self, hge_ctx, transport): + st_code, resp = hge_ctx.v1graphql_f(self.dir() + '/author_foreign_key_violation_mssql.yaml') + assert st_code == 200, resp + assert len(resp['errors']) == 1, resp + + @classmethod + def dir(cls): + return "queries/graphql_mutation/delete/constraints" + @use_mutation_fixtures class TestGraphqlDeletePermissions: @@ -572,15 +621,57 @@ class TestGraphqlDeletePermissions: def test_user_delete_author(self, hge_ctx): check_query_f(hge_ctx, self.dir() + "/user_delete_author.yaml") + def test_user_delete_author_by_pk(self, hge_ctx): + check_query_f(hge_ctx, self.dir() + "/user_delete_author_by_pk.yaml") + def test_user_delete_account_success(self, hge_ctx): check_query_f(hge_ctx, self.dir() + "/user_delete_account_success.yaml") def test_user_delete_account_no_rows(self, hge_ctx): check_query_f(hge_ctx, self.dir() + "/user_delete_account_no_rows.yaml") + @classmethod def dir(cls): return "queries/graphql_mutation/delete/permissions" + +@pytest.mark.parametrize("backend", ['mssql']) +@usefixtures('per_method_tests_db_state') +class TestGraphqlDeletePermissionsMSSQL: + + def test_author_can_delete_his_articles(self, hge_ctx): + check_query_f(hge_ctx, self.dir() + "/author_can_delete_his_articles.yaml") + + def test_author_cannot_delete_other_users_articles(self, hge_ctx): + check_query_f(hge_ctx, self.dir() + "/author_cannot_delete_other_users_articles.yaml") + + def test_resident_delete_without_select_perm_fail(self, hge_ctx): + check_query_f(hge_ctx, self.dir() + "/resident_delete_without_select_perm_fail.yaml") + + # array types for session values not supported + # def test_agent_delete_perm_arr_sess_var(self, hge_ctx): + # check_query_f(hge_ctx, self.dir() + "/agent_delete_perm_arr_sess_var.yaml") + + # def test_agent_delete_perm_arr_sess_var_fail(self, hge_ctx): + # check_query_f(hge_ctx, self.dir() + "/agent_delete_perm_arr_sess_var_fail.yaml") + + def test_user_delete_author(self, hge_ctx): + check_query_f(hge_ctx, self.dir() + "/user_delete_author.yaml") + + def test_user_delete_author_by_pk(self, hge_ctx): + check_query_f(hge_ctx, self.dir() + "/user_delete_author_by_pk.yaml") + + def test_user_delete_account_success(self, hge_ctx): + check_query_f(hge_ctx, self.dir() + "/user_delete_account_success.yaml") + + def test_user_delete_account_no_rows(self, hge_ctx): + check_query_f(hge_ctx, self.dir() + "/user_delete_account_no_rows.yaml") + + @classmethod + def dir(cls): + return "queries/graphql_mutation/delete/permissions" + + @pytest.mark.parametrize("transport", ['http', 'websocket']) @use_mutation_fixtures class TestGraphqlMutationCustomSchema: diff --git a/server/tests-py/test_graphql_queries.py b/server/tests-py/test_graphql_queries.py index ef4baf2fcc3..7d94c91ece9 100644 --- a/server/tests-py/test_graphql_queries.py +++ b/server/tests-py/test_graphql_queries.py @@ -306,6 +306,12 @@ class TestGraphQLQueryBasicCommon: def test_select_query_multiple_columns_obj_fkey(self, hge_ctx, transport): check_query_f(hge_ctx, self.dir() + "/select_multiple_columns_obj_fkey.yaml", transport) + def test_select_query_author_pk(self, hge_ctx, transport): + check_query_f(hge_ctx, self.dir() + '/select_query_author_by_pkey.yaml', transport) + + def test_select_query_author_pk_null(self, hge_ctx, transport): + check_query_f(hge_ctx, self.dir() + '/select_query_author_by_pkey_null.yaml', transport) + @classmethod def dir(cls): return 'queries/graphql_query/basic'