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

Implement connection directive #319

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
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
50 changes: 48 additions & 2 deletions src/ParsedQueryNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ import { CacheContext } from './context';
import { ConflictingFieldsError } from './errors';
import { DeepReadonly, JsonScalar, JsonObject, JsonValue, NestedObject, NestedValue } from './primitive';
import {
FieldNode,
ArgumentNode,
FragmentMap,
SelectionNode,
SelectionSetNode,
DirectiveNode,
ValueNode,
isObject,
fieldHasStaticDirective,
Expand Down Expand Up @@ -130,7 +132,7 @@ function _buildNodeMap(
// the schema. E.g. parameters are ignored, and an alias is considered
// to be truth.
if (!fieldHasStaticDirective(selection)) {
args = _buildFieldArgs(variables, selection.arguments);
args = _buildFieldArgs(variables, selection);
schemaName = selection.alias ? selection.name.value : undefined;
}

Expand Down Expand Up @@ -179,8 +181,15 @@ export function areChildrenDynamic(children?: ParsedQueryWithVariables) {
/**
* Build the map of arguments to their natural JS values (or variables).
*/
function _buildFieldArgs(variables: Set<string>, argumentsNode?: ArgumentNode[]) {
function _buildFieldArgs(variables: Set<string>, selection: FieldNode) {
const argumentsNode = selection.arguments;
if (!argumentsNode) return undefined;
if (selection.directives) {
const foundConnectionDirective = selection.directives.find(x => x.name.value === 'connection');
if (foundConnectionDirective) {
return _buildFieldArgsForConnectionDirective(variables, selection, foundConnectionDirective);
}
}

const args = {};
for (const arg of argumentsNode) {
Expand All @@ -191,6 +200,43 @@ function _buildFieldArgs(variables: Set<string>, argumentsNode?: ArgumentNode[])
return Object.keys(args).length ? args : undefined;
}

/**
* Applies the connection directive as described on (https://www.apollographql.com/docs/react/recipes/pagination.html#connection-directive)
*/
function _buildFieldArgsForConnectionDirective(variables: Set<string>, selection: FieldNode, connectionDirective: DirectiveNode) {
const argumentsNode = selection.arguments;
if (!argumentsNode) {
return undefined;
}
if (!connectionDirective.arguments || connectionDirective.arguments.length === 0) {
throw new Error('the connection directive requires arguments');
}
return connectionDirective.arguments.reduce((acc: Object, directive: ArgumentNode) => {
const name = directive.name.value;
const value = _valueFromNode(variables, directive.value);
if (name === 'key') {
if (typeof value !== 'string') {
throw new Error('the connection directive only supports keys which are strings');
}
return {
...acc,
key: value,
};
}
if (name === 'filter') {
if (!(value instanceof Array)) {
throw new Error('the connection directive only supports a list of keys');
}
const filterArgs = argumentsNode.filter(arg => value.some(v => v === arg.name.value));
return filterArgs.reduce((args, filterArg) => ({
...args,
[filterArg.name.value]: _valueFromNode(variables, filterArg.value),
}), acc);
}
throw new Error('Connection directive expects arguments of either key or filter');
}, {});
}

/**
* Evaluate a ValueNode and yield its value in its natural JS form.
*/
Expand Down
3 changes: 2 additions & 1 deletion src/util/ast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ export {
SelectionNode,
SelectionSetNode,
ValueNode,
// FieldNode,
FieldNode,
DirectiveNode,
} from 'graphql';

/**
Expand Down
203 changes: 203 additions & 0 deletions test/unit/ParsedQueryNode/directives.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,207 @@ describe(`parseQuery with queries with directives`, () => {
);
});

describe('the connection directive', () => {
it('fails when the directive is passed invalid arguments', () => {
const tests = [`
query getThings {
viewer {
friends @connection(key: 123) {
name
email
mobile
}
}
}
`, `
query getThings {
viewer {
friends @connection(filter: "wowie zowie") {
name
email
mobile
}
}
}
`, `
query getThings {
viewer {
friends @connection(someRandomArgs: "wowie zowie") {
name
email
mobile
}
}
}
`, `
query getThings {
viewer {
friends @connection {
name
email
mobile
}
}
}
`];
tests.forEach(test => expect(() => parseOperation(test)).to.throw(Error));
});

it(`ignores arguments and caches at the given key`, () => {
const operation1 = `
query getThings {
viewer {
friends(first: 50, after: "id") @connection(key: "friends") {
name
email
mobile
}
}
}
`;
const operation2 = `
query getThings {
viewer {
friends(first: 50, after: "id3") @connection(key: "friends") {
name
email
mobile
}
}
}
`;
expect(parseOperation(operation1)).to.deep.eq(parseOperation(operation2));
});

it(`caches by the key argument`, () => {
const operation1 = `
query FriendsQuery {
viewer {
friends(first: 50, after: "id") @connection(key: "friends") {
name
email
mobile
}
}
}
`;
const operation2 = `
query FriendsQuery {
viewer {
friends(first: 50, after: "id3") @connection(key: "friend") {
name
email
mobile
}
}
}
`;
expect(parseOperation(operation1)).to.not.deep.eq(parseOperation(operation2));
});

it(`caches by the combination of key and filter`, () => {
const operation1 = `
query FriendsQuery {
viewer {
friends(onlyCloseFriends: true, first: 1) @connection(key: "friends", filter: ["onlyCloseFriends"]) {
name
email
mobile
}
}
}
`;
const operation2 = `
query FriendsQuery {
viewer {
friends(onlyCloseFriends: true, first: 2) @connection(key: "friends", filter: ["onlyCloseFriends"]) {
name
email
mobile
}
}
}
`;
const operation3 = `
query FriendsQuery {
viewer {
friends(onlyCloseFriends: false, first: 1) @connection(key: "friends", filter: ["onlyCloseFriends"]) {
name
email
mobile
}
}
}
`;
expect(parseOperation(operation1)).to.deep.eq(parseOperation(operation2));
expect(parseOperation(operation1)).to.not.deep.eq(parseOperation(operation3));
});

it('caches based on multiple filters', () => {
const operation1 = `
query FriendsQuery {
viewer {
friends(
onlyCloseFriends: true,
attractive: true,
first: 1
) @connection(key: "friends", filter: ["onlyCloseFriends", "attractive"]) {
name
email
mobile
}
}
}
`;
const operation2 = `
query FriendsQuery {
viewer {
friends(
onlyCloseFriends: true,
attractive: true,
first: 2
) @connection(key: "friends", filter: ["onlyCloseFriends", "attractive"]) {
name
email
mobile
}
}
}
`;
const operation3 = `
query FriendsQuery {
viewer {
friends(
onlyCloseFriends: false,
attractive: true,
first: 1
) @connection(key: "friends", filter: ["onlyCloseFriends", "attractive"]) {
name
email
mobile
}
}
}
`;
const operation4 = `
query FriendsQuery {
viewer {
friends(
onlyCloseFriends: false,
attractive: false,
first: 1
) @connection(key: "friends", filter: ["onlyCloseFriends", "attractive"]) {
name
email
mobile
}
}
}
`;
expect(parseOperation(operation1)).to.deep.eq(parseOperation(operation2));
expect(parseOperation(operation1)).to.not.deep.eq(parseOperation(operation3));
expect(parseOperation(operation1)).to.not.deep.eq(parseOperation(operation4));
expect(parseOperation(operation3)).to.not.deep.eq(parseOperation(operation4));
});
});
});