Skip to content

Commit f1fb77b

Browse files
authored
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).
1 parent 791b5dc commit f1fb77b

File tree

5 files changed

+367
-22
lines changed

5 files changed

+367
-22
lines changed

‎.changeset/poor-files-buy.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@graphql-codegen/visitor-plugin-common': minor
3+
'@graphql-codegen/typescript-operations': minor
4+
---
5+
6+
feat: Add option to squash exactly similar fragment types

‎packages/plugins/other/visitor-plugin-common/src/base-documents-visitor.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ export interface ParsedDocumentsConfig extends ParsedTypesConfig {
3838
exportFragmentSpreadSubTypes: boolean;
3939
skipTypeNameForRoot: boolean;
4040
experimentalFragmentVariables: boolean;
41+
mergeFragmentTypes: boolean;
4142
}
4243

4344
export interface RawDocumentsConfig extends RawTypesConfig {
@@ -103,6 +104,11 @@ export interface RawDocumentsConfig extends RawTypesConfig {
103104
* @description If set to true, it will enable support for parsing variables on fragments.
104105
*/
105106
experimentalFragmentVariables?: boolean;
107+
/**
108+
* @default false
109+
* @description If set to true, merge equal fragment interfaces.
110+
*/
111+
mergeFragmentTypes?: boolean;
106112

107113
// The following are internal, and used by presets
108114
/**

‎packages/plugins/other/visitor-plugin-common/src/selection-set-to-object.ts

Lines changed: 113 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import {
4242
} from './selection-set-processor/base';
4343
import autoBind from 'auto-bind';
4444
import { getRootTypes } from '@graphql-tools/utils';
45+
import { createHash } from 'crypto';
4546

4647
type FragmentSpreadUsage = {
4748
fragmentName: string;
@@ -304,35 +305,120 @@ export class SelectionSetToObject<Config extends ParsedDocumentsConfig = ParsedD
304305
// in case there is not a selection for each type, we need to add a empty type.
305306
let mustAddEmptyObject = false;
306307

307-
const grouped = getPossibleTypes(this._schema, this._parentSchemaType).reduce((prev, type) => {
308-
const typeName = type.name;
309-
const schemaType = this._schema.getType(typeName);
308+
const possibleTypes = getPossibleTypes(this._schema, this._parentSchemaType);
310309

311-
if (!isObjectType(schemaType)) {
312-
throw new TypeError(`Invalid state! Schema type ${typeName} is not a valid GraphQL object!`);
313-
}
310+
if (!this._config.mergeFragmentTypes || this._config.inlineFragmentTypes === 'mask') {
311+
const grouped = possibleTypes.reduce((prev, type) => {
312+
const typeName = type.name;
313+
const schemaType = this._schema.getType(typeName);
314314

315-
const selectionNodes = selectionNodesByTypeName.get(typeName) || [];
315+
if (!isObjectType(schemaType)) {
316+
throw new TypeError(`Invalid state! Schema type ${typeName} is not a valid GraphQL object!`);
317+
}
316318

317-
if (!prev[typeName]) {
318-
prev[typeName] = [];
319-
}
319+
const selectionNodes = selectionNodesByTypeName.get(typeName) || [];
320320

321-
const transformedSet = this.buildSelectionSetString(schemaType, selectionNodes);
321+
if (!prev[typeName]) {
322+
prev[typeName] = [];
323+
}
322324

323-
if (transformedSet) {
324-
prev[typeName].push(transformedSet);
325-
} else {
326-
mustAddEmptyObject = true;
327-
}
325+
const { fields } = this.buildSelectionSet(schemaType, selectionNodes);
326+
const transformedSet = this.selectionSetStringFromFields(fields);
327+
328+
if (transformedSet) {
329+
prev[typeName].push(transformedSet);
330+
} else {
331+
mustAddEmptyObject = true;
332+
}
333+
334+
return prev;
335+
}, {} as Record<string, string[]>);
336+
337+
return { grouped, mustAddEmptyObject };
338+
} else {
339+
// Accumulate a map of selected fields to the typenames that
340+
// share the exact same selected fields. When we find multiple
341+
// typenames with the same set of fields, we can collapse the
342+
// generated type to the selected fields and a string literal
343+
// union of the typenames.
344+
//
345+
// E.g. {
346+
// __typename: "foo" | "bar";
347+
// shared: string;
348+
// }
349+
const grouped = possibleTypes.reduce<
350+
Record<string, { fields: (string | NameAndType)[]; types: { name: string; type: string }[] }>
351+
>((prev, type) => {
352+
const typeName = type.name;
353+
const schemaType = this._schema.getType(typeName);
354+
355+
if (!isObjectType(schemaType)) {
356+
throw new TypeError(`Invalid state! Schema type ${typeName} is not a valid GraphQL object!`);
357+
}
328358

329-
return prev;
330-
}, {} as Record<string, string[]>);
359+
const selectionNodes = selectionNodesByTypeName.get(typeName) || [];
360+
361+
const { typeInfo, fields } = this.buildSelectionSet(schemaType, selectionNodes);
362+
363+
const key = this.selectionSetStringFromFields(fields);
364+
prev[key] = {
365+
fields,
366+
types: [...(prev[key]?.types ?? []), typeInfo || { name: '', type: type.name }].filter(Boolean),
367+
};
368+
369+
return prev;
370+
}, {});
371+
372+
// For every distinct set of fields, create the corresponding
373+
// string literal union of typenames.
374+
const compacted = Object.keys(grouped).reduce<Record<string, string[]>>((acc, key) => {
375+
const typeNames = grouped[key].types.map(t => t.type);
376+
// Don't create very large string literal unions. TypeScript
377+
// will stop comparing some nested union types types when
378+
// they contain props with more than some number of string
379+
// literal union members (testing with TS 4.5 stops working
380+
// at 25 for a naive test case:
381+
// https://www.typescriptlang.org/play?ts=4.5.4&ssl=29&ssc=10&pln=29&pc=1#code/C4TwDgpgBAKg9nAMgQwE4HNoF4BQV9QA+UA3ngRQJYB21EqAXDsQEQCMLzULATJ6wGZ+3ACzCWAVnEA2cQHZxADnEBOcWwAM6jl3Z9dbIQbEGpB2QYUHlBtbp5b7O1j30ujLky7Os4wABb0nAC+ODigkFAAQlBYUOT4xGQUVLT0TKzO3G7cHqLiPtwWrFasNqx2mY6ZWXrqeexe3GyF7MXNpc3lzZXZ1dm1ruI8DTxNvGahFEkJKTR0jLMpRNx+gaicy6E4APQ7AALAAM4AtJTo1HCoEDgANhDAUMgMsAgoGNikwQDcdw9QACMXjE4shfmEItAAGI0bCzGbLfDzdIGYbiBrjVrtFidFjdFi9dj9di1Ng5dgNNjjFrqbFsXFsfFsQkOYaDckjYbjNZBHDbPaHU7nS7XP6PZBsF4wuixL6-e6PAGS6KyiXfIA
382+
const max_types = 20;
383+
for (let i = 0; i < typeNames.length; i += max_types) {
384+
const selectedTypes = typeNames.slice(i, i + max_types);
385+
const typenameUnion = grouped[key].types[0].name
386+
? this._processor.transformTypenameField(selectedTypes.join(' | '), grouped[key].types[0].name)
387+
: [];
388+
const transformedSet = this.selectionSetStringFromFields([...typenameUnion, ...grouped[key].fields]);
389+
390+
// The keys here will be used to generate intermediary
391+
// fragment names. To avoid blowing up the type name on large
392+
// unions, calculate a stable hash here instead.
393+
//
394+
// Also use fragment hashing if skipTypename is true, since we
395+
// then don't have a typename for naming the fragment.
396+
acc[
397+
selectedTypes.length <= 3
398+
? selectedTypes.join('_')
399+
: createHash('sha256')
400+
.update(selectedTypes.join() || transformedSet || '')
401+
.digest('base64')
402+
] = [transformedSet];
403+
}
404+
return acc;
405+
}, {});
406+
407+
return { grouped: compacted, mustAddEmptyObject };
408+
}
409+
}
331410

332-
return { grouped, mustAddEmptyObject };
411+
protected selectionSetStringFromFields(fields: (string | NameAndType)[]): string | null {
412+
const allStrings = fields.filter((f: string | NameAndType): f is string => typeof f === 'string');
413+
const allObjects = fields
414+
.filter((f: string | NameAndType): f is NameAndType => typeof f !== 'string')
415+
.map(t => `${t.name}: ${t.type}`);
416+
const mergedObjects = allObjects.length ? this._processor.buildFieldsIntoObject(allObjects) : null;
417+
const transformedSet = this._processor.buildSelectionSetFromStrings([...allStrings, mergedObjects].filter(Boolean));
418+
return transformedSet;
333419
}
334420

335-
protected buildSelectionSetString(
421+
protected buildSelectionSet(
336422
parentSchemaType: GraphQLObjectType,
337423
selectionNodes: Array<SelectionNode | FragmentSpreadUsage | DirectiveNode>
338424
) {
@@ -461,7 +547,12 @@ export class SelectionSetToObject<Config extends ParsedDocumentsConfig = ParsedD
461547
this._config.skipTypeNameForRoot
462548
);
463549
const transformed: ProcessResult = [
464-
...(typeInfoField ? this._processor.transformTypenameField(typeInfoField.type, typeInfoField.name) : []),
550+
// Only add the typename field if we're not merging fragment
551+
// types. If we are merging, we need to wait until we know all
552+
// the involved typenames.
553+
...(typeInfoField && (!this._config.mergeFragmentTypes || this._config.inlineFragmentTypes === 'mask')
554+
? this._processor.transformTypenameField(typeInfoField.type, typeInfoField.name)
555+
: []),
465556
...this._processor.transformPrimitiveFields(
466557
parentSchemaType,
467558
Array.from(primitiveFields.values()).map(field => ({
@@ -499,7 +590,7 @@ export class SelectionSetToObject<Config extends ParsedDocumentsConfig = ParsedD
499590
}
500591
}
501592

502-
return this._processor.buildSelectionSetFromStrings(fields);
593+
return { typeInfo: typeInfoField, fields };
503594
}
504595

505596
protected buildTypeNameField(

‎packages/plugins/typescript/operations/src/visitor.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export class TypeScriptDocumentsVisitor extends BaseDocumentsVisitor<
4040
immutableTypes: getConfigValue(config.immutableTypes, false),
4141
nonOptionalTypename: getConfigValue(config.nonOptionalTypename, false),
4242
preResolveTypes: getConfigValue(config.preResolveTypes, true),
43+
mergeFragmentTypes: getConfigValue(config.mergeFragmentTypes, false),
4344
} as TypeScriptDocumentsParsedConfig,
4445
schema
4546
);

0 commit comments

Comments
 (0)