From ceeb73bb62ec5f426198cc75bfd55879d3ee590c Mon Sep 17 00:00:00 2001 From: Jovi De Croock Date: Sat, 2 Mar 2024 17:57:55 +0100 Subject: [PATCH] feat(graphcache): allow for defining inline-fragment/fragment-definition client controlled nullability directives (#3502) Co-authored-by: Phil Pluckthun --- .changeset/happy-peas-sin.md | 5 + exchanges/graphcache/src/ast/traversal.ts | 16 + .../graphcache/src/cacheExchange.test.ts | 346 ++++++++++++++++++ exchanges/graphcache/src/operations/query.ts | 9 +- .../graphcache/src/operations/shared.test.ts | 5 + exchanges/graphcache/src/operations/shared.ts | 14 +- exchanges/graphcache/src/operations/write.ts | 1 + 7 files changed, 394 insertions(+), 2 deletions(-) create mode 100644 .changeset/happy-peas-sin.md diff --git a/.changeset/happy-peas-sin.md b/.changeset/happy-peas-sin.md new file mode 100644 index 0000000000..230936f40a --- /dev/null +++ b/.changeset/happy-peas-sin.md @@ -0,0 +1,5 @@ +--- +'@urql/exchange-graphcache': minor +--- + +Allow `@_optional` and `@_required` to be placed on fragment definitions and inline fragments diff --git a/exchanges/graphcache/src/ast/traversal.ts b/exchanges/graphcache/src/ast/traversal.ts index 43889ee35e..0c4e7357a7 100644 --- a/exchanges/graphcache/src/ast/traversal.ts +++ b/exchanges/graphcache/src/ast/traversal.ts @@ -93,3 +93,19 @@ export const isDeferred = ( return false; }; + +/** Resolves @_optional and @_required directive to determine whether the fields in a fragment are conaidered optional. */ +export const isOptional = ( + node: FormattedNode +): boolean | undefined => { + const { optional, required } = getDirectives(node); + if (required) { + return false; + } + + if (optional) { + return true; + } + + return undefined; +}; diff --git a/exchanges/graphcache/src/cacheExchange.test.ts b/exchanges/graphcache/src/cacheExchange.test.ts index f4b89baf27..cc1157325f 100644 --- a/exchanges/graphcache/src/cacheExchange.test.ts +++ b/exchanges/graphcache/src/cacheExchange.test.ts @@ -827,6 +827,352 @@ describe('directives', () => { }); }); + it('Does not return partial data for nested selections', () => { + const client = createClient({ + url: 'http://0.0.0.0', + exchanges: [], + }); + const { source: ops$, next } = makeSubject(); + + const query = gql` + { + todo { + ... on Todo @_optional { + id + text + author { + id + name + } + } + } + } + `; + + const operation = client.createRequestOperation('query', { + key: 1, + query, + variables: undefined, + }); + + const queryResult: OperationResult = { + ...queryResponse, + operation, + data: { + __typename: 'Query', + todo: { + id: '1', + text: 'learn urql', + __typename: 'Todo', + author: { + __typename: 'Author', + }, + }, + }, + }; + + const reexecuteOperation = vi + .spyOn(client, 'reexecuteOperation') + .mockImplementation(next); + + const response = vi.fn((forwardOp: Operation): OperationResult => { + if (forwardOp.key === 1) return queryResult; + return undefined as any; + }); + + const result = vi.fn(); + const forward: ExchangeIO = ops$ => pipe(ops$, map(response), share); + + pipe( + cacheExchange({})({ forward, client, dispatchDebug })(ops$), + tap(result), + publish + ); + + next(operation); + + expect(response).toHaveBeenCalledTimes(1); + expect(result).toHaveBeenCalledTimes(1); + expect(reexecuteOperation).toHaveBeenCalledTimes(0); + expect(result.mock.calls[0][0].data).toEqual(null); + }); + + it('returns partial results when an inline-fragment is marked as optional', () => { + const client = createClient({ + url: 'http://0.0.0.0', + exchanges: [], + }); + const { source: ops$, next } = makeSubject(); + + const query = gql` + { + todos { + id + text + ... on Todo @_optional { + completed + } + } + } + `; + + const operation = client.createRequestOperation('query', { + key: 1, + query, + variables: undefined, + }); + + const queryResult: OperationResult = { + ...queryResponse, + operation, + data: { + __typename: 'Query', + todos: [ + { + id: '1', + text: 'learn urql', + __typename: 'Todo', + }, + ], + }, + }; + + const reexecuteOperation = vi + .spyOn(client, 'reexecuteOperation') + .mockImplementation(next); + + const response = vi.fn((forwardOp: Operation): OperationResult => { + if (forwardOp.key === 1) return queryResult; + return undefined as any; + }); + + const result = vi.fn(); + const forward: ExchangeIO = ops$ => pipe(ops$, map(response), share); + + pipe( + cacheExchange({})({ forward, client, dispatchDebug })(ops$), + tap(result), + publish + ); + + next(operation); + + expect(response).toHaveBeenCalledTimes(1); + expect(result).toHaveBeenCalledTimes(1); + expect(reexecuteOperation).toHaveBeenCalledTimes(0); + expect(result.mock.calls[0][0].data).toEqual({ + todos: [ + { + completed: null, + id: '1', + text: 'learn urql', + }, + ], + }); + }); + + it('does not return partial results when an inline-fragment is marked as optional with a required child fragment', () => { + const client = createClient({ + url: 'http://0.0.0.0', + exchanges: [], + }); + const { source: ops$, next } = makeSubject(); + + const query = gql` + { + todos { + id + ... on Todo @_optional { + text + ... on Todo @_required { + completed + } + } + } + } + `; + + const operation = client.createRequestOperation('query', { + key: 1, + query, + variables: undefined, + }); + + const queryResult: OperationResult = { + ...queryResponse, + operation, + data: { + __typename: 'Query', + todos: [ + { + id: '1', + text: 'learn urql', + __typename: 'Todo', + }, + ], + }, + }; + + const reexecuteOperation = vi + .spyOn(client, 'reexecuteOperation') + .mockImplementation(next); + + const response = vi.fn((forwardOp: Operation): OperationResult => { + if (forwardOp.key === 1) return queryResult; + return undefined as any; + }); + + const result = vi.fn(); + const forward: ExchangeIO = ops$ => pipe(ops$, map(response), share); + + pipe( + cacheExchange({})({ forward, client, dispatchDebug })(ops$), + tap(result), + publish + ); + + next(operation); + + expect(response).toHaveBeenCalledTimes(1); + expect(result).toHaveBeenCalledTimes(1); + expect(reexecuteOperation).toHaveBeenCalledTimes(0); + expect(result.mock.calls[0][0].data).toEqual(null); + }); + + it('does not return partial results when an inline-fragment is marked as optional with a required field', () => { + const client = createClient({ + url: 'http://0.0.0.0', + exchanges: [], + }); + const { source: ops$, next } = makeSubject(); + + const query = gql` + { + todos { + id + ... on Todo @_optional { + text + completed @_required + } + } + } + `; + + const operation = client.createRequestOperation('query', { + key: 1, + query, + variables: undefined, + }); + + const queryResult: OperationResult = { + ...queryResponse, + operation, + data: { + __typename: 'Query', + todos: [ + { + id: '1', + text: 'learn urql', + __typename: 'Todo', + }, + ], + }, + }; + + const reexecuteOperation = vi + .spyOn(client, 'reexecuteOperation') + .mockImplementation(next); + + const response = vi.fn((forwardOp: Operation): OperationResult => { + if (forwardOp.key === 1) return queryResult; + return undefined as any; + }); + + const result = vi.fn(); + const forward: ExchangeIO = ops$ => pipe(ops$, map(response), share); + + pipe( + cacheExchange({})({ forward, client, dispatchDebug })(ops$), + tap(result), + publish + ); + + next(operation); + + expect(response).toHaveBeenCalledTimes(1); + expect(result).toHaveBeenCalledTimes(1); + expect(reexecuteOperation).toHaveBeenCalledTimes(0); + expect(result.mock.calls[0][0].data).toEqual(null); + }); + + it('returns partial results when a fragment-definition is marked as optional', () => { + const client = createClient({ + url: 'http://0.0.0.0', + exchanges: [], + }); + const { source: ops$, next } = makeSubject(); + + const query = gql` + { + todos { + id + text + ...Fields + } + } + + fragment Fields on Todo @_optional { + completed + } + `; + + const operation = client.createRequestOperation('query', { + key: 1, + query, + variables: undefined, + }); + + const queryResult: OperationResult = { + ...queryResponse, + operation, + data: { + __typename: 'Query', + todos: [ + { + id: '1', + text: 'learn urql', + __typename: 'Todo', + }, + ], + }, + }; + + const reexecuteOperation = vi + .spyOn(client, 'reexecuteOperation') + .mockImplementation(next); + + const response = vi.fn((forwardOp: Operation): OperationResult => { + if (forwardOp.key === 1) return queryResult; + return undefined as any; + }); + + const result = vi.fn(); + const forward: ExchangeIO = ops$ => pipe(ops$, map(response), share); + + pipe( + cacheExchange({})({ forward, client, dispatchDebug })(ops$), + tap(result), + publish + ); + + next(operation); + + expect(response).toHaveBeenCalledTimes(1); + expect(result).toHaveBeenCalledTimes(1); + expect(reexecuteOperation).toHaveBeenCalledTimes(0); + expect(result.mock.calls[0][0].data).toEqual(null); + }); + it('does not return missing required fields', () => { const client = createClient({ url: 'http://0.0.0.0', diff --git a/exchanges/graphcache/src/operations/query.ts b/exchanges/graphcache/src/operations/query.ts index 2d03120221..c85725e70a 100644 --- a/exchanges/graphcache/src/operations/query.ts +++ b/exchanges/graphcache/src/operations/query.ts @@ -43,6 +43,7 @@ import { updateContext, getFieldError, deferRef, + optionalRef, } from './shared'; import { @@ -145,6 +146,7 @@ const readRoot = ( entityKey, entityKey, deferRef, + undefined, select, ctx ); @@ -389,6 +391,7 @@ const readSelection = ( typename, entityKey, deferRef, + undefined, select, ctx ); @@ -529,6 +532,7 @@ const readSelection = ( !deferRef && dataFieldValue === undefined && (directives.optional || + (optionalRef && !directives.required) || !!getFieldError(ctx) || (store.schema && isFieldNullable(store.schema, typename, fieldName, ctx.store.logger))) @@ -536,7 +540,10 @@ const readSelection = ( // The field is uncached or has errored, so it'll be set to null and skipped ctx.partial = true; dataFieldValue = null; - } else if (dataFieldValue === null && directives.required) { + } else if ( + dataFieldValue === null && + (directives.required || optionalRef === false) + ) { if ( ctx.store.logger && process.env.NODE_ENV !== 'production' && diff --git a/exchanges/graphcache/src/operations/shared.test.ts b/exchanges/graphcache/src/operations/shared.test.ts index 18b8059afd..a9603201d0 100644 --- a/exchanges/graphcache/src/operations/shared.test.ts +++ b/exchanges/graphcache/src/operations/shared.test.ts @@ -34,6 +34,7 @@ describe('makeSelectionIterator', () => { 'Query', 'Query', false, + undefined, selection, ctx ); @@ -93,6 +94,7 @@ describe('makeSelectionIterator', () => { 'Query', 'Query', false, + undefined, selection, ctx ); @@ -123,6 +125,7 @@ describe('makeSelectionIterator', () => { 'Query', 'Query', false, + undefined, selection, ctx ); @@ -208,6 +211,7 @@ describe('makeSelectionIterator', () => { 'Query', 'Query', false, + undefined, selection, ctx ); @@ -243,6 +247,7 @@ describe('makeSelectionIterator', () => { 'Query', 'Query', true, + undefined, selection, ctx ); diff --git a/exchanges/graphcache/src/operations/shared.ts b/exchanges/graphcache/src/operations/shared.ts index 458e599fc8..7ad1800ed6 100644 --- a/exchanges/graphcache/src/operations/shared.ts +++ b/exchanges/graphcache/src/operations/shared.ts @@ -8,7 +8,13 @@ import type { import { Kind } from '@0no-co/graphql.web'; import type { SelectionSet } from '../ast'; -import { isDeferred, getTypeCondition, getSelectionSet, getName } from '../ast'; +import { + isDeferred, + getTypeCondition, + getSelectionSet, + getName, + isOptional, +} from '../ast'; import { warn, pushDebugNode, popDebugNode } from '../helpers/help'; import { hasField, currentOperation, currentOptimistic } from '../store/data'; @@ -49,6 +55,7 @@ export interface Context { export let contextRef: Context | null = null; export let deferRef = false; +export let optionalRef: boolean | undefined = undefined; // Checks whether the current data field is a cache miss because of a GraphQLError export const getFieldError = (ctx: Context): ErrorLike | undefined => @@ -160,6 +167,7 @@ export const makeSelectionIterator = ( typename: void | string, entityKey: string, defer: boolean, + optional: boolean | undefined, selectionSet: FormattedNode, ctx: Context ): SelectionIterator => { @@ -171,6 +179,7 @@ export const makeSelectionIterator = ( while (child || index < selectionSet.length) { node = undefined; deferRef = defer; + optionalRef = optional; if (child) { if ((node = child())) { return node; @@ -203,10 +212,13 @@ export const makeSelectionIterator = ( if (isMatching) { if (process.env.NODE_ENV !== 'production') pushDebugNode(typename, fragment); + + const isFragmentOptional = isOptional(select); child = makeSelectionIterator( typename, entityKey, defer || isDeferred(select, ctx.variables), + isFragmentOptional, getSelectionSet(fragment), ctx ); diff --git a/exchanges/graphcache/src/operations/write.ts b/exchanges/graphcache/src/operations/write.ts index ed3b45398a..a2410178e9 100644 --- a/exchanges/graphcache/src/operations/write.ts +++ b/exchanges/graphcache/src/operations/write.ts @@ -240,6 +240,7 @@ const writeSelection = ( typename, entityKey || typename, deferRef, + undefined, select, ctx );