mirror of
https://github.com/hasura/graphql-engine.git
synced 2024-12-17 20:41:49 +03:00
346804fc67
## Description This change adds support for nested object fields in HGE IR and Schema Cache, the Data Connectors backend and API, and the MongoDB agent. ### Data Connector API changes - The `/schema` endpoint response now includes an optional set of GraphQL type definitions. Table column types can refer to these definitions by name. - Queries can now include a new field type `object` which contains a column name and a nested query. This allows querying into a nested object within a field. ### MongoDB agent changes - Add support for querying into nested documents using the new `object` field type. ### HGE changes - The `Backend` type class has a new type family `XNestedObjects b` which controls whether or not a backend supports querying into nested objects. This is currently enabled only for the `DataConnector` backend. - For backends that support nested objects, the `FieldInfo` type gets a new constructor `FINestedObject`, and the `AnnFieldG` type gets a new constructor `AFNestedObject`. - If the DC `/schema` endpoint returns any custom GraphQL type definitions they are stored in the `TableInfo` for each table in the source. - During schema cache building, the function `addNonColumnFields` will check whether any column types match custom GraphQL object types stored in the `TableInfo`. If so, they are converted into `FINestedObject` instead of `FIColumn` in the `FieldInfoMap`. - When building the `FieldParser`s from `FieldInfo` (function `fieldSelection`) any `FINestedObject` fields are converted into nested object parsers returning `AFNestedObject`. - The `DataConnector` query planner converts `AFNestedObject` fields into `object` field types in the query sent to the agent. ## Limitations ### HGE not yet implemented: - Support for nested arrays - Support for nested objects/arrays in mutations - Support for nested objects/arrays in order-by - Support for filters (`where`) in nested objects/arrays - Support for adding custom GraphQL types via track table metadata API - Support for interface and union types - Tests for nested objects ### Mongo agent not yet implemented: - Generate nested object types from validation schema - Support for aggregates - Support for order-by - Configure agent port - Build agent in CI - Agent tests for nested objects and MongoDB agent PR-URL: https://github.com/hasura/graphql-engine-mono/pull/7844 GitOrigin-RevId: aec9ec1e4216293286a68f9b1af6f3f5317db423
350 lines
14 KiB
Haskell
350 lines
14 KiB
Haskell
{-# LANGUAGE ApplicativeDo #-}
|
||
{-# LANGUAGE TemplateHaskell #-}
|
||
|
||
module Hasura.GraphQL.Schema.BoolExp
|
||
( AggregationPredicatesSchema (..),
|
||
tableBoolExp,
|
||
customReturnTypeBoolExp,
|
||
mkBoolOperator,
|
||
equalityOperators,
|
||
comparisonOperators,
|
||
)
|
||
where
|
||
|
||
import Data.Has (getter)
|
||
import Data.Text.Casing (GQLNameIdentifier)
|
||
import Data.Text.Casing qualified as C
|
||
import Data.Text.Extended
|
||
import Hasura.Base.Error (throw500)
|
||
import Hasura.CustomReturnType.Cache (CustomReturnTypeInfo (..))
|
||
import Hasura.CustomReturnType.Common
|
||
import Hasura.Function.Cache
|
||
import Hasura.GraphQL.Parser.Class
|
||
import Hasura.GraphQL.Schema.Backend
|
||
import Hasura.GraphQL.Schema.Common
|
||
import Hasura.GraphQL.Schema.NamingCase
|
||
import Hasura.GraphQL.Schema.Options qualified as Options
|
||
import Hasura.GraphQL.Schema.Parser
|
||
( InputFieldsParser,
|
||
Kind (..),
|
||
Parser,
|
||
)
|
||
import Hasura.GraphQL.Schema.Parser qualified as P
|
||
import Hasura.GraphQL.Schema.Table
|
||
import Hasura.GraphQL.Schema.Typename
|
||
import Hasura.Name qualified as Name
|
||
import Hasura.Prelude
|
||
import Hasura.RQL.IR.BoolExp
|
||
import Hasura.RQL.IR.Value
|
||
import Hasura.RQL.Types.Backend
|
||
import Hasura.RQL.Types.Column
|
||
import Hasura.RQL.Types.ComputedField
|
||
import Hasura.RQL.Types.Relationships.Local
|
||
import Hasura.RQL.Types.SchemaCache hiding (askTableInfo)
|
||
import Hasura.RQL.Types.Source
|
||
import Hasura.RQL.Types.SourceCustomization
|
||
import Hasura.RQL.Types.Table
|
||
import Hasura.SQL.Backend (BackendType)
|
||
import Language.GraphQL.Draft.Syntax qualified as G
|
||
import Type.Reflection
|
||
|
||
-- | Backends implement this type class to specify the schema of
|
||
-- aggregation predicates.
|
||
--
|
||
-- The default implementation results in a parser that does not parse anything.
|
||
--
|
||
-- The scope of this class is local to the function 'boolExp'. In particular,
|
||
-- methods in `class BackendSchema` and `type MonadBuildSchema` should *NOT*
|
||
-- include this class as a constraint.
|
||
class AggregationPredicatesSchema (b :: BackendType) where
|
||
aggregationPredicatesParser ::
|
||
forall r m n.
|
||
MonadBuildSourceSchema b r m n =>
|
||
TableInfo b ->
|
||
SchemaT r m (Maybe (InputFieldsParser n [AggregationPredicates b (UnpreparedValue b)]))
|
||
|
||
-- Overlapping instance for backends that do not implement Aggregation Predicates.
|
||
instance {-# OVERLAPPABLE #-} (AggregationPredicates b ~ Const Void) => AggregationPredicatesSchema (b :: BackendType) where
|
||
aggregationPredicatesParser ::
|
||
forall r m n.
|
||
(MonadBuildSourceSchema b r m n) =>
|
||
TableInfo b ->
|
||
SchemaT r m (Maybe (InputFieldsParser n [AggregationPredicates b (UnpreparedValue b)]))
|
||
aggregationPredicatesParser _ = return Nothing
|
||
|
||
-- |
|
||
-- > input type_bool_exp {
|
||
-- > _or: [type_bool_exp!]
|
||
-- > _and: [type_bool_exp!]
|
||
-- > _not: type_bool_exp
|
||
-- > column: type_comparison_exp
|
||
-- > ...
|
||
-- > }
|
||
boolExpInternal ::
|
||
forall b r m n name.
|
||
( Typeable name,
|
||
Ord name,
|
||
ToTxt name,
|
||
MonadBuildSchema b r m n,
|
||
AggregationPredicatesSchema b
|
||
) =>
|
||
GQLNameIdentifier ->
|
||
[FieldInfo b] ->
|
||
G.Description ->
|
||
name ->
|
||
SchemaT r m (Maybe (InputFieldsParser n [AggregationPredicates b (UnpreparedValue b)])) ->
|
||
SchemaT r m (Parser 'Input n (AnnBoolExp b (UnpreparedValue b)))
|
||
boolExpInternal gqlName fieldInfos description memoizeKey mkAggPredParser = do
|
||
sourceInfo :: SourceInfo b <- asks getter
|
||
P.memoizeOn 'boolExpInternal (_siName sourceInfo, memoizeKey) do
|
||
let customization = _siCustomization sourceInfo
|
||
tCase = _rscNamingConvention customization
|
||
mkTypename = runMkTypename $ _rscTypeNames customization
|
||
name = mkTypename $ applyTypeNameCaseIdentifier tCase $ mkTableBoolExpTypeName gqlName
|
||
|
||
tableFieldParsers <- catMaybes <$> traverse mkField fieldInfos
|
||
|
||
aggregationPredicatesParser' <- fromMaybe (pure []) <$> mkAggPredParser
|
||
recur <- boolExpInternal gqlName fieldInfos description memoizeKey mkAggPredParser
|
||
|
||
-- Bafflingly, ApplicativeDo doesn’t work if we inline this definition (I
|
||
-- think the TH splices throw it off), so we have to define it separately.
|
||
let connectiveFieldParsers =
|
||
[ P.fieldOptional Name.__or Nothing (BoolOr <$> P.list recur),
|
||
P.fieldOptional Name.__and Nothing (BoolAnd <$> P.list recur),
|
||
P.fieldOptional Name.__not Nothing (BoolNot <$> recur)
|
||
]
|
||
|
||
pure $
|
||
BoolAnd <$> P.object name (Just description) do
|
||
tableFields <- map BoolField . catMaybes <$> sequenceA tableFieldParsers
|
||
specialFields <- catMaybes <$> sequenceA connectiveFieldParsers
|
||
aggregationPredicateFields <- map (BoolField . AVAggregationPredicates) <$> aggregationPredicatesParser'
|
||
pure (tableFields ++ specialFields ++ aggregationPredicateFields)
|
||
where
|
||
mkField ::
|
||
FieldInfo b ->
|
||
SchemaT r m (Maybe (InputFieldsParser n (Maybe (AnnBoolExpFld b (UnpreparedValue b)))))
|
||
mkField fieldInfo = runMaybeT do
|
||
!roleName <- retrieve scRole
|
||
fieldName <- hoistMaybe $ fieldInfoGraphQLName fieldInfo
|
||
P.fieldOptional fieldName Nothing <$> case fieldInfo of
|
||
-- field_name: field_type_comparison_exp
|
||
FIColumn columnInfo ->
|
||
lift $ fmap (AVColumn columnInfo) <$> comparisonExps @b (ciType columnInfo)
|
||
-- field_name: field_type_bool_exp
|
||
FIRelationship relationshipInfo -> do
|
||
remoteTableInfo <- askTableInfo $ riRTable relationshipInfo
|
||
let remoteTableFilter =
|
||
(fmap . fmap) partialSQLExpToUnpreparedValue $
|
||
maybe annBoolExpTrue spiFilter $
|
||
tableSelectPermissions roleName remoteTableInfo
|
||
remoteBoolExp <- lift $ tableBoolExp remoteTableInfo
|
||
pure $ fmap (AVRelationship relationshipInfo . andAnnBoolExps remoteTableFilter) remoteBoolExp
|
||
FIComputedField ComputedFieldInfo {..} -> do
|
||
let ComputedFieldFunction {..} = _cfiFunction
|
||
-- For a computed field to qualify in boolean expression it shouldn't have any input arguments
|
||
case toList _cffInputArgs of
|
||
[] -> do
|
||
let functionArgs =
|
||
flip FunctionArgsExp mempty $
|
||
fromComputedFieldImplicitArguments @b UVSession _cffComputedFieldImplicitArgs
|
||
|
||
fmap (AVComputedField . AnnComputedFieldBoolExp _cfiXComputedFieldInfo _cfiName _cffName functionArgs)
|
||
<$> case computedFieldReturnType @b _cfiReturnType of
|
||
ReturnsScalar scalarType -> lift $ fmap CFBEScalar <$> comparisonExps @b (ColumnScalar scalarType)
|
||
ReturnsTable table -> do
|
||
info <- askTableInfo table
|
||
lift $ fmap (CFBETable table) <$> tableBoolExp info
|
||
ReturnsOthers -> hoistMaybe Nothing
|
||
_ -> hoistMaybe Nothing
|
||
|
||
-- Using remote relationship fields in boolean expressions is not supported.
|
||
FIRemoteRelationship _ -> empty
|
||
FINestedObject _ -> empty -- TODO(dmoverton)
|
||
|
||
-- |
|
||
-- > input type_bool_exp {
|
||
-- > _or: [type_bool_exp!]
|
||
-- > _and: [type_bool_exp!]
|
||
-- > _not: type_bool_exp
|
||
-- > column: type_comparison_exp
|
||
-- > ...
|
||
-- > }
|
||
-- | Boolean expression for custom return types
|
||
customReturnTypeBoolExp ::
|
||
forall b r m n.
|
||
( MonadBuildSchema b r m n,
|
||
AggregationPredicatesSchema b
|
||
) =>
|
||
G.Name ->
|
||
CustomReturnTypeInfo b ->
|
||
SchemaT r m (Parser 'Input n (AnnBoolExp b (UnpreparedValue b)))
|
||
customReturnTypeBoolExp name customReturnType =
|
||
case toFieldInfo (_crtiFields customReturnType) of
|
||
Nothing -> throw500 $ "Error creating fields for custom type " <> tshow (_crtiName customReturnType)
|
||
Just fieldInfo -> do
|
||
let gqlName = mkTableBoolExpTypeName (C.fromCustomName name)
|
||
|
||
-- Aggregation parsers let us say things like, "select all authors
|
||
-- with at least one article": they are predicates based on the
|
||
-- object's relationship with some other entity.
|
||
--
|
||
-- Currently, custom return types can't be defined to have
|
||
-- relationships to other entities, and so they don't support
|
||
-- aggregation predicates.
|
||
--
|
||
-- If you're here because you've been asked to implement them, this
|
||
-- is where you want to put the parser.
|
||
mkAggPredParser = pure (pure mempty)
|
||
|
||
memoizeKey = name
|
||
description =
|
||
G.Description $
|
||
"Boolean expression to filter rows from the custom return type for "
|
||
<> name
|
||
<<> ". All fields are combined with a logical 'AND'."
|
||
in boolExpInternal gqlName fieldInfo description memoizeKey mkAggPredParser
|
||
|
||
-- |
|
||
-- > input type_bool_exp {
|
||
-- > _or: [type_bool_exp!]
|
||
-- > _and: [type_bool_exp!]
|
||
-- > _not: type_bool_exp
|
||
-- > column: type_comparison_exp
|
||
-- > ...
|
||
-- > }
|
||
-- | Booleans expressions for tables
|
||
tableBoolExp ::
|
||
forall b r m n.
|
||
(MonadBuildSchema b r m n, AggregationPredicatesSchema b) =>
|
||
TableInfo b ->
|
||
SchemaT r m (Parser 'Input n (AnnBoolExp b (UnpreparedValue b)))
|
||
tableBoolExp tableInfo = do
|
||
gqlName <- getTableIdentifierName tableInfo
|
||
fieldInfos <- tableSelectFields tableInfo
|
||
let mkAggPredParser = aggregationPredicatesParser tableInfo
|
||
let description =
|
||
G.Description $
|
||
"Boolean expression to filter rows from the table "
|
||
<> tableInfoName tableInfo
|
||
<<> ". All fields are combined with a logical 'AND'."
|
||
|
||
let memoizeKey = tableInfoName tableInfo
|
||
boolExpInternal gqlName fieldInfos description memoizeKey mkAggPredParser
|
||
|
||
{- Note [Nullability in comparison operators]
|
||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||
|
||
In comparisonExps, we hardcode most operators with `Nullability False` when
|
||
calling `column`, which might seem a bit sketchy. Shouldn’t the nullability
|
||
depend on the nullability of the underlying Postgres column?
|
||
|
||
No. If we did that, then we would allow boolean expressions like this:
|
||
|
||
delete_users(where: {status: {eq: null}})
|
||
|
||
which in turn would generate a SQL query along the lines of:
|
||
|
||
DELETE FROM users WHERE users.status = NULL
|
||
|
||
but `= NULL` might not do what they expect. For instance, on Postgres, it always
|
||
evaluates to False!
|
||
|
||
Even operators for which `null` is a valid value must be careful in their
|
||
implementation. An explicit `null` must always be handled explicitly! If,
|
||
instead, an explicit null is ignored:
|
||
|
||
foo <- fmap join $ fieldOptional "_foo_level" $ nullable int
|
||
|
||
then
|
||
|
||
delete_users(where: {_foo_level: null})
|
||
=> delete_users(where: {})
|
||
=> delete_users()
|
||
|
||
Now we’ve gone and deleted every user in the database. Whoops! Hopefully the
|
||
user had backups!
|
||
|
||
In most cases, as mentioned above, we avoid this problem by making the column
|
||
value non-nullable (which is correct, since we never treat a null value as a SQL
|
||
NULL), then creating the field using 'fieldOptional'. This creates a parser that
|
||
rejects nulls, but won’t be called at all if the field is not specified, which
|
||
is permitted by the GraphQL specification. See Note [The value of omitted
|
||
fields] in Hasura.GraphQL.Parser.Internal.Parser for more details.
|
||
|
||
Additionally, it is worth nothing that the `column` parser *does* handle
|
||
explicit nulls, by creating a Null column value.
|
||
|
||
But... the story doesn't end there. Some of our users WANT this peculiar
|
||
behaviour. For instance, they want to be able to express the following:
|
||
|
||
query($isVerified: Boolean) {
|
||
users(where: {_isVerified: {_eq: $isVerified}}) {
|
||
name
|
||
}
|
||
}
|
||
|
||
$isVerified is True -> return users who are verified
|
||
$isVerified is False -> return users who aren't
|
||
$isVerified is null -> return all users
|
||
|
||
In the future, we will likely introduce a separate group of operators that do
|
||
implement this particular behaviour explicitly; but for now we have an option that
|
||
reverts to the previous behaviour.
|
||
|
||
To do so, we have to treat explicit nulls as implicit one: this is what the
|
||
'nullable' combinator does: it treats an explicit null as if the field has never
|
||
been called at all.
|
||
-}
|
||
|
||
-- This is temporary, and should be removed as soon as possible.
|
||
mkBoolOperator ::
|
||
(MonadParse n, 'Input P.<: k) =>
|
||
-- | Naming convention for the field
|
||
NamingCase ->
|
||
-- | shall this be collapsed to True when null is given?
|
||
Options.DangerouslyCollapseBooleans ->
|
||
-- | name of this operator
|
||
GQLNameIdentifier ->
|
||
-- | optional description
|
||
Maybe G.Description ->
|
||
-- | parser for the underlying value
|
||
Parser k n a ->
|
||
InputFieldsParser n (Maybe a)
|
||
mkBoolOperator tCase Options.DangerouslyCollapseBooleans name desc = fmap join . P.fieldOptional (applyFieldNameCaseIdentifier tCase name) desc . P.nullable
|
||
mkBoolOperator tCase Options.Don'tDangerouslyCollapseBooleans name desc = P.fieldOptional (applyFieldNameCaseIdentifier tCase name) desc
|
||
|
||
equalityOperators ::
|
||
(MonadParse n, 'Input P.<: k) =>
|
||
NamingCase ->
|
||
-- | shall this be collapsed to True when null is given?
|
||
Options.DangerouslyCollapseBooleans ->
|
||
-- | parser for one column value
|
||
Parser k n (UnpreparedValue b) ->
|
||
-- | parser for a list of column values
|
||
Parser k n (UnpreparedValue b) ->
|
||
[InputFieldsParser n (Maybe (OpExpG b (UnpreparedValue b)))]
|
||
equalityOperators tCase collapseIfNull valueParser valueListParser =
|
||
[ mkBoolOperator tCase collapseIfNull (C.fromAutogeneratedTuple $$(G.litGQLIdentifier ["_is", "null"])) Nothing $ bool ANISNOTNULL ANISNULL <$> P.boolean,
|
||
mkBoolOperator tCase collapseIfNull (C.fromAutogeneratedName Name.__eq) Nothing $ AEQ True <$> valueParser,
|
||
mkBoolOperator tCase collapseIfNull (C.fromAutogeneratedName Name.__neq) Nothing $ ANE True <$> valueParser,
|
||
mkBoolOperator tCase collapseIfNull (C.fromAutogeneratedName Name.__in) Nothing $ AIN <$> valueListParser,
|
||
mkBoolOperator tCase collapseIfNull (C.fromAutogeneratedName Name.__nin) Nothing $ ANIN <$> valueListParser
|
||
]
|
||
|
||
comparisonOperators ::
|
||
(MonadParse n, 'Input P.<: k) =>
|
||
NamingCase ->
|
||
-- | shall this be collapsed to True when null is given?
|
||
Options.DangerouslyCollapseBooleans ->
|
||
-- | parser for one column value
|
||
Parser k n (UnpreparedValue b) ->
|
||
[InputFieldsParser n (Maybe (OpExpG b (UnpreparedValue b)))]
|
||
comparisonOperators tCase collapseIfNull valueParser =
|
||
[ mkBoolOperator tCase collapseIfNull (C.fromAutogeneratedName Name.__gt) Nothing $ AGT <$> valueParser,
|
||
mkBoolOperator tCase collapseIfNull (C.fromAutogeneratedName Name.__lt) Nothing $ ALT <$> valueParser,
|
||
mkBoolOperator tCase collapseIfNull (C.fromAutogeneratedName Name.__gte) Nothing $ AGTE <$> valueParser,
|
||
mkBoolOperator tCase collapseIfNull (C.fromAutogeneratedName Name.__lte) Nothing $ ALTE <$> valueParser
|
||
]
|