graphql-engine/server/src-lib/Hasura/GraphQL/Analyse.hs
Samir Talwar 8db9b77c77 server: Reorganize quasi-quoted names.
Pretty much all quasi-quoted names in the server code base have ended up in `Hasura.GraphQL.Parser.Constants`. I'm now finding this unpleasant for two reasons:

1. I would like to factor out the parser code into its own Cabal package, and I don't want to have to expose all these names.
2. Most of them really have nothing to do with the parsers.

In order to remedy this, I have:

1. moved the names used by parser code to `Hasura.GraphQL.Parser.DirectiveName`, as they're all related to directives;
2. moved `Hasura.GraphQL.Parser.Constants` to `Hasura.Name`, changing the qualified import name from `G` to `Name`;
3. moved names only used in tests to the appropriate test case;
4. removed unused items from `Hasura.Name`; and
5. grouped related names.

Most of the changes are simply changing `G` to `Name`, which I find much more meaningful.

PR-URL: https://github.com/hasura/graphql-engine-mono/pull/4777
GitOrigin-RevId: a77aa0aee137b2b5e6faec94495d3a9fbfa1348b
2022-06-23 09:15:31 +00:00

353 lines
12 KiB
Haskell

-- | Tools to analyze the structure of a GraphQL request.
module Hasura.GraphQL.Analyse
( -- * Query structure
Structure (..),
Selection (..),
FieldInfo (..),
VariableInfo (..),
-- * Analysis
diagnoseGraphQLQuery,
analyzeGraphQLQuery,
)
where
import Control.Monad.Writer (Writer, runWriter)
import Data.HashMap.Strict qualified as Map
import Data.Sequence ((|>))
import Data.Text qualified as T
import Hasura.GraphQL.Parser.Name qualified as GName
import Hasura.Name qualified as Name
import Hasura.Prelude
import Language.GraphQL.Draft.Syntax qualified as G
-------------------------------------------------------------------------------
-- GraphQL query structure
-- | Overall structure of a given query.
data Structure = Structure
{ _stSelection :: Selection,
_stVariables :: HashMap G.Name VariableInfo
}
instance Semigroup Structure where
Structure s1 v1 <> Structure s2 v2 = Structure (s1 <> s2) (v1 <> v2)
instance Monoid Structure where
mempty = Structure mempty mempty
-- | Represents a selection of fields within a query.
data Selection = Selection
{ _seFields :: HashMap G.Name FieldInfo
}
-- | In case of field collisions, we want to keep the union of all their
-- selections sets. For instance, given:
--
-- > query MyQuery {
-- > test {
-- > a
-- > b
-- > }
-- > test {
-- > b
-- > c
-- > }
-- > }
--
-- We want to keep a Selection with all three @a@, @b@, and @c@.
instance Semigroup Selection where
Selection s1 <> Selection s2 = Selection $ Map.unionWith combineFields s1 s2
where
combineFields f1 f2 = f1 {_fiSelection = _fiSelection f1 <> _fiSelection f2}
instance Monoid Selection where
mempty = Selection mempty
data FieldInfo = FieldInfo
{ _fiType :: G.GType,
_fiTypeDefinition :: G.TypeDefinition [G.Name] G.InputValueDefinition,
_fiSelection :: Maybe Selection
}
-- | TODO: document this
data VariableInfo = VariableInfo
{ _viType :: G.GType,
_viDefaultValue :: Maybe (G.Value Void)
}
-------------------------------------------------------------------------------
-- Analysis
-- | Given the schema's definition, and a query, validate that the query is
-- consistent. We do this by running the analysis, but discarding the result: we
-- do not care about the structure, only about the validity of the query.
--
-- Returns 'Nothing' if the query is valid, or a list of messages otherwise.
diagnoseGraphQLQuery ::
G.SchemaIntrospection ->
G.TypedOperationDefinition G.NoFragments G.Name ->
Maybe [Text]
diagnoseGraphQLQuery schema query =
let (_structure, errors) = analyzeGraphQLQuery schema query
in if null errors
then Nothing
else Just errors
-- | Given the schema's definition, and a query, run the analysis.
--
-- We process all possible fields, and return a partially filled structure if
-- necessary. Given the following query:
--
-- > query {
-- > foo {
-- > bar
-- > }
-- > does_not_exist {
-- > ghsdflgh
-- > }
-- > }
--
-- We would return a structure containing:
--
-- > foo: {
-- > bar: {
-- > }
-- > }
--
-- AND an error about "does_not_exist" not existing.
analyzeGraphQLQuery ::
G.SchemaIntrospection ->
G.TypedOperationDefinition G.NoFragments G.Name ->
(Structure, [Text])
analyzeGraphQLQuery schema G.TypedOperationDefinition {..} = runAnalysis schema $
do
-- traverse the fields recursively
selection <- withCatchAndRecord do
let rootTypeName = case _todType of
G.OperationTypeQuery -> queryRootName
G.OperationTypeMutation -> mutationRootName
G.OperationTypeSubscription -> subscriptionRootName
rootTypeDefinition <-
lookupType rootTypeName
`onNothingM` throwDiagnosis (TypeNotFound rootTypeName)
analyzeSelectionSet rootTypeDefinition _todSelectionSet
`onNothingM` throwDiagnosis NoTopLevelSelection
-- collect variables
let variables =
_todVariableDefinitions <&> \G.VariableDefinition {..} ->
(_vdName, VariableInfo _vdType _vdDefaultValue)
pure $
Structure
(fromMaybe mempty selection)
(Map.fromList variables)
-- | Check the consistency between the schema information about a type and a
-- selection set (or lack thereof) on that type.
analyzeSelectionSet ::
G.TypeDefinition [G.Name] G.InputValueDefinition ->
G.SelectionSet G.NoFragments G.Name ->
Analysis (Maybe Selection)
analyzeSelectionSet typeDef selectionSet = case typeDef of
G.TypeDefinitionInputObject iotd -> do
-- input objects aren't allowed in selection sets
throwDiagnosis $ InputObjectSelectionSet $ G._iotdName iotd
G.TypeDefinitionScalar std -> do
-- scalars do not admit a selection set
unless (null selectionSet) $
throwDiagnosis $ ScalarSelectionSet $ G._stdName std
pure Nothing
G.TypeDefinitionEnum etd -> do
-- enums do not admit a selection set
unless (null selectionSet) $
throwDiagnosis $ EnumSelectionSet $ G._etdName etd
pure Nothing
G.TypeDefinitionUnion _utd ->
-- TODO: implement unions
pure Nothing
G.TypeDefinitionInterface _itd ->
-- TODO: implement interfaces
pure Nothing
G.TypeDefinitionObject otd ->
Just <$> analyzeObjectSelectionSet otd selectionSet
-- | Analyze the fields of an object selection set against its definition, and
-- emit the corresponding 'Selection'. We ignore the fields that fail, and we
-- continue accumulating the others.
analyzeObjectSelectionSet ::
G.ObjectTypeDefinition G.InputValueDefinition ->
G.SelectionSet G.NoFragments G.Name ->
Analysis Selection
analyzeObjectSelectionSet (G.ObjectTypeDefinition {..}) selectionSet = do
mconcat . catMaybes <$> for selectionSet analyzeObjectSelection
where
analyzeObjectSelection :: G.Selection G.NoFragments G.Name -> Analysis (Maybe Selection)
analyzeObjectSelection = \case
G.SelectionInlineFragment inlineFrag -> do
-- analyze the inline fragment's selection set
mconcat <$> for (G._ifSelectionSet inlineFrag) analyzeObjectSelection
G.SelectionField (G.Field {..}) ->
withField _fName $ withCatchAndRecord do
-- attempt to find that field in the object's definition
G.FieldDefinition {..} <-
findDefinition _fName
`onNothing` throwDiagnosis (ObjectFieldNotFound _otdName _fName)
-- attempt to find its type in the schema
let baseType = G.getBaseType _fldType
typeDefinition <-
lookupType baseType
`onNothingM` throwDiagnosis (TypeNotFound baseType)
-- recursively processthe selection set
subSelection <- analyzeSelectionSet typeDefinition _fSelectionSet
-- TODO: should we check arguments?
pure $
Selection $
Map.singleton
(fromMaybe _fName _fAlias)
(FieldInfo _fldType typeDefinition subSelection)
-- Additional hidden fields that are allowed despite not being listed in the
-- schema.
systemFields :: [G.FieldDefinition G.InputValueDefinition]
systemFields =
if _otdName `elem` [queryRootName, mutationRootName, subscriptionRootName]
then [typenameField, schemaField, typeField]
else [typenameField]
-- Search for that field in the schema's definition.
findDefinition :: G.Name -> Maybe (G.FieldDefinition G.InputValueDefinition)
findDefinition name =
find
(\fieldDef -> G._fldName fieldDef == name)
(_otdFieldsDefinition <> systemFields)
-------------------------------------------------------------------------------
-- Internal Analysis monad and helpers
-- | The monad in which we run our analysis.
--
-- Has three capabilities:
-- - reader carries the current path, and the full schema for lookups
-- - writer logs all errors we have caught
-- - except allows for short-circuiting errors
newtype Analysis a
= Analysis
( ExceptT
AnalysisError
( ReaderT
(Path, G.SchemaIntrospection)
(Writer [AnalysisError])
)
a
)
deriving newtype
( Functor,
Applicative,
Monad,
MonadReader (Path, G.SchemaIntrospection),
MonadWriter [AnalysisError],
MonadError AnalysisError
)
runAnalysis :: Monoid a => G.SchemaIntrospection -> Analysis a -> (a, [Text])
runAnalysis schema (Analysis a) =
postProcess $
runWriter $
flip runReaderT (pure "$", schema) $
runExceptT a
where
-- if there was an uncaught error, add it to the list
postProcess = \case
(Left err, errors) ->
postProcess (Right mempty, errors ++ [err])
(Right result, errors) ->
(result, map render errors)
-- | Look up a type in the schema.
lookupType :: G.Name -> Analysis (Maybe (G.TypeDefinition [G.Name] G.InputValueDefinition))
lookupType name = do
G.SchemaIntrospection types <- asks snd
pure $ Map.lookup name types
-- | Add the current field to the error path.
withField :: G.Name -> Analysis a -> Analysis a
withField name = local $ first (|> G.unName name)
-- | Throws an 'AnalysisError' by combining the given diagnosis with the current
-- path. This interrupts the computation in the given branch, and must be caught
-- for the analysis to resume.
throwDiagnosis :: Diagnosis -> Analysis a
throwDiagnosis d = do
currentPath <- asks fst
throwError $ AnalysisError currentPath d
-- | Runs the given computation. if it fails, cacthes the error, records it in
-- the monad, and return 'Nothing'. This allows for a clean recovery.
withCatchAndRecord :: Analysis a -> Analysis (Maybe a)
withCatchAndRecord action =
fmap Just action `catchError` \e -> do
tell [e]
pure Nothing
-------------------------------------------------------------------------------
-- Analysis errors
data AnalysisError = AnalysisError
{ _aePath :: Path,
_aeDiagnosis :: Diagnosis
}
type Path = Seq Text
data Diagnosis
= NoTopLevelSelection
| TypeNotFound G.Name
| EnumSelectionSet G.Name
| ScalarSelectionSet G.Name
| InputObjectSelectionSet G.Name
| ObjectFieldNotFound G.Name G.Name
render :: AnalysisError -> Text
render (AnalysisError path diagnosis) =
T.intercalate "." (toList path) <> ": " <> case diagnosis of
NoTopLevelSelection ->
"did not find a valid selection set at the top level"
TypeNotFound name ->
"type '" <> G.unName name <> "' not found in the schema"
EnumSelectionSet name ->
"enum '" <> G.unName name <> "' does not accept a selection set"
ScalarSelectionSet name ->
"scalar '" <> G.unName name <> "' does not accept a selection set"
InputObjectSelectionSet name ->
"input object '" <> G.unName name <> "' cannot be used for output"
ObjectFieldNotFound objName fieldName ->
"field '" <> G.unName fieldName <> "' not found in object '" <> G.unName objName <> "'"
-------------------------------------------------------------------------------
-- GraphQL internals
-- Special type names
queryRootName :: G.Name
queryRootName = Name._query_root
mutationRootName :: G.Name
mutationRootName = Name._mutation_root
subscriptionRootName :: G.Name
subscriptionRootName = Name._subscription_root
-- Reserved fields
typenameField :: G.FieldDefinition G.InputValueDefinition
typenameField = mkReservedField GName.___typename GName._String
schemaField :: G.FieldDefinition G.InputValueDefinition
schemaField = mkReservedField GName.___schema GName.___Schema
typeField :: G.FieldDefinition G.InputValueDefinition
typeField = mkReservedField GName.___type GName.___Type
mkReservedField :: G.Name -> G.Name -> G.FieldDefinition G.InputValueDefinition
mkReservedField fieldName typeName =
G.FieldDefinition Nothing fieldName [] (G.TypeNamed (G.Nullability False) typeName) []