From 84027dad04f9db0bd09a6b8418624913d82b636a Mon Sep 17 00:00:00 2001 From: Philip Lykke Carlsen Date: Thu, 18 Nov 2021 19:02:58 +0100 Subject: [PATCH] Breaking up the Postgres implementation of the update-schema into reusable components PR-URL: https://github.com/hasura/graphql-engine-mono/pull/2889 GitOrigin-RevId: 49c5d59a6f817832f11b1773b078aa24cc650ab5 --- server/graphql-engine.cabal | 3 +- .../Backends/MSSQL/Instances/Execute.hs | 1 - .../Backends/Postgres/Execute/Mutation.hs | 2 +- .../Backends/Postgres/Instances/Execute.hs | 5 +- .../Backends/Postgres/Instances/Schema.hs | 543 +++++++++++++----- .../Backends/Postgres/Instances/Types.hs | 6 + .../Backends/Postgres/Translate/Update.hs | 8 +- .../Hasura/Backends/Postgres/Types/Update.hs | 34 ++ .../src-lib/Hasura/GraphQL/Schema/BoolExp.hs | 4 +- server/src-lib/Hasura/GraphQL/Schema/Build.hs | 4 +- .../src-lib/Hasura/GraphQL/Schema/Mutation.hs | 26 +- server/src-lib/Hasura/GraphQL/Schema/Table.hs | 4 + server/src-lib/Hasura/RQL/DML/Update.hs | 10 +- server/src-lib/Hasura/RQL/IR/Root.hs | 2 +- server/src-lib/Hasura/RQL/IR/Update.hs | 37 +- server/src-lib/Hasura/RQL/Types/Backend.hs | 14 +- server/src-lib/Hasura/RQL/Types/Column.hs | 6 +- .../article_column_multiple_operators.yaml | 2 +- 18 files changed, 511 insertions(+), 200 deletions(-) create mode 100644 server/src-lib/Hasura/Backends/Postgres/Types/Update.hs diff --git a/server/graphql-engine.cabal b/server/graphql-engine.cabal index 8352538d0e0..9ce057ad5ea 100644 --- a/server/graphql-engine.cabal +++ b/server/graphql-engine.cabal @@ -424,9 +424,10 @@ library , Hasura.Backends.Postgres.Translate.Types , Hasura.Backends.Postgres.Translate.Update , Hasura.Backends.Postgres.Types.BoolExp + , Hasura.Backends.Postgres.Types.CitusExtraTableMetadata , Hasura.Backends.Postgres.Types.Column , Hasura.Backends.Postgres.Types.Table - , Hasura.Backends.Postgres.Types.CitusExtraTableMetadata + , Hasura.Backends.Postgres.Types.Update , Hasura.Backends.MySQL.DataLoader.Execute , Hasura.Backends.MySQL.DataLoader.Plan diff --git a/server/src-lib/Hasura/Backends/MSSQL/Instances/Execute.hs b/server/src-lib/Hasura/Backends/MSSQL/Instances/Execute.hs index 74fc8bf6711..cd8060002c4 100644 --- a/server/src-lib/Hasura/Backends/MSSQL/Instances/Execute.hs +++ b/server/src-lib/Hasura/Backends/MSSQL/Instances/Execute.hs @@ -226,7 +226,6 @@ msDBMutationPlan :: msDBMutationPlan userInfo stringifyNum sourceName sourceConfig mrf = do go <$> case mrf of MDBInsert annInsert -> executeInsert userInfo stringifyNum sourceConfig annInsert - MDBUpdate _annUpdate -> throw400 NotSupported "update mutations are not supported in MSSQL" MDBDelete _annDelete -> throw400 NotSupported "delete mutations are not supported in MSSQL" MDBFunction {} -> throw400 NotSupported "function mutations are not supported in MSSQL" where diff --git a/server/src-lib/Hasura/Backends/Postgres/Execute/Mutation.hs b/server/src-lib/Hasura/Backends/Postgres/Execute/Mutation.hs index a38824ca253..e261e6e1b44 100644 --- a/server/src-lib/Hasura/Backends/Postgres/Execute/Mutation.hs +++ b/server/src-lib/Hasura/Backends/Postgres/Execute/Mutation.hs @@ -109,7 +109,7 @@ execUpdateQuery :: ) => Bool -> UserInfo -> - (AnnUpd ('Postgres pgKind), DS.Seq Q.PrepArg) -> + (AnnotatedUpdateNode ('Postgres pgKind), DS.Seq Q.PrepArg) -> m EncJSON execUpdateQuery strfyNum userInfo (u, p) = runMutation diff --git a/server/src-lib/Hasura/Backends/Postgres/Instances/Execute.hs b/server/src-lib/Hasura/Backends/Postgres/Instances/Execute.hs index f1282045ae5..840d880b009 100644 --- a/server/src-lib/Hasura/Backends/Postgres/Instances/Execute.hs +++ b/server/src-lib/Hasura/Backends/Postgres/Instances/Execute.hs @@ -32,6 +32,7 @@ import Hasura.Backends.Postgres.SQL.Types qualified as PG import Hasura.Backends.Postgres.SQL.Value qualified as PG import Hasura.Backends.Postgres.Translate.Select (PostgresAnnotatedFieldJSON) import Hasura.Backends.Postgres.Translate.Select qualified as DS +import Hasura.Backends.Postgres.Types.Update import Hasura.Base.Error (QErr) import Hasura.EncJSON (EncJSON, encJFromJValue) import Hasura.GraphQL.Execute.Backend @@ -196,13 +197,13 @@ convertUpdate :: PostgresAnnotatedFieldJSON pgKind ) => UserInfo -> - IR.AnnUpdG ('Postgres pgKind) (Const Void) (UnpreparedValue ('Postgres pgKind)) -> + IR.AnnotatedUpdateNodeG ('Postgres pgKind) (Const Void) (UnpreparedValue ('Postgres pgKind)) -> Bool -> QueryTagsComment -> m (Tracing.TraceT (Q.TxET QErr IO) EncJSON) convertUpdate userInfo updateOperation stringifyNum queryTags = do preparedUpdate <- traverse (prepareWithoutPlan userInfo) updateOperation - if null $ IR.uqp1OpExps updateOperation + if null $ updateOperations . IR.uqp1BackendIR $ updateOperation then pure $ pure $ IR.buildEmptyMutResp $ IR.uqp1Output preparedUpdate else pure $ diff --git a/server/src-lib/Hasura/Backends/Postgres/Instances/Schema.hs b/server/src-lib/Hasura/Backends/Postgres/Instances/Schema.hs index 77dd9434b98..3e0f46ad46c 100644 --- a/server/src-lib/Hasura/Backends/Postgres/Instances/Schema.hs +++ b/server/src-lib/Hasura/Backends/Postgres/Instances/Schema.hs @@ -10,16 +10,16 @@ import Data.Aeson qualified as J import Data.Has import Data.HashMap.Strict qualified as Map import Data.HashMap.Strict.Extended qualified as M -import Data.HashMap.Strict.InsOrd.Extended qualified as OMap import Data.List.NonEmpty qualified as NE import Data.Parser.JSONPath import Data.Text qualified as T import Data.Text.Extended -import Hasura.Backends.Postgres.SQL.DML as PG hiding (CountType) +import Hasura.Backends.Postgres.SQL.DML as PG hiding (CountType, incOp) import Hasura.Backends.Postgres.SQL.Types as PG hiding (FunctionName, TableName) import Hasura.Backends.Postgres.SQL.Value as PG import Hasura.Backends.Postgres.Types.BoolExp import Hasura.Backends.Postgres.Types.Column +import Hasura.Backends.Postgres.Types.Update as PGIR import Hasura.Base.Error import Hasura.GraphQL.Parser hiding (EnumValueInfo, field) import Hasura.GraphQL.Parser qualified as P @@ -39,7 +39,6 @@ import Hasura.GraphQL.Schema.Table import Hasura.Prelude import Hasura.RQL.IR import Hasura.RQL.IR.Select qualified as IR -import Hasura.RQL.IR.Update qualified as IR import Hasura.RQL.Types import Hasura.SQL.AnyBackend qualified as AB import Hasura.SQL.Types @@ -52,8 +51,11 @@ import Language.GraphQL.Draft.Syntax qualified as G -- Some functions of 'BackendSchema' differ across different Postgres "kinds", -- or call to functions (such as those related to Relay) that have not been -- generalized to all kinds of Postgres and still explicitly work on Vanilla --- Postgres. This class alllows each "kind" to specify its own specific +-- Postgres. This class allows each "kind" to specify its own specific -- implementation. All common code is directly part of `BackendSchema`. +-- +-- Note: Users shouldn't ever put this as a constraint. Use `BackendSchema +-- ('Postgres pgKind)` instead. class PostgresSchema (pgKind :: PostgresKind) where pgkBuildTableRelayQueryFields :: BS.MonadBuildSchema ('Postgres pgKind) r m n => @@ -121,7 +123,7 @@ instance buildTableQueryFields = GSB.buildTableQueryFields buildTableRelayQueryFields = pgkBuildTableRelayQueryFields buildTableInsertMutationFields = GSB.buildTableInsertMutationFields - buildTableUpdateMutationFields = GSB.buildTableUpdateMutationFields updateOperators + buildTableUpdateMutationFields = GSB.buildTableUpdateMutationFields (\ti updP -> fmap BackendUpdate <$> updateOperators ti updP) -- TODO: simplify this! buildTableDeleteMutationFields = GSB.buildTableDeleteMutationFields buildFunctionQueryFields = GSB.buildFunctionQueryFields buildFunctionRelayQueryFields = pgkBuildFunctionRelayQueryFields @@ -645,140 +647,407 @@ mkCountType _ Nothing = PG.CTStar mkCountType (Just True) (Just cols) = PG.CTDistinct cols mkCountType _ (Just cols) = PG.CTSimple cols +-- | @UpdateOperator b m n t@ represents one single update operator for a +-- backend @b@, parsing a value of type @t@. @UpdateOperator b m n@ is a +-- @Functor@, which (apart from the type variable @b@) is what enables +-- multi-backend support. +-- +-- Use the 'Functor (UpdateOperator b m n)' instance to inject the +-- @UpdateOperator b m n (UnpreparedValue b)@ operators into backend-specific +-- IR types that encode update operators. +data UpdateOperator b m n t = UpdateOperator + { updateOperatorApplicableColumn :: ColumnInfo b -> Bool, + updateOperatorParser :: + G.Name -> + TableName b -> + NonEmpty (ColumnInfo b) -> + m (InputFieldsParser n (HashMap (Column b) t)) + } + deriving (Functor) + +-- | The top-level component for building update operators parsers. +-- +-- * It implements the 'preset' functionality from Update Permissions (see +-- ) +-- * It validates that that the update fields parsed are sound when taken as a +-- whole, i.e. that some changes are actually specified (either in the +-- mutation query text or in update preset columns) and that each column is +-- only used in one operator. +buildUpdateOperators :: + forall b n t m. + (BackendSchema b, MonadSchema n m, MonadError QErr m) => + -- | Columns with @preset@ expressions + (HashMap (Column b) t) -> + -- | Update operators to include in the Schema + [UpdateOperator b m n t] -> + TableInfo b -> + UpdPermInfo b -> + m (InputFieldsParser n (HashMap (Column b) t)) +buildUpdateOperators presetCols ops tableInfo updatePermissions = do + parsers :: InputFieldsParser n [HashMap (Column b) t] <- + sequenceA . catMaybes <$> traverse (runUpdateOperator tableInfo updatePermissions) ops + pure $ + parsers + `P.bindFields` ( \opExps -> do + let withPreset = presetCols : opExps + mergeDisjoint @b withPreset + ) + +-- | The columns that have 'preset' definitions applied to them. (see +-- ) +presetColumns :: UpdPermInfo b -> HashMap (Column b) (UnpreparedValue b) +presetColumns = fmap partialSQLExpToUnpreparedValue . upiSet + +-- | Produce an InputFieldsParser from an UpdateOperator, but only if the operator +-- applies to the table (i.e., it admits a non-empty column set). +runUpdateOperator :: + forall b m n t. + (Backend b, MonadSchema n m, MonadError QErr m) => + TableInfo b -> + UpdPermInfo b -> + UpdateOperator b m n t -> + m + ( Maybe + ( InputFieldsParser + n + (HashMap (Column b) t) + ) + ) +runUpdateOperator tableInfo updatePermissions UpdateOperator {..} = do + let tableName = tableInfoName tableInfo + tableGQLName <- getTableGQLName tableInfo + columns <- tableUpdateColumns tableInfo updatePermissions + + let applicableCols :: Maybe (NonEmpty (ColumnInfo b)) = + nonEmpty . filter updateOperatorApplicableColumn $ columns + + (sequenceA :: Maybe (m a) -> m (Maybe a)) + (applicableCols <&> updateOperatorParser tableGQLName tableName) + +-- | Ensure that /some/ updates have been specified in a mutation. +ensureNonEmpty :: + forall b m t. + (MonadParse m, Backend b) => + [Text] -> + [HashMap (Column b) t] -> + m () +ensureNonEmpty allowedOperators parsedResults = + when (null $ M.unions parsedResults) $ + parseError $ + "At least any one of " + <> commaSeparated allowedOperators + <> " is expected" + +-- | Merge the results of parsed update operators. Throws an error if the same +-- column has been specified in multiple operators. +mergeDisjoint :: + forall b m t. + (Backend b, MonadParse m) => + [HashMap (Column b) t] -> + m (HashMap (Column b) t) +mergeDisjoint parsedResults = do + let unioned = M.unionsAll parsedResults + duplicates = + M.keys $ + M.filter + ( \case + _ :| [] -> False + _ -> True + ) + unioned + + unless (null duplicates) $ + parseError + ( "Column found in multiple operators: " + <> commaSeparated (map dquote duplicates) + <> "." + ) + + return $ M.map NE.head unioned + +setOp :: + forall b n r m. + ( BackendSchema b, + MonadReader r m, + Has MkTypename r, + MonadError QErr m, + MonadSchema n m + ) => + UpdateOperator b m n (UnpreparedValue b) +setOp = UpdateOperator {..} + where + updateOperatorApplicableColumn = const True + + updateOperatorParser tableGQLName tableName columns = do + let typedParser columnInfo = + fmap P.mkParameter + <$> BS.columnParser + (pgiType columnInfo) + (G.Nullability $ pgiIsNullable columnInfo) + + updateOperator + tableGQLName + $$(G.litName "_set") + typedParser + columns + "sets the columns of the filtered rows to the given values" + (G.Description $ "input type for updating data in table " <>> tableName) + +incOp :: + forall b m n r. + ( Backend b, + MonadReader r m, + MonadError QErr m, + MonadSchema n m, + BackendSchema b, + Has MkTypename r + ) => + UpdateOperator b m n (UnpreparedValue b) +incOp = UpdateOperator {..} + where + updateOperatorApplicableColumn = isNumCol + + updateOperatorParser tableGQLName tableName columns = do + let typedParser columnInfo = + fmap P.mkParameter + <$> BS.columnParser + (pgiType columnInfo) + (G.Nullability $ pgiIsNullable columnInfo) + + updateOperator + tableGQLName + $$(G.litName "_inc") + typedParser + columns + "increments the numeric columns with given value of the filtered values" + (G.Description $ "input type for incrementing numeric columns in table " <>> tableName) + +-- | Update operator that prepends a value to a column containing jsonb arrays. +-- +-- Note: Currently this is Postgres specific because json columns have not been ported +-- to other backends yet. +prependOp :: + forall pgKind m n r. + ( BackendSchema ('Postgres pgKind), + MonadReader r m, + MonadError QErr m, + MonadSchema n m, + Has MkTypename r + ) => + UpdateOperator ('Postgres pgKind) m n (UnpreparedValue ('Postgres pgKind)) +prependOp = UpdateOperator {..} + where + updateOperatorApplicableColumn = (isScalarColumnWhere (== PGJSONB) . pgiType) + + updateOperatorParser tableGQLName tableName columns = do + let typedParser columnInfo = + fmap P.mkParameter + <$> BS.columnParser + (pgiType columnInfo) + (G.Nullability $ pgiIsNullable columnInfo) + + desc = "prepend existing jsonb value of filtered columns with new jsonb value" + + updateOperator + tableGQLName + $$(G.litName "_prepend") + typedParser + columns + desc + desc + +-- | Update operator that appends a value to a column containing jsonb arrays. +-- +-- Note: Currently this is Postgres specific because json columns have not been ported +-- to other backends yet. +appendOp :: + forall pgKind m n r. + ( BackendSchema ('Postgres pgKind), + MonadReader r m, + MonadError QErr m, + MonadSchema n m, + Has MkTypename r + ) => + UpdateOperator ('Postgres pgKind) m n (UnpreparedValue ('Postgres pgKind)) +appendOp = UpdateOperator {..} + where + updateOperatorApplicableColumn = (isScalarColumnWhere (== PGJSONB) . pgiType) + + updateOperatorParser tableGQLName tableName columns = do + let typedParser columnInfo = + fmap P.mkParameter + <$> BS.columnParser + (pgiType columnInfo) + (G.Nullability $ pgiIsNullable columnInfo) + + desc = "append existing jsonb value of filtered columns with new jsonb value" + updateOperator + tableGQLName + $$(G.litName "_append") + typedParser + columns + desc + desc + +-- | Update operator that deletes a value at a specified key from a column +-- containing jsonb objects. +-- +-- Note: Currently this is Postgres specific because json columns have not been ported +-- to other backends yet. +deleteKeyOp :: + forall pgKind m n r. + ( BackendSchema ('Postgres pgKind), + MonadReader r m, + MonadError QErr m, + MonadSchema n m, + Has MkTypename r + ) => + UpdateOperator ('Postgres pgKind) m n (UnpreparedValue ('Postgres pgKind)) +deleteKeyOp = UpdateOperator {..} + where + updateOperatorApplicableColumn = (isScalarColumnWhere (== PGJSONB) . pgiType) + + updateOperatorParser tableGQLName tableName columns = do + let nullableTextParser _ = fmap P.mkParameter <$> columnParser (ColumnScalar PGText) (G.Nullability True) + desc = "delete key/value pair or string element. key/value pairs are matched based on their key value" + + updateOperator + tableGQLName + $$(G.litName "_delete_key") + nullableTextParser + columns + desc + desc + +-- | Update operator that deletes a value at a specific index from a column +-- containing jsonb arrays. +-- +-- Note: Currently this is Postgres specific because json columns have not been ported +-- to other backends yet. +deleteElemOp :: + forall pgKind m n r. + ( BackendSchema ('Postgres pgKind), + MonadReader r m, + MonadError QErr m, + MonadSchema n m, + Has MkTypename r + ) => + UpdateOperator ('Postgres pgKind) m n (UnpreparedValue ('Postgres pgKind)) +deleteElemOp = UpdateOperator {..} + where + updateOperatorApplicableColumn = (isScalarColumnWhere (== PGJSONB) . pgiType) + + updateOperatorParser tableGQLName tableName columns = do + let nonNullableIntParser _ = fmap P.mkParameter <$> columnParser (ColumnScalar PGInteger) (G.Nullability False) + desc = + "delete the array element with specified index (negative integers count from the end). " + <> "throws an error if top level container is not an array" + + updateOperator + tableGQLName + $$(G.litName "_delete_elem") + nonNullableIntParser + columns + desc + desc + +-- | Update operator that deletes a field at a certan path from a column +-- containing jsonb objects. +-- +-- Note: Currently this is Postgres specific because json columns have not been ported +-- to other backends yet. +deleteAtPathOp :: + forall pgKind m n r. + ( BackendSchema ('Postgres pgKind), + MonadReader r m, + MonadError QErr m, + MonadSchema n m, + Has MkTypename r + ) => + UpdateOperator ('Postgres pgKind) m n [UnpreparedValue ('Postgres pgKind)] +deleteAtPathOp = UpdateOperator {..} + where + updateOperatorApplicableColumn = (isScalarColumnWhere (== PGJSONB) . pgiType) + + updateOperatorParser tableGQLName tableName columns = do + let nonNullableTextListParser _ = P.list . fmap (P.mkParameter) <$> columnParser (ColumnScalar PGText) (G.Nullability False) + desc = "delete the field or element with specified path (for JSON arrays, negative integers count from the end)" + + updateOperator + tableGQLName + $$(G.litName "_delete_at_path") + nonNullableTextListParser + columns + desc + desc + +-- | Construct a parser for a single update operator. +-- +-- @updateOperator _ "op" fp MkOp ["col1","col2"]@ gives a parser that accepts +-- objects in the shape of: +-- +-- > op: { +-- > col1: "x", +-- > col2: "y" +-- > } +-- +-- And (morally) parses into values: +-- +-- > M.fromList [("col1", MkOp (fp "x")), ("col2", MkOp (fp "y"))] +updateOperator :: + forall n r m b a. + (MonadParse n, MonadReader r m, Has MkTypename r, Backend b) => + G.Name -> + G.Name -> + (ColumnInfo b -> m (Parser 'Both n a)) -> + NonEmpty (ColumnInfo b) -> -- TODO: Should actually be a nonempty set - do we have a lib for that? + G.Description -> + G.Description -> + m (InputFieldsParser n (HashMap (Column b) a)) +updateOperator tableGQLName opName mkParser columns opDesc objDesc = do + fieldParsers :: NonEmpty (InputFieldsParser n (Maybe (Column b, a))) <- + for columns \columnInfo -> do + let fieldName = pgiName columnInfo + fieldDesc = pgiDescription columnInfo + fieldParser <- mkParser columnInfo + pure $ + P.fieldOptional fieldName fieldDesc fieldParser + `mapField` \value -> (pgiColumn columnInfo, value) + + objName <- P.mkTypename $ tableGQLName <> opName <> $$(G.litName "_input") + + pure $ + fmap (M.fromList . (fold :: Maybe [(Column b, a)] -> [(Column b, a)])) $ + P.fieldOptional opName (Just opDesc) $ + P.object objName (Just objDesc) $ + (catMaybes . toList) <$> sequenceA fieldParsers +{-# ANN updateOperator ("HLint: ignore Use tuple-section" :: String) #-} + -- | Various update operators updateOperators :: forall pgKind m n r. - (BackendSchema ('Postgres pgKind), MonadSchema n m, MonadTableInfo r m, Has MkTypename r) => - -- | table info + ( MonadParse n, + MonadReader r m, + Has MkTypename r, + MonadError QErr m, + MonadSchema n m, + BackendSchema ('Postgres pgKind) + ) => TableInfo ('Postgres pgKind) -> - -- | update permissions of the table UpdPermInfo ('Postgres pgKind) -> - m (InputFieldsParser n [(Column ('Postgres pgKind), IR.UpdOpExpG (UnpreparedValue ('Postgres pgKind)))]) -updateOperators tableInfo updatePermissions = do - tableGQLName <- getTableGQLName tableInfo - columns <- tableUpdateColumns tableInfo updatePermissions - let numericCols = onlyNumCols columns - jsonCols = onlyJSONBCols columns - parsers <- - catMaybes - <$> sequenceA - [ updateOperator - tableGQLName - $$(G.litName "_set") - typedParser - IR.UpdSet - columns - "sets the columns of the filtered rows to the given values" - (G.Description $ "input type for updating data in table " <>> tableName), - updateOperator - tableGQLName - $$(G.litName "_inc") - typedParser - IR.UpdInc - numericCols - "increments the numeric columns with given value of the filtered values" - (G.Description $ "input type for incrementing numeric columns in table " <>> tableName), - let desc = "prepend existing jsonb value of filtered columns with new jsonb value" - in updateOperator - tableGQLName - $$(G.litName "_prepend") - typedParser - IR.UpdPrepend - jsonCols - desc - desc, - let desc = "append existing jsonb value of filtered columns with new jsonb value" - in updateOperator - tableGQLName - $$(G.litName "_append") - typedParser - IR.UpdAppend - jsonCols - desc - desc, - let desc = "delete key/value pair or string element. key/value pairs are matched based on their key value" - in updateOperator - tableGQLName - $$(G.litName "_delete_key") - nullableTextParser - IR.UpdDeleteKey - jsonCols - desc - desc, - let desc = - "delete the array element with specified index (negative integers count from the end). " - <> "throws an error if top level container is not an array" - in updateOperator - tableGQLName - $$(G.litName "_delete_elem") - nonNullableIntParser - IR.UpdDeleteElem - jsonCols - desc - desc, - let desc = "delete the field or element with specified path (for JSON arrays, negative integers count from the end)" - in updateOperator - tableGQLName - $$(G.litName "_delete_at_path") - (fmap P.list . nonNullableTextParser) - IR.UpdDeleteAtPath - jsonCols - desc - desc - ] - let allowedOperators = fst <$> parsers - pure $ - fmap catMaybes (sequenceA $ snd <$> parsers) - `P.bindFields` \opExps -> do - -- there needs to be at least one operator in the update, even if it is empty - let presetColumns = Map.toList $ IR.UpdSet . partialSQLExpToUnpreparedValue <$> upiSet updatePermissions - when (null opExps && null presetColumns) $ - parseError $ - "at least any one of " <> commaSeparated allowedOperators <> " is expected" - - -- no column should appear twice - let flattenedExps = concat opExps - erroneousExps = OMap.filter ((> 1) . length) $ OMap.groupTuples flattenedExps - unless (OMap.null erroneousExps) $ - parseError $ - "column found in multiple operators; " - <> T.intercalate - ". " - [ dquote columnName <> " in " <> commaSeparated (IR.updateOperatorText <$> ops) - | (columnName, ops) <- OMap.toList erroneousExps - ] - - pure $ presetColumns <> flattenedExps - where - tableName = tableInfoName tableInfo - typedParser columnInfo = fmap P.mkParameter <$> columnParser (pgiType columnInfo) (G.Nullability $ pgiIsNullable columnInfo) - nonNullableTextParser _ = fmap P.mkParameter <$> columnParser (ColumnScalar PGText) (G.Nullability False) - nullableTextParser _ = fmap P.mkParameter <$> columnParser (ColumnScalar PGText) (G.Nullability True) - nonNullableIntParser _ = fmap P.mkParameter <$> columnParser (ColumnScalar PGInteger) (G.Nullability False) - - onlyJSONBCols = filter (isScalarColumnWhere (== PGJSONB) . pgiType) - - updateOperator :: - G.Name -> - G.Name -> - (ColumnInfo b -> m (Parser 'Both n a)) -> - (a -> IR.UpdOpExpG (UnpreparedValue b)) -> - [ColumnInfo b] -> - G.Description -> - G.Description -> - m (Maybe (Text, InputFieldsParser n (Maybe [(Column b, IR.UpdOpExpG (UnpreparedValue b))]))) - updateOperator tableGQLName opName mkParser updOpExp columns opDesc objDesc = - whenMaybe (not $ null columns) do - fields <- for columns \columnInfo -> do - let fieldName = pgiName columnInfo - fieldDesc = pgiDescription columnInfo - fieldParser <- mkParser columnInfo - pure $ - P.fieldOptional fieldName fieldDesc fieldParser - `mapField` \value -> (pgiColumn columnInfo, updOpExp value) - objName <- P.mkTypename $ tableGQLName <> opName <> $$(G.litName "_input") - pure $ - (G.unName opName,) $ - P.fieldOptional opName (Just opDesc) $ - P.object objName (Just objDesc) $ - catMaybes <$> sequenceA fields + m (InputFieldsParser n (HashMap (Column ('Postgres pgKind)) (UpdOpExpG (UnpreparedValue ('Postgres pgKind))))) +updateOperators tableInfo updatePermissions = + buildUpdateOperators + (PGIR.UpdSet <$> presetColumns updatePermissions) + [ PGIR.UpdSet <$> setOp, + PGIR.UpdInc <$> incOp, + PGIR.UpdPrepend <$> prependOp, + PGIR.UpdAppend <$> appendOp, + PGIR.UpdDeleteKey <$> deleteKeyOp, + PGIR.UpdDeleteElem <$> deleteElemOp, + PGIR.UpdDeleteAtPath <$> deleteAtPathOp + ] + tableInfo + updatePermissions diff --git a/server/src-lib/Hasura/Backends/Postgres/Instances/Types.hs b/server/src-lib/Hasura/Backends/Postgres/Instances/Types.hs index 4cd2b8efdc2..01175abe798 100644 --- a/server/src-lib/Hasura/Backends/Postgres/Instances/Types.hs +++ b/server/src-lib/Hasura/Backends/Postgres/Instances/Types.hs @@ -15,6 +15,7 @@ import Hasura.Backends.Postgres.SQL.Types qualified as PG import Hasura.Backends.Postgres.SQL.Value qualified as PG import Hasura.Backends.Postgres.Types.BoolExp qualified as PG import Hasura.Backends.Postgres.Types.CitusExtraTableMetadata qualified as Citus +import Hasura.Backends.Postgres.Types.Update qualified as PG import Hasura.Base.Error import Hasura.Prelude import Hasura.RQL.Types.Backend @@ -28,6 +29,9 @@ import Hasura.SQL.Tag -- Some types of 'Backend' differ across different Postgres "kinds". This -- class alllows each "kind" to specify its own specific implementation. All -- common code is directly part of the `Backend` instance. +-- +-- Note: Users shouldn't ever put this as a constraint. Use `Backend ('Postgres +-- pgKind)` instead. class ( Representable (PgExtraTableMetadata pgKind), J.ToJSON (PgExtraTableMetadata pgKind), @@ -71,6 +75,8 @@ instance type SQLExpression ('Postgres pgKind) = PG.SQLExp type SQLOperator ('Postgres pgKind) = PG.SQLOp + type BackendUpdate ('Postgres pgKind) = PG.BackendUpdate + type ExtraTableMetadata ('Postgres pgKind) = PgExtraTableMetadata pgKind type ExtraInsertData ('Postgres pgKind) = () diff --git a/server/src-lib/Hasura/Backends/Postgres/Translate/Update.hs b/server/src-lib/Hasura/Backends/Postgres/Translate/Update.hs index caec6424009..5998b235c57 100644 --- a/server/src-lib/Hasura/Backends/Postgres/Translate/Update.hs +++ b/server/src-lib/Hasura/Backends/Postgres/Translate/Update.hs @@ -3,11 +3,13 @@ module Hasura.Backends.Postgres.Translate.Update ) where +import Data.HashMap.Strict qualified as Map import Hasura.Backends.Postgres.SQL.DML qualified as S import Hasura.Backends.Postgres.SQL.Types import Hasura.Backends.Postgres.Translate.BoolExp import Hasura.Backends.Postgres.Translate.Insert import Hasura.Backends.Postgres.Translate.Returning +import Hasura.Backends.Postgres.Types.Update import Hasura.Prelude import Hasura.RQL.IR.Update import Hasura.RQL.Types @@ -15,9 +17,9 @@ import Hasura.SQL.Types mkUpdateCTE :: Backend ('Postgres pgKind) => - AnnUpd ('Postgres pgKind) -> + AnnotatedUpdateNode ('Postgres pgKind) -> S.CTE -mkUpdateCTE (AnnUpd tn opExps (permFltr, wc) chk _ columnsInfo) = +mkUpdateCTE (AnnotatedUpdateNode tn (permFltr, wc) chk (BackendUpdate opExps) _ columnsInfo) = S.CTEUpdate update where update = @@ -27,7 +29,7 @@ mkUpdateCTE (AnnUpd tn opExps (permFltr, wc) chk _ columnsInfo) = $ [ S.selectStar, asCheckErrorExtractor $ insertCheckConstraint checkExpr ] - setExp = S.SetExp $ map (expandOperator columnsInfo) opExps + setExp = S.SetExp $ map (expandOperator columnsInfo) (Map.toList opExps) tableFltr = Just $ S.WhereFrag tableFltrExpr tableFltrExpr = toSQLBoolExp (S.QualTable tn) $ andAnnBoolExps permFltr wc checkExpr = toSQLBoolExp (S.QualTable tn) chk diff --git a/server/src-lib/Hasura/Backends/Postgres/Types/Update.hs b/server/src-lib/Hasura/Backends/Postgres/Types/Update.hs new file mode 100644 index 00000000000..766c63c8c8a --- /dev/null +++ b/server/src-lib/Hasura/Backends/Postgres/Types/Update.hs @@ -0,0 +1,34 @@ +-- | This module defines the Update-related IR types specific to Postgres. +module Hasura.Backends.Postgres.Types.Update + ( BackendUpdate (..), + UpdOpExpG (..), + ) +where + +import Hasura.Backends.Postgres.SQL.Types (PGCol) +import Hasura.Prelude + +-- | The PostgreSQL-specific data of an Update expression. +-- +-- This is parameterised over @v@ which enables different phases of IR +-- transformation to maintain the overall structure while enriching/transforming +-- the data at the leaves. +data BackendUpdate v = BackendUpdate + { -- | The update operations to perform on each colum. + updateOperations :: !(HashMap PGCol (UpdOpExpG v)) + } + deriving (Functor, Foldable, Traversable, Generic, Data) + +-- | The various @update operators@ supported by PostgreSQL, +-- i.e. the @_set@, @_inc@ operators that appear in the schema. +-- +-- See +data UpdOpExpG v + = UpdSet !v + | UpdInc !v + | UpdAppend !v + | UpdPrepend !v + | UpdDeleteKey !v + | UpdDeleteElem !v + | UpdDeleteAtPath ![v] + deriving (Functor, Foldable, Traversable, Generic, Data) diff --git a/server/src-lib/Hasura/GraphQL/Schema/BoolExp.hs b/server/src-lib/Hasura/GraphQL/Schema/BoolExp.hs index c992c677453..d05fdef3ded 100644 --- a/server/src-lib/Hasura/GraphQL/Schema/BoolExp.hs +++ b/server/src-lib/Hasura/GraphQL/Schema/BoolExp.hs @@ -83,7 +83,9 @@ boolExp sourceName tableInfo selectPermissions = memoizeOn 'boolExp (sourceName, FIRelationship relationshipInfo -> do remoteTableInfo <- askTableInfo sourceName $ riRTable relationshipInfo remotePermissions <- lift $ tableSelectPermissions remoteTableInfo - let remoteTableFilter = (fmap . fmap) partialSQLExpToUnpreparedValue $ maybe annBoolExpTrue spiFilter remotePermissions + let remoteTableFilter = + fmap partialSQLExpToUnpreparedValue + <$> maybe annBoolExpTrue spiFilter remotePermissions remoteBoolExp <- lift $ boolExp sourceName remoteTableInfo remotePermissions pure $ fmap (AVRelationship relationshipInfo . andAnnBoolExps remoteTableFilter) remoteBoolExp FIComputedField ComputedFieldInfo {..} -> do diff --git a/server/src-lib/Hasura/GraphQL/Schema/Build.hs b/server/src-lib/Hasura/GraphQL/Schema/Build.hs index 0094b41d772..07e3ba4c4c5 100644 --- a/server/src-lib/Hasura/GraphQL/Schema/Build.hs +++ b/server/src-lib/Hasura/GraphQL/Schema/Build.hs @@ -174,11 +174,11 @@ buildTableInsertMutationFields buildTableUpdateMutationFields :: forall b r m n. MonadBuildSchema b r m n => - -- | Action that builds the @update operators@ the backend supports + -- | TODO: Docs. WAS: Action that builds the @update operators@ the backend supports ( TableInfo b -> UpdPermInfo b -> m - (InputFieldsParser n [(Column b, UpdOpExpG (UnpreparedValue b))]) + (InputFieldsParser n (BackendUpdate b (UnpreparedValue b))) ) -> -- | The source that the table lives in SourceName -> diff --git a/server/src-lib/Hasura/GraphQL/Schema/Mutation.hs b/server/src-lib/Hasura/GraphQL/Schema/Mutation.hs index c1b824d45ec..8de5e83b1fe 100644 --- a/server/src-lib/Hasura/GraphQL/Schema/Mutation.hs +++ b/server/src-lib/Hasura/GraphQL/Schema/Mutation.hs @@ -407,7 +407,7 @@ updateTable :: ( TableInfo b -> UpdPermInfo b -> m - (InputFieldsParser n [(Column b, UpdOpExpG (UnpreparedValue b))]) + (InputFieldsParser n (BackendUpdate b (UnpreparedValue b))) ) -> -- | table source SourceName -> @@ -421,13 +421,13 @@ updateTable :: UpdPermInfo b -> -- | select permissions of the table (if any) Maybe (SelPermInfo b) -> - m (FieldParser n (IR.AnnUpdG b (IR.RemoteSelect UnpreparedValue) (UnpreparedValue b))) -updateTable updateOperators sourceName tableInfo fieldName description updatePerms selectPerms = do + m (FieldParser n (IR.AnnotatedUpdateNodeG b (IR.RemoteSelect UnpreparedValue) (UnpreparedValue b))) +updateTable updateIR sourceName tableInfo fieldName description updatePerms selectPerms = do let tableName = tableInfoName tableInfo columns = tableColumns tableInfo whereName = $$(G.litName "where") whereDesc = "filter the rows which have to be updated" - opArgs <- updateOperators tableInfo updatePerms + opArgs <- updateIR tableInfo updatePerms whereArg <- P.field whereName (Just whereDesc) <$> boolExp sourceName tableInfo selectPerms selection <- mutationSelectionSet sourceName tableInfo selectPerms let argsParser = liftA2 (,) opArgs whereArg @@ -445,7 +445,7 @@ updateTableByPk :: -- | Update Operators ( TableInfo b -> UpdPermInfo b -> - m (InputFieldsParser n [(Column b, UpdOpExpG (UnpreparedValue b))]) + m (InputFieldsParser n (BackendUpdate b (UnpreparedValue b))) ) -> -- | table source SourceName -> @@ -459,14 +459,14 @@ updateTableByPk :: UpdPermInfo b -> -- | select permissions of the table SelPermInfo b -> - m (Maybe (FieldParser n (IR.AnnUpdG b (IR.RemoteSelect UnpreparedValue) (UnpreparedValue b)))) -updateTableByPk updateOperators sourceName tableInfo fieldName description updatePerms selectPerms = runMaybeT $ do + m (Maybe (FieldParser n (IR.AnnotatedUpdateNodeG b (IR.RemoteSelect UnpreparedValue) (UnpreparedValue b)))) +updateTableByPk backendIR sourceName tableInfo fieldName description updatePerms selectPerms = runMaybeT $ do let columns = tableColumns tableInfo tableName = tableInfoName tableInfo tableGQLName <- getTableGQLName tableInfo pkArgs <- MaybeT $ primaryKeysArguments tableInfo selectPerms lift $ do - opArgs <- updateOperators tableInfo updatePerms + opArgs <- backendIR tableInfo updatePerms selection <- tableSelectionSet sourceName tableInfo selectPerms pkObjectName <- P.mkTypename $ tableGQLName <> $$(G.litName "_pk_columns_input") let pkFieldName = $$(G.litName "pk_columns") @@ -485,16 +485,16 @@ mkUpdateObject :: TableName b -> [ColumnInfo b] -> UpdPermInfo b -> - ( ( [(Column b, IR.UpdOpExpG (UnpreparedValue b))], + ( ( BackendUpdate b (UnpreparedValue b), AnnBoolExp b (UnpreparedValue b) ), IR.MutationOutputG b (IR.RemoteSelect UnpreparedValue) (UnpreparedValue b) ) -> - IR.AnnUpdG b (IR.RemoteSelect UnpreparedValue) (UnpreparedValue b) -mkUpdateObject table columns updatePerms ((opExps, whereExp), mutationOutput) = - IR.AnnUpd + IR.AnnotatedUpdateNodeG b (IR.RemoteSelect UnpreparedValue) (UnpreparedValue b) +mkUpdateObject table columns updatePerms ((backendIR, whereExp), mutationOutput) = + IR.AnnotatedUpdateNode { IR.uqp1Table = table, - IR.uqp1OpExps = opExps, + IR.uqp1BackendIR = backendIR, IR.uqp1Where = (permissionFilter, whereExp), IR.uqp1Check = checkExp, IR.uqp1Output = mutationOutput, diff --git a/server/src-lib/Hasura/GraphQL/Schema/Table.hs b/server/src-lib/Hasura/GraphQL/Schema/Table.hs index 1b57cfdb776..023a9cba4b5 100644 --- a/server/src-lib/Hasura/GraphQL/Schema/Table.hs +++ b/server/src-lib/Hasura/GraphQL/Schema/Table.hs @@ -163,6 +163,8 @@ tableColumns tableInfo = columnInfo (FIColumn ci) = Just ci columnInfo _ = Nothing +-- | Get the columns of a table that my be selected under the given select +-- permissions. tableSelectColumns :: forall m n r b. (Backend b, MonadSchema n m, MonadTableInfo r m, MonadRole r m) => @@ -176,6 +178,8 @@ tableSelectColumns sourceName tableInfo permissions = columnInfo (FIColumn ci) = Just ci columnInfo _ = Nothing +-- | Get the columns of a table that my be updated under the given update +-- permissions. tableUpdateColumns :: forall m n b. (Backend b, MonadSchema n m) => diff --git a/server/src-lib/Hasura/RQL/DML/Update.hs b/server/src-lib/Hasura/RQL/DML/Update.hs index 1bb3b689e3f..a79d21cacd4 100644 --- a/server/src-lib/Hasura/RQL/DML/Update.hs +++ b/server/src-lib/Hasura/RQL/DML/Update.hs @@ -8,6 +8,7 @@ where import Control.Monad.Trans.Control (MonadBaseControl) import Data.Aeson.Types import Data.HashMap.Strict qualified as M +import Data.HashMap.Strict qualified as Map import Data.Sequence qualified as DS import Data.Text.Extended import Database.PG.Query qualified as Q @@ -17,6 +18,7 @@ import Hasura.Backends.Postgres.SQL.DML qualified as S import Hasura.Backends.Postgres.SQL.Types import Hasura.Backends.Postgres.Translate.Returning import Hasura.Backends.Postgres.Types.Table +import Hasura.Backends.Postgres.Types.Update import Hasura.Base.Error import Hasura.EncJSON import Hasura.Prelude @@ -98,7 +100,7 @@ validateUpdateQueryWith :: SessionVariableBuilder ('Postgres 'Vanilla) m -> ValueParser ('Postgres 'Vanilla) m S.SQLExp -> UpdateQuery -> - m (AnnUpd ('Postgres 'Vanilla)) + m (AnnotatedUpdateNode ('Postgres 'Vanilla)) validateUpdateQueryWith sessVarBldr prepValBldr uq = do let tableName = uqTable uq tableInfo <- withPathK "table" $ askTabInfoSource tableName @@ -177,11 +179,11 @@ validateUpdateQueryWith sessVarBldr prepValBldr uq = do (upiCheck updPerm) return $ - AnnUpd + AnnotatedUpdateNode tableName - (fmap UpdSet <$> setExpItems) (resolvedUpdFltr, annSQLBoolExp) resolvedUpdCheck + (BackendUpdate $ Map.fromList $ fmap UpdSet <$> setExpItems) (mkDefaultMutFlds mAnnRetCols) allCols where @@ -194,7 +196,7 @@ validateUpdateQueryWith sessVarBldr prepValBldr uq = do validateUpdateQuery :: (QErrM m, UserInfoM m, CacheRM m) => UpdateQuery -> - m (AnnUpd ('Postgres 'Vanilla), DS.Seq Q.PrepArg) + m (AnnotatedUpdateNode ('Postgres 'Vanilla), DS.Seq Q.PrepArg) validateUpdateQuery query = do let source = uqSource query tableCache :: TableCache ('Postgres 'Vanilla) <- askTableCache source diff --git a/server/src-lib/Hasura/RQL/IR/Root.hs b/server/src-lib/Hasura/RQL/IR/Root.hs index 642f82eef08..b09144c2b13 100644 --- a/server/src-lib/Hasura/RQL/IR/Root.hs +++ b/server/src-lib/Hasura/RQL/IR/Root.hs @@ -41,7 +41,7 @@ data RootField (db :: BackendType -> Type) remote action raw where data MutationDB (b :: BackendType) (r :: BackendType -> Type) v = MDBInsert (AnnInsert b r v) - | MDBUpdate (AnnUpdG b r v) + | MDBUpdate (AnnotatedUpdateNodeG b r v) | MDBDelete (AnnDelG b r v) | -- | This represents a VOLATILE function, and is AnnSimpleSelG for easy -- re-use of non-VOLATILE function tracking code. diff --git a/server/src-lib/Hasura/RQL/IR/Update.hs b/server/src-lib/Hasura/RQL/IR/Update.hs index ccce0a95c66..79f28d1555d 100644 --- a/server/src-lib/Hasura/RQL/IR/Update.hs +++ b/server/src-lib/Hasura/RQL/IR/Update.hs @@ -1,8 +1,6 @@ module Hasura.RQL.IR.Update - ( AnnUpd, - AnnUpdG (..), - UpdOpExpG (..), - updateOperatorText, + ( AnnotatedUpdateNode, + AnnotatedUpdateNodeG (..), ) where @@ -14,11 +12,12 @@ import Hasura.RQL.Types.Backend import Hasura.RQL.Types.Column import Hasura.SQL.Backend -data AnnUpdG (b :: BackendType) (r :: BackendType -> Type) v = AnnUpd +data AnnotatedUpdateNodeG (b :: BackendType) (r :: BackendType -> Type) v = AnnotatedUpdateNode { uqp1Table :: !(TableName b), - uqp1OpExps :: ![(Column b, UpdOpExpG v)], uqp1Where :: !(AnnBoolExp b v, AnnBoolExp b v), uqp1Check :: !(AnnBoolExp b v), + -- | All the backend-specific data related to an update mutation + uqp1BackendIR :: BackendUpdate b v, -- we don't prepare the arguments for returning -- however the session variable can still be -- converted as desired @@ -27,28 +26,4 @@ data AnnUpdG (b :: BackendType) (r :: BackendType -> Type) v = AnnUpd } deriving (Functor, Foldable, Traversable) -type AnnUpd b = AnnUpdG b (Const Void) (SQLExpression b) - -data UpdOpExpG v - = UpdSet !v - | UpdInc !v - | UpdAppend !v - | UpdPrepend !v - | UpdDeleteKey !v - | UpdDeleteElem !v - | UpdDeleteAtPath ![v] - deriving (Functor, Foldable, Traversable, Generic, Data) - --- NOTE: This function can be improved, because we use --- the literal values defined below in the 'updateOperators' --- function in 'Hasura.GraphQL.Schema.Mutation'. It would --- be nice if we could avoid duplicating the string literal --- values -updateOperatorText :: UpdOpExpG a -> Text -updateOperatorText (UpdSet _) = "_set" -updateOperatorText (UpdInc _) = "_inc" -updateOperatorText (UpdAppend _) = "_append" -updateOperatorText (UpdPrepend _) = "_prepend" -updateOperatorText (UpdDeleteKey _) = "_delete_key" -updateOperatorText (UpdDeleteElem _) = "_delete_elem" -updateOperatorText (UpdDeleteAtPath _) = "_delete_at_path" +type AnnotatedUpdateNode b = AnnotatedUpdateNodeG b (Const Void) (SQLExpression b) diff --git a/server/src-lib/Hasura/RQL/Types/Backend.hs b/server/src-lib/Hasura/RQL/Types/Backend.hs index 54a90c2d86f..0170d4f111c 100644 --- a/server/src-lib/Hasura/RQL/Types/Backend.hs +++ b/server/src-lib/Hasura/RQL/Types/Backend.hs @@ -104,7 +104,11 @@ class Eq (XNodesAgg b), Show (XNodesAgg b), Eq (XRelay b), - Show (XRelay b) + Show (XRelay b), + -- Intermediate Representations + Functor (BackendUpdate b), + Foldable (BackendUpdate b), + Traversable (BackendUpdate b) ) => Backend (b :: BackendType) where @@ -129,6 +133,14 @@ class type ExtraTableMetadata b :: Type + -- Backend-specific IR types + + -- | Intermediate Representation of Update Mutations. + -- The default implementation makes update expressions uninstantiable. + type BackendUpdate b :: Type -> Type + + type BackendUpdate b = Const Void + -- | Extra backend specific context needed for insert mutations. type ExtraInsertData b :: Type diff --git a/server/src-lib/Hasura/RQL/Types/Column.hs b/server/src-lib/Hasura/RQL/Types/Column.hs index d4a1944f307..374ce4bfb41 100644 --- a/server/src-lib/Hasura/RQL/Types/Column.hs +++ b/server/src-lib/Hasura/RQL/Types/Column.hs @@ -7,6 +7,7 @@ module Hasura.RQL.Types.Column isScalarColumnWhere, ValueParser, onlyNumCols, + isNumCol, onlyComparableCols, parseScalarValueColumnType, parseScalarValuesColumnType, @@ -218,7 +219,10 @@ instance Backend b => ToJSON (ColumnInfo b) where type PrimaryKeyColumns b = NESeq (ColumnInfo b) onlyNumCols :: forall b. Backend b => [ColumnInfo b] -> [ColumnInfo b] -onlyNumCols = filter (isScalarColumnWhere (isNumType @b) . pgiType) +onlyNumCols = filter isNumCol + +isNumCol :: forall b. Backend b => ColumnInfo b -> Bool +isNumCol = isScalarColumnWhere (isNumType @b) . pgiType onlyComparableCols :: forall b. Backend b => [ColumnInfo b] -> [ColumnInfo b] onlyComparableCols = filter (isScalarColumnWhere (isComparableType @b) . pgiType) diff --git a/server/tests-py/queries/graphql_mutation/update/basic/article_column_multiple_operators.yaml b/server/tests-py/queries/graphql_mutation/update/basic/article_column_multiple_operators.yaml index 47bb3c754e9..c462d3bf405 100644 --- a/server/tests-py/queries/graphql_mutation/update/basic/article_column_multiple_operators.yaml +++ b/server/tests-py/queries/graphql_mutation/update/basic/article_column_multiple_operators.yaml @@ -6,7 +6,7 @@ response: - extensions: path: "$.selectionSet.update_article.args" code: validation-failed - message: column found in multiple operators; "author_id" in _set, _inc. "id" in _set, _inc + message: 'Column found in multiple operators: "author_id", "id".' query: query: |