Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(core/graphcache): Add support for client-side-only directive processing #3317

Merged
merged 9 commits into from
Jul 19, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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