diff --git a/packages/load/tests/loaders/documents/documents-from-glob.spec.ts b/packages/load/tests/loaders/documents/documents-from-glob.spec.ts index f48d465f3cd..0b718f1b965 100644 --- a/packages/load/tests/loaders/documents/documents-from-glob.spec.ts +++ b/packages/load/tests/loaders/documents/documents-from-glob.spec.ts @@ -1,9 +1,13 @@ import { loadDocuments, loadDocumentsSync } from '@graphql-tools/load'; import { join } from 'path'; -import { separateOperations } from 'graphql'; +import { parse, separateOperations } from 'graphql'; import { GraphQLFileLoader } from '@graphql-tools/graphql-file-loader'; import { CodeFileLoader } from '@graphql-tools/code-file-loader'; import { runTests } from '../../../../testing/utils'; +import '../../../../testing/to-be-similar-string' +import globby from 'globby'; +import { readFileSync } from 'fs'; +import { removeLoc } from '@graphql-tools/optimize'; describe('documentsFromGlob', () => { runTests({ @@ -15,8 +19,17 @@ describe('documentsFromGlob', () => { const result = await load(glob, { loaders: [new GraphQLFileLoader()] }); - expect(result.length).toBe(1); - expect(result[0].document).toBeDefined(); + expect(result).toHaveLength(1); + const expectedFiles = globby.sync(glob); + for (const expectedFileName of expectedFiles) { + const fileNameResult = result?.find(({ location }) => location === expectedFileName); + if (fileNameResult) { + const fileContent = readFileSync(expectedFileName, 'utf-8'); + const expectedDocument: any = parse(fileContent); + expect(removeLoc(fileNameResult!.document!)).toStrictEqual(removeLoc(expectedDocument)); + expect(fileNameResult!.rawSDL!).toBeSimilarString(fileContent); + } + } }); test(`Should load multiple GraphQL document from glob expression`, async () => { @@ -24,12 +37,20 @@ describe('documentsFromGlob', () => { const result = await load(glob, { loaders: [new GraphQLFileLoader()] }); - expect(result.length).toBe(2); - expect(result[0].document).toBeDefined(); - expect(result[1].document).toBeDefined(); + expect(result).toHaveLength(2); + const expectedFiles = globby.sync(glob); + for (const expectedFileName of expectedFiles) { + const fileNameResult = result?.find(({ location }) => location === expectedFileName); + if (fileNameResult) { + const fileContent = readFileSync(expectedFileName, 'utf-8'); + const expectedDocument: any = parse(fileContent); + expect(removeLoc(fileNameResult!.document!)).toStrictEqual(removeLoc(expectedDocument)); + expect(fileNameResult!.rawSDL!).toBeSimilarString(fileContent); + } + } }); - test(`Should load two GraphQL documents both for gatsby and graphql-tag by default`, async () => { + test(`Should load two GraphQL operations both for gatsby and graphql-tag by default`, async () => { const glob = join(__dirname, './test-files/', 'tags.js'); const result = await load(glob, { loaders: [new CodeFileLoader()] @@ -38,7 +59,7 @@ describe('documentsFromGlob', () => { expect(result).toHaveLength(2); }); - test(`Should load GraphQL documents that match custom settings`, async () => { + test(`Should load GraphQL operations that match custom settings`, async () => { const glob = join(__dirname, './test-files/', 'tags.js'); const result = await load(glob, { @@ -56,10 +77,8 @@ describe('documentsFromGlob', () => { ] }); - const { document } = result[0]; - const operations = document && separateOperations(document); - - expect(operations && Object.keys(operations)).toHaveLength(1); + expect(result[0]?.document).toBeDefined(); + expect(Object.keys(separateOperations(result[0].document!))).toHaveLength(1); }); test(`Should throw on syntax errors`, async () => { diff --git a/packages/merge/src/typedefs-mergers/comments.ts b/packages/merge/src/typedefs-mergers/comments.ts index fdd80c9d54f..7c64d871aa4 100644 --- a/packages/merge/src/typedefs-mergers/comments.ts +++ b/packages/merge/src/typedefs-mergers/comments.ts @@ -1,3 +1,4 @@ +import { Maybe } from '@graphql-tools/utils'; import { getDescription, StringValueNode, @@ -8,8 +9,11 @@ import { visit, VisitFn, } from 'graphql'; +import type { ASTVisitor } from 'graphql/language/visitor'; import { NamedDefinitionNode } from './merge-nodes'; +const MAX_LINE_LENGTH = 80; + let commentsRegistry: { [path: string]: string[]; } = {}; @@ -105,9 +109,13 @@ function join(maybeArray?: readonly any[], separator?: string) { return maybeArray ? maybeArray.filter(x => x).join(separator || '') : ''; } +function hasMultilineItems(maybeArray: Maybe>): boolean { + return maybeArray?.some(str => str.includes('\n')) ?? false; +} + function addDescription(cb: VisitFn): VisitFn { return ( - node: { description?: StringValueNode; name: NameNode; type?: TypeNode; kind: string }, + node: { description?: StringValueNode; name?: NameNode; type?: TypeNode; kind: string }, _key, _parent, path, @@ -122,10 +130,10 @@ function addDescription(cb: VisitFn): VisitFn { return prev[key]; }, ancestors[0]); - const key = [...keys, parent.name.value].join('.'); + const key = [...keys, parent?.name?.value].filter(Boolean).join('.'); const items: string[] = []; - if (commentsRegistry[key]) { + if (node.kind.includes('Definition') && commentsRegistry[key]) { items.push(...commentsRegistry[key]); } @@ -158,137 +166,237 @@ function wrap(start: string, maybeString: any, end?: string) { * trailing blank line. However, if a block string starts with whitespace and is * a single-line, adding a leading blank line would strip that whitespace. */ -function printBlockString(value: string, isDescription: boolean) { +function printBlockString(value: string, isDescription = false) { const escaped = value.replace(/"""/g, '\\"""'); return (value[0] === ' ' || value[0] === '\t') && value.indexOf('\n') === -1 ? `"""${escaped.replace(/"$/, '"\n')}"""` : `"""\n${isDescription ? escaped : indent(escaped)}\n"""`; } -/** - * Converts an AST into a string, using one set of reasonable - * formatting rules. - */ -export function printWithComments(ast: ASTNode) { - return visit(ast, { - leave: { - Name: node => node.value, - Variable: node => `$${node.name}`, - - // Document - - Document: node => - `${node.definitions - .map(defNode => `${defNode}\n${defNode[0] === '#' ? '' : '\n'}`) - .join('') - .trim()}\n`, - - OperationTypeDefinition: node => `${node.operation}: ${node.type}`, +const printDocASTReducer: ASTVisitor = { + Name: { leave: node => node.value }, + Variable: { leave: node => '$' + node.name }, - VariableDefinition: ({ variable, type, defaultValue }) => `${variable}: ${type}${wrap(' = ', defaultValue)}`, + // Document - SelectionSet: ({ selections }) => block(selections), + Document: { + leave: node => join(node.definitions, '\n\n'), + }, - Field: ({ alias, name, arguments: args, directives, selectionSet }) => - join( - [wrap('', alias, ': ') + name + wrap('(', join(args, ', '), ')'), join(directives, ' '), selectionSet], - ' ' - ), - - Argument: addDescription(({ name, value }) => `${name}: ${value}`), - - // Value - - IntValue: ({ value }) => value, - FloatValue: ({ value }) => value, - StringValue: ({ value, block: isBlockString }, key) => - isBlockString ? printBlockString(value, key === 'description') : JSON.stringify(value), - BooleanValue: ({ value }) => (value ? 'true' : 'false'), - NullValue: () => 'null', - EnumValue: ({ value }) => value, - ListValue: ({ values }) => `[${join(values, ', ')}]`, - ObjectValue: ({ fields }) => `{${join(fields, ', ')}}`, - ObjectField: ({ name, value }) => `${name}: ${value}`, - - // Directive - - Directive: ({ name, arguments: args }) => `@${name}${wrap('(', join(args, ', '), ')')}`, - - // Type - - NamedType: ({ name }) => name, - ListType: ({ type }) => `[${type}]`, - NonNullType: ({ type }) => `${type}!`, - - // Type System Definitions - - SchemaDefinition: ({ directives, operationTypes }) => - join(['schema', join(directives, ' '), block(operationTypes)], ' '), - - ScalarTypeDefinition: addDescription(({ name, directives }) => - join(['scalar', name, join(directives, ' ')], ' ') - ), + OperationDefinition: { + leave: node => { + const varDefs = wrap('(', join(node.variableDefinitions, ', '), ')'); + const prefix = join([node.operation, join([node.name, varDefs]), join(node.directives, ' ')], ' '); - ObjectTypeDefinition: addDescription(({ name, interfaces, directives, fields }) => - join(['type', name, wrap('implements ', join(interfaces, ' & ')), join(directives, ' '), block(fields)], ' ') - ), + // the query short form. + return prefix + ' ' + node.selectionSet; + }, + }, - FieldDefinition: addDescription( - ({ name, arguments: args, type, directives }) => - `${name + wrap('(', join(args, ', '), ')')}: ${type}${wrap(' ', join(directives, ' '))}` - ), + VariableDefinition: { + leave: ({ variable, type, defaultValue, directives }) => + variable + ': ' + type + wrap(' = ', defaultValue) + wrap(' ', join(directives, ' ')), + }, - InputValueDefinition: addDescription(({ name, type, defaultValue, directives }) => - join([`${name}: ${type}`, wrap('= ', defaultValue), join(directives, ' ')], ' ') - ), + SelectionSet: { leave: ({ selections }) => block(selections) }, - InterfaceTypeDefinition: addDescription(({ name, directives, fields }) => - join(['interface', name, join(directives, ' '), block(fields)], ' ') - ), + Field: { + leave({ alias, name, arguments: args, directives, selectionSet }) { + const prefix = wrap('', alias, ': ') + name; + let argsLine = prefix + wrap('(', join(args, ', '), ')'); - UnionTypeDefinition: addDescription(({ name, directives, types }) => - join(['union', name, join(directives, ' '), types && types.length !== 0 ? `= ${join(types, ' | ')}` : ''], ' ') - ), + if (argsLine.length > MAX_LINE_LENGTH) { + argsLine = prefix + wrap('(\n', indent(join(args, '\n')), '\n)'); + } - EnumTypeDefinition: addDescription(({ name, directives, values }) => - join(['enum', name, join(directives, ' '), block(values)], ' ') + return join([argsLine, join(directives, ' '), selectionSet], ' '); + }, + }, + + Argument: { leave: ({ name, value }) => name + ': ' + value }, + + // Fragments + + FragmentSpread: { + leave: ({ name, directives }) => '...' + name + wrap(' ', join(directives, ' ')), + }, + + InlineFragment: { + leave: ({ typeCondition, directives, selectionSet }) => + join(['...', wrap('on ', typeCondition), join(directives, ' '), selectionSet], ' '), + }, + + FragmentDefinition: { + leave: ({ name, typeCondition, variableDefinitions, directives, selectionSet }) => + // Note: fragment variable definitions are experimental and may be changed + // or removed in the future. + `fragment ${name}${wrap('(', join(variableDefinitions, ', '), ')')} ` + + `on ${typeCondition} ${wrap('', join(directives, ' '), ' ')}` + + selectionSet, + }, + + // Value + + IntValue: { leave: ({ value }) => value }, + FloatValue: { leave: ({ value }) => value }, + StringValue: { + leave: ({ value, block: isBlockString }) => (isBlockString ? printBlockString(value) : JSON.stringify(value)), + }, + BooleanValue: { leave: ({ value }) => (value ? 'true' : 'false') }, + NullValue: { leave: () => 'null' }, + EnumValue: { leave: ({ value }) => value }, + ListValue: { leave: ({ values }) => '[' + join(values, ', ') + ']' }, + ObjectValue: { leave: ({ fields }) => '{' + join(fields, ', ') + '}' }, + ObjectField: { leave: ({ name, value }) => name + ': ' + value }, + + // Directive + + Directive: { + leave: ({ name, arguments: args }) => '@' + name + wrap('(', join(args, ', '), ')'), + }, + + // Type + + NamedType: { leave: ({ name }) => name }, + ListType: { leave: ({ type }) => '[' + type + ']' }, + NonNullType: { leave: ({ type }) => type + '!' }, + + // Type System Definitions + + SchemaDefinition: { + leave: ({ description, directives, operationTypes }: any) => + wrap('', description, '\n') + join(['schema', join(directives, ' '), block(operationTypes)], ' '), + }, + + OperationTypeDefinition: { + leave: ({ operation, type }) => operation + ': ' + type, + }, + + ScalarTypeDefinition: { + leave: ({ description, name, directives }) => + wrap('', description, '\n') + join(['scalar', name, join(directives, ' ')], ' '), + }, + + ObjectTypeDefinition: { + leave: ({ description, name, interfaces, directives, fields }) => + wrap('', description, '\n') + + join(['type', name, wrap('implements ', join(interfaces, ' & ')), join(directives, ' '), block(fields)], ' '), + }, + + FieldDefinition: { + leave: ({ description, name, arguments: args, type, directives }) => + wrap('', description, '\n') + + name + + (hasMultilineItems(args as any as string[]) + ? wrap('(\n', indent(join(args, '\n')), '\n)') + : wrap('(', join(args, ', '), ')')) + + ': ' + + type + + wrap(' ', join(directives, ' ')), + }, + + InputValueDefinition: { + leave: ({ description, name, type, defaultValue, directives }) => + wrap('', description, '\n') + join([name + ': ' + type, wrap('= ', defaultValue), join(directives, ' ')], ' '), + }, + + InterfaceTypeDefinition: { + leave: ({ description, name, interfaces, directives, fields }: any) => + wrap('', description, '\n') + + join( + ['interface', name, wrap('implements ', join(interfaces, ' & ')), join(directives, ' '), block(fields)], + ' ' ), - - EnumValueDefinition: addDescription(({ name, directives }) => join([name, join(directives, ' ')], ' ')), - - InputObjectTypeDefinition: addDescription(({ name, directives, fields }) => - join(['input', name, join(directives, ' '), block(fields)], ' ') + }, + + UnionTypeDefinition: { + leave: ({ description, name, directives, types }) => + wrap('', description, '\n') + join(['union', name, join(directives, ' '), wrap('= ', join(types, ' | '))], ' '), + }, + + EnumTypeDefinition: { + leave: ({ description, name, directives, values }) => + wrap('', description, '\n') + join(['enum', name, join(directives, ' '), block(values)], ' '), + }, + + EnumValueDefinition: { + leave: ({ description, name, directives }) => + wrap('', description, '\n') + join([name, join(directives, ' ')], ' '), + }, + + InputObjectTypeDefinition: { + leave: ({ description, name, directives, fields }) => + wrap('', description, '\n') + join(['input', name, join(directives, ' '), block(fields)], ' '), + }, + + DirectiveDefinition: { + leave: ({ description, name, arguments: args, repeatable, locations }) => + wrap('', description, '\n') + + 'directive @' + + name + + (hasMultilineItems(args as any as string[]) + ? wrap('(\n', indent(join(args, '\n')), '\n)') + : wrap('(', join(args, ', '), ')')) + + (repeatable ? ' repeatable' : '') + + ' on ' + + join(locations, ' | '), + }, + + SchemaExtension: { + leave: ({ directives, operationTypes }) => + join(['extend schema', join(directives, ' '), block(operationTypes)], ' '), + }, + + ScalarTypeExtension: { + leave: ({ name, directives }) => join(['extend scalar', name, join(directives, ' ')], ' '), + }, + + ObjectTypeExtension: { + leave: ({ name, interfaces, directives, fields }) => + join( + ['extend type', name, wrap('implements ', join(interfaces, ' & ')), join(directives, ' '), block(fields)], + ' ' ), + }, - ScalarTypeExtension: ({ name, directives }) => join(['extend scalar', name, join(directives, ' ')], ' '), - - ObjectTypeExtension: ({ name, interfaces, directives, fields }) => - join( - ['extend type', name, wrap('implements ', join(interfaces, ' & ')), join(directives, ' '), block(fields)], - ' ' - ), - - InterfaceTypeExtension: ({ name, directives, fields }) => - join(['extend interface', name, join(directives, ' '), block(fields)], ' '), - - UnionTypeExtension: ({ name, directives, types }) => - join( - ['extend union', name, join(directives, ' '), types && types.length !== 0 ? `= ${join(types, ' | ')}` : ''], - ' ' - ), - - EnumTypeExtension: ({ name, directives, values }) => - join(['extend enum', name, join(directives, ' '), block(values)], ' '), - - InputObjectTypeExtension: ({ name, directives, fields }) => - join(['extend input', name, join(directives, ' '), block(fields)], ' '), - - DirectiveDefinition: addDescription( - ({ name, arguments: args, locations }) => - `directive @${name}${wrap('(', join(args, ', '), ')')} on ${join(locations, ' | ')}` + InterfaceTypeExtension: { + leave: ({ name, interfaces, directives, fields }: any) => + join( + ['extend interface', name, wrap('implements ', join(interfaces, ' & ')), join(directives, ' '), block(fields)], + ' ' ), + }, + + UnionTypeExtension: { + leave: ({ name, directives, types }) => + join(['extend union', name, join(directives, ' '), wrap('= ', join(types, ' | '))], ' '), + }, + + EnumTypeExtension: { + leave: ({ name, directives, values }) => join(['extend enum', name, join(directives, ' '), block(values)], ' '), + }, + + InputObjectTypeExtension: { + leave: ({ name, directives, fields }) => join(['extend input', name, join(directives, ' '), block(fields)], ' '), + }, +}; + +const printDocASTReducerWithComments = Object.keys(printDocASTReducer).reduce( + (prev, key) => ({ + ...prev, + [key]: { + leave: addDescription(printDocASTReducer[key].leave), }, - }); + }), + {} as typeof printDocASTReducer +); + +/** + * Converts an AST into a string, using one set of reasonable + * formatting rules. + */ +export function printWithComments(ast: ASTNode) { + return visit(ast, printDocASTReducerWithComments); } function isFieldDefinitionNode(node: any): node is FieldDefinitionNode { diff --git a/packages/merge/tests/merge-typedefs.spec.ts b/packages/merge/tests/merge-typedefs.spec.ts index eb3df437285..d44719818fb 100644 --- a/packages/merge/tests/merge-typedefs.spec.ts +++ b/packages/merge/tests/merge-typedefs.spec.ts @@ -1029,11 +1029,11 @@ describe('Merge TypeDefs', () => { create_product(description: String!, price: Int!): Product update_product( # product id - id: ID!, + id: ID! # product description - description: String!, + description: String! # product price - price: Int!): Product + price: Int! ): Product } `); const schema = stripWhitespaces(mergedTypes); diff --git a/packages/utils/src/parse-graphql-sdl.ts b/packages/utils/src/parse-graphql-sdl.ts index 6f22cf9ed55..a0f214e3977 100644 --- a/packages/utils/src/parse-graphql-sdl.ts +++ b/packages/utils/src/parse-graphql-sdl.ts @@ -41,7 +41,6 @@ export function parseGraphQLSDL(location: string | undefined, rawSDL: string, op } return { - rawSDL, location, document, };