Skip to content

Commit

Permalink
feat(core/graphcache): Add support for client-side-only directive pro…
Browse files Browse the repository at this point in the history
…cessing (#3317)
  • Loading branch information
kitten authored Jul 19, 2023
1 parent 7ddccc1 commit d3eb256
Show file tree
Hide file tree
Showing 23 changed files with 467 additions and 320 deletions.
5 changes: 5 additions & 0 deletions .changeset/brave-lamps-punch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@urql/exchange-graphcache': patch
---

Use new `FormattedNode` / `formatDocument` functionality added to `@urql/core` to slightly speed up directive processing by using the client-side `_directives` dictionary that `formatDocument` adds.
5 changes: 5 additions & 0 deletions .changeset/rude-waves-check.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@urql/core': minor
---

Update `formatDocument` to output `FormattedNode` type mapping. The formatter will now annotate added `__typename` fields with `_generated: true`, place selection nodes' directives onto a `_directives` dictionary, and will filter directives to not include `"_"` underscore prefixed directives in the final query. This prepares us for a feature that allows enhanced client-side directives in Graphcache.
29 changes: 16 additions & 13 deletions exchanges/graphcache/src/ast/node.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,23 @@
import {
NamedTypeNode,
NameNode,
DirectiveNode,
SelectionNode,
SelectionSetNode,
InlineFragmentNode,
FieldNode,
FragmentDefinitionNode,
Kind,
} from '@0no-co/graphql.web';

export type SelectionSet = ReadonlyArray<SelectionNode>;
import { FormattedNode } from '@urql/core';

export type SelectionSet = readonly FormattedNode<SelectionNode>[];

const EMPTY_DIRECTIVES: Record<string, DirectiveNode | undefined> = {};

/** Returns the directives dictionary of a given node */
export const getDirectives = (node: {
_directives?: Record<string, DirectiveNode | undefined>;
}) => node._directives || EMPTY_DIRECTIVES;

/** Returns the name of a given node */
export const getName = (node: { name: NameNode }): string => node.name.value;
Expand All @@ -25,18 +33,13 @@ const emptySelectionSet: SelectionSet = [];

/** Returns the SelectionSet for a given inline or defined fragment node */
export const getSelectionSet = (node: {
selectionSet?: SelectionSetNode;
}): SelectionSet =>
node.selectionSet ? node.selectionSet.selections : emptySelectionSet;
selectionSet?: FormattedNode<SelectionSetNode>;
}): FormattedNode<SelectionSet> =>
(node.selectionSet
? node.selectionSet.selections
: emptySelectionSet) as FormattedNode<SelectionSet>;

export const getTypeCondition = (node: {
typeCondition?: NamedTypeNode;
}): string | null =>
node.typeCondition ? node.typeCondition.name.value : null;

export const isFieldNode = (node: SelectionNode): node is FieldNode =>
node.kind === Kind.FIELD;

export const isInlineFragment = (
node: SelectionNode
): node is InlineFragmentNode => node.kind === Kind.INLINE_FRAGMENT;
37 changes: 22 additions & 15 deletions exchanges/graphcache/src/ast/traversal.test.ts
Original file line number Diff line number Diff line change
@@ -1,83 +1,90 @@
import { gql } from '@urql/core';
import { formatDocument, gql } from '@urql/core';
import { describe, it, expect } from 'vitest';

import { getSelectionSet } from './node';
import { getMainOperation, shouldInclude } from './traversal';

describe('getMainOperation', () => {
it('retrieves the first operation', () => {
const doc = gql`
const doc = formatDocument(gql`
query Query {
field
}
`;
`);

const operation = getMainOperation(doc);
expect(operation).toBe(doc.definitions[0]);
});

it('throws when no operation is found', () => {
const doc = gql`
const doc = formatDocument(gql`
fragment _ on Query {
field
}
`;
`);

expect(() => getMainOperation(doc)).toThrow();
});
});

describe('shouldInclude', () => {
it('should include fields with truthy @include or falsy @skip directives', () => {
const doc = gql`
const doc = formatDocument(gql`
{
fieldA @include(if: true)
fieldB @skip(if: false)
}
`;
`);

const fieldA = getSelectionSet(getMainOperation(doc))[0];
const fieldB = getSelectionSet(getMainOperation(doc))[1];
expect(shouldInclude(fieldA, {})).toBe(true);
expect(shouldInclude(fieldB, {})).toBe(true);
});

it('should exclude fields with falsy @include or truthy @skip directives', () => {
const doc = gql`
const doc = formatDocument(gql`
{
fieldA @include(if: false)
fieldB @skip(if: true)
}
`;
`);

const fieldA = getSelectionSet(getMainOperation(doc))[0];
const fieldB = getSelectionSet(getMainOperation(doc))[1];
expect(shouldInclude(fieldA, {})).toBe(false);
expect(shouldInclude(fieldB, {})).toBe(false);
});

