diff --git a/.changeset/gold-apricots-report.md b/.changeset/gold-apricots-report.md new file mode 100644 index 0000000..126f9d3 --- /dev/null +++ b/.changeset/gold-apricots-report.md @@ -0,0 +1,5 @@ +--- +"@0no-co/graphql.web": minor +--- + +Add support for executable definitions as defined in https://github.com/graphql/graphql-spec/pull/1170 diff --git a/src/__tests__/description.test.ts b/src/__tests__/description.test.ts new file mode 100644 index 0000000..979492a --- /dev/null +++ b/src/__tests__/description.test.ts @@ -0,0 +1,278 @@ +import { describe, it, expect } from 'vitest'; +import { parse } from '../parser'; +import { print } from '../printer'; +import type { + OperationDefinitionNode, + VariableDefinitionNode, + FragmentDefinitionNode, +} from '../ast'; + +describe('GraphQL descriptions', () => { + describe('OperationDefinition descriptions', () => { + it('parses operation with description', () => { + const source = ` + """ + Request the current status of a time machine and its operator. + """ + query GetTimeMachineStatus { + timeMachine { + id + status + } + } + `; + + const doc = parse(source, { noLocation: true }); + const operation = doc.definitions[0] as OperationDefinitionNode; + + expect(operation.description).toBeDefined(); + expect(operation.description?.value).toBe( + 'Request the current status of a time machine and its operator.' + ); + expect(operation.description?.block).toBe(true); + }); + + it('parses operation with single-line description', () => { + const source = ` + "Simple query description" + query SimpleQuery { + field + } + `; + + const doc = parse(source, { noLocation: true }); + const operation = doc.definitions[0] as OperationDefinitionNode; + + expect(operation.description).toBeDefined(); + expect(operation.description?.value).toBe('Simple query description'); + expect(operation.description?.block).toBe(false); + }); + + it('does not allow description on anonymous operations', () => { + const source = ` + "This should fail" + { + field + } + `; + + expect(() => parse(source)).toThrow(); + }); + + it('parses mutation with description', () => { + const source = ` + """ + Create a new time machine entry. + """ + mutation CreateTimeMachine($input: TimeMachineInput!) { + createTimeMachine(input: $input) { + id + } + } + `; + + const doc = parse(source, { noLocation: true }); + const operation = doc.definitions[0] as OperationDefinitionNode; + + expect(operation.description).toBeDefined(); + expect(operation.description?.value).toBe('Create a new time machine entry.'); + }); + }); + + describe('VariableDefinition descriptions', () => { + it('parses variable with description', () => { + const source = ` + query GetTimeMachineStatus( + "The unique serial number of the time machine to inspect." + $machineId: ID! + + """ + The year to check the status for. + **Warning:** certain years may trigger an anomaly in the space-time continuum. + """ + $year: Int + ) { + timeMachine(id: $machineId) { + status(year: $year) + } + } + `; + + const doc = parse(source, { noLocation: true }); + const operation = doc.definitions[0] as OperationDefinitionNode; + const variables = operation.variableDefinitions as VariableDefinitionNode[]; + + expect(variables[0].description).toBeDefined(); + expect(variables[0].description?.value).toBe( + 'The unique serial number of the time machine to inspect.' + ); + expect(variables[0].description?.block).toBe(false); + + expect(variables[1].description).toBeDefined(); + expect(variables[1].description?.value).toBe( + 'The year to check the status for.\n**Warning:** certain years may trigger an anomaly in the space-time continuum.' + ); + expect(variables[1].description?.block).toBe(true); + }); + + it('parses mixed variables with and without descriptions', () => { + const source = ` + query Mixed( + "Described variable" + $described: String + $undescribed: Int + ) { + field + } + `; + + const doc = parse(source, { noLocation: true }); + const operation = doc.definitions[0] as OperationDefinitionNode; + const variables = operation.variableDefinitions as VariableDefinitionNode[]; + + expect(variables[0].description).toBeDefined(); + expect(variables[0].description?.value).toBe('Described variable'); + expect(variables[1].description).toBeUndefined(); + }); + }); + + describe('FragmentDefinition descriptions', () => { + it('parses fragment with description', () => { + const source = ` + "Time machine details." + fragment TimeMachineDetails on TimeMachine { + id + model + lastMaintenance + } + `; + + const doc = parse(source, { noLocation: true }); + const fragment = doc.definitions[0] as FragmentDefinitionNode; + + expect(fragment.description).toBeDefined(); + expect(fragment.description?.value).toBe('Time machine details.'); + expect(fragment.description?.block).toBe(false); + }); + + it('parses fragment with block description', () => { + const source = ` + """ + Comprehensive time machine information + including maintenance history and operational status. + """ + fragment FullTimeMachineInfo on TimeMachine { + id + model + lastMaintenance + operationalStatus + } + `; + + const doc = parse(source, { noLocation: true }); + const fragment = doc.definitions[0] as FragmentDefinitionNode; + + expect(fragment.description).toBeDefined(); + expect(fragment.description?.value).toBe( + 'Comprehensive time machine information\nincluding maintenance history and operational status.' + ); + expect(fragment.description?.block).toBe(true); + }); + }); + + describe('print with descriptions', () => { + it('prints operation description correctly', () => { + const source = `""" +Request the current status of a time machine and its operator. +""" +query GetTimeMachineStatus { + timeMachine { + id + } +}`; + + const doc = parse(source, { noLocation: true }); + const printed = print(doc); + + expect(printed).toContain('"""'); + expect(printed).toContain('Request the current status of a time machine and its operator.'); + }); + + it('prints variable descriptions correctly', () => { + const source = `query GetStatus( + "Machine ID" + $id: ID! +) { + field +}`; + + const doc = parse(source, { noLocation: true }); + const printed = print(doc); + + expect(printed).toContain('"Machine ID"'); + }); + + it('prints fragment description correctly', () => { + const source = `"Details fragment" +fragment Details on Type { + field +}`; + + const doc = parse(source, { noLocation: true }); + const printed = print(doc); + + expect(printed).toContain('"Details fragment"'); + }); + }); + + describe('roundtrip parsing and printing', () => { + it('maintains descriptions through parse and print cycle', () => { + const source = `""" +Request the current status of a time machine and its operator. +""" +query GetTimeMachineStatus( + "The unique serial number of the time machine to inspect." + $machineId: ID! + + """ + The year to check the status for. + **Warning:** certain years may trigger an anomaly in the space-time continuum. + """ + $year: Int +) { + timeMachine(id: $machineId) { + ...TimeMachineDetails + operator { + name + licenseLevel + } + status(year: $year) + } +} + +"Time machine details." +fragment TimeMachineDetails on TimeMachine { + id + model + lastMaintenance +}`; + + const doc = parse(source, { noLocation: true }); + const printed = print(doc); + const reparsed = parse(printed, { noLocation: true }); + + const operation = doc.definitions[0] as OperationDefinitionNode; + const reparsedOperation = reparsed.definitions[0] as OperationDefinitionNode; + + // The printed/reparsed cycle may have slightly different formatting but same content + expect(reparsedOperation.description?.value?.trim()).toBe( + operation.description?.value?.trim() + ); + + const fragment = doc.definitions[1] as FragmentDefinitionNode; + const reparsedFragment = reparsed.definitions[1] as FragmentDefinitionNode; + + expect(reparsedFragment.description?.value).toBe(fragment.description?.value); + }); + }); +}); diff --git a/src/ast.ts b/src/ast.ts index 6287f26..c94024d 100644 --- a/src/ast.ts +++ b/src/ast.ts @@ -103,11 +103,12 @@ export type ExecutableDefinitionNode = Or< >; export type OperationDefinitionNode = Or< - GraphQL.OperationDefinitionNode, + GraphQL.OperationDefinitionNode & { description?: StringValueNode }, { readonly kind: Kind.OPERATION_DEFINITION; readonly operation: OperationTypeNode; readonly name?: NameNode; + readonly description?: StringValueNode; readonly variableDefinitions?: ReadonlyArray; readonly directives?: ReadonlyArray; readonly selectionSet: SelectionSetNode; @@ -116,12 +117,13 @@ export type OperationDefinitionNode = Or< >; export type VariableDefinitionNode = Or< - GraphQL.VariableDefinitionNode, + GraphQL.VariableDefinitionNode & { description?: StringValueNode }, { readonly kind: Kind.VARIABLE_DEFINITION; readonly variable: VariableNode; readonly type: TypeNode; readonly defaultValue?: ConstValueNode; + readonly description?: StringValueNode; readonly directives?: ReadonlyArray; readonly loc?: Location; } @@ -205,10 +207,11 @@ export type InlineFragmentNode = Or< >; export type FragmentDefinitionNode = Or< - GraphQL.FragmentDefinitionNode, + GraphQL.FragmentDefinitionNode & { description?: StringValueNode }, { readonly kind: Kind.FRAGMENT_DEFINITION; readonly name: NameNode; + readonly description?: StringValueNode; readonly typeCondition: NamedTypeNode; readonly directives?: ReadonlyArray; readonly selectionSet: SelectionSetNode; diff --git a/src/parser.ts b/src/parser.ts index c439b38..d78a1c4 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -421,6 +421,10 @@ function variableDefinitions(): ast.VariableDefinitionNode[] | undefined { idx++; ignored(); do { + let _description: ast.StringValueNode | undefined; + if (input.charCodeAt(idx) === 34 /*'"'*/) { + _description = value(true) as ast.StringValueNode; + } if (input.charCodeAt(idx++) !== 36 /*'$'*/) throw error('Variable'); const name = nameNode(); if (input.charCodeAt(idx++) !== 58 /*':'*/) throw error('VariableDefinition'); @@ -433,7 +437,7 @@ function variableDefinitions(): ast.VariableDefinitionNode[] | undefined { _defaultValue = value(true); } ignored(); - vars.push({ + const varDef: ast.VariableDefinitionNode = { kind: 'VariableDefinition' as Kind.VARIABLE_DEFINITION, variable: { kind: 'Variable' as Kind.VARIABLE, @@ -442,7 +446,11 @@ function variableDefinitions(): ast.VariableDefinitionNode[] | undefined { type: _type, defaultValue: _defaultValue, directives: directives(true), - }); + }; + if (_description) { + varDef.description = _description; + } + vars.push(varDef); } while (input.charCodeAt(idx) !== 41 /*')'*/); idx++; ignored(); @@ -450,12 +458,12 @@ function variableDefinitions(): ast.VariableDefinitionNode[] | undefined { } } -function fragmentDefinition(): ast.FragmentDefinitionNode { +function fragmentDefinition(description?: ast.StringValueNode): ast.FragmentDefinitionNode { const name = nameNode(); if (input.charCodeAt(idx++) !== 111 /*'o'*/ || input.charCodeAt(idx++) !== 110 /*'n'*/) throw error('FragmentDefinition'); ignored(); - return { + const fragDef: ast.FragmentDefinitionNode = { kind: 'FragmentDefinition' as Kind.FRAGMENT_DEFINITION, name, typeCondition: { @@ -465,12 +473,22 @@ function fragmentDefinition(): ast.FragmentDefinitionNode { directives: directives(false), selectionSet: selectionSetStart(), }; + if (description) { + fragDef.description = description; + } + return fragDef; } function definitions(): ast.DefinitionNode[] { const _definitions: ast.ExecutableDefinitionNode[] = []; do { + let _description: ast.StringValueNode | undefined; + if (input.charCodeAt(idx) === 34 /*'"'*/) { + _description = value(true) as ast.StringValueNode; + } if (input.charCodeAt(idx) === 123 /*'{'*/) { + // Anonymous operations can't have descriptions + if (_description) throw error('Document'); idx++; ignored(); _definitions.push({ @@ -485,7 +503,7 @@ function definitions(): ast.DefinitionNode[] { const definition = name(); switch (definition) { case 'fragment': - _definitions.push(fragmentDefinition()); + _definitions.push(fragmentDefinition(_description)); break; case 'query': case 'mutation': @@ -499,14 +517,18 @@ function definitions(): ast.DefinitionNode[] { ) { name = nameNode(); } - _definitions.push({ + const opDef: ast.OperationDefinitionNode = { kind: 'OperationDefinition' as Kind.OPERATION_DEFINITION, operation: definition as OperationTypeNode, name, variableDefinitions: variableDefinitions(), directives: directives(false), selectionSet: selectionSetStart(), - }); + }; + if (_description) { + opDef.description = _description; + } + _definitions.push(opDef); break; default: throw error('Document'); diff --git a/src/printer.ts b/src/printer.ts index 0e35a4a..9643600 100644 --- a/src/printer.ts +++ b/src/printer.ts @@ -49,7 +49,11 @@ let LF = '\n'; const nodes = { OperationDefinition(node: OperationDefinitionNode): string { - let out: string = node.operation; + let out: string = ''; + if (node.description) { + out += nodes.StringValue(node.description) + '\n'; + } + out += node.operation; if (node.name) out += ' ' + node.name.value; if (node.variableDefinitions && node.variableDefinitions.length) { if (!node.name) out += ' '; @@ -57,12 +61,15 @@ const nodes = { } if (node.directives && node.directives.length) out += ' ' + mapJoin(node.directives, ' ', nodes.Directive); - return out !== 'query' - ? out + ' ' + nodes.SelectionSet(node.selectionSet) - : nodes.SelectionSet(node.selectionSet); + const selectionSet = nodes.SelectionSet(node.selectionSet); + return out !== 'query' ? out + ' ' + selectionSet : selectionSet; }, VariableDefinition(node: VariableDefinitionNode): string { - let out = nodes.Variable!(node.variable) + ': ' + _print(node.type); + let out = ''; + if (node.description) { + out += nodes.StringValue(node.description) + ' '; + } + out += nodes.Variable!(node.variable) + ': ' + _print(node.type); if (node.defaultValue) out += ' = ' + _print(node.defaultValue); if (node.directives && node.directives.length) out += ' ' + mapJoin(node.directives, ' ', nodes.Directive); @@ -152,7 +159,11 @@ const nodes = { return out; }, FragmentDefinition(node: FragmentDefinitionNode): string { - let out = 'fragment ' + node.name.value; + let out = ''; + if (node.description) { + out += nodes.StringValue(node.description) + '\n'; + } + out += 'fragment ' + node.name.value; out += ' on ' + node.typeCondition.name.value; if (node.directives && node.directives.length) out += ' ' + mapJoin(node.directives, ' ', nodes.Directive);