diff --git a/GAP-0001/DRAFT/AbstractFilterArgumentSpec.md b/GAP-0001/DRAFT/AbstractFilterArgumentSpec.md new file mode 100644 index 0000000..752ca40 --- /dev/null +++ b/GAP-0001/DRAFT/AbstractFilterArgumentSpec.md @@ -0,0 +1,312 @@ +# Abstract Type Filter Argument + +## @limitTypes + +```graphql +directive @limitTypes on ARGUMENT_DEFINITION +``` + +`@limitTypes` is a type system directive that may be applied to a field +argument definition in order to express that it will define the exclusive set of +types that the field is allowed to return. + +The server must enforce and validate the allowed types according to this +specification. + +**Example Usage** + +```graphql example +type Query { + allPets(only: [String] @limitTypes): [Pet] +} + +interface Pet { + name: String! +} + +type Cat implements Pet { + name: String! +} + +type Dog implements Pet { + name: String! +} +``` + +`@limitTypes` may also be applied to schema that implements the +[GraphQL Cursor Connections Specification](https://relay.dev/graphql/connections.htm#sec-Connection-Types): + +```graphql example +type Query { + allPetsConnection( + first: Int + after: String + only: [String] @limitTypes + ): PetConnection +} + +type PetConnection { + edges: [PetEdge] + pageInfo: PageInfo! +} + +type PetEdge { + cursor: String! + node: Pet +} +``` + +## Schema Validation + +The `@limitTypes` directive must not appear on more than one argument on the +same field. + +The `@limitTypes` directive may only appear on an argument that accepts a +(possibly non-nullable) list of (possibly non-nullable) String. + +The `@limitTypes` directive may only appear on an field argument where the field +returns either: + +- an abstract type +- a list of an abstract type +- a connection type (conforming to the + [GraphQL Cursor Connections Specification](https://relay.dev/graphql/connections.htm#sec-Connection-Types) + over an abstract type) + +## Execution + +The `@limitTypes` directive places requirements on the {resolver} used to +satisfy the field. Implementers of this specification must honor these +requirements. + +### Coercing Allowed Types + +:: A *filter argument* is a field argument which has the `@limitTypes` +directive applied. + +The input to the *filter argument* is a list of strings, however this must be +made meaningful to the resolver such that it may perform its filtering - thus we +must resolve it into a list of valid concrete object types that are possible in +this position. + +:: The coerced list of valid concrete object types are the *allowed types*. + +CoerceAllowedTypes(abstractType, typeNames): + +- Let {possibleTypes} be a set of the possible types of {abstractType}. +- Let {allowedTypes} be an empty unordered set of object types. +- For each {typeName} in {typeNames}: + - Let {type} be the type in the schema named {typeName}. + - If {type} does not exist, raise an execution error. + - If {type} is an object type: + - If {type} is a member of {possibleTypes}, add {type} to {allowedTypes}. + - Otherwise, raise an execution error. + - Otherwise, if {type} is a union type: + - For each {concreteType} in {type}: + - If {concreteType} is a member of {possibleTypes}, add {concreteType} to + {allowedTypes}. + - Otherwise, if {type} is an interface type: + - For each {concreteType} that implements {type}: + - If {concreteType} is a member of {possibleTypes}, add {concreteType} to + {allowedTypes}. + - Otherwise, raise an execution error (scalars, enums, and input types are not + valid filter argument values). +- Return {allowedTypes}. + +**Explanatory Text** + +The input to the *filter argument* may include both concrete and abstract types. +{CoerceAllowedTypes} expands *allowed types* to include the possible and valid +concrete types for each abstract type. + +To see why this is needed, we will expand our example schema above to include +the following types: + +```graphql example +interface Fish { + swimSpeed: Int! +} + +type Goldfish implements Pet & Fish { + name: String! + swimSpeed: Int! +} + +type Haddock implements Fish { + swimSpeed: Int! +} +``` + +It is possible for types to implement multiple interfaces. It therefore must be +possible to select concrete types of another interface in the *filter argument*: + +```graphql example +{ + allPets(only: ["Fish"]) { + ... on Goldfish { + swimSpeed + } + } +} +``` + +The below example must fail, since `Haddock` does not implement the `Pet` +interface, and is therefore not a possible return type. + +```graphql counter-example +{ + allPets(only: ["Haddock"]) { + ... on Fish { + swimSpeed + } + } +} +``` + +### Allowed Types Restriction + +Enforcement of the *allowed types* is the responsibility of the {resolver} +called in +[`ResolveFieldValue()`]() +during the [`ExecuteField()`]() +algorithm. + +:: When the field returns an abstract type, the *collection* is this type. +When the field returns a list of an abstract type, the *collection* is this +list. When the field returns a connection type over an abstract type, the +*collection* is the list of abstract type the connection represents. + +The resolver must apply this restriction when fetching or generating the source +data to produce the *collection*. This is because the filtering must occur prior +to applying pagination logic in order to produce the correct number of results. + +When a field with a `@limitTypes` argument is being resolved: + +- Let {limitTypesArgument} be the first argument with the `@limitTypes` + directive. +- If no such argument exists, no further action is necessary. +- If {argumentValues} does not provide a value for {limitTypesArgument}, no + further action is necessary. +- Let {limitTypes} be the value in {argumentValues} of {limitTypesArgument}. +- If {limitTypes} is {null}, no further action is necessary. +- Let {abstractType} be the abstract type the {collection} represents. +- Let {allowedTypes} be {CoerceAllowedTypes(abstractType, limitTypes)}. + +Note: The restriction must be applied before pagination arguments so that +non-terminal pages in the collection get full representation - i.e. there +are no gaps. + +## Validation Algorithms + +`@limitTypes` fields must implement the algorithms listed in the +[Execution](#Execution) section above to be spec-compliant. However, it may be +impossible or extremely difficult for GraphQL servers to statically verify the +correctness of the runtime and prevent non-compliant implementations. + +To this end, this section specifies a set of algorithms in order for the server +to validate that the *filter argument* value and the field response are valid. + +Usage of these algorithms is **optional**, but highly recommended to guard +against programmer error. + +All algorithms in this section run either before or after +[`ResolveFieldValue()`](), +and must be run automatically by the server when executing fields for which +the `@limitTypes` directive is applied, + +### Filter Argument Value Validation + +Each member of the *filter argument* value must exist in the type system and be +a member type of {collection}. + +For example, the query below must yield an execution error - since +`LochNessMonster` is not a type that exists in the example schema. + +```graphql counter-example +{ + allPets(only: ["Cat", "Dog", "LochNessMonster"]) { + name + } +} +``` + +When used, this algorithm must be applied before the execution of the resolver +provided by the application code. + +ValidateFilterArgument(filterArgumentValue): + +- Let {abstractType} be the abstract type the {collection} represents. +- Let {possibleTypes} be a set of the possible types of {abstractType}. +- For each {typeName} in {filterArgumentValue}: + - Let {type} be the type in the schema named {typeName}. + - If {type} does not exist, raise an execution error. + - If {type} is an object type: + - If {type} is not a member of {possibleTypes} raise an execution error. + - Otherwise, if {type} is a union type: + - Let {hasValidMember} be {false}. + - For each {concreteType} in {type}: + - If {concreteType} is a member of {possibleTypes}, let {hasValidMember} + be {true}. + - If {hasValidMember} is {false}, raise an execution error. + - Otherwise, if {type} is an interface type: + - Let {hasValidMember} be {false}. + - For each {concreteType} that implements {type}: + - If {concreteType} is a member of {possibleTypes}, let {hasValidMember} + be {true}. + - If {hasValidMember} is {false}, raise an execution error. + - Otherwise, raise an execution error (scalars, enums, and input types are not + valid filter argument values). + +Note: Schema-aware clients or linting tools are encouraged to implement this +validation locally. + +### Field Collection Validation (wip) + +For example, the following query must raise an execution error since `Mouse` +does not appear as a value in {allowedTypes} + +```graphql counter-example +{ + allPets(only: ["Cat", "Dog"]) { + ... on Cat { name } + ... on Dog { name } + ... on Mouse { name } + } +} +``` + +TODO: implement algorithm + +### Field Response Validation (wip) + +TODO: if the response array of the field contains a type that did not appear in +{CoerceAllowedTypes()}, raise an execution error

