From 0759d0829351152ea7f3ff51d3267e2ae719d684 Mon Sep 17 00:00:00 2001 From: Mark Larah Date: Tue, 9 Sep 2025 19:48:17 +0200 Subject: [PATCH 01/12] Create MatchesDirective.md --- rfcs/MatchesDirective.md | 327 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 327 insertions(+) create mode 100644 rfcs/MatchesDirective.md diff --git a/rfcs/MatchesDirective.md b/rfcs/MatchesDirective.md new file mode 100644 index 00000000..442806ae --- /dev/null +++ b/rfcs/MatchesDirective.md @@ -0,0 +1,327 @@ +# RFC: Matches Directive + +**Proposed by:** [Mark Larah](https://twitter.com/mark_larah) - Yelp + +**Implementation PR**: todo + +This RFC proposes adding a new directive `@matches` and associated validation +rules to enforce the safe selection of "supported" types when using fragment +spreads on a field that returns an array of unions of polymorphic types. + +## πŸ“œ Problem Statement + +We need to be able to communicate to the server what possible return types are +supported in an array of unions or interfaces. + +**Example** + +A client application may wish to render a fixed number of possible media items: + +```graphql +query GetMedia { + getMedia { # returns a fixed array of `Book | Movie` + ... on Book { title author } + ... on Movie { title director } + } +} +``` + +...but when we introduce the new media type `Opera` into the union, an old +client using the above query will not be able to display any returned results +for `Opera`, leaving empty "slots" in the UI. + +## Current Solutions + +A number of alternative approaches exist: + +### 1. `supports` argument + +We could manually introduce an argument (`only`, `supports`, etc) to the field +to communicate which types are supported: + +```graphql +query GetMedia { + getMedia(supports: [Book, Movie]) { + ... on Book { title author } + ... on Movie { title director } + } +} +``` + +The resolver must then read the `supports` field argument to filter and only +return compatible types. + +#### 1.a. With enums + +**Example SDL** +```graphql +union Media = Book | Movie | Symphony + +enum MediaFilter { + Book + Movie + Symphony +} + +type Query { + getMedia(supported: [MediaFilter!]!): [Media] +} +``` + +**πŸ‘Ž Downsides**: + +- Requires humans to manually create a mirror enum of the union +- Does not guarantee that the `supports` argument is respected at runtime + +#### 1.b. With strings + +**Example SDL** +```graphql +union Media = Book | Movie | Symphony + +type Query { + getMedia(supported: [String!]!): [Media] +} +``` + +**πŸ‘Ž Downsides**: + +- Strings are more error prone - e.g. humans may get the capitalization wrong +- Does not guarantee that the `supports` argument is respected at runtime + +#### 1.c. Compiled + +Relay provides a `@match` directive: + +https://relay.dev/docs/guides/data-driven-dependencies/server-3d/#match-design-principles + +Queries are compiled, such that this: + +```graphql +query GetMedia { + getMedia { + ... on Book { title author } + ... on Movie { title director } + } +} +``` + +...becomes this (at build time): + +```graphql +query GetMedia { + getMedia(supports: ["Book", "Movie"]) { + ... on Book { title author } + ... on Movie { title director } + } +} +``` + +This improves on both `1.a.` and `1.b`: + +**πŸ‘ Upsides**: + +- There's no extra enum to maintain +- Avoid humans passing freeform strings + +**πŸ‘Ž Downsides**: + +- Requires a compiler step. Non compiling clients cannot support this (see #3 + below for why) +- Does not guarantee that the `supports` argument is respected at runtime + +### 2. Server-side mapping + +Let's assume that clients send a header on every request to identify their +version e.g. (`v1`, `v2`). + +We could maintain a mapping on the server that encodes the knowledge of which +client supports the rendering of which types: + +```json +{ + "v1": ["Book", "Movie"], + "v2": ["Book", "Movie", "Opera"], + "v3": ["Book", "Movie", "Opera", "Audiobook"], + ... +} +``` + +The resolver checks this mapping to filter what types to return. + +**πŸ‘Ž Downsides**: + +- Extra source of truth / moving part / thing to maintain +- Stores freeform strings - no validation that the typenames are valid +- Does not guarantee that this is respected at runtime + +### 3. Runtime AST inspection + +> [!WARNING] +> This example is not actually safe, do not use. + +In theory, the names of the fragments in the selection set is available in the +AST of the query passed to resolvers at runtime. A resolver could attempt +something like this: + +```js +const resolvers = { + Query: { + getMedia: (_, __, ___, info) => { + // ignore https://github.com/graphql/graphql-js/issues/605 + const node = info.fieldNodes[0]; + + // gets something like ["Book", "Movie"] + const supportedTypes = node.selectionSet.selections.map( + ({ typeCondition }) => typeCondition.name.value + ); +``` + +However, this would cause (many) issues at runtime. + +One such problem: if a client writes this: + +```graphql +query GetMedia { + ...BooksTab + ...MoviesTab +} + +fragment BooksTab on Query { + getMedia { + ... on Book { title author } + } +} + +fragment MoviesTab on Query { + getMedia { + ... on Movie { title director } + } +} +``` + +The selection sets in fragments get merged, and the `getMedia` resolver is only +executed once. With the above implementation, we'd *only* return Books (the +first to be evaluated). Since the resolver is only executed once, this is +impossible to implement correclty. + +#### 3.b. Add the `supports` argument at runtime + +We could use this approach to instead add the `supports` argument at runtime +(similar to 1.c.). + +By default however, this would cause type checking on the client to fail (since +the "required" argument isn't provided). + +In addition, noramlized cache layers wouldn't be aware of this runtime +transform. + +If a client issues this query: + +```graphql +query GetMedia { + getMedia { + ... on Book { title author } + } +} +``` + +...and later on, this query: + +```graphql +query GetMedia { + getMedia { + ... on Movie { title author } + } +} +``` + +Oops! That's a cache hit! And overriding the cache policy to fetch anyway would +overwrite the previous cache value for `Query.getMedia`, causing the previously +rendered UI to have empty slots. + + +## πŸ§‘β€πŸ’» Proposed Solution + +Provide a new directive `@matches` that can be applied to field arguments when +returning an array of unions of polymorphic types. + +#### Example + +**SDL** + +```graphql +union Media = Book | Movie | Opera + +type Query { + getMedia(supports: [String!] @matches): [Media] +} +``` + +**Query** + +```graphql +query GetMedia { + getMedia(supports: ["Book", "Movie"]) { + ... on Book { title author } + ... on Movie { title director } + } +} +``` + +### Validation + +The following new validation rules are applied: + +#### Request Validation + +1. All selected types must match an entry in `supports`: + + ```graphql + query { + getMedia(supports: ["Book"]) { + ... on Book { title author } + ... on Movie { title director } # ❌ Error: `supports` did not specify `Movie`. + } + } + ``` + +2. All specified type names must be valid return types: + + ```graphql + query { + getMedia(supports: ["VideoGame"]) { # ❌ Error: `Media` union does not contain `VideoGame` + ``` + +#### Response Validation + +Throw an error if the resolver returns a type that is not present in the +`supports` array: + +```js +const resolvers = { + Query: { + getMedia: (_, { supports }) => { + // supports = ['Book', 'Movie'] + + // ❌ Error: `supports` did not specify `Opera`. + return [{ __typename: 'Opera', title: 'La Boheme'}] + } + } +} +``` + +## Alternative names + +`@matches` is proposed in order to avoid conflicting with Relay's `@match`. + +Also considered: + +- `@filter` +- `@filterTypes` +- `@matchFragments` +- `@matchTypes` +- `@only` +- `@supports` +- `@supportsTypes` +- `@typeFilter` From 27edddb7883547187cbf128a2969bd2269afdf67 Mon Sep 17 00:00:00 2001 From: Mark Larah Date: Tue, 9 Sep 2025 20:18:46 +0200 Subject: [PATCH 02/12] Update MatchesDirective.md --- rfcs/MatchesDirective.md | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/rfcs/MatchesDirective.md b/rfcs/MatchesDirective.md index 442806ae..ec6050c3 100644 --- a/rfcs/MatchesDirective.md +++ b/rfcs/MatchesDirective.md @@ -311,6 +311,44 @@ const resolvers = { } ``` +### Controlling if ordering matters + +There is a meaningful difference between these two queries: + +```graphql +query PrefersBooks { + getMedia(supports: ["Book", "Movie"]) { + ... on Book { title author } + ... on Movie { title director } + } +} +``` + +```graphql +query PrefersMovies { + getMedia(supports: ["Movie", "Book"]) { + ... on Book { title author } + ... on Movie { title director } + } +} +``` + +A client may rely on the ordering of `supports` fields to indicate the prefernce and rank order in which to return objects. + +However, this may cause confusion and unintentional cache misses. + +The client must decide if they wish to make the ordering of `supports` meaningful or not - and it not, we should enforce that the ordering is consistent (alphabetically sorted). + +A `sort` argument is provided: + +```graphql +type Query { + getMedia(supports: [String!] @matches(sort: Boolean = True)): [Media] +} +``` + +If `sort` is True (default), an additional request validation rule will enforce this. + ## Alternative names `@matches` is proposed in order to avoid conflicting with Relay's `@match`. From 5644fe76c7940e81f80983b1299d038807e4f8af Mon Sep 17 00:00:00 2001 From: Mark Larah Date: Tue, 9 Sep 2025 21:43:27 +0200 Subject: [PATCH 03/12] Update rfcs/MatchesDirective.md Co-authored-by: Steve Rice --- rfcs/MatchesDirective.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rfcs/MatchesDirective.md b/rfcs/MatchesDirective.md index ec6050c3..4a28d0f3 100644 --- a/rfcs/MatchesDirective.md +++ b/rfcs/MatchesDirective.md @@ -203,7 +203,7 @@ fragment MoviesTab on Query { The selection sets in fragments get merged, and the `getMedia` resolver is only executed once. With the above implementation, we'd *only* return Books (the first to be evaluated). Since the resolver is only executed once, this is -impossible to implement correclty. +impossible to implement correctly. #### 3.b. Add the `supports` argument at runtime From 9be62129331d4b2f0f886e8ee66a947bbde46f54 Mon Sep 17 00:00:00 2001 From: Mark Larah Date: Tue, 9 Sep 2025 21:55:54 +0200 Subject: [PATCH 04/12] Update MatchesDirective.md --- rfcs/MatchesDirective.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rfcs/MatchesDirective.md b/rfcs/MatchesDirective.md index 4a28d0f3..29574a7d 100644 --- a/rfcs/MatchesDirective.md +++ b/rfcs/MatchesDirective.md @@ -333,7 +333,7 @@ query PrefersMovies { } ``` -A client may rely on the ordering of `supports` fields to indicate the prefernce and rank order in which to return objects. +A client may rely on the ordering of `supports` fields to indicate the preference and rank order in which to return objects. However, this may cause confusion and unintentional cache misses. @@ -343,7 +343,7 @@ A `sort` argument is provided: ```graphql type Query { - getMedia(supports: [String!] @matches(sort: Boolean = True)): [Media] + getMedia(supports: [String!] @matches(sort: True)): [Media] } ``` From c555945be06037c5d5b7c3bfd16836f6da8930b8 Mon Sep 17 00:00:00 2001 From: Mark Larah Date: Tue, 9 Sep 2025 21:58:03 +0200 Subject: [PATCH 05/12] Update MatchesDirective.md --- rfcs/MatchesDirective.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/rfcs/MatchesDirective.md b/rfcs/MatchesDirective.md index 29574a7d..c5c0654e 100644 --- a/rfcs/MatchesDirective.md +++ b/rfcs/MatchesDirective.md @@ -6,7 +6,11 @@ This RFC proposes adding a new directive `@matches` and associated validation rules to enforce the safe selection of "supported" types when using fragment -spreads on a field that returns an array of unions of polymorphic types. +spreads on a field that returns an array of unions of polymorphic types: + +```graphql +directive @matches(sort: Boolean = True) on ARGUMENT_DEFINITION +``` ## πŸ“œ Problem Statement From a4c2eb77dddb19344db27e91e4edc3a46cbd8056 Mon Sep 17 00:00:00 2001 From: Mark Larah Date: Tue, 9 Sep 2025 22:00:59 +0200 Subject: [PATCH 06/12] Update MatchesDirective.md --- rfcs/MatchesDirective.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/rfcs/MatchesDirective.md b/rfcs/MatchesDirective.md index c5c0654e..daa76d54 100644 --- a/rfcs/MatchesDirective.md +++ b/rfcs/MatchesDirective.md @@ -351,7 +351,8 @@ type Query { } ``` -If `sort` is True (default), an additional request validation rule will enforce this. +- If `sort` is True (default), an additional request validation rule will enforce this. +- If `sort` is False, `supports` argument ordering is not enforced, allowing different fragments to specify different orderings (and be cached independently) ## Alternative names From abcd0df4f17ed9378aff935c875b3e2f69157b93 Mon Sep 17 00:00:00 2001 From: Mark Larah Date: Tue, 9 Sep 2025 22:01:36 +0200 Subject: [PATCH 07/12] Update MatchesDirective.md --- rfcs/MatchesDirective.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rfcs/MatchesDirective.md b/rfcs/MatchesDirective.md index daa76d54..216113ec 100644 --- a/rfcs/MatchesDirective.md +++ b/rfcs/MatchesDirective.md @@ -351,7 +351,7 @@ type Query { } ``` -- If `sort` is True (default), an additional request validation rule will enforce this. +- If `sort` is True (default), `supports` argument ordering is enforced via a request validation rule. - If `sort` is False, `supports` argument ordering is not enforced, allowing different fragments to specify different orderings (and be cached independently) ## Alternative names From deec07eb6ec364db9b1e25c39db1ed8b70fde94d Mon Sep 17 00:00:00 2001 From: Mark Larah Date: Tue, 9 Sep 2025 22:03:23 +0200 Subject: [PATCH 08/12] Update MatchesDirective.md --- rfcs/MatchesDirective.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rfcs/MatchesDirective.md b/rfcs/MatchesDirective.md index 216113ec..68704de1 100644 --- a/rfcs/MatchesDirective.md +++ b/rfcs/MatchesDirective.md @@ -19,7 +19,7 @@ supported in an array of unions or interfaces. **Example** -A client application may wish to render a fixed number of possible media items: +A client application may wish to display a fixed number of media items: ```graphql query GetMedia { From db072c3a5ade8addec2cb9b7cede6dbb2547f790 Mon Sep 17 00:00:00 2001 From: Mark Larah Date: Tue, 9 Sep 2025 22:06:03 +0200 Subject: [PATCH 09/12] Update MatchesDirective.md --- rfcs/MatchesDirective.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/rfcs/MatchesDirective.md b/rfcs/MatchesDirective.md index 68704de1..f2675dcb 100644 --- a/rfcs/MatchesDirective.md +++ b/rfcs/MatchesDirective.md @@ -255,6 +255,8 @@ returning an array of unions of polymorphic types. **SDL** ```graphql +directive @matches(sort: Boolean = True) on ARGUMENT_DEFINITION + union Media = Book | Movie | Opera type Query { From 1a47da8ddfcc7ebbb391b3b16facd1e3eb65cde6 Mon Sep 17 00:00:00 2001 From: Mark Larah Date: Tue, 9 Sep 2025 22:08:14 +0200 Subject: [PATCH 10/12] Update MatchesDirective.md --- rfcs/MatchesDirective.md | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/rfcs/MatchesDirective.md b/rfcs/MatchesDirective.md index f2675dcb..f5c1d8ea 100644 --- a/rfcs/MatchesDirective.md +++ b/rfcs/MatchesDirective.md @@ -9,7 +9,7 @@ rules to enforce the safe selection of "supported" types when using fragment spreads on a field that returns an array of unions of polymorphic types: ```graphql -directive @matches(sort: Boolean = True) on ARGUMENT_DEFINITION +directive @matches(sorted: Boolean = True) on ARGUMENT_DEFINITION ``` ## πŸ“œ Problem Statement @@ -255,7 +255,7 @@ returning an array of unions of polymorphic types. **SDL** ```graphql -directive @matches(sort: Boolean = True) on ARGUMENT_DEFINITION +directive @matches(sorted: Boolean = True) on ARGUMENT_DEFINITION union Media = Book | Movie | Opera @@ -345,17 +345,20 @@ However, this may cause confusion and unintentional cache misses. The client must decide if they wish to make the ordering of `supports` meaningful or not - and it not, we should enforce that the ordering is consistent (alphabetically sorted). -A `sort` argument is provided: +A `sort` argument is provided to support this: + +- If `sorted` is True (default), `supports` argument ordering is enforced via a request validation rule. +- If `sorted` is False, `supports` argument ordering is not enforced, allowing different fragments to specify different orderings (and be cached independently) + +**Example** ```graphql type Query { - getMedia(supports: [String!] @matches(sort: True)): [Media] + # different parts of the app want different media items with different ordering - we're ok with this field being cached multiple times + getMedia(supports: [String!] @matches(sorted: False)): [Media] } ``` -- If `sort` is True (default), `supports` argument ordering is enforced via a request validation rule. -- If `sort` is False, `supports` argument ordering is not enforced, allowing different fragments to specify different orderings (and be cached independently) - ## Alternative names `@matches` is proposed in order to avoid conflicting with Relay's `@match`. From b21928304d33cd5a88a867391fc615f7a078d272 Mon Sep 17 00:00:00 2001 From: Mark Larah Date: Sun, 14 Sep 2025 22:15:41 -0500 Subject: [PATCH 11/12] add path --- rfcs/MatchesDirective.md | 104 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 96 insertions(+), 8 deletions(-) diff --git a/rfcs/MatchesDirective.md b/rfcs/MatchesDirective.md index f5c1d8ea..3ec6128d 100644 --- a/rfcs/MatchesDirective.md +++ b/rfcs/MatchesDirective.md @@ -9,7 +9,19 @@ rules to enforce the safe selection of "supported" types when using fragment spreads on a field that returns an array of unions of polymorphic types: ```graphql -directive @matches(sorted: Boolean = True) on ARGUMENT_DEFINITION +directive @matches( + """ + Optional dotted field path (relative to the field’s return value) that + identifies where the polymorphic element(s) appear. + """ + path: String, + + """ + Whether or not argument ordering is enforced. This may be set to False if the + order is meaingful and used by the application logic. + """ + sort: Boolean = True +) repeatable on ARGUMENT_DEFINITION ``` ## πŸ“œ Problem Statement @@ -247,8 +259,9 @@ rendered UI to have empty slots. ## πŸ§‘β€πŸ’» Proposed Solution -Provide a new directive `@matches` that can be applied to field arguments when -returning an array of unions of polymorphic types. +The `@matches` directive can be applied to a field argument when returning an +array of unions of polymorphic types. It declares the set of valid response +types, and is enforced by the server. #### Example @@ -275,6 +288,73 @@ query GetMedia { } ``` +### `sort` argument + +If `sort` is true (default), the argument’s values are required to appear in +sorted (alphabetical) order: + +- `getMedia(supports: ["Book", "Movie"]) # OK` +- `getMedia(supports: ["Movie", "Book"]) # ❌ Error: not sorted` + +This is desirable as a default behaviour. If not enforced, this would imply +cache denormalization and cache misses in clients. + +If `sort` is false, this is not enforced, and both examples above are allowed. +This may be desirable if the application logic depends on the ordering of the +argument values - e.g. to signal a desired priority or ordering of result types. + +### `path` argument + +`path` is an optional argument that accepts a dot-seperated +[response path][response position] relative to the field. + +[response position]: https://spec.graphql.org/September2025/#sec-Response-Position + +This primarily in in order to support nested responses inside connection objects +when using pagination: + +**Example** + +```graphql +type Query { + getPaginatedMedia( + first: Int + after: String + only: [String!] @matches(path: "nodes") + ): MediaConnection +} + +type MediaConnection { + nodes: [Media!] + pageInfo: PageInfo +} +``` + +### `repeatable` + +`@matches` may be applied multiple times in order to support multiple nested +fields: + +**Example** + +```graphql +type Query { + getMedia( + first: Int + after: String + only: [String!] @matches(path: "nodes") @matches(path: "all") + ): MediaConnection +} + +type MediaConnection { + nodes: [Media!] + pageInfo: PageInfo + + """Clients may use this if they don't want to use pagination.""" + all: [Media!] +} +``` + ### Validation The following new validation rules are applied: @@ -317,6 +397,8 @@ const resolvers = { } ``` +## Appendix + ### Controlling if ordering matters There is a meaningful difference between these two queries: @@ -339,16 +421,22 @@ query PrefersMovies { } ``` -A client may rely on the ordering of `supports` fields to indicate the preference and rank order in which to return objects. +A client may rely on the ordering of `supports` fields to indicate the +preference and rank order in which to return objects. However, this may cause confusion and unintentional cache misses. -The client must decide if they wish to make the ordering of `supports` meaningful or not - and it not, we should enforce that the ordering is consistent (alphabetically sorted). +The client must decide if they wish to make the ordering of `supports` +meaningful or not - and it not, we should enforce that the ordering is +consistent (alphabetically sorted). A `sort` argument is provided to support this: -- If `sorted` is True (default), `supports` argument ordering is enforced via a request validation rule. -- If `sorted` is False, `supports` argument ordering is not enforced, allowing different fragments to specify different orderings (and be cached independently) +- If `sorted` is True (default), `supports` argument ordering is enforced via a + request validation rule. +- If `sorted` is False, `supports` argument ordering is not enforced, allowing + different fragments to specify different orderings (and be cached + independently). **Example** @@ -359,7 +447,7 @@ type Query { } ``` -## Alternative names +### Alternative names `@matches` is proposed in order to avoid conflicting with Relay's `@match`. From f8cf2ae52af5d107193c0368b712b74398a2822f Mon Sep 17 00:00:00 2001 From: Mark Larah Date: Sun, 14 Sep 2025 22:34:12 -0500 Subject: [PATCH 12/12] typo --- rfcs/MatchesDirective.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rfcs/MatchesDirective.md b/rfcs/MatchesDirective.md index 3ec6128d..4cfa653a 100644 --- a/rfcs/MatchesDirective.md +++ b/rfcs/MatchesDirective.md @@ -310,7 +310,7 @@ argument values - e.g. to signal a desired priority or ordering of result types. [response position]: https://spec.graphql.org/September2025/#sec-Response-Position -This primarily in in order to support nested responses inside connection objects +This is primarily in order to support nested responses inside connection objects when using pagination: **Example**