Skip to content

feat: add experimental support for parsing fragment arguments #4015

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 5 commits into from
Sep 6, 2024
Merged
Show file tree
Hide file tree
Changes from 2 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
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,7 @@ export type {
SelectionNode,
FieldNode,
ArgumentNode,
FragmentArgumentNode,
NullabilityAssertionNode,
NonNullAssertionNode,
ErrorBoundaryNode,
Expand Down
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', 'FragmentArgument', { kind: 'BooleanValue', value: false }],
['enter', 'Name', 'v'],
['leave', 'Name', 'v'],
['enter', 'BooleanValue', false],
['leave', 'BooleanValue', false],
['leave', 'FragmentArgument', { 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
13 changes: 11 additions & 2 deletions src/language/ast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ export type ASTNode =
| SelectionSetNode
| FieldNode
| ArgumentNode
| FragmentArgumentNode
| FragmentSpreadNode
| InlineFragmentNode
| FragmentDefinitionNode
Expand Down Expand Up @@ -221,13 +222,14 @@ export const QueryDocumentKeys: {
'nullabilityAssertion',
],
Argument: ['name', 'value'],
FragmentArgument: ['name', 'value'],
// Note: Client Controlled Nullability is experimental and may be changed
// or removed in the future.
ListNullabilityOperator: ['nullabilityAssertion'],
NonNullAssertion: ['nullabilityAssertion'],
ErrorBoundary: ['nullabilityAssertion'],

FragmentSpread: ['name', 'directives'],
FragmentSpread: ['name', 'arguments', 'directives'],
InlineFragment: ['typeCondition', 'directives', 'selectionSet'],
FragmentDefinition: [
'name',
Expand Down Expand Up @@ -422,12 +424,20 @@ export interface ConstArgumentNode {
readonly value: ConstValueNode;
}

export interface FragmentArgumentNode {
readonly kind: Kind.FRAGMENT_ARGUMENT;
readonly loc?: Location | undefined;
readonly name: NameNode;
readonly value: ValueNode;
}

/** Fragments */

export interface FragmentSpreadNode {
readonly kind: Kind.FRAGMENT_SPREAD;
readonly loc?: Location | undefined;
readonly name: NameNode;
readonly arguments?: ReadonlyArray<FragmentArgumentNode> | undefined;
readonly directives?: ReadonlyArray<DirectiveNode> | undefined;
}

Expand All @@ -443,7 +453,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',
}
1 change: 1 addition & 0 deletions src/language/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export type {
ErrorBoundaryNode,
ListNullabilityOperatorNode,
ArgumentNode,
FragmentArgumentNode /* for experimental fragment arguments */,
ConstArgumentNode,
FragmentSpreadNode,
InlineFragmentNode,
Expand Down
1 change: 1 addition & 0 deletions src/language/kinds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export enum Kind {
SELECTION_SET = 'SelectionSet',
FIELD = 'Field',
ARGUMENT = 'Argument',
FRAGMENT_ARGUMENT = 'FragmentArgument',

/** Nullability Modifiers */
LIST_NULLABILITY_OPERATOR = 'ListNullabilityOperator',
Expand Down
61 changes: 47 additions & 14 deletions src/language/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import type {
FieldDefinitionNode,
FieldNode,
FloatValueNode,
FragmentArgumentNode,
FragmentDefinitionNode,
FragmentSpreadNode,
InlineFragmentNode,
Expand Down Expand Up @@ -92,21 +93,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 @@ -524,6 +529,12 @@ export class Parser {
return this.optionalMany(TokenKind.PAREN_L, item, TokenKind.PAREN_R);
}

/* experimental */
parseFragmentArguments(): Array<FragmentArgumentNode> {
const item = this.parseFragmentArgument;
return this.optionalMany(TokenKind.PAREN_L, item, TokenKind.PAREN_R);
}

/**
* Argument[Const] : Name : Value[?Const]
*/
Expand All @@ -545,12 +556,25 @@ export class Parser {
return this.parseArgument(true);
}

/* experimental */
parseFragmentArgument(): FragmentArgumentNode {
const start = this._lexer.token;
const name = this.parseName();

this.expectToken(TokenKind.COLON);
return this.node<FragmentArgumentNode>(start, {
kind: Kind.FRAGMENT_ARGUMENT,
name,
value: this.parseValueLiteral(false),
});
}

// Implements the parsing rules in the Fragments section.

/**
* Corresponds to both FragmentSpread and InlineFragment in the spec.
*
* FragmentSpread : ... FragmentName Directives?
* FragmentSpread : ... FragmentName Arguments? Directives?
*
* InlineFragment : ... TypeCondition? Directives? SelectionSet
*/
Expand All @@ -560,9 +584,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.parseFragmentArguments(),
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 +612,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
Loading
Loading