graphql-engine/server/src-lib/Hasura/GraphQL/Schema/Action.hs
Auke Booij 1007ea27ae server: refactor MonadSchema into MonadMemoize
Followup to hasura/graphql-engine-mono#4713.

The `memoizeOn` method, part of `MonadSchema`, originally had the following type:
```haskell
  memoizeOn
    :: (HasCallStack, Ord a, Typeable a, Typeable b, Typeable k)
    => TH.Name
    -> a
    -> m (Parser k n b)
    -> m (Parser k n b)
```
The reason for operating on `Parser`s specifically was that the `MonadSchema` effect would additionally initialize certain `Unique` values, which appear (nested in) the type of `Parser`.

hasura/graphql-engine-mono#518 changed the type of `memoizeOn`, to additionally allow memoizing `FieldParser`s. These also contained a `Unique` value, which was similarly initialized by the `MonadSchema` effect. The new type of `memoizeOn` was as follows:
```haskell
  memoizeOn
    :: forall p d a b
     . (HasCallStack, HasDefinition (p n b) d, Ord a, Typeable p, Typeable a, Typeable b)
    => TH.Name
    -> a
    -> m (p n b)
    -> m (p n b)
```

Note the type `p n b` of the value being memoized: by choosing `p` to be either `Parser k` or `FieldParser`, both can be memoized. Also note the new `HasDefinition (p n b) d` constraint, which provided a `Lens` for accessing the `Unique` value to be initialized.

A quick simplification is that the `HasCallStack` constraint has never been used by any code. This was realized in hasura/graphql-engine-mono#4713, by removing that constraint.

hasura/graphql-engine-mono#2980 removed the `Unique` value from our GraphQL-related types entirely, as their original purpose was never truly realized. One part of removing `Unique` consisted of dropping the `HasDefinition (p n b) d` constraint from `memoizeOn`.

What I didn't realize at the time was that this meant that the type of `memoizeOn` could be generalized and simplified much further. This PR finally implements that generalization. The new type is as follows:
```haskell
  memoizeOn ::
    forall a p.
    (Ord a, Typeable a, Typeable p) =>
    TH.Name ->
    a ->
    m p ->
    m p
```

This change has a couple of consequences.

1. While constructing the schema, we often output `Maybe (Parser ...)`, to model that the existence of certain pieces of GraphQL schema sometimes depends on the permissions that a certain role has. The previous versions of `memoizeOn` were not able to handle this, as the only thing they could memoize was fully-defined (if not yet fully-evaluated) `(Field)Parser`s. This much more general API _would_ allow memoizing `Maybe (Parser ...)`s. However, we probably have to be continue being cautious with this: if we blindly memoize all `Maybe (Parser ...)`s, the resulting code may never be able to decide whether the value is `Just` or `Nothing` - i.e. it never commits to the existence-or-not of a GraphQL schema fragment. This would manifest as a non-well-founded knot tying, and this would get reported as an error by the implementation of `memoizeOn`.

   tl;dr: This generalization _technically_ allows for memoizing `Maybe` values, but we probably still want to avoid doing so.

   For this reason, the PR adds a specialized version of `memoizeOn` to `Hasura.GraphQL.Schema.Parser`.
2. There is no longer any need to connect the `MonadSchema` knot-tying effect with the `MonadParse` effect. In fact, after this PR, the `memoizeOn` method is completely GraphQL-agnostic, and so we implement hasura/graphql-engine-mono#4726, separating `memoizeOn` from `MonadParse` entirely - `memoizeOn` can be defined and implemented as a general Haskell typeclass method.

   Since `MonadSchema` has been made into a single-type-parameter type class, it has been renamed to something more general, namely `MonadMemoize`. Its only task is to memoize arbitrary `Typeable p` objects under a combined key consisting of a `TH.Name` and a `Typeable a`.

   Also for this reason, the new `MonadMemoize` has been moved to the more general `Control.Monad.Memoize`.
3. After this change, it's somewhat clearer what `memoizeOn` does: it memoizes an arbitrary value of a `Typeable` type. The only thing that needs to be understood in its implementation is how the manual blackholing works. There is no more semantic interaction with _any_ GraphQL code.

