graphql-engine/server/src-lib/Hasura/GraphQL/Resolve/Mutation.hs
Vamshi Surabhi b84db36ebb
allow custom mutations through actions (#3042)
* basic doc for actions

* custom_types, sync and async actions

* switch to graphql-parser-hs on github

* update docs

* metadata import/export

* webhook calls are now supported

* relationships in sync actions

* initialise.sql is now in sync with the migration file

* fix metadata tests

* allow specifying arguments of actions

* fix blacklist check on check_build_worthiness job

* track custom_types and actions related tables

* handlers are now triggered on async actions

* default to pgjson unless a field is involved in relationships, for generating definition list

* use 'true' for action filter for non admin role

* fix create_action_permission sql query

* drop permissions when dropping an action

* add a hdb_role view (and relationships) to fetch all roles in the system

* rename 'webhook' key in action definition to 'handler'

* allow templating actions wehook URLs with env vars

* add 'update_action' /v1/query type

* allow forwarding client headers by setting `forward_client_headers` in action definition

* add 'headers' configuration in action definition

* handle webhook error response based on status codes

* support array relationships for custom types

* implement single row mutation, see https://github.com/hasura/graphql-engine/issues/3731

* single row mutation: rename 'pk_columns' -> 'columns' and no-op refactor

* use top level primary key inputs for delete_by_pk & account select permissions for single row mutations

* use only REST semantics to resolve the webhook response

* use 'pk_columns' instead of 'columns' for update_by_pk input

* add python basic tests for single row mutations

* add action context (name) in webhook payload

* Async action response is accessible for non admin roles only if
  the request session vars equals to action's

* clean nulls, empty arrays for actions, custom types in export metadata

* async action mutation returns only the UUID of the action

* unit tests for URL template parser

* Basic sync actions python tests

* fix output in async query & add async tests

* add admin secret header in async actions python test

* document async action architecture in Resolve/Action.hs file

* support actions returning array of objects

* tests for list type response actions

* update docs with actions and custom types metadata API reference

* update actions python tests as per #f8e1330

Co-authored-by: Tirumarai Selvan <tirumarai.selvan@gmail.com>
Co-authored-by: Aravind Shankar <face11301@gmail.com>
Co-authored-by: Rakesh Emmadi <12475069+rakeshkky@users.noreply.github.com>
2020-02-13 23:08:23 +05:30

334 lines
13 KiB
Haskell

module Hasura.GraphQL.Resolve.Mutation
( convertUpdate
, convertUpdateByPk
, convertDelete
, convertDeleteByPk
, resolveMutationFields
, buildEmptyMutResp
) where
import Data.Has
import Hasura.Prelude
import qualified Control.Monad.Validate as MV
import qualified Data.Aeson as J
import qualified Data.HashMap.Strict as Map
import qualified Data.HashMap.Strict.InsOrd as OMap
import qualified Data.HashMap.Strict.InsOrd.Extended as OMap
import qualified Data.Sequence.NonEmpty as NESeq
import qualified Data.Text as T
import qualified Language.GraphQL.Draft.Syntax as G
import qualified Hasura.RQL.DML.Delete as RD
import qualified Hasura.RQL.DML.Returning as RR
import qualified Hasura.RQL.DML.Update as RU
import qualified Hasura.RQL.DML.Select as RS
import qualified Hasura.SQL.DML as S
import Hasura.EncJSON
import Hasura.GraphQL.Resolve.BoolExp
import Hasura.GraphQL.Resolve.Context
import Hasura.GraphQL.Resolve.InputValue
import Hasura.GraphQL.Resolve.Select (processTableSelectionSet)
import Hasura.GraphQL.Validate.Field
import Hasura.GraphQL.Validate.Types
import Hasura.RQL.DML.Internal (currentSession, sessVarFromCurrentSetting)
import Hasura.RQL.Types
import Hasura.SQL.Types
import Hasura.SQL.Value
resolveMutationFields
:: ( MonadReusability m, MonadError QErr m, MonadReader r m, Has FieldMap r
, Has OrdByCtx r, Has SQLGenCtx r
)
=> G.NamedType -> SelSet -> m (RR.MutFldsG UnresolvedVal)
resolveMutationFields ty selSet = fmap (map (first FieldName)) $
withSelSet selSet $ \fld -> case _fName fld of
"__typename" -> return $ RR.MExp $ G.unName $ G.unNamedType ty
"affected_rows" -> return RR.MCount
"returning" -> do
annFlds <- processTableSelectionSet (_fType fld) $ _fSelSet fld
annFldsResolved <- traverse
(traverse (RS.traverseAnnFld convertPGValueToTextValue)) annFlds
return $ RR.MRet annFldsResolved
G.Name t -> throw500 $ "unexpected field in mutation resp : " <> t
where
convertPGValueToTextValue = \case
UVPG annPGVal -> UVSQL <$> txtConverter annPGVal
UVSessVar colTy sessVar -> pure $ UVSessVar colTy sessVar
UVSQL sqlExp -> pure $ UVSQL sqlExp
UVSession -> pure UVSession
convertRowObj
:: (MonadReusability m, MonadError QErr m)
=> PGColGNameMap
-> AnnInpVal
-> m [(PGCol, UnresolvedVal)]
convertRowObj colGNameMap val =
flip withObject val $ \_ obj ->
forM (OMap.toList obj) $ \(k, v) -> do
prepExpM <- fmap mkParameterizablePGValue <$> asPGColumnValueM v
pgCol <- pgiColumn <$> resolvePGCol colGNameMap k
let prepExp = fromMaybe (UVSQL S.SENull) prepExpM
return (pgCol, prepExp)
type ApplySQLOp = (PGCol, S.SQLExp) -> S.SQLExp
rhsExpOp :: S.SQLOp -> S.TypeAnn -> ApplySQLOp
rhsExpOp op annTy (col, e) =
S.mkSQLOpExp op (S.SEIden $ toIden col) annExp
where
annExp = S.SETyAnn e annTy
lhsExpOp :: S.SQLOp -> S.TypeAnn -> ApplySQLOp
lhsExpOp op annTy (col, e) =
S.mkSQLOpExp op annExp $ S.SEIden $ toIden col
where
annExp = S.SETyAnn e annTy
convObjWithOp
:: (MonadReusability m, MonadError QErr m)
=> PGColGNameMap -> ApplySQLOp -> AnnInpVal -> m [(PGCol, UnresolvedVal)]
convObjWithOp colGNameMap opFn val =
flip withObject val $ \_ obj -> forM (OMap.toList obj) $ \(k, v) -> do
colVal <- openOpaqueValue =<< asPGColumnValue v
pgCol <- pgiColumn <$> resolvePGCol colGNameMap k
-- TODO: why are we using txtEncoder here?
let encVal = txtEncoder $ pstValue $ _apvValue colVal
sqlExp = opFn (pgCol, encVal)
return (pgCol, UVSQL sqlExp)
convDeleteAtPathObj
:: (MonadReusability m, MonadError QErr m)
=> PGColGNameMap -> AnnInpVal -> m [(PGCol, UnresolvedVal)]
convDeleteAtPathObj colGNameMap val =
flip withObject val $ \_ obj -> forM (OMap.toList obj) $ \(k, v) -> do
vals <- traverse (openOpaqueValue <=< asPGColumnValue) =<< asArray v
pgCol <- pgiColumn <$> resolvePGCol colGNameMap k
let valExps = map (txtEncoder . pstValue . _apvValue) vals
annEncVal = S.SETyAnn (S.SEArray valExps) S.textArrTypeAnn
sqlExp = S.SEOpApp S.jsonbDeleteAtPathOp
[S.SEIden $ toIden pgCol, annEncVal]
return (pgCol, UVSQL sqlExp)
convertUpdateP1
:: (MonadReusability m, MonadError QErr m)
=> UpdOpCtx -- the update context
-> (ArgsMap -> m AnnBoolExpUnresolved) -- the bool expression parser
-> (Field -> m (RR.MutationOutputG UnresolvedVal)) -- the selection set resolver
-> Field -- the mutation field
-> m (RU.AnnUpdG UnresolvedVal)
convertUpdateP1 opCtx boolExpParser selectionResolver fld = do
-- a set expression is same as a row object
setExpM <- resolveUpdateOperator "_set" $ convertRowObj colGNameMap
-- where bool expression to filter column
whereExp <- boolExpParser args
-- increment operator on integer columns
incExpM <- resolveUpdateOperator "_inc" $
convObjWithOp' $ rhsExpOp S.incOp S.intTypeAnn
-- append jsonb value
appendExpM <- resolveUpdateOperator "_append" $
convObjWithOp' $ rhsExpOp S.jsonbConcatOp S.jsonbTypeAnn
-- prepend jsonb value
prependExpM <- resolveUpdateOperator "_prepend" $
convObjWithOp' $ lhsExpOp S.jsonbConcatOp S.jsonbTypeAnn
-- delete a key in jsonb object
deleteKeyExpM <- resolveUpdateOperator "_delete_key" $
convObjWithOp' $ rhsExpOp S.jsonbDeleteOp S.textTypeAnn
-- delete an element in jsonb array
deleteElemExpM <- resolveUpdateOperator "_delete_elem" $
convObjWithOp' $ rhsExpOp S.jsonbDeleteOp S.intTypeAnn
-- delete at path in jsonb value
deleteAtPathExpM <- resolveUpdateOperator "_delete_at_path" $
convDeleteAtPathObj colGNameMap
updateItems <- combineUpdateExpressions
[ setExpM, incExpM, appendExpM, prependExpM
, deleteKeyExpM, deleteElemExpM, deleteAtPathExpM
]
mutOutput <- selectionResolver fld
pure $ RU.AnnUpd tn updateItems (unresolvedPermFilter, whereExp) unresolvedPermCheck mutOutput allCols
where
convObjWithOp' = convObjWithOp colGNameMap
allCols = Map.elems colGNameMap
UpdOpCtx tn _ colGNameMap filterExp checkExpr preSetCols = opCtx
args = _fArguments fld
resolvedPreSetItems = Map.toList $ fmap partialSQLExpToUnresolvedVal preSetCols
unresolvedPermFilter = fmapAnnBoolExp partialSQLExpToUnresolvedVal filterExp
unresolvedPermCheck = maybe annBoolExpTrue (fmapAnnBoolExp partialSQLExpToUnresolvedVal) checkExpr
resolveUpdateOperator operator resolveAction =
(operator,) <$> withArgM args operator resolveAction
combineUpdateExpressions updateExps = do
let allOperatorNames = map fst updateExps
updateItems = mapMaybe (\(op, itemsM) -> (op,) <$> itemsM) updateExps
-- Atleast any one of operator is expected or preset expressions shouldn't be empty
if null updateItems && null resolvedPreSetItems then
throwVE $ "atleast any one of " <> showNames allOperatorNames <> " is expected"
else do
let itemsWithOps = concatMap (\(op, items) -> map (second (op,)) items) updateItems
validateMultiOps col items = do
when (length items > 1) $ MV.dispute [(col, map fst $ toList items)]
pure $ snd $ NESeq.head items
eitherResult = MV.runValidate $ OMap.traverseWithKey validateMultiOps $
OMap.groupTuples itemsWithOps
case eitherResult of
-- A column shouldn't be present in more than one operator.
-- If present, then generated UPDATE statement throws unexpected query error
Left columnsWithMultiOps -> throwVE $
"column found in multiple operators; "
<> T.intercalate ". "
(map (\(col, ops) -> col <<> " in " <> showNames ops)
columnsWithMultiOps)
Right items -> pure $ resolvedPreSetItems <> OMap.toList items
convertUpdateGeneric
:: ( MonadReusability m, MonadError QErr m
, MonadReader r m
, Has SQLGenCtx r
)
=> UpdOpCtx -- the update context
-> (ArgsMap -> m AnnBoolExpUnresolved) -- the bool exp parser
-> (Field -> m (RR.MutationOutputG UnresolvedVal)) -- the selection set resolver
-> Field
-> m RespTx
convertUpdateGeneric opCtx boolExpParser selectionResolver fld = do
annUpdUnresolved <- convertUpdateP1 opCtx boolExpParser selectionResolver fld
(annUpdResolved, prepArgs) <- withPrepArgs $ RU.traverseAnnUpd
resolveValPrep annUpdUnresolved
strfyNum <- stringifyNum <$> asks getter
let whenNonEmptyItems = return $ RU.updateQueryToTx strfyNum
(annUpdResolved, prepArgs)
whenEmptyItems = return $ return $
buildEmptyMutResp $ RU.uqp1Output annUpdResolved
-- if there are not set items then do not perform
-- update and return empty mutation response
bool whenNonEmptyItems whenEmptyItems $ null $ RU.uqp1SetExps annUpdResolved
convertUpdate
:: ( MonadReusability m, MonadError QErr m
, MonadReader r m, Has FieldMap r
, Has OrdByCtx r, Has SQLGenCtx r
)
=> UpdOpCtx -- the update context
-> Field -- the mutation field
-> m RespTx
convertUpdate opCtx =
convertUpdateGeneric opCtx whereExpressionParser mutationFieldsResolver
convertUpdateByPk
:: ( MonadReusability m, MonadError QErr m
, MonadReader r m, Has FieldMap r
, Has OrdByCtx r, Has SQLGenCtx r
)
=> UpdOpCtx -- the update context
-> Field -- the mutation field
-> m RespTx
convertUpdateByPk opCtx field =
convertUpdateGeneric opCtx boolExpParser tableSelectionAsMutationOutput field
where
boolExpParser args = withArg args "pk_columns" $ \inpVal -> do
obj <- asObject inpVal
pgColValToBoolExp (_uocAllCols opCtx) $ Map.fromList $ OMap.toList obj
convertDeleteGeneric
:: ( MonadReusability m
, MonadReader r m
, Has SQLGenCtx r
)
=> DelOpCtx -- the delete context
-> (ArgsMap -> m AnnBoolExpUnresolved) -- the bool exp parser
-> (Field -> m (RR.MutationOutputG UnresolvedVal)) -- the selection set resolver
-> Field -- the mutation field
-> m RespTx
convertDeleteGeneric opCtx boolExpParser selectionResolver fld = do
whereExp <- boolExpParser $ _fArguments fld
mutOutput <- selectionResolver fld
let unresolvedPermFltr =
fmapAnnBoolExp partialSQLExpToUnresolvedVal filterExp
annDelUnresolved = RD.AnnDel tn (unresolvedPermFltr, whereExp)
mutOutput allCols
(annDelResolved, prepArgs) <- withPrepArgs $ RD.traverseAnnDel
resolveValPrep annDelUnresolved
strfyNum <- stringifyNum <$> asks getter
return $ RD.deleteQueryToTx strfyNum (annDelResolved, prepArgs)
where
DelOpCtx tn _ colGNameMap filterExp = opCtx
allCols = Map.elems colGNameMap
convertDelete
:: ( MonadReusability m, MonadError QErr m
, MonadReader r m, Has FieldMap r
, Has OrdByCtx r, Has SQLGenCtx r
)
=> DelOpCtx -- the delete context
-> Field -- the mutation field
-> m RespTx
convertDelete opCtx =
convertDeleteGeneric opCtx whereExpressionParser mutationFieldsResolver
convertDeleteByPk
:: ( MonadReusability m, MonadError QErr m
, MonadReader r m, Has FieldMap r
, Has OrdByCtx r, Has SQLGenCtx r
)
=> DelOpCtx -- the delete context
-> Field -- the mutation field
-> m RespTx
convertDeleteByPk opCtx field =
convertDeleteGeneric opCtx boolExpParser tableSelectionAsMutationOutput field
where
boolExpParser = pgColValToBoolExp (_docAllCols opCtx)
whereExpressionParser
:: ( MonadReusability m, MonadError QErr m
, MonadReader r m, Has FieldMap r
)
=> ArgsMap -> m AnnBoolExpUnresolved
whereExpressionParser args = withArg args "where" parseBoolExp
mutationFieldsResolver
:: ( MonadReusability m, MonadError QErr m
, MonadReader r m, Has FieldMap r
, Has OrdByCtx r, Has SQLGenCtx r
)
=> Field -> m (RR.MutationOutputG UnresolvedVal)
mutationFieldsResolver field =
RR.MOutMultirowFields <$> resolveMutationFields (_fType field) (_fSelSet field)
tableSelectionAsMutationOutput
:: ( MonadReusability m, MonadError QErr m
, MonadReader r m, Has FieldMap r
, Has OrdByCtx r, Has SQLGenCtx r
)
=> Field -> m (RR.MutationOutputG UnresolvedVal)
tableSelectionAsMutationOutput field =
RR.MOutSinglerowObject <$> processTableSelectionSet (_fType field) (_fSelSet field)
-- | build mutation response for empty objects
buildEmptyMutResp :: RR.MutationOutput -> EncJSON
buildEmptyMutResp = mkTx
where
mkTx = \case
RR.MOutMultirowFields mutFlds -> encJFromJValue $ OMap.fromList $ map (second convMutFld) mutFlds
RR.MOutSinglerowObject _ -> encJFromJValue $ J.Object mempty
-- generate empty mutation response
convMutFld = \case
RR.MCount -> J.toJSON (0 :: Int)
RR.MExp e -> J.toJSON e
RR.MRet _ -> J.toJSON ([] :: [J.Value])
resolveValPrep
:: (MonadState PrepArgs m)
=> UnresolvedVal -> m S.SQLExp
resolveValPrep = \case
UVPG annPGVal -> prepare annPGVal
UVSessVar colTy sessVar -> sessVarFromCurrentSetting colTy sessVar
UVSQL sqlExp -> pure sqlExp
UVSession -> pure currentSession