diff --git a/.changeset/good-spiders-brush.md b/.changeset/good-spiders-brush.md new file mode 100644 index 0000000..412f3f2 --- /dev/null +++ b/.changeset/good-spiders-brush.md @@ -0,0 +1,5 @@ +--- +'@0no-co/graphql.web': minor +--- + +Add support for variable definitions on fragments and arguments on fragment spreads (Fragment Arguments Spec Addition) diff --git a/src/__tests__/__snapshots__/parser.test.ts.snap b/src/__tests__/__snapshots__/parser.test.ts.snap index 1fe3a73..9c58d44 100644 --- a/src/__tests__/__snapshots__/parser.test.ts.snap +++ b/src/__tests__/__snapshots__/parser.test.ts.snap @@ -185,6 +185,7 @@ exports[`parse > parses the kitchen sink document like graphql.js does 1`] = ` "selectionSet": undefined, }, { + "arguments": undefined, "directives": [ { "arguments": undefined, @@ -669,6 +670,7 @@ exports[`parse > parses the kitchen sink document like graphql.js does 1`] = ` "value": "Friend", }, }, + "variableDefinitions": undefined, }, { "directives": undefined, diff --git a/src/__tests__/parser.test.ts b/src/__tests__/parser.test.ts index d35f4ea..51709fe 100644 --- a/src/__tests__/parser.test.ts +++ b/src/__tests__/parser.test.ts @@ -134,6 +134,105 @@ describe('parse', () => { expect(() => parse('fragment Name on Type { field }')).not.toThrow(); }); + it('parses fragment variable definitions', () => { + expect(parse('fragment x($var: Int = 1) on Type { field }').definitions[0]).toEqual({ + kind: Kind.FRAGMENT_DEFINITION, + directives: undefined, + name: { + kind: Kind.NAME, + value: 'x', + }, + typeCondition: { + kind: Kind.NAMED_TYPE, + name: { + kind: Kind.NAME, + value: 'Type', + }, + }, + variableDefinitions: [ + { + kind: Kind.VARIABLE_DEFINITION, + type: { + kind: Kind.NAMED_TYPE, + name: { + kind: Kind.NAME, + value: 'Int', + }, + }, + variable: { + kind: Kind.VARIABLE, + name: { + kind: Kind.NAME, + value: 'var', + }, + }, + defaultValue: { + kind: Kind.INT, + value: '1', + }, + directives: undefined, + }, + ], + selectionSet: { + kind: Kind.SELECTION_SET, + selections: [ + { + alias: undefined, + kind: Kind.FIELD, + directives: undefined, + selectionSet: undefined, + arguments: undefined, + name: { + kind: Kind.NAME, + value: 'field', + }, + }, + ], + }, + }); + }); + + it('parses fragment spread arguments', () => { + expect(parse('query x { ...x(varA: 2, varB: $var) }').definitions[0]).toHaveProperty( + 'selectionSet.selections.0', + { + kind: Kind.FRAGMENT_SPREAD, + directives: undefined, + name: { + kind: Kind.NAME, + value: 'x', + }, + arguments: [ + { + kind: 'Argument', + name: { + kind: 'Name', + value: 'varA', + }, + value: { + kind: 'IntValue', + value: '2', + }, + }, + { + kind: 'Argument', + name: { + kind: 'Name', + value: 'varB', + }, + value: { + kind: 'Variable', + name: { + kind: 'Name', + value: 'var', + }, + }, + }, + ], + } + ); + }); + it('parses fields', () => { expect(() => parse('{ field: }')).toThrow(); expect(() => parse('{ alias: field() }')).toThrow(); diff --git a/src/__tests__/printer.test.ts b/src/__tests__/printer.test.ts index daa7d8f..4a8682c 100644 --- a/src/__tests__/printer.test.ts +++ b/src/__tests__/printer.test.ts @@ -4,6 +4,7 @@ import * as graphql16 from 'graphql16'; import { parse } from '../parser'; import { print, printString, printBlockString } from '../printer'; import kitchenSinkAST from './fixtures/kitchen_sink.json'; +import { Kind } from 'src/kind'; function dedentString(string: string) { const trimmedStr = string @@ -115,6 +116,94 @@ describe('print', () => { ).toBe('[Type!]'); }); + it('prints fragment-definition with variables', () => { + expect( + print({ + kind: Kind.FRAGMENT_DEFINITION, + directives: [], + name: { + kind: Kind.NAME, + value: 'x', + }, + typeCondition: { + kind: Kind.NAMED_TYPE, + name: { + kind: Kind.NAME, + value: 'Type', + }, + }, + variableDefinitions: [ + { + kind: Kind.VARIABLE_DEFINITION, + type: { + kind: Kind.NAMED_TYPE, + name: { + kind: Kind.NAME, + value: 'Int', + }, + }, + variable: { + kind: Kind.VARIABLE, + name: { + kind: Kind.NAME, + value: 'var', + }, + }, + defaultValue: { + kind: Kind.INT, + value: '1', + }, + directives: [], + }, + ], + selectionSet: { + kind: Kind.SELECTION_SET, + selections: [ + { + alias: undefined, + kind: Kind.FIELD, + directives: [], + selectionSet: undefined, + arguments: [], + name: { + kind: Kind.NAME, + value: 'field', + }, + }, + ], + }, + } as any) + ).toBe(`fragment x($var: Int = 1) on Type { + field +}`); + }); + + it('prints fragment-spread with arguments', () => { + expect( + print({ + kind: Kind.FRAGMENT_SPREAD, + directives: [], + name: { + kind: Kind.NAME, + value: 'x', + }, + arguments: [ + { + kind: 'Argument', + name: { + kind: 'Name', + value: 'var', + }, + value: { + kind: 'IntValue', + value: '2', + }, + }, + ], + } as any) + ).toBe(`...x(var: 2)`); + }); + // NOTE: The shim won't throw for invalid AST nodes it('returns empty strings for invalid AST', () => { const badAST = { random: 'Data' }; diff --git a/src/ast.ts b/src/ast.ts index 6287f26..1008147 100644 --- a/src/ast.ts +++ b/src/ast.ts @@ -191,7 +191,9 @@ export type FragmentSpreadNode = Or< readonly directives?: ReadonlyArray; readonly loc?: Location; } ->; +> & { + readonly arguments?: ReadonlyArray; +}; export type InlineFragmentNode = Or< GraphQL.InlineFragmentNode, @@ -209,6 +211,7 @@ export type FragmentDefinitionNode = Or< { readonly kind: Kind.FRAGMENT_DEFINITION; readonly name: NameNode; + readonly variableDefinitions?: ReadonlyArray; readonly typeCondition: NamedTypeNode; readonly directives?: ReadonlyArray; readonly selectionSet: SelectionSetNode; diff --git a/src/parser.ts b/src/parser.ts index a97355c..6bcd720 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -328,8 +328,9 @@ function selectionSet(): ast.SelectionSetNode { selections.push({ kind: 'FragmentSpread' as Kind.FRAGMENT_SPREAD, name: { kind: 'Name' as Kind.NAME, value: match }, + arguments: arguments_(false), directives: directives(false), - }); + } as ast.FragmentSpreadNode); } else { ignored(); if (match === 'on') { @@ -434,7 +435,7 @@ function fragmentDefinition(): ast.FragmentDefinitionNode { let _name: string | undefined; let _condition: string | undefined; if ((_name = advance(nameRe)) == null) throw error('FragmentDefinition'); - ignored(); + const _variableDefinitions = variableDefinitions(); if (advance(nameRe) !== 'on') throw error('FragmentDefinition'); ignored(); if ((_condition = advance(nameRe)) == null) throw error('FragmentDefinition'); @@ -449,6 +450,7 @@ function fragmentDefinition(): ast.FragmentDefinitionNode { kind: 'NamedType' as Kind.NAMED_TYPE, name: { kind: 'Name' as Kind.NAME, value: _condition }, }, + variableDefinitions: _variableDefinitions, directives: _directives, selectionSet: selectionSet(), }; diff --git a/src/printer.ts b/src/printer.ts index 10a11bc..54cd639 100644 --- a/src/printer.ts +++ b/src/printer.ts @@ -47,6 +47,15 @@ const MAX_LINE_LENGTH = 80; let LF = '\n'; +function Arguments(length: number, node: readonly ArgumentNode[]): string { + const args = mapJoin(node, ', ', nodes.Argument); + if (length + args.length + 2 > MAX_LINE_LENGTH) { + return '(' + (LF += ' ') + mapJoin(node, LF, nodes.Argument) + (LF = LF.slice(0, -2)) + ')'; + } else { + return '(' + args + ')'; + } +} + const nodes = { OperationDefinition(node: OperationDefinitionNode): string { let out: string = node.operation; @@ -70,19 +79,7 @@ const nodes = { }, Field(node: FieldNode): string { let out = node.alias ? node.alias.value + ': ' + node.name.value : node.name.value; - if (node.arguments && node.arguments.length) { - const args = mapJoin(node.arguments, ', ', nodes.Argument); - if (out.length + args.length + 2 > MAX_LINE_LENGTH) { - out += - '(' + - (LF += ' ') + - mapJoin(node.arguments, LF, nodes.Argument) + - (LF = LF.slice(0, -2)) + - ')'; - } else { - out += '(' + args + ')'; - } - } + if (node.arguments && node.arguments.length) out += Arguments(out.length, node.arguments); if (node.directives && node.directives.length) out += ' ' + mapJoin(node.directives, ' ', nodes.Directive); if (node.selectionSet) out += ' ' + nodes.SelectionSet(node.selectionSet); @@ -137,6 +134,7 @@ const nodes = { }, FragmentSpread(node: FragmentSpreadNode): string { let out = '...' + node.name.value; + if (node.arguments && node.arguments.length) out += Arguments(out.length, node.arguments); if (node.directives && node.directives.length) out += ' ' + mapJoin(node.directives, ' ', nodes.Directive); return out; @@ -151,6 +149,8 @@ const nodes = { }, FragmentDefinition(node: FragmentDefinitionNode): string { let out = 'fragment ' + node.name.value; + if (node.variableDefinitions && node.variableDefinitions.length) + out += '(' + mapJoin(node.variableDefinitions, ', ', nodes.VariableDefinition) + ')'; out += ' on ' + node.typeCondition.name.value; if (node.directives && node.directives.length) out += ' ' + mapJoin(node.directives, ' ', nodes.Directive);