it('ignore other directives', () => {
const doc = gql`
const doc = formatDocument(gql`
{
field @test(if: false)
}
`;
`);

const field = getSelectionSet(getMainOperation(doc))[0];
expect(shouldInclude(field, {})).toBe(true);
});

it('ignore unknown arguments on directives', () => {
const doc = gql`
const doc = formatDocument(gql`
{
field @skip(if: true, other: false)
}
`;
`);

const field = getSelectionSet(getMainOperation(doc))[0];
expect(shouldInclude(field, {})).toBe(false);
});

it('ignore directives with invalid first arguments', () => {
const doc = gql`
const doc = formatDocument(gql`
{
field @skip(other: true)
}
`;
`);

const field = getSelectionSet(getMainOperation(doc))[0];
expect(shouldInclude(field, {})).toBe(true);
});
Expand Down
80 changes: 39 additions & 41 deletions exchanges/graphcache/src/ast/traversal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,21 @@ import {
Kind,
} from '@0no-co/graphql.web';

import { getName } from './node';

import { FormattedNode } from '@urql/core';
import { getName, getDirectives } from './node';
import { invariant } from '../helpers/help';
import { Fragments, Variables } from '../types';

function getMainOperation(
doc: FormattedNode<DocumentNode>
): FormattedNode<OperationDefinitionNode>;
function getMainOperation(doc: DocumentNode): OperationDefinitionNode;

/** Returns the main operation's definition */
export const getMainOperation = (
doc: DocumentNode
): OperationDefinitionNode => {
function getMainOperation(doc: DocumentNode): OperationDefinitionNode {
for (let i = 0; i < doc.definitions.length; i++) {
if (doc.definitions[i].kind === Kind.OPERATION_DEFINITION) {
return doc.definitions[i] as OperationDefinitionNode;
return doc.definitions[i] as FormattedNode<OperationDefinitionNode>;
}
}

Expand All @@ -29,10 +32,12 @@ export const getMainOperation = (
'node for a query, subscription, or mutation.',
1
);
};
}

export { getMainOperation };

/** Returns a mapping from fragment names to their selections */
export const getFragments = (doc: DocumentNode): Fragments => {
export const getFragments = (doc: FormattedNode<DocumentNode>): Fragments => {
const fragments: Fragments = {};
for (let i = 0; i < doc.definitions.length; i++) {
const node = doc.definitions[i];
Expand All @@ -46,52 +51,45 @@ export const getFragments = (doc: DocumentNode): Fragments => {

/** Resolves @include and @skip directives to determine whether field is included. */
export const shouldInclude = (
node: SelectionNode,
node: FormattedNode<SelectionNode>,
vars: Variables
): boolean => {
// Finds any @include or @skip directive that forces the node to be skipped
for (let i = 0; node.directives && i < node.directives.length; i++) {
const directive = node.directives[i];
const name = getName(directive);
if (
(name === 'include' || name === 'skip') &&
directive.arguments &&
directive.arguments[0] &&
getName(directive.arguments[0]) === 'if'
) {
// Return whether this directive forces us to skip
// `@include(if: false)` or `@skip(if: true)`
const value = valueFromASTUntyped(directive.arguments[0].value, vars);
return name === 'include' ? !!value : !value;
const directives = getDirectives(node);
if (directives.include || directives.skip) {
// Finds any @include or @skip directive that forces the node to be skipped
for (const name in directives) {
const directive = directives[name];
if (
directive &&
(name === 'include' || name === 'skip') &&
directive.arguments &&
directive.arguments[0] &&
getName(directive.arguments[0]) === 'if'
) {
// Return whether this directive forces us to skip
// `@include(if: false)` or `@skip(if: true)`
const value = valueFromASTUntyped(directive.arguments[0].value, vars);
return name === 'include' ? !!value : !value;
}
}
}

return true;
};

/** Resolves @defer directive to determine whether a fragment is potentially skipped. */
export const isDeferred = (
node: FragmentSpreadNode | InlineFragmentNode,
node: FormattedNode<FragmentSpreadNode | InlineFragmentNode>,
vars: Variables
): boolean => {
for (let i = 0; node.directives && i < node.directives.length; i++) {
const directive = node.directives[i];
const name = getName(directive);
if (name === 'defer') {
for (
let j = 0;
directive.arguments && j < directive.arguments.length;
j++
) {
const argument = directive.arguments[i];
if (getName(argument) === 'if') {
// Return whether `@defer(if: )` is enabled
return !!valueFromASTUntyped(argument.value, vars);
}
const { defer } = getDirectives(node);
if (defer) {
for (const argument of defer.arguments || []) {
if (getName(argument) === 'if') {
// Return whether `@defer(if: )` is enabled
return !!valueFromASTUntyped(argument.value, vars);
}

return true;
}
return true;
}

return false;
Expand Down
Loading

0 comments on commit d3eb256

Please sign in to comment.