PR-URL: https://github.com/hasura/graphql-engine-mono/pull/4725
Co-authored-by: Daniel Harvey <4729125+danieljharvey@users.noreply.github.com>
GitOrigin-RevId: 089fa2e82c2ce29da76850e994eabb1e261f9c92
2022-08-04 13:45:53 +00:00

463 lines
21 KiB
Haskell

{-# LANGUAGE TemplateHaskellQuotes #-}
module Hasura.GraphQL.Schema.Action
( actionExecute,
actionAsyncMutation,
actionAsyncQuery,
)
where
import Data.Aeson qualified as J
import Data.Aeson.Key qualified as K
import Data.Aeson.KeyMap qualified as KM
import Data.HashMap.Strict qualified as Map
import Data.Text.Extended
import Data.Text.NonEmpty
import Hasura.Backends.Postgres.Instances.Schema ()
import Hasura.Backends.Postgres.SQL.Types
import Hasura.Backends.Postgres.Types.Column
import Hasura.Base.Error
import Hasura.Base.ErrorMessage (toErrorMessage)
import Hasura.GraphQL.Parser.Class
import Hasura.GraphQL.Parser.Name qualified as GName
import Hasura.GraphQL.Schema.Backend
import Hasura.GraphQL.Schema.Common
import Hasura.GraphQL.Schema.Options qualified as Options
import Hasura.GraphQL.Schema.Parser
( FieldParser,
InputFieldsParser,
Kind (..),
Parser,
)
import Hasura.GraphQL.Schema.Parser qualified as P
import Hasura.GraphQL.Schema.Typename (mkTypename)
import Hasura.Name qualified as Name
import Hasura.Prelude
import Hasura.RQL.IR.Action qualified as IR
import Hasura.RQL.IR.Root qualified as IR
import Hasura.RQL.IR.Value qualified as IR
import Hasura.RQL.Types.Action
import Hasura.RQL.Types.Backend
import Hasura.RQL.Types.Column
import Hasura.RQL.Types.Common
import Hasura.RQL.Types.CustomTypes
import Hasura.RQL.Types.Relationships.Remote
import Hasura.RQL.Types.Table
import Hasura.SQL.AnyBackend qualified as AB
import Hasura.SQL.Backend
import Hasura.Session
import Language.GraphQL.Draft.Syntax qualified as G
-- | actionExecute is used to execute either a query action or a synchronous
-- mutation action. A query action or a synchronous mutation action accepts
-- the field name and input arguments and a selectionset. The
-- input argument and selectionset types are defined by the user.
--
-- > action_name(action_input_arguments) {
-- > col1: col1_type
-- > col2: col2_type
-- > }
actionExecute ::
forall r m n.
MonadBuildSchemaBase r m n =>
AnnotatedCustomTypes ->
ActionInfo ->
m (Maybe (FieldParser n (IR.AnnActionExecution (IR.RemoteRelationshipField IR.UnpreparedValue))))
actionExecute customTypes actionInfo = runMaybeT do
roleName <- retrieve scRole
guard (roleName == adminRoleName || roleName `Map.member` permissions)
let fieldName = unActionName actionName
description = G.Description <$> comment
inputArguments <- lift $ actionInputArguments (_actInputTypes customTypes) $ _adArguments definition
parserOutput <- case outputObject of
AOTObject aot -> do
selectionSet <- lift $ actionOutputFields outputType aot (_actObjectTypes customTypes)
pure $ P.subselection fieldName description inputArguments selectionSet
AOTScalar ast -> do
let selectionSet = customScalarParser ast
pure $ P.selection fieldName description inputArguments selectionSet <&> (,[])
pure $
parserOutput
<&> \(argsJson, fields) ->
IR.AnnActionExecution
{ _aaeName = actionName,
_aaeFields = fields,
_aaePayload = argsJson,
_aaeOutputType = _adOutputType definition,
_aaeOutputFields = IR.getActionOutputFields outputObject,
_aaeWebhook = _adHandler definition,
_aaeHeaders = _adHeaders definition,
_aaeForwardClientHeaders = _adForwardClientHeaders definition,
_aaeTimeOut = _adTimeout definition,
_aaeRequestTransform = _adRequestTransform definition,
_aaeResponseTransform = _adResponseTransform definition
}
where
ActionInfo actionName (outputType, outputObject) definition permissions _ comment = actionInfo
-- | actionAsyncMutation is used to execute a asynchronous mutation action. An
-- asynchronous action expects the field name and the input arguments to the
-- action. A selectionset is *not* expected. An action ID (UUID) will be
-- returned after performing the action
--
-- > action_name(action_input_arguments)
actionAsyncMutation ::
forall r m n.
MonadBuildSchemaBase r m n =>
HashMap G.Name AnnotatedInputType ->
ActionInfo ->
m (Maybe (FieldParser n IR.AnnActionMutationAsync))
actionAsyncMutation nonObjectTypeMap actionInfo = runMaybeT do
roleName <- retrieve scRole
guard $ roleName == adminRoleName || roleName `Map.member` permissions
inputArguments <- lift $ actionInputArguments nonObjectTypeMap $ _adArguments definition
let fieldName = unActionName actionName
description = G.Description <$> comment
pure $
P.selection fieldName description inputArguments actionIdParser
<&> IR.AnnActionMutationAsync actionName forwardClientHeaders
where
ActionInfo actionName _ definition permissions forwardClientHeaders comment = actionInfo
-- | actionAsyncQuery is used to query/subscribe to the result of an
-- asynchronous mutation action. The only input argument to an
-- asynchronous mutation action is the action ID (UUID) and a selection
-- set is expected, the selection set contains 4 fields namely 'id',
-- 'created_at','errors' and 'output'. The result of the action can be queried
-- through the 'output' field.
--
-- > action_name (id: UUID!) {
-- > id: UUID!
-- > created_at: timestampz!
-- > errors: JSON
-- > output: user_defined_type!
-- > }
actionAsyncQuery ::
forall r m n.
MonadBuildSchema ('Postgres 'Vanilla) r m n =>
HashMap G.Name AnnotatedObjectType ->
ActionInfo ->
m (Maybe (FieldParser n (IR.AnnActionAsyncQuery ('Postgres 'Vanilla) (IR.RemoteRelationshipField IR.UnpreparedValue))))
actionAsyncQuery objectTypes actionInfo = runMaybeT do
roleName <- retrieve scRole
guard $ roleName == adminRoleName || roleName `Map.member` permissions
createdAtFieldParser <-
lift $ columnParser @('Postgres 'Vanilla) (ColumnScalar PGTimeStampTZ) (G.Nullability False)
errorsFieldParser <-
lift $ columnParser @('Postgres 'Vanilla) (ColumnScalar PGJSON) (G.Nullability True)
outputTypeName <- mkTypename $ unActionName actionName
let fieldName = unActionName actionName
description = G.Description <$> comment
actionIdInputField =
P.field idFieldName (Just idFieldDescription) actionIdParser
allFieldParsers actionOutputParser =
let idField = P.selection_ idFieldName (Just idFieldDescription) actionIdParser $> IR.AsyncId
createdAtField =
P.selection_
Name._created_at
(Just "the time at which this action was created")
createdAtFieldParser
$> IR.AsyncCreatedAt
errorsField =
P.selection_
Name._errors
(Just "errors related to the invocation")
errorsFieldParser
$> IR.AsyncErrors
outputField =
P.subselection_
Name._output
(Just "the output fields of this action")
actionOutputParser
<&> IR.AsyncOutput
in [idField, createdAtField, errorsField, outputField]
parserOutput <- case outputObject of
AOTObject aot -> do
actionOutputParser <- lift $ actionOutputFields outputType aot objectTypes
let desc = G.Description $ "fields of action: " <>> actionName
selectionSet =
-- Note: If we want support for Apollo Federation for Actions later,
-- we'd need to add support for "key" directive here as well.
P.selectionSet outputTypeName (Just desc) (allFieldParsers actionOutputParser)
<&> parsedSelectionsToFields IR.AsyncTypename
pure $ P.subselection fieldName description actionIdInputField selectionSet
AOTScalar ast -> do
let selectionSet = customScalarParser ast
pure $ P.selection fieldName description actionIdInputField selectionSet <&> (,[])
stringifyNumbers <- retrieve Options.soStringifyNumbers
definitionsList <- lift $ mkDefinitionList outputObject
pure $
parserOutput
<&> \(idArg, fields) ->
IR.AnnActionAsyncQuery
{ _aaaqName = actionName,
_aaaqActionId = idArg,
_aaaqOutputType = _adOutputType definition,
_aaaqFields = fields,
_aaaqDefinitionList = definitionsList,
_aaaqStringifyNum = stringifyNumbers,
_aaaqForwardClientHeaders = forwardClientHeaders,
_aaaqSource = getActionSourceInfo outputObject
}
where
ActionInfo actionName (outputType, outputObject) definition permissions forwardClientHeaders comment = actionInfo
idFieldName = Name._id
idFieldDescription = "the unique id of an action"
getActionSourceInfo :: AnnotatedOutputType -> IR.ActionSourceInfo ('Postgres 'Vanilla)
getActionSourceInfo = \case
AOTObject aot -> fromMaybe IR.ASINoSource $ listToMaybe do
AnnotatedTypeRelationship {..} <- _aotRelationships aot
pure $ IR.ASISource _atrSource _atrSourceConfig
AOTScalar _ -> IR.ASINoSource
mkDefinitionList :: AnnotatedOutputType -> m [(PGCol, ScalarType ('Postgres 'Vanilla))]
mkDefinitionList = \case
AOTScalar _ -> pure []
AOTObject AnnotatedObjectType {..} -> do
let fieldReferences = Map.unions $ map _atrFieldMapping _aotRelationships
for (toList _aotFields) \ObjectFieldDefinition {..} ->
(unsafePGCol . G.unName . unObjectFieldName $ _ofdName,)
<$> case Map.lookup _ofdName fieldReferences of
Nothing -> fieldTypeToScalarType $ snd _ofdType
Just columnInfo -> pure $ unsafePGColumnToBackend $ ciType columnInfo
-- warning: we don't support other backends than Postgres for async queries;
-- here, we fail if we encounter a non-Postgres scalar type
fieldTypeToScalarType :: AnnotatedObjectFieldType -> m PGScalarType
fieldTypeToScalarType = \case
AOFTEnum _ -> pure PGText
AOFTObject _ -> pure PGJSON
AOFTScalar annotatedScalar -> case annotatedScalar of
ASTReusedScalar _ scalar ->
case AB.unpackAnyBackend @('Postgres 'Vanilla) scalar of
Just pgScalar -> pure $ unwrapScalar pgScalar
Nothing -> throw500 "encountered non-Postgres scalar in async query actions"
ASTCustom ScalarTypeDefinition {..} ->
pure $
if
| _stdName == GName._ID -> PGText
| _stdName == GName._Int -> PGInteger
| _stdName == GName._Float -> PGFloat
| _stdName == GName._String -> PGText
| _stdName == GName._Boolean -> PGBoolean
| otherwise -> PGJSON
-- | Async action's unique id
actionIdParser :: MonadParse n => Parser 'Both n ActionId
actionIdParser = ActionId <$> P.uuid
actionOutputFields ::
forall r m n.
MonadBuildSchemaBase r m n =>
G.GType ->
AnnotatedObjectType ->
HashMap G.Name AnnotatedObjectType ->
m (Parser 'Output n (AnnotatedActionFields))
actionOutputFields outputType annotatedObject objectTypes = do
scalarOrEnumOrObjectFields <- forM (toList $ _aotFields annotatedObject) outputFieldParser
relationshipFields <- traverse relationshipFieldParser $ _aotRelationships annotatedObject
outputTypeName <- mkTypename $ unObjectTypeName $ _aotName annotatedObject
let allFieldParsers =
scalarOrEnumOrObjectFields
<> concat (catMaybes relationshipFields)
outputTypeDescription = _aotDescription annotatedObject
pure $
outputParserModifier outputType $
P.selectionSet outputTypeName outputTypeDescription allFieldParsers
<&> parsedSelectionsToFields IR.ACFExpression
where
outputParserModifier :: G.GType -> Parser 'Output n a -> Parser 'Output n a
outputParserModifier = \case
G.TypeNamed (G.Nullability True) _ -> P.nullableParser
G.TypeNamed (G.Nullability False) _ -> P.nonNullableParser
G.TypeList (G.Nullability True) t -> P.nullableParser . P.multiple . outputParserModifier t
G.TypeList (G.Nullability False) t -> P.nonNullableParser . P.multiple . outputParserModifier t
outputFieldParser ::
ObjectFieldDefinition (G.GType, AnnotatedObjectFieldType) ->
m (FieldParser n (AnnotatedActionField))
outputFieldParser (ObjectFieldDefinition name _ description (gType, objectFieldType)) = P.memoizeOn 'actionOutputFields (_aotName annotatedObject, name) do
case objectFieldType of
AOFTScalar def ->
wrapScalar $ customScalarParser def
AOFTEnum def ->
wrapScalar $ customEnumParser def
AOFTObject objectName -> do
def <- Map.lookup objectName objectTypes `onNothing` throw500 ("Custom type " <> objectName <<> " not found")
parser <- fmap (IR.ACFNestedObject fieldName) <$> actionOutputFields gType def objectTypes
pure $ P.subselection_ fieldName description parser
where
fieldName = unObjectFieldName name
wrapScalar parser =
pure $
P.wrapFieldParser gType (P.selection_ fieldName description parser)
$> IR.ACFScalar fieldName
relationshipFieldParser ::
AnnotatedTypeRelationship ->
m (Maybe [FieldParser n (AnnotatedActionField)])
relationshipFieldParser (AnnotatedTypeRelationship {..}) = runMaybeT do
relName <- hoistMaybe $ RelName <$> mkNonEmptyText (toTxt _atrName)
-- `lhsJoinFields` is a map of `x: y`
-- where 'x' is the 'reference name' of a join field, i.e, how a join
-- field is referenced in the remote relationships definition
-- while 'y' is the join field.
-- In case of custom types, they are pretty much the same.
-- In case of databases, 'y' could be a computed field with session variables etc.
let lhsJoinFields = Map.fromList [(FieldName $ G.unName k, k) | ObjectFieldName k <- Map.keys _atrFieldMapping]
joinMapping = Map.fromList $ do
(k, v) <- Map.toList _atrFieldMapping
let scalarType = case ciType v of
ColumnScalar scalar -> scalar
-- We don't currently allow enum types as fields of custom types so they should not appear here.
-- If we do allow them in future then they would be represented in Postgres as Text.
ColumnEnumReference _ -> PGText
pure (FieldName $ G.unName $ unObjectFieldName k, (scalarType, ciColumn v))
remoteFieldInfo =
RemoteFieldInfo
{ _rfiLHS = lhsJoinFields,
_rfiRHS =
RFISource $
AB.mkAnyBackend @('Postgres 'Vanilla) $
RemoteSourceFieldInfo
{ _rsfiName = relName,
_rsfiType = _atrType,
_rsfiSource = _atrSource,
_rsfiSourceConfig = _atrSourceConfig,
_rsfiSourceCustomization = _atrSourceCustomization,
_rsfiTable = tableInfoName _atrTableInfo,
_rsfiMapping = joinMapping
}
}
RemoteRelationshipParserBuilder remoteRelationshipField <- retrieve scRemoteRelationshipParserBuilder
remoteRelationshipFieldParsers <- MaybeT $ remoteRelationshipField remoteFieldInfo
pure $ remoteRelationshipFieldParsers <&> fmap (IR.ACFRemote . IR.ActionRemoteRelationshipSelect lhsJoinFields)
actionInputArguments ::
forall r m n.
MonadBuildSchemaBase r m n =>
HashMap G.Name AnnotatedInputType ->
[ArgumentDefinition (G.GType, AnnotatedInputType)] ->
m (InputFieldsParser n J.Value)
actionInputArguments nonObjectTypeMap arguments = do
argumentParsers <- for arguments $ \argument -> do
let ArgumentDefinition argumentName (gType, nonObjectType) argumentDescription = argument
name = unArgumentName argumentName
(name,) <$> argumentParser name argumentDescription gType nonObjectType
pure $ J.Object <$> inputFieldsToObject argumentParsers
where
inputFieldsToObject ::
[(G.Name, InputFieldsParser n (Maybe J.Value))] ->
InputFieldsParser n J.Object
inputFieldsToObject inputFields =
let mkTuple (name, parser) = fmap (K.fromText (G.unName name),) <$> parser
in KM.fromList . catMaybes <$> traverse mkTuple inputFields
argumentParser ::
G.Name ->
Maybe G.Description ->
G.GType ->
AnnotatedInputType ->
m (InputFieldsParser n (Maybe J.Value))
argumentParser name description gType nonObjectType = do
let mkResult :: forall k. ('Input P.<: k) => Parser k n J.Value -> InputFieldsParser n (Maybe J.Value)
mkResult = mkArgumentInputFieldParser name description gType
case nonObjectType of
-- scalar and enum parsers are not recursive and need not be memoized
NOCTScalar def -> pure $ mkResult $ customScalarParser def
NOCTEnum def -> pure $ mkResult $ customEnumParser def
-- input objects however may recursively contain one another
NOCTInputObject (InputObjectTypeDefinition (InputObjectTypeName objectName) objectDesc inputFields) ->
mkResult <$> P.memoizeOn 'actionInputArguments objectName do
inputFieldsParsers <- forM
(toList inputFields)
\(InputObjectFieldDefinition (InputObjectFieldName fieldName) fieldDesc (GraphQLType fieldType)) -> do
nonObjectFieldType <-
Map.lookup (G.getBaseType fieldType) nonObjectTypeMap
`onNothing` throw500 "object type for a field found in custom input object type"
(fieldName,) <$> argumentParser fieldName fieldDesc fieldType nonObjectFieldType
pure $
P.object objectName objectDesc $
J.Object <$> inputFieldsToObject inputFieldsParsers
mkArgumentInputFieldParser ::
forall m k.
(MonadParse m, 'Input P.<: k) =>
G.Name ->
Maybe G.Description ->
G.GType ->
Parser k m J.Value ->
InputFieldsParser m (Maybe J.Value)
mkArgumentInputFieldParser name description gType parser =
if G.isNullable gType
then P.fieldOptional name description modifiedParser
else Just <$> P.field name description modifiedParser
where
modifiedParser = parserModifier gType parser
parserModifier ::
G.GType -> Parser k m J.Value -> Parser k m J.Value
parserModifier = \case
G.TypeNamed nullable _ -> nullableModifier nullable
G.TypeList nullable ty ->
nullableModifier nullable . fmap J.toJSON . P.list . parserModifier ty
where
nullableModifier =
bool (fmap J.toJSON) (fmap J.toJSON . P.nullable) . G.unNullability
customScalarParser ::
MonadParse m =>
AnnotatedScalarType ->
Parser 'Both m J.Value
customScalarParser = \case
ASTCustom ScalarTypeDefinition {..} ->
if
| _stdName == GName._ID -> J.toJSON <$> P.identifier
| _stdName == GName._Int -> J.toJSON <$> P.int
| _stdName == GName._Float -> J.toJSON <$> P.float
| _stdName == GName._String -> J.toJSON <$> P.string
| _stdName == GName._Boolean -> J.toJSON <$> P.boolean
| otherwise -> P.jsonScalar _stdName _stdDescription
ASTReusedScalar name backendScalarType ->
let schemaType = P.TNamed P.NonNullable $ P.Definition name Nothing Nothing [] P.TIScalar
backendScalarValidator =
AB.dispatchAnyBackend @Backend backendScalarType \(scalarType :: ScalarWrapper b) jsonInput -> do
-- We attempt to parse the value from JSON to validate it, but still
-- output it as JSON. On one hand this allows us to detect issues
-- ahead of time: if the value is not formatted correctly, we don't
-- send the action at all; on the other, it means we are at risk of
-- rejecting valid queries if our parser is more strict than the one
-- of the remote server. We do not parse scalars for remote servers
-- for that reason; we might want to reconsider this validation as
-- well.
void $
parseScalarValue @b (unwrapScalar scalarType) jsonInput
`onLeft` \e -> parseErrorWith P.ParseFailed . toErrorMessage $ qeError e
pure jsonInput
in P.Parser
{ pType = schemaType,
pParser = P.valueToJSON (P.toGraphQLType schemaType) >=> backendScalarValidator
}
customEnumParser ::
MonadParse m =>
EnumTypeDefinition ->
Parser 'Both m J.Value
customEnumParser (EnumTypeDefinition typeName description enumValues) =
let enumName = unEnumTypeName typeName
enumValueDefinitions =
enumValues <&> \enumValue ->
let valueName = G.unEnumValue $ _evdValue enumValue
in (,J.toJSON valueName) $
P.Definition
valueName
(_evdDescription enumValue)
Nothing
[]
P.EnumValueInfo
in P.enum enumName description enumValueDefinitions