From f1fb77bd45dbb0b332066d096577552b88d76949 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=85smund=20Grammeltvedt?= Date: Mon, 9 May 2022 10:39:03 +0200 Subject: [PATCH] feat: Add option to squash exactly similar fragment types (#6826) * feat: Add option to squash exactly similar fragment types When selecting from an interface, the default code path will declare the fragment type as a union of all implementing types. This happens regardless of whether we actually selecting something from the implementing types. E.g. ``` type FooFragment = | { __typename: "foo"; shared: string; } | { __typename: "bar"; shared: string; } ``` This behavior can be noisy and redundant. In combination with heavy use of interfaces, especially in nested schemas, this can also potentially trigger TypeScript's "union type is too complex to represent". In order to reduce the size of the generated fragments, we introduce the `mergeFragmentTypes` configuration option. When enabled, this will collapse all types with the same selection set to a single union member of the fragment type E.g. ``` type FooFragment = { __typename: "foo" | "bar"; shared: string; } ``` * Support skipTypename * Avoid mangling fragment type names unless we have to * Work around TS limits * Fix tests * Disable interface merging when masking inline fragments. While we'd ideally make this work, the way we determine type uniqueness would probably require a two-pass approach,n since we need to determine whether a type can be compacted before we add the fragment reference to the type (just adding the fragment reference straight away makes all types unique). --- .changeset/poor-files-buy.md | 6 + .../src/base-documents-visitor.ts | 6 + .../src/selection-set-to-object.ts | 135 ++++++++-- .../typescript/operations/src/visitor.ts | 1 + .../operations/tests/ts-documents.spec.ts | 241 ++++++++++++++++++ 5 files changed, 367 insertions(+), 22 deletions(-) create mode 100644 .changeset/poor-files-buy.md 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 {