Skip to content

support @include/@skip directives #23

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

Merged
merged 2 commits into from
Jan 22, 2020
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
33 changes: 32 additions & 1 deletion src/QueryComplexity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,22 @@

import {
getArgumentValues,
getDirectiveValues,
} from 'graphql/execution/values';

import {
ValidationContext,
FragmentDefinitionNode,
OperationDefinitionNode,
DirectiveNode,
FieldNode,
FragmentSpreadNode,
InlineFragmentNode,
assertCompositeType,
GraphQLField, isCompositeType, GraphQLCompositeType, GraphQLFieldMap,
GraphQLSchema, DocumentNode, TypeInfo,
visit, visitWithTypeInfo
visit, visitWithTypeInfo,
GraphQLDirective,
} from 'graphql';
import {
GraphQLUnionType,
Expand Down Expand Up @@ -102,6 +105,8 @@ export default class QueryComplexity {
options: QueryComplexityOptions;
OperationDefinition: Object;
estimators: Array<ComplexityEstimator>;
includeDirectiveDef: GraphQLDirective;
skipDirectiveDef: GraphQLDirective;

constructor(
context: ValidationContext,
Expand All @@ -115,6 +120,9 @@ export default class QueryComplexity {
this.complexity = 0;
this.options = options;

this.includeDirectiveDef = this.context.getSchema().getDirective('include');
this.skipDirectiveDef = this.context.getSchema().getDirective('skip');

if (!options.estimators) {
console.warn(
'DEPRECATION WARNING: Estimators should be configured in the queryComplexity options.'
Expand Down Expand Up @@ -183,6 +191,29 @@ export default class QueryComplexity {
(total: number, childNode: FieldNode | FragmentSpreadNode | InlineFragmentNode) => {
let nodeComplexity = 0;

let includeNode = true;
let skipNode = false;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We only need one variable to determine if the node should be skipped/included, this avoids contradictory (temporary) values...

Copy link
Contributor Author

@zimuliu zimuliu Jan 20, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These two variables were created for a better alignment with the GraphQL standard:

Neither @Skip nor @include has precedence over the other. In the case that both the @Skip and @include directives are provided in on the same the field or fragment, it must be queried only if the @Skip condition is false and the @include condition is true. Stated conversely, the field or fragment must not be queried if either the @Skip condition is true or the @include condition is false.

from: http://spec.graphql.org/June2018/#sec--include

We can technically use a single variable, but it would be a bit harder to follow.


childNode.directives.forEach((directive: DirectiveNode) => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In case a server makes excessive use of directives it would iterate over all the directives, extract values, etc. regardless of whether it was already determined if we include or skip the field.

For example:

{
  field @skip(if: true) @otherDirective @otherDirective2 @etc
}

In this case, we could skip the evaluation of the last 3 directives. I'd use Array.find instead and find the directives that result in the exclusion of the field.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@skip and @include can only be used in queries/mutations from the client, and not in schema definitions on the server side. The chance of multiple directives on a query is really rare. Also, as the directives are not sorted, the big O complexity is the same for both forEach and find. If we use forEach, we just need to scan directives in one pass to get the job done.

Directives from the standard:

directive @include(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT
directive @skip(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT

const directiveName = directive.name.value;
switch (directiveName) {
case 'include': {
const values = getDirectiveValues(this.includeDirectiveDef, childNode, this.options.variables || {});
includeNode = values.if;
break;
}
case 'skip': {
const values = getDirectiveValues(this.skipDirectiveDef, childNode, this.options.variables || {});
skipNode = values.if;
break;
}
}
});

if (!includeNode || skipNode) {
return total;
}

switch (childNode.kind) {
case Kind.FIELD: {
const field = fields[childNode.name.value];
Expand Down
87 changes: 86 additions & 1 deletion src/__tests__/QueryComplexity-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,91 @@ describe('QueryComplexity analysis', () => {
expect(complexity).to.equal(1);
});

it('should respect @include(if: false)', () => {
const ast = parse(`
query {
variableScalar(count: 10) @include(if: false)
}
`);

const complexity = getComplexity({
estimators: [
simpleEstimator({defaultComplexity: 1})
],
schema,
query: ast
});
expect(complexity).to.equal(0);
});

it('should respect @include(if: true)', () => {
const ast = parse(`
query {
variableScalar(count: 10) @include(if: true)
}
`);

const complexity = getComplexity({
estimators: [
simpleEstimator({defaultComplexity: 1})
],
schema,
query: ast
});
expect(complexity).to.equal(1);
});

it('should respect @skip(if: true)', () => {
const ast = parse(`
query {
variableScalar(count: 10) @skip(if: true)
}
`);

const complexity = getComplexity({
estimators: [
simpleEstimator({defaultComplexity: 1})
],
schema,
query: ast
});
expect(complexity).to.equal(0);
});

it('should respect @skip(if: false)', () => {
const ast = parse(`
query {
variableScalar(count: 10) @skip(if: false)
}
`);

const complexity = getComplexity({
estimators: [
simpleEstimator({defaultComplexity: 1})
],
schema,
query: ast
});
expect(complexity).to.equal(1);
});

it('should respect @skip(if: false) @include(if: true)', () => {
const ast = parse(`
query {
variableScalar(count: 10) @skip(if: false) @include(if: true)
}
`);

const complexity = getComplexity({
estimators: [
simpleEstimator({defaultComplexity: 1})
],
schema,
query: ast
});
expect(complexity).to.equal(1);
});

it('should calculate complexity with variables', () => {
const ast = parse(`
query Q($count: Int) {
Expand Down Expand Up @@ -341,7 +426,7 @@ describe('QueryComplexity analysis', () => {
});
expect(Number.isNaN(complexity)).to.equal(true);
});

it('should skip complexity calculation by directiveEstimator when no astNode available on field', () => {
const ast = parse(`
query {
Expand Down