Mirror: The highly customizable and versatile GraphQL client with which you add on features like normalized caching as you grow.

feat(graphcache): allow for defining inline-fragment/fragment-definition client controlled nullability directives (#3502)

Co-authored-by: Phil Pluckthun <phil@kitten.sh>

Changed files
+394 -2
.changeset
exchanges
+5
.changeset/happy-peas-sin.md
···
+
---
+
'@urql/exchange-graphcache': minor
+
---
+
+
Allow `@_optional` and `@_required` to be placed on fragment definitions and inline fragments
+16
exchanges/graphcache/src/ast/traversal.ts
···
return false;
};
+
+
/** Resolves @_optional and @_required directive to determine whether the fields in a fragment are conaidered optional. */
+
export const isOptional = (
+
node: FormattedNode<FragmentSpreadNode | InlineFragmentNode>
+
): boolean | undefined => {
+
const { optional, required } = getDirectives(node);
+
if (required) {
+
return false;
+
}
+
+
if (optional) {
+
return true;
+
}
+
+
return undefined;
+
};
+346
exchanges/graphcache/src/cacheExchange.test.ts
···
});
});
+
it('Does not return partial data for nested selections', () => {
+
const client = createClient({
+
url: 'http://0.0.0.0',
+
exchanges: [],
+
});
+
const { source: ops$, next } = makeSubject<Operation>();
+
+
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<Operation>();
+
+
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<Operation>();
+
+
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<Operation>();
+
+
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<Operation>();
+
+
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',
+8 -1
exchanges/graphcache/src/operations/query.ts
···
updateContext,
getFieldError,
deferRef,
+
optionalRef,
} from './shared';
import {
···
entityKey,
entityKey,
deferRef,
+
undefined,
select,
ctx
);
···
typename,
entityKey,
deferRef,
+
undefined,
select,
ctx
);
···
!deferRef &&
dataFieldValue === undefined &&
(directives.optional ||
+
(optionalRef && !directives.required) ||
!!getFieldError(ctx) ||
(store.schema &&
isFieldNullable(store.schema, typename, fieldName, ctx.store.logger)))
···
// 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' &&
+5
exchanges/graphcache/src/operations/shared.test.ts
···
'Query',
'Query',
false,
+
undefined,
selection,
ctx
);
···
'Query',
'Query',
false,
+
undefined,
selection,
ctx
);
···
'Query',
'Query',
false,
+
undefined,
selection,
ctx
);
···
'Query',
'Query',
false,
+
undefined,
selection,
ctx
);
···
'Query',
'Query',
true,
+
undefined,
selection,
ctx
);
+13 -1
exchanges/graphcache/src/operations/shared.ts
···
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';
···
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 =>
···
typename: void | string,
entityKey: string,
defer: boolean,
+
optional: boolean | undefined,
selectionSet: FormattedNode<SelectionSet>,
ctx: Context
): SelectionIterator => {
···
while (child || index < selectionSet.length) {
node = undefined;
deferRef = defer;
+
optionalRef = optional;
if (child) {
if ((node = child())) {
return node;
···
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
);
+1
exchanges/graphcache/src/operations/write.ts
···
typename,
entityKey || typename,
deferRef,
+
undefined,
select,
ctx
);