Skip to content

Commit

Permalink
feat: Add option to squash exactly similar fragment types (#6826)
Browse files Browse the repository at this point in the history
* 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).
  • Loading branch information
asmundg committed May 9, 2022
1 parent 791b5dc commit f1fb77b
Show file tree
Hide file tree
Showing 5 changed files with 367 additions and 22 deletions.
6 changes: 6 additions & 0 deletions .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
Expand Up @@ -38,6 +38,7 @@ export interface ParsedDocumentsConfig extends ParsedTypesConfig {
exportFragmentSpreadSubTypes: boolean;
skipTypeNameForRoot: boolean;
experimentalFragmentVariables: boolean;
mergeFragmentTypes: boolean;
}

export interface RawDocumentsConfig extends RawTypesConfig {
Expand Down Expand Up @@ -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
/**
Expand Down
Expand Up @@ -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;
Expand Down Expand Up @@ -304,35 +305,120 @@ export class SelectionSetToObject<Config extends ParsedDocumentsConfig = ParsedD
// in case there is not a selection for each type, we need to add a empty type.
let mustAddEmptyObject = false;

const grouped = getPossibleTypes(this._schema, this._parentSchemaType).reduce((prev, type) => {
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<string, string[]>);

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<string, { fields: (string | NameAndType)[]; types: { name: string; type: string }[] }>
>((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<string, string[]>);
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<Record<string, string[]>>((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<SelectionNode | FragmentSpreadUsage | DirectiveNode>
) {
Expand Down Expand Up @@ -461,7 +547,12 @@ export class SelectionSetToObject<Config extends ParsedDocumentsConfig = ParsedD
this._config.skipTypeNameForRoot
);
const transformed: ProcessResult = [
...(typeInfoField ? this._processor.transformTypenameField(typeInfoField.type, typeInfoField.name) : []),
// Only add the typename field if we're not merging fragment
// types. If we are merging, we need to wait until we know all
// the involved typenames.
...(typeInfoField && (!this._config.mergeFragmentTypes || this._config.inlineFragmentTypes === 'mask')
? this._processor.transformTypenameField(typeInfoField.type, typeInfoField.name)
: []),
...this._processor.transformPrimitiveFields(
parentSchemaType,
Array.from(primitiveFields.values()).map(field => ({
Expand Down Expand Up @@ -499,7 +590,7 @@ export class SelectionSetToObject<Config extends ParsedDocumentsConfig = ParsedD
}
}

return this._processor.buildSelectionSetFromStrings(fields);
return { typeInfo: typeInfoField, fields };
}

protected buildTypeNameField(
Expand Down
1 change: 1 addition & 0 deletions packages/plugins/typescript/operations/src/visitor.ts
Expand Up @@ -40,6 +40,7 @@ export class TypeScriptDocumentsVisitor extends BaseDocumentsVisitor<
immutableTypes: getConfigValue(config.immutableTypes, false),
nonOptionalTypename: getConfigValue(config.nonOptionalTypename, false),
preResolveTypes: getConfigValue(config.preResolveTypes, true),
mergeFragmentTypes: getConfigValue(config.mergeFragmentTypes, false),
} as TypeScriptDocumentsParsedConfig,
schema
);
Expand Down

1 comment on commit f1fb77b

@vercel
Copy link

@vercel vercel bot commented on f1fb77b May 9, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.