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: add experimental support for parsing fragment arguments #4015

Open
wants to merge 1 commit into
base: main
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
25 changes: 22 additions & 3 deletions src/language/__tests__/parser-test.ts
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
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
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
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
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',
}
53 changes: 30 additions & 23 deletions src/language/parser.ts
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,29 +592,20 @@ 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) {
return this.node<FragmentDefinitionNode>(start, {
kind: Kind.FRAGMENT_DEFINITION,
name: this.parseFragmentName(),
variableDefinitions: this.parseVariableDefinitions(),
typeCondition: (this.expectKeyword('on'), this.parseNamedType()),
directives: this.parseDirectives(false),
selectionSet: this.parseSelectionSet(),
});
}
return this.node<FragmentDefinitionNode>(start, {
kind: Kind.FRAGMENT_DEFINITION,
name: this.parseFragmentName(),
variableDefinitions:
this._options.experimentalFragmentArguments === true
? this.parseVariableDefinitions()
: undefined,
typeCondition: (this.expectKeyword('on'), this.parseNamedType()),
directives: this.parseDirectives(false),
selectionSet: this.parseSelectionSet(),
Expand Down
27 changes: 19 additions & 8 deletions src/language/printer.ts
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
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
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
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
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