+yes, if a resolver already correctly implements the "Enforcing Allowed Types" +logic then this isn't necessary - but - I think this is worth speccing out as a +dedicated step because this is likely something tooling will want to be able to +automatically apply to all @limitTypes'd fields as a middleware. This is to +provide an extra layer of safety (otherwise we're trusting that human +implementers got it right inside the resolver) + +For example, given a *filter argument* of `["Cat", "Dog"]`, the following would +be invalid since {allPets} contains `Mouse`: + +```json counter-example +{ + "data": { + "allPets": [ + { "__typename": "Cat", "name": "Tom" }, + { "__typename": "Mouse", "name": "Jerry" } + ] + } +} +``` + +...is this even possible? this assumes that client asks for `__typename` +which isn't guaranteed. https://spec.graphql.org/draft/#ResolveAbstractType() +likely is not possible since this logic is intended to be run generically as a +middleware - i.e _after_ the field has completed, and the in-memory object +representation has been converted into json blob (potentially without +`__typename`) + +or can we look at using \_\_resolveType()? diff --git a/GAP-0001/DRAFT/Index.md b/GAP-0001/DRAFT/Index.md new file mode 100644 index 0000000..f4c9013 --- /dev/null +++ b/GAP-0001/DRAFT/Index.md @@ -0,0 +1,59 @@ +# GraphQL Abstract Type Filter Specification + +This specification aims to provide a standardized way for clients to communicate +the exclusive set of types allowed in a resolver’s response when returning one +or more abstract types (i.e. an Interface or Union return type). + +In the following example, `allPets` will return **only** `Cat` or `Dog` types: + +```graphql example +{ + allPets(only: ["Cat", "Dog"]) { + ... on Cat { name } + ... on Dog { name } + } +} +``` + +This is enforced on the server when using the `@limitTypes` type system +directive: + +```graphql example +type Query { + allPets(only: [String] @limitTypes): [Pet] +} +``` + +**@matches** + +This document also specifies the `@matches` executable directive. Client tooling +may implement this to let query authors avoid manually defining the allowed +types (which is implicitly already defined inside the +[selection set]() of the +{field} for which the {argument} the directive is applied to). + +The following example is identical to the query above when compiled (either at +build time, or as a runtime transformation): + +```graphql example +{ + allPets @matches { + ... on Cat { name } + ... on Dog { name } + } +} +``` + +**Use Cases** + +Applications may implement this specification to provide a filter for what +type(s) may be returned by a resolver. Notably, the filtering happens on the +server side allowing clients to receive a fixed length of results. + +This may also be used a versioning scheme by applications that dynamically +render different parts of a user interface mapped from the return type(s) of a +resolver. Each version of the application can define the exclusive set of types +it supports displaying in the user interface. + +# [AbstractFilterArgumentSpec](AbstractFilterArgumentSpec.md) +# [Matches](Matches.md) diff --git a/GAP-0001/DRAFT/Matches.md b/GAP-0001/DRAFT/Matches.md new file mode 100644 index 0000000..d3b3838 --- /dev/null +++ b/GAP-0001/DRAFT/Matches.md @@ -0,0 +1,153 @@ +# @matches Directive + +## @matches + +```graphql +directive @matches( + argument: String! = "only" + sort: Boolean! = true +) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT +``` + +*@matches* is an executable directive that clients or code generation tools +may provide in order to generate the input value for a field argument which uses +`@limitTypes` type system directive. + +Note: Usage of `@matches` is optional, but recommended to avoid duplication of +the list of allowed types. + +**Directive Arguments** + +argument +: The name of the field argument to populate with the list of allowed types. + Defaults to {"only"}. + +sort +: Whether or not to alphabetically sort the generated value for {argument}. + This normalization avoids unintentional cache misses for fields that have + otherwise equivalent {argument} values. Defaults to {true}. + +Note: If the ordering of type conditions in a selection set carries semantic +meaning (such as indicating preference or priority), {sort} can be set to +{false} to persist this ordering in the resulting {argument} value. + +**Example Usage** + +This operation expresses that the `allPets` field may only return types that +are selected for in the field's selection set (`Cat` and `Dog`): + +```graphql example +{ + allPets @matches { + ... on Cat { name } + ... on Dog { name } + } +} +``` + +The result of applying the document transform would be: + +```graphql example +{ + allPets(only: ["Cat", "Dog"]) { + ... on Cat { name } + ... on Dog { name } + } +} +``` + +`@matches` may also be applied in operations against schema that implements the +[GraphQL Cursor Connections Specification](https://relay.dev/graphql/connections.htm#sec-Connection-Types): + + +```graphql example +{ + allPetsConnection(first: 10, after: "opaqueCursor") @matches { + edges { + node { + ... on Cat { name } + ... on Dog { name } + } + } + } +} +``` + +The result of applying the transform would be: + + +```graphql example +{ + allPetsConnection(first: 10, after: "opaqueCursor", only: ["Cat", "Dog"]) { + edges { + node { + ... on Cat { name } + ... on Dog { name } + } + } + } +} +``` + +## Document Transform + +`@matches` is a local-only directive. The client must transform the operation +(either at build-time, or at runtime) before sending the operation to the +server. `@matches` must not appear in an operation sent to the server. + +Note: The server schema does not need to define the `@matches` directive, since +it is stripped before the operation is sent to the server. + +Fields that use `@matches` must not already define the filter argument. + +**Formal Specification** + +CollectAllowedTypes(selectionSet): + +- Let {allowedTypes} be an empty set. +- For each {selection} in {selectionSet}: + - If {selection} is an InlineFragment: + - Let {typeCondition} be the type condition of {selection}. + - If {typeCondition} exists, add {typeCondition} to {allowedTypes}. + - If {selection} is a FragmentSpread: + - Let {fragment} be the fragment definition referenced by {selection}. + - Let {typeCondition} be the type condition of {fragment}. + - Add {typeCondition} to {allowedTypes}. + - If {selection} is a Field and its name is {"edges"}: + - Let {edgesSelectionSet} be the selection set of {selection}. + - For each {edgeSelection} in {edgesSelectionSet}: + - If {edgeSelection} is a Field and its name is {"node"}: + - Let {nodeSelectionSet} be the selection set of {edgeSelection}. + - Let {nodeTypes} be {CollectAllowedTypes(nodeSelectionSet)}. + - Add each type in {nodeTypes} to {allowedTypes}. +- Return {allowedTypes}. + + + +TransformDocument(document): + +- For each {field} in {document}: + - Let {matchesDirective} be the directive named {"matches"} applied to {field}. + - If {matchesDirective} does not exist, continue to the next {field}. + - Let {argumentName} be the argument value of the {"argument"} argument of {matchesDirective}. + - Let {shouldSort} be the argument value of the {"sort"} argument of {matchesDirective}. + - If {field} has an argument named {argumentName}, raise an error. + - Let {selectionSet} be the selection set of {field}. + - Let {allowedTypes} be {CollectAllowedTypes(selectionSet)}. + - Let {typeNames} be a list of the names of each type in {allowedTypes}. + - If {shouldSort} is {true}, sort {typeNames} alphabetically. + - Add an argument named {argumentName} with value {typeNames} to {field}. + - Remove {matchesDirective} from {field}. +- Return {document}. diff --git a/GAP-0001/README.md b/GAP-0001/README.md new file mode 100644 index 0000000..14ebc95 --- /dev/null +++ b/GAP-0001/README.md @@ -0,0 +1,220 @@ +# Abstract Type Filter + +## Overview + +A schema may offer a field that returns a list of an abstract type (an interface +or union), but the user may only want a subset of these types to be returned, or +the client may only support a subset of these types (or only current versions of +the types, not those that are added to the union or implement the interface in +the future). Typically, the user's preference will be that that filtering take +place _before_ pagination arguments are applied. + +For example, a user is browsing a pet store, but is only interested in cats and +fish, they want dogs to be excluded: + +```graphql +{ + allPets(first: 5, only: ["Cat", "Fish"]) { + name + price + ... on Cat { + breed + } + ... on Fish { + species + } + } +} +``` + +Now consider widget-based user interfaces, where each concrete type has its own +widget. This is common in server-driven UI (SDUI); for example: + +```graphql +{ + newsFeed(first: 5) { + ...StatusFragment + ...PhotoFragment + ...EventFragment + } +} +fragment StatusFragment on Status { + author { + name + avatar + } + text + comments(first: 2) { + author { + name + avatar + } + text + } +} +fragment PhotoFragment on Photo { + url + tags { + user { + name + avatar + } + text + } + comments(first: 2) { + author { + name + avatar + } + text + } +} +fragment EventFragment on Event { + title + startTime + duration + description + location + entryFee +} +``` + +As the schema evolves, `newsFeed` might add a new type to the union: `Video`. +The existing deployed client now receives empty objects where a video would be, +and the UI no longer renders 5 tiles reliably. The client would like to filter +to only the types it supports: + +```graphql +{ + newsFeed(first: 5, only: ["Status", "Photo", "Event"]) { + ...StatusFragment + ...PhotoFragment + ...EventFragment + } +} +``` + +This RFC comes in two parts: + +1. An `ARGUMENT_DEFINITION` directive and associated behaviors that indicates + that an argument will ensure only the given types are returned +2. A client-only `FIELD` directive that will cause this argument to be + automatically populated based on the types of fragments used in this field's + selection set + +## Decisions + +Here's a rough log of the things that we've determined so far. + +### String, not enum + +It was proposed that the `only` argument could accept an array of enum values, +where the enum contained a value for each type the abstract type supported. + +Pros: + +- Could be auto-detected by convention (field returns list of (or connection of) + abstract type, argument name is `only`, argument is list of enum type, enum + enum contains a value for each of the possible types of the abstract type and + nothing else) - no need for spec changes +- Auto-complete in GraphiQL +- Automatically validated with errors surfaced with existing tooling already +- Looks neat and obvious, less visual noise: + `{ newsFeed(only: [Status, Photo, Event]) {...} }` + +Cons: + +- Would add potentially long enums to schema (noisy) +- Keeping enums in sync would lean towards some kind of generation in SDL-first + (e.g. `enum PetType @possibleTypes(typeName: "Pet")`) or dynamic construction + in code-first. +- Using the type name verbatim (`GuineaPig`) would break the `UPPER_CAMEL_CASE` + convention, causing lint failures in some schemas + +The clincher that ruled out the enum type was the "Argument must accept both +abstract and concrete types" decision - an enum composed of all of the possible +types of an abstract type **plus** all of the abstract types that those types +implement or are a member of would be exceedingly verbose. + +### Argument must accept both abstract and concrete types + +A "know-nothing" client (one that does not know the schema definition) would not +know whether a fragment spread was on a concrete or abstract type, so the +argument should support both concrete and abstract types to avoid developer +pain. + +### No custom syntax + +Various custom syntaxes were proposed, such that the set of possible types could +be automatically determined; for example the double-brace syntax: + +```graphql +{ + allPets {{ # e.g. resolver passed special argument `__only: ["Cat". "Dog"]` + Cat { ... CatFragment } + Dog { ... DogFragment } + }} +} +``` + +This list of possible types would be used by the client as part of the cache key +in the normalized store, and also would be fed to the server's resolver to +ensure only the compatible types were returned (and GraphQL would throw an error +if this promise were broken). + +All of these fell down when considering indirect relations between the selection +set and the argument position - how would the client/schema know to feed this +into a connection? + +```graphql +{ + allPetsConnection { # How would we know to pass the special argument here?! + edges { + cursor + node {{ + Cat { ... CatFragment } + Dog { ... DogFragment } + }} + } + } +} +``` + +It's essential that the client know that this filtering is applied such that its +normalized store does not become corrupted, and thus it was agreed that we +should stick with passing the list of allowed types as an argument (since +arguments already factor into normalized cache keys). + +### Two specifications + +Originally this was considered as a single feature for SDUI usage; however it +was realised that the filter argument (and associated behavior) was useful even +without the auto-generation of the value - the definition of behavior of such an +argument would allow auto-completion in editors, and would be behavior the +client could rely upon even without requiring the query be formed in a +particular format. + +### Directive, not convention + +Clients relying on this functionality need strong guarantees. The GraphQL Cursor +Connections Specification can rely on conventions since the requirements are +sufficiently complex that we can be pretty sure someone is deliberately +implementing that pattern. However, with this proposal, short of using an enum +type (ruled out above), there's insufficient information to give us the +confidence that the argument definitely applies the behaviors we expect. + +Given the above, the argument needs an annotation to indicate its special +behavior for automated tooling to be able to rely on it, and the way to add that +annotation currently is via a directive. + +It is recognized that currently this directive will not be available via +introspection. This part of the spec therefore relies on the resolution of +[#300](https://github.com/graphql/graphql-spec/issues/300). + +## Prior art + +Relay `@match` directive: +https://relay.dev/docs/guides/data-driven-dependencies/server-3d/#match-design-principles + +PostGraphile `only` argument: +https://github.com/graphile/crystal/blob/bcf8326bef7930b02c00b67e4ebda22a49e4f5fa/graphile-build/graphile-build-pg/src/plugins/PgPolymorphismOnlyArgumentPlugin.ts#L202-L204 diff --git a/GAP-0001/metadata.yml b/GAP-0001/metadata.yml new file mode 100644 index 0000000..73de219 --- /dev/null +++ b/GAP-0001/metadata.yml @@ -0,0 +1,8 @@ +id: 1 +title: Abstract Type Filter +status: draft +authors: + - "Mark Larah " + - "Benjie Gillam " +sponsor: "@magicmark" +discussion: "https://github.com/graphql/graphql-wg/pull/1817"