Skip to content

Commit

Permalink
Add support for parsing fragment arguments
Browse files Browse the repository at this point in the history
Co-authored-by: mjmahone <[email protected]>
  • Loading branch information
JoviDeCroock and mjmahone committed Aug 20, 2024
1 parent 5c7d4d1 commit 3918bb5
Show file tree
Hide file tree
Showing 14 changed files with 187 additions and 46 deletions.
25 changes: 22 additions & 3 deletions src/language/__tests__/parser-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -607,13 +607,32 @@ describe('Parser', () => {
expect('loc' in result).to.equal(false);
});

it('Legacy: allows parsing fragment defined variables', () => {
it('allows parsing fragment defined variables', () => {
const document = 'fragment a($v: Boolean = false) on t { f(v: $v) }';

expect(() =>
parse(document, { allowLegacyFragmentVariables: true }),
parse(document, { experimentalFragmentArguments: true }),
).to.not.throw();
expect(() => parse(document)).to.throw('Syntax Error');
});

it('disallows parsing fragment defined variables without experimental flag', () => {
const document = 'fragment a($v: Boolean = false) on t { f(v: $v) }';

expect(() => parse(document)).to.throw();
});

it('allows parsing fragment spread arguments', () => {
const document = 'fragment a on t { ...b(v: $v) }';

expect(() =>
parse(document, { experimentalFragmentArguments: true }),
).to.not.throw();
});

it('disallows parsing fragment spread arguments without experimental flag', () => {
const document = 'fragment a on t { ...b(v: $v) }';

expect(() => parse(document)).to.throw();
});

it('contains location that can be Object.toStringified, JSON.stringified, or jsutils.inspected', () => {
Expand Down
44 changes: 36 additions & 8 deletions src/language/__tests__/printer-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,34 +168,62 @@ describe('Printer: Query document', () => {
`);
});

it('Legacy: prints fragment with variable directives', () => {
const queryASTWithVariableDirective = parse(
it('prints fragment with argument definition directives', () => {
const fragmentWithArgumentDefinitionDirective = parse(
'fragment Foo($foo: TestType @test) on TestType @testDirective { id }',
{ allowLegacyFragmentVariables: true },
{ experimentalFragmentArguments: true },
);
expect(print(queryASTWithVariableDirective)).to.equal(dedent`
expect(print(fragmentWithArgumentDefinitionDirective)).to.equal(dedent`
fragment Foo($foo: TestType @test) on TestType @testDirective {
id
}
`);
});

it('Legacy: correctly prints fragment defined variables', () => {
const fragmentWithVariable = parse(
it('correctly prints fragment defined arguments', () => {
const fragmentWithArgumentDefinition = parse(
`
fragment Foo($a: ComplexType, $b: Boolean = false) on TestType {
id
}
`,
{ allowLegacyFragmentVariables: true },
{ experimentalFragmentArguments: true },
);
expect(print(fragmentWithVariable)).to.equal(dedent`
expect(print(fragmentWithArgumentDefinition)).to.equal(dedent`
fragment Foo($a: ComplexType, $b: Boolean = false) on TestType {
id
}
`);
});

it('prints fragment spread with arguments', () => {
const fragmentSpreadWithArguments = parse(
'fragment Foo on TestType { ...Bar(a: {x: $x}, b: true) }',
{ experimentalFragmentArguments: true },
);
expect(print(fragmentSpreadWithArguments)).to.equal(dedent`
fragment Foo on TestType {
...Bar(a: { x: $x }, b: true)
}
`);
});

it('prints fragment spread with multi-line arguments', () => {
const fragmentSpreadWithArguments = parse(
'fragment Foo on TestType { ...Bar(a: {x: $x, y: $y, z: $z, xy: $xy}, b: true, c: "a long string extending arguments over max length") }',
{ experimentalFragmentArguments: true },
);
expect(print(fragmentSpreadWithArguments)).to.equal(dedent`
fragment Foo on TestType {
...Bar(
a: { x: $x, y: $y, z: $z, xy: $xy }
b: true
c: "a long string extending arguments over max length"
)
}
`);
});

it('prints kitchen sink without altering ast', () => {
const ast = parse(kitchenSinkQuery, {
noLocation: true,
Expand Down
48 changes: 46 additions & 2 deletions src/language/__tests__/visitor-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -455,10 +455,10 @@ describe('Visitor', () => {
]);
});

it('Legacy: visits variables defined in fragments', () => {
it('visits arguments defined on fragments', () => {
const ast = parse('fragment a($v: Boolean = false) on t { f }', {
noLocation: true,
allowLegacyFragmentVariables: true,
experimentalFragmentArguments: true,
});
const visited: Array<any> = [];

Expand Down Expand Up @@ -505,6 +505,50 @@ describe('Visitor', () => {
]);
});

it('visits arguments on fragment spreads', () => {
const ast = parse('fragment a on t { ...s(v: false) }', {
noLocation: true,
experimentalFragmentArguments: true,
});
const visited: Array<any> = [];

visit(ast, {
enter(node) {
checkVisitorFnArgs(ast, arguments);
visited.push(['enter', node.kind, getValue(node)]);
},
leave(node) {
checkVisitorFnArgs(ast, arguments);
visited.push(['leave', node.kind, getValue(node)]);
},
});

expect(visited).to.deep.equal([
['enter', 'Document', undefined],
['enter', 'FragmentDefinition', undefined],
['enter', 'Name', 'a'],
['leave', 'Name', 'a'],
['enter', 'NamedType', undefined],
['enter', 'Name', 't'],
['leave', 'Name', 't'],
['leave', 'NamedType', undefined],
['enter', 'SelectionSet', undefined],
['enter', 'FragmentSpread', undefined],
['enter', 'Name', 's'],
['leave', 'Name', 's'],
['enter', 'Argument', { kind: 'BooleanValue', value: false }],
['enter', 'Name', 'v'],
['leave', 'Name', 'v'],
['enter', 'BooleanValue', false],
['leave', 'BooleanValue', false],
['leave', 'Argument', { kind: 'BooleanValue', value: false }],
['leave', 'FragmentSpread', undefined],
['leave', 'SelectionSet', undefined],
['leave', 'FragmentDefinition', undefined],
['leave', 'Document', undefined],
]);
});

it('properly visits the kitchen sink query', () => {
const ast = parse(kitchenSinkQuery, {
experimentalClientControlledNullability: true,
Expand Down
4 changes: 2 additions & 2 deletions src/language/ast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ export const QueryDocumentKeys: {
NonNullAssertion: ['nullabilityAssertion'],
ErrorBoundary: ['nullabilityAssertion'],

FragmentSpread: ['name', 'directives'],
FragmentSpread: ['name', 'arguments', 'directives'],
InlineFragment: ['typeCondition', 'directives', 'selectionSet'],
FragmentDefinition: [
'name',
Expand Down Expand Up @@ -428,6 +428,7 @@ export interface FragmentSpreadNode {
readonly kind: Kind.FRAGMENT_SPREAD;
readonly loc?: Location | undefined;
readonly name: NameNode;
readonly arguments?: ReadonlyArray<ArgumentNode> | undefined;
readonly directives?: ReadonlyArray<DirectiveNode> | undefined;
}

Expand All @@ -443,7 +444,6 @@ export interface FragmentDefinitionNode {
readonly kind: Kind.FRAGMENT_DEFINITION;
readonly loc?: Location | undefined;
readonly name: NameNode;
/** @deprecated variableDefinitions will be removed in v17.0.0 */
readonly variableDefinitions?:
| ReadonlyArray<VariableDefinitionNode>
| undefined;
Expand Down
1 change: 1 addition & 0 deletions src/language/directiveLocation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,5 @@ export enum DirectiveLocation {
ENUM_VALUE = 'ENUM_VALUE',
INPUT_OBJECT = 'INPUT_OBJECT',
INPUT_FIELD_DEFINITION = 'INPUT_FIELD_DEFINITION',
FRAGMENT_VARIABLE_DEFINITION = 'FRAGMENT_VARIABLE_DEFINITION',
}
41 changes: 27 additions & 14 deletions src/language/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,21 +92,25 @@ export interface ParseOptions {
maxTokens?: number | undefined;

/**
* @deprecated will be removed in the v17.0.0
* EXPERIMENTAL:
*
* If enabled, the parser will understand and parse variable definitions
* contained in a fragment definition. They'll be represented in the
* `variableDefinitions` field of the FragmentDefinitionNode.
* If enabled, the parser will understand and parse fragment variable definitions
* and arguments on fragment spreads. Fragment variable definitions will be represented
* in the `variableDefinitions` field of the FragmentDefinitionNode.
* Fragment spread arguments will be represented in the `arguments` field of FragmentSpreadNode.
*
* The syntax is identical to normal, query-defined variables. For example:
* For example:
*
* ```graphql
* {
* t { ...A(var: true) }
* }
* fragment A($var: Boolean = false) on T {
* ...
* ...B(x: $var)
* }
* ```
*/
allowLegacyFragmentVariables?: boolean | undefined;
experimentalFragmentArguments?: boolean | undefined;

/**
* EXPERIMENTAL:
Expand Down Expand Up @@ -550,7 +554,7 @@ export class Parser {
/**
* Corresponds to both FragmentSpread and InlineFragment in the spec.
*
* FragmentSpread : ... FragmentName Directives?
* FragmentSpread : ... FragmentName Arguments? Directives?
*
* InlineFragment : ... TypeCondition? Directives? SelectionSet
*/
Expand All @@ -560,9 +564,21 @@ export class Parser {

const hasTypeCondition = this.expectOptionalKeyword('on');
if (!hasTypeCondition && this.peek(TokenKind.NAME)) {
const name = this.parseFragmentName();
if (
this.peek(TokenKind.PAREN_L) &&
this._options.experimentalFragmentArguments
) {
return this.node<FragmentSpreadNode>(start, {
kind: Kind.FRAGMENT_SPREAD,
name,
arguments: this.parseArguments(false),
directives: this.parseDirectives(false),
});
}
return this.node<FragmentSpreadNode>(start, {
kind: Kind.FRAGMENT_SPREAD,
name: this.parseFragmentName(),
name,
directives: this.parseDirectives(false),
});
}
Expand All @@ -576,17 +592,14 @@ export class Parser {

/**
* FragmentDefinition :
* - fragment FragmentName on TypeCondition Directives? SelectionSet
* - fragment FragmentName VariableDefinitions? on TypeCondition Directives? SelectionSet
*
* TypeCondition : NamedType
*/
parseFragmentDefinition(): FragmentDefinitionNode {
const start = this._lexer.token;
this.expectKeyword('fragment');
// Legacy support for defining variables within fragments changes
// the grammar of FragmentDefinition:
// - fragment FragmentName VariableDefinitions? on TypeCondition Directives? SelectionSet
if (this._options.allowLegacyFragmentVariables === true) {
if (this._options.experimentalFragmentArguments === true) {
return this.node<FragmentDefinitionNode>(start, {
kind: Kind.FRAGMENT_DEFINITION,
name: this.parseFragmentName(),
Expand Down
27 changes: 19 additions & 8 deletions src/language/printer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,14 +64,9 @@ const printDocASTReducer: ASTReducer<string> = {
selectionSet,
}) {
const prefix = join([wrap('', alias, ': '), name], '');
let argsLine = prefix + wrap('(', join(args, ', '), ')');

if (argsLine.length > MAX_LINE_LENGTH) {
argsLine = prefix + wrap('(\n', indent(join(args, '\n')), '\n)');
}

return join([
argsLine,
wrappedLineAndArgs(prefix, args),
// Note: Client Controlled Nullability is experimental and may be
// changed or removed in the future.
nullabilityAssertion,
Expand Down Expand Up @@ -105,8 +100,12 @@ const printDocASTReducer: ASTReducer<string> = {
// Fragments

FragmentSpread: {
leave: ({ name, directives }) =>
'...' + name + wrap(' ', join(directives, ' ')),
leave: ({ name, arguments: args, directives }) => {
const prefix = '...' + name;
return (
wrappedLineAndArgs(prefix, args) + wrap(' ', join(directives, ' '))
);
},
},

InlineFragment: {
Expand Down Expand Up @@ -392,3 +391,15 @@ function hasMultilineItems(maybeArray: Maybe<ReadonlyArray<string>>): boolean {
/* c8 ignore next */
return maybeArray?.some((str) => str.includes('\n')) ?? false;
}

function wrappedLineAndArgs(
prefix: string,
args: ReadonlyArray<string> | undefined,
): string {
let argsLine = prefix + wrap('(', join(args, ', '), ')');

if (argsLine.length > MAX_LINE_LENGTH) {
argsLine = prefix + wrap('(\n', indent(join(args, '\n')), '\n)');
}
return argsLine;
}
5 changes: 5 additions & 0 deletions src/type/__tests__/introspection-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -859,6 +859,11 @@ describe('Introspection', () => {
isDeprecated: false,
deprecationReason: null,
},
{
name: 'FRAGMENT_VARIABLE_DEFINITION',
isDeprecated: false,
deprecationReason: null,
},
{
name: 'SCHEMA',
isDeprecated: false,
Expand Down
6 changes: 5 additions & 1 deletion src/type/introspection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,11 @@ export const __DirectiveLocation: GraphQLEnumType = new GraphQLEnumType({
},
VARIABLE_DEFINITION: {
value: DirectiveLocation.VARIABLE_DEFINITION,
description: 'Location adjacent to a variable definition.',
description: 'Location adjacent to an operation variable definition.',
},
FRAGMENT_VARIABLE_DEFINITION: {
value: DirectiveLocation.FRAGMENT_VARIABLE_DEFINITION,
description: 'Location adjacent to a fragment variable definition.',
},
SCHEMA: {
value: DirectiveLocation.SCHEMA,
Expand Down
5 changes: 4 additions & 1 deletion src/utilities/__tests__/printSchema-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -971,9 +971,12 @@ describe('Type System Printer', () => {
"""Location adjacent to an inline fragment."""
INLINE_FRAGMENT
"""Location adjacent to a variable definition."""
"""Location adjacent to an operation variable definition."""
VARIABLE_DEFINITION
"""Location adjacent to a fragment variable definition."""
FRAGMENT_VARIABLE_DEFINITION
"""Location adjacent to a schema definition."""
SCHEMA
Expand Down
2 changes: 1 addition & 1 deletion src/utilities/buildASTSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ export function buildSchema(
): GraphQLSchema {
const document = parse(source, {
noLocation: options?.noLocation,
allowLegacyFragmentVariables: options?.allowLegacyFragmentVariables,
experimentalFragmentArguments: options?.experimentalFragmentArguments,
});

return buildASTSchema(document, {
Expand Down
Loading

0 comments on commit 3918bb5

Please sign in to comment.