diff --git a/.changeset/poor-files-buy.md b/.changeset/poor-files-buy.md new file mode 100644 index 00000000000..2c8add6fd62 --- /dev/null +++ b/.changeset/poor-files-buy.md @@ -0,0 +1,6 @@ +--- +'@graphql-codegen/visitor-plugin-common': minor +'@graphql-codegen/typescript-operations': minor +--- + +feat: Add option to squash exactly similar fragment types diff --git a/packages/plugins/other/visitor-plugin-common/src/base-documents-visitor.ts b/packages/plugins/other/visitor-plugin-common/src/base-documents-visitor.ts index e70b0d9c060..e62b1676bcf 100644 --- a/packages/plugins/other/visitor-plugin-common/src/base-documents-visitor.ts +++ b/packages/plugins/other/visitor-plugin-common/src/base-documents-visitor.ts @@ -38,6 +38,7 @@ export interface ParsedDocumentsConfig extends ParsedTypesConfig { exportFragmentSpreadSubTypes: boolean; skipTypeNameForRoot: boolean; experimentalFragmentVariables: boolean; + mergeFragmentTypes: boolean; } export interface RawDocumentsConfig extends RawTypesConfig { @@ -103,6 +104,11 @@ export interface RawDocumentsConfig extends RawTypesConfig { * @description If set to true, it will enable support for parsing variables on fragments. */ experimentalFragmentVariables?: boolean; + /** + * @default false + * @description If set to true, merge equal fragment interfaces. + */ + mergeFragmentTypes?: boolean; // The following are internal, and used by presets /** diff --git a/packages/plugins/other/visitor-plugin-common/src/selection-set-to-object.ts b/packages/plugins/other/visitor-plugin-common/src/selection-set-to-object.ts index e7667b59198..92f3fb28565 100644 --- a/packages/plugins/other/visitor-plugin-common/src/selection-set-to-object.ts +++ b/packages/plugins/other/visitor-plugin-common/src/selection-set-to-object.ts @@ -42,6 +42,7 @@ import { } from './selection-set-processor/base'; import autoBind from 'auto-bind'; import { getRootTypes } from '@graphql-tools/utils'; +import { createHash } from 'crypto'; type FragmentSpreadUsage = { fragmentName: string; @@ -304,35 +305,120 @@ export class SelectionSetToObject { - const typeName = type.name; - const schemaType = this._schema.getType(typeName); + const possibleTypes = getPossibleTypes(this._schema, this._parentSchemaType); - if (!isObjectType(schemaType)) { - throw new TypeError(`Invalid state! Schema type ${typeName} is not a valid GraphQL object!`); - } + if (!this._config.mergeFragmentTypes || this._config.inlineFragmentTypes === 'mask') { + const grouped = possibleTypes.reduce((prev, type) => { + const typeName = type.name; + const schemaType = this._schema.getType(typeName); - const selectionNodes = selectionNodesByTypeName.get(typeName) || []; + if (!isObjectType(schemaType)) { + throw new TypeError(`Invalid state! Schema type ${typeName} is not a valid GraphQL object!`); + } - if (!prev[typeName]) { - prev[typeName] = []; - } + const selectionNodes = selectionNodesByTypeName.get(typeName) || []; - const transformedSet = this.buildSelectionSetString(schemaType, selectionNodes); + if (!prev[typeName]) { + prev[typeName] = []; + } - if (transformedSet) { - prev[typeName].push(transformedSet); - } else { - mustAddEmptyObject = true; - } + const { fields } = this.buildSelectionSet(schemaType, selectionNodes); + const transformedSet = this.selectionSetStringFromFields(fields); + + if (transformedSet) { + prev[typeName].push(transformedSet); + } else { + mustAddEmptyObject = true; + } + + return prev; + }, {} as Record); + + return { grouped, mustAddEmptyObject }; + } else { + // Accumulate a map of selected fields to the typenames that + // share the exact same selected fields. When we find multiple + // typenames with the same set of fields, we can collapse the + // generated type to the selected fields and a string literal + // union of the typenames. + // + // E.g. { + // __typename: "foo" | "bar"; + // shared: string; + // } + const grouped = possibleTypes.reduce< + Record + >((prev, type) => { + const typeName = type.name; + const schemaType = this._schema.getType(typeName); + + if (!isObjectType(schemaType)) { + throw new TypeError(`Invalid state! Schema type ${typeName} is not a valid GraphQL object!`); + } - return prev; - }, {} as Record); + const selectionNodes = selectionNodesByTypeName.get(typeName) || []; + + const { typeInfo, fields } = this.buildSelectionSet(schemaType, selectionNodes); + + const key = this.selectionSetStringFromFields(fields); + prev[key] = { + fields, + types: [...(prev[key]?.types ?? []), typeInfo || { name: '', type: type.name }].filter(Boolean), + }; + + return prev; + }, {}); + + // For every distinct set of fields, create the corresponding + // string literal union of typenames. + const compacted = Object.keys(grouped).reduce>((acc, key) => { + const typeNames = grouped[key].types.map(t => t.type); + // Don't create very large string literal unions. TypeScript + // will stop comparing some nested union types types when + // they contain props with more than some number of string + // literal union members (testing with TS 4.5 stops working + // at 25 for a naive test case: + // https://www.typescriptlang.org/play?ts=4.5.4&ssl=29&ssc=10&pln=29&pc=1#code/C4TwDgpgBAKg9nAMgQwE4HNoF4BQV9QA+UA3ngRQJYB21EqAXDsQEQCMLzULATJ6wGZ+3ACzCWAVnEA2cQHZxADnEBOcWwAM6jl3Z9dbIQbEGpB2QYUHlBtbp5b7O1j30ujLky7Os4wABb0nAC+ODigkFAAQlBYUOT4xGQUVLT0TKzO3G7cHqLiPtwWrFasNqx2mY6ZWXrqeexe3GyF7MXNpc3lzZXZ1dm1ruI8DTxNvGahFEkJKTR0jLMpRNx+gaicy6E4APQ7AALAAM4AtJTo1HCoEDgANhDAUMgMsAgoGNikwQDcdw9QACMXjE4shfmEItAAGI0bCzGbLfDzdIGYbiBrjVrtFidFjdFi9dj9di1Ng5dgNNjjFrqbFsXFsfFsQkOYaDckjYbjNZBHDbPaHU7nS7XP6PZBsF4wuixL6-e6PAGS6KyiXfIA + const max_types = 20; + for (let i = 0; i < typeNames.length; i += max_types) { + const selectedTypes = typeNames.slice(i, i + max_types); + const typenameUnion = grouped[key].types[0].name + ? this._processor.transformTypenameField(selectedTypes.join(' | '), grouped[key].types[0].name) + : []; + const transformedSet = this.selectionSetStringFromFields([...typenameUnion, ...grouped[key].fields]); + + // The keys here will be used to generate intermediary + // fragment names. To avoid blowing up the type name on large + // unions, calculate a stable hash here instead. + // + // Also use fragment hashing if skipTypename is true, since we + // then don't have a typename for naming the fragment. + acc[ + selectedTypes.length <= 3 + ? selectedTypes.join('_') + : createHash('sha256') + .update(selectedTypes.join() || transformedSet || '') + .digest('base64') + ] = [transformedSet]; + } + return acc; + }, {}); + + return { grouped: compacted, mustAddEmptyObject }; + } + } - return { grouped, mustAddEmptyObject }; + protected selectionSetStringFromFields(fields: (string | NameAndType)[]): string | null { + const allStrings = fields.filter((f: string | NameAndType): f is string => typeof f === 'string'); + const allObjects = fields + .filter((f: string | NameAndType): f is NameAndType => typeof f !== 'string') + .map(t => `${t.name}: ${t.type}`); + const mergedObjects = allObjects.length ? this._processor.buildFieldsIntoObject(allObjects) : null; + const transformedSet = this._processor.buildSelectionSetFromStrings([...allStrings, mergedObjects].filter(Boolean)); + return transformedSet; } - protected buildSelectionSetString( + protected buildSelectionSet( parentSchemaType: GraphQLObjectType, selectionNodes: Array ) { @@ -461,7 +547,12 @@ export class SelectionSetToObject ({ @@ -499,7 +590,7 @@ export class SelectionSetToObject { await validate(content, config); }); + it('Should support merging identical fragment union types', async () => { + const ast = parse(/* GraphQL */ ` + query test { + notifications { + ...N + } + } + + fragment N on Notifiction { + id + } + `); + const config = { preResolveTypes: true, mergeFragmentTypes: true }; + const { content } = await plugin(schema, [{ location: 'test-file.ts', document: ast }], config, { + outputFile: '', + }); + + expect(content).toBeSimilarStringTo(` + export type TestQueryVariables = Exact<{ [key: string]: never; }>; + + export type TestQuery = ( + { notifications: Array<( + { id: string } + & { __typename?: 'TextNotification' | 'ImageNotification' } + )> } + & { __typename?: 'Query' } + ); + + export type NFragment = ( + { id: string } + & { __typename?: 'TextNotification' | 'ImageNotification' } + ); + `); + await validate(content, config); + }); + + it('Should support computing correct names for merged fragment union types', async () => { + const ast = parse(/* GraphQL */ ` + fragment N on Notifiction { + id + ... on TextNotification { + text + } + } + `); + const config = { preResolveTypes: true, mergeFragmentTypes: true }; + const { content } = await plugin(schema, [{ location: 'test-file.ts', document: ast }], config, { + outputFile: '', + }); + + expect(content).toBeSimilarStringTo(` + type N_TextNotification_Fragment = ( + { text: string, id: string } + & { __typename?: 'TextNotification' } + ); + + type N_ImageNotification_Fragment = ( + { id: string } + & { __typename?: 'ImageNotification' } + ); + + export type NFragment = N_TextNotification_Fragment | N_ImageNotification_Fragment; + `); + await validate(content, config); + }); + + it('Should support computing correct names for large merged fragment union types', async () => { + const testSchema = buildSchema(/* GraphQL */ ` + interface Node { + id: ID! + } + + type A implements Node { + id: ID! + text: String! + } + + type B implements Node { + id: ID! + text: String! + } + + type C implements Node { + id: ID! + text: String! + } + + type D implements Node { + id: ID! + text: String! + } + + type E implements Node { + id: ID! + text: String! + } + `); + + const ast = parse(/* GraphQL */ ` + fragment N on Node { + id + ... on A { + text + } + } + `); + const config = { preResolveTypes: true, mergeFragmentTypes: true }; + const { content } = await plugin(testSchema, [{ location: 'test-file.ts', document: ast }], config, { + outputFile: '', + }); + + expect(content).toBeSimilarStringTo(` + type N_A_Fragment = ( + { text: string, id: string } + & { __typename?: 'A' } + ); + + type N_ZhJjUzpMTyh98zugnx0IKwiLetPNjV8KYbSlmpAeuu_Fragment = ( + { id: string } + & { __typename?: 'B' | 'C' | 'D' | 'E' } + ); + + export type NFragment = N_A_Fragment | N_ZhJjUzpMTyh98zugnx0IKwiLetPNjV8KYbSlmpAeuu_Fragment; + `); + await validate(content, config); + }); + + it('Should not create empty types when merging fragment union types', async () => { + const ast = parse(/* GraphQL */ ` + fragment N on Query { + notifications { + ... on TextNotification { + text + } + } + } + `); + const config = { preResolveTypes: true, mergeFragmentTypes: true }; + const { content } = await plugin(schema, [{ location: 'test-file.ts', document: ast }], config, { + outputFile: '', + }); + + expect(content).toBeSimilarStringTo(` + export type NFragment = ( + { notifications: Array<( + { text: string } + & { __typename?: 'TextNotification' } + ) | { __typename?: 'ImageNotification' }> } + & { __typename?: 'Query' } + ); + `); + await validate(content, config); + }); + + it('Should support merging identical fragment union types with skipTypename', async () => { + const ast = parse(/* GraphQL */ ` + query test { + notifications { + ...N + } + } + + fragment N on Notifiction { + id + } + `); + const config = { preResolveTypes: true, skipTypename: true, mergeFragmentTypes: true }; + const { content } = await plugin(schema, [{ location: 'test-file.ts', document: ast }], config, { + outputFile: '', + }); + + expect(content).toBeSimilarStringTo(` + export type TestQueryVariables = Exact<{ [key: string]: never; }>; + + export type TestQuery = { notifications: Array<{ id: string }> }; + `); + await validate(content, config); + }); + + it('Should support computing correct names for merged fragment union types with skipTypename', async () => { + const ast = parse(/* GraphQL */ ` + fragment N on Notifiction { + id + ... on TextNotification { + text + } + } + `); + const config = { preResolveTypes: true, skipTypename: true, mergeFragmentTypes: true }; + const { content } = await plugin(schema, [{ location: 'test-file.ts', document: ast }], config, { + outputFile: '', + }); + + expect(content).toBeSimilarStringTo(` + type N_TextNotification_Fragment = { text: string, id: string }; + + type N_ImageNotification_Fragment = { id: string }; + + export type NFragment = N_TextNotification_Fragment | N_ImageNotification_Fragment; + `); + await validate(content, config); + }); + + it('Ignores merging when enabled alongside inline fragment masking', async () => { + const ast = parse(/* GraphQL */ ` + query test { + notifications { + ...N + } + } + + fragment N on Notifiction { + id + } + `); + const config = { preResolveTypes: true, mergeFragmentTypes: true, inlineFragmentTypes: 'mask' } as const; + const { content } = await plugin(schema, [{ location: 'test-file.ts', document: ast }], config, { + outputFile: '', + }); + + expect(content).toBeSimilarStringTo(` + export type TestQueryVariables = Exact<{ [key: string]: never; }>; + + + export type TestQuery = { __typename?: 'Query', notifications: Array<( + { __typename?: 'TextNotification' } + & { ' $fragmentRefs': { 'N_TextNotification_Fragment': N_TextNotification_Fragment } } + ) | ( + { __typename?: 'ImageNotification' } + & { ' $fragmentRefs': { 'N_ImageNotification_Fragment': N_ImageNotification_Fragment } } + )> }; + + type N_TextNotification_Fragment = { __typename?: 'TextNotification', id: string }; + + type N_ImageNotification_Fragment = { __typename?: 'ImageNotification', id: string }; + + export type NFragment = N_TextNotification_Fragment | N_ImageNotification_Fragment; + `); + await validate(content, config); + }); + it('Should support inline fragments', async () => { const ast = parse(/* GraphQL */ ` query currentUser {