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 4b690c1..f618e56 100644 --- a/src/__tests__/parser.test.ts +++ b/src/__tests__/parser.test.ts @@ -174,6 +174,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: 'FragmentArgument', + name: { + kind: 'Name', + value: 'varA', + }, + value: { + kind: 'IntValue', + value: '2', + }, + }, + { + kind: 'FragmentArgument', + 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 4ad014d..9dfd643 100644 --- a/src/__tests__/printer.test.ts +++ b/src/__tests__/printer.test.ts @@ -117,6 +117,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: 'FragmentArgument', + 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 c94024d..5a93641 100644 --- a/src/ast.ts +++ b/src/ast.ts @@ -27,52 +27,54 @@ import type { InputObjectTypeExtensionNode, } from './schemaAst'; -export type ASTNode = Or< - GraphQL.ASTNode, - | NameNode - | DocumentNode - | OperationDefinitionNode - | VariableDefinitionNode - | VariableNode - | SelectionSetNode - | FieldNode - | ArgumentNode - | FragmentSpreadNode - | InlineFragmentNode - | FragmentDefinitionNode - | IntValueNode - | FloatValueNode - | StringValueNode - | BooleanValueNode - | NullValueNode - | EnumValueNode - | ListValueNode - | ObjectValueNode - | ObjectFieldNode - | DirectiveNode - | NamedTypeNode - | ListTypeNode - | NonNullTypeNode - | SchemaDefinitionNode - | OperationTypeDefinitionNode - | ScalarTypeDefinitionNode - | ObjectTypeDefinitionNode - | FieldDefinitionNode - | InputValueDefinitionNode - | InterfaceTypeDefinitionNode - | UnionTypeDefinitionNode - | EnumTypeDefinitionNode - | EnumValueDefinitionNode - | InputObjectTypeDefinitionNode - | DirectiveDefinitionNode - | SchemaExtensionNode - | ScalarTypeExtensionNode - | ObjectTypeExtensionNode - | InterfaceTypeExtensionNode - | UnionTypeExtensionNode - | EnumTypeExtensionNode - | InputObjectTypeExtensionNode ->; +export type ASTNode = + | Or< + GraphQL.ASTNode, + | NameNode + | DocumentNode + | OperationDefinitionNode + | VariableDefinitionNode + | VariableNode + | SelectionSetNode + | FieldNode + | ArgumentNode + | FragmentSpreadNode + | InlineFragmentNode + | FragmentDefinitionNode + | IntValueNode + | FloatValueNode + | StringValueNode + | BooleanValueNode + | NullValueNode + | EnumValueNode + | ListValueNode + | ObjectValueNode + | ObjectFieldNode + | DirectiveNode + | NamedTypeNode + | ListTypeNode + | NonNullTypeNode + | SchemaDefinitionNode + | OperationTypeDefinitionNode + | ScalarTypeDefinitionNode + | ObjectTypeDefinitionNode + | FieldDefinitionNode + | InputValueDefinitionNode + | InterfaceTypeDefinitionNode + | UnionTypeDefinitionNode + | EnumTypeDefinitionNode + | EnumValueDefinitionNode + | InputObjectTypeDefinitionNode + | DirectiveDefinitionNode + | SchemaExtensionNode + | ScalarTypeExtensionNode + | ObjectTypeExtensionNode + | InterfaceTypeExtensionNode + | UnionTypeExtensionNode + | EnumTypeExtensionNode + | InputObjectTypeExtensionNode + > + | FragmentArgumentNode; export type NameNode = Or< GraphQL.NameNode, @@ -147,10 +149,7 @@ export type SelectionSetNode = Or< } >; -export declare type SelectionNode = Or< - GraphQL.SelectionNode, - FieldNode | FragmentSpreadNode | InlineFragmentNode ->; +export declare type SelectionNode = FieldNode | FragmentSpreadNode | InlineFragmentNode; export type FieldNode = Or< GraphQL.FieldNode, @@ -185,6 +184,13 @@ export type ConstArgumentNode = Or< } >; +export type FragmentArgumentNode = { + readonly kind: 'FragmentArgument'; + readonly name: NameNode; + readonly value: ValueNode; + readonly loc?: Location; +}; + export type FragmentSpreadNode = Or< GraphQL.FragmentSpreadNode, { @@ -193,7 +199,9 @@ export type FragmentSpreadNode = Or< readonly directives?: ReadonlyArray; readonly loc?: Location; } ->; +> & { + readonly arguments?: ReadonlyArray; +}; export type InlineFragmentNode = Or< GraphQL.InlineFragmentNode, @@ -217,7 +225,9 @@ export type FragmentDefinitionNode = Or< readonly selectionSet: SelectionSetNode; readonly loc?: Location; } ->; +> & { + readonly variableDefinitions?: ReadonlyArray; +}; export type ValueNode = Or< GraphQL.ValueNode, diff --git a/src/parser.ts b/src/parser.ts index afdc0de..dc15d85 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -245,9 +245,17 @@ function value(constant: boolean): ast.ValueNode { }; } -function arguments_(constant: boolean): ast.ArgumentNode[] | undefined { +function arguments_(constant: boolean, fragmentArgument: false): ast.ArgumentNode[] | undefined; +function arguments_( + constant: boolean, + fragmentArgument: true +): ast.FragmentArgumentNode[] | undefined; +function arguments_( + constant: boolean, + fragmentArgument: boolean +): ast.ArgumentNode[] | ast.FragmentArgumentNode[] | undefined { if (input.charCodeAt(idx) === 40 /*'('*/) { - const args: ast.ArgumentNode[] = []; + const args: (ast.ArgumentNode | ast.FragmentArgumentNode)[] = []; idx++; ignored(); do { @@ -255,14 +263,14 @@ function arguments_(constant: boolean): ast.ArgumentNode[] | undefined { if (input.charCodeAt(idx++) !== 58 /*':'*/) throw error('Argument'); ignored(); args.push({ - kind: 'Argument' as Kind.ARGUMENT, + kind: fragmentArgument ? 'FragmentArgument' : ('Argument' as Kind.ARGUMENT), name, value: value(constant), }); } while (input.charCodeAt(idx) !== 41 /*')'*/); idx++; ignored(); - return args; + return args as ast.ArgumentNode[] | ast.FragmentArgumentNode[]; } } @@ -277,7 +285,7 @@ function directives(constant: boolean): ast.DirectiveNode[] | undefined { directives.push({ kind: 'Directive' as Kind.DIRECTIVE, name: nameNode(), - arguments: arguments_(constant), + arguments: arguments_(constant, false), }); } while (input.charCodeAt(idx) === 64 /*'@'*/); return directives; @@ -357,6 +365,7 @@ function selectionSet(): ast.SelectionSetNode { selections.push({ kind: 'FragmentSpread' as Kind.FRAGMENT_SPREAD, name: nameNode(), + arguments: arguments_(false, true) as readonly ast.FragmentArgumentNode[], directives: directives(false), }); } @@ -377,6 +386,7 @@ function selectionSet(): ast.SelectionSetNode { selections.push({ kind: 'FragmentSpread' as Kind.FRAGMENT_SPREAD, name: nameNode(), + arguments: arguments_(false, true), directives: directives(false), }); } @@ -389,7 +399,7 @@ function selectionSet(): ast.SelectionSetNode { alias = name; name = nameNode(); } - const _arguments = arguments_(false); + const _arguments = arguments_(false, false); const _directives = directives(false); let _selectionSet: ast.SelectionSetNode | undefined; if (input.charCodeAt(idx) === 123 /*'{'*/) { @@ -461,6 +471,7 @@ function variableDefinitions(): ast.VariableDefinitionNode[] | undefined { function fragmentDefinition(description?: ast.StringValueNode): ast.FragmentDefinitionNode { const name = nameNode(); + const _variableDefinitions = variableDefinitions(); if (input.charCodeAt(idx++) !== 111 /*'o'*/ || input.charCodeAt(idx++) !== 110 /*'n'*/) throw error('FragmentDefinition'); ignored(); @@ -471,6 +482,7 @@ function fragmentDefinition(description?: ast.StringValueNode): ast.FragmentDefi kind: 'NamedType' as Kind.NAMED_TYPE, name: nameNode(), }, + variableDefinitions: _variableDefinitions, directives: directives(false), selectionSet: selectionSetStart(), }; diff --git a/src/printer.ts b/src/printer.ts index 9643600..2efdebf 100644 --- a/src/printer.ts +++ b/src/printer.ts @@ -24,6 +24,7 @@ import type { NamedTypeNode, ListTypeNode, NonNullTypeNode, + FragmentArgumentNode, } from './ast'; function mapJoin(value: readonly T[], joiner: string, mapper: (value: T) => string): string { @@ -47,6 +48,18 @@ const MAX_LINE_LENGTH = 80; let LF = '\n'; +function Arguments( + length: number, + node: readonly ArgumentNode[] | readonly FragmentArgumentNode[] +): 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 = ''; @@ -77,19 +90,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 && node.selectionSet.selections.length) { @@ -141,11 +142,12 @@ const nodes = { SelectionSet(node: SelectionSetNode): string { return '{' + (LF += ' ') + mapJoin(node.selections, LF, _print) + (LF = LF.slice(0, -2)) + '}'; }, - Argument(node: ArgumentNode): string { + Argument(node: ArgumentNode | FragmentArgumentNode): string { return node.name.value + ': ' + _print(node.value); }, 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; @@ -164,6 +166,8 @@ const nodes = { out += nodes.StringValue(node.description) + '\n'; } 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);