From e1ea93d2724e08163233f12fba03354306cfd27a Mon Sep 17 00:00:00 2001 From: hwillson Date: Tue, 3 Jan 2023 14:22:22 -0500 Subject: [PATCH] Add initial typescript `@defer` support This commit takes a first pass at introducing `@defer` typescript codegen support. It follows a similar approach as the `@skip` / `@include` functionality introduced in #5017. Related issue: #7885 --- .changeset/calm-oranges-speak.md | 6 + .../src/selection-set-processor/base.ts | 9 +- .../pre-resolve-types.ts | 5 +- .../src/selection-set-to-object.ts | 53 +- .../other/visitor-plugin-common/src/types.ts | 6 +- .../other/visitor-plugin-common/src/utils.ts | 9 +- .../src/ts-selection-set-processor.ts | 14 +- .../typescript/operations/src/visitor.ts | 8 +- .../operations/tests/ts-documents.spec.ts | 565 ++++++++++++++++++ 9 files changed, 650 insertions(+), 25 deletions(-) create mode 100644 .changeset/calm-oranges-speak.md diff --git a/.changeset/calm-oranges-speak.md b/.changeset/calm-oranges-speak.md new file mode 100644 index 000000000000..35a4cabe907d --- /dev/null +++ b/.changeset/calm-oranges-speak.md @@ -0,0 +1,6 @@ +--- +'@graphql-codegen/visitor-plugin-common': minor +'@graphql-codegen/typescript-operations': minor +--- + +Add typescript codegen `@defer` support diff --git a/packages/plugins/other/visitor-plugin-common/src/selection-set-processor/base.ts b/packages/plugins/other/visitor-plugin-common/src/selection-set-processor/base.ts index f44698405983..187e9005d240 100644 --- a/packages/plugins/other/visitor-plugin-common/src/selection-set-processor/base.ts +++ b/packages/plugins/other/visitor-plugin-common/src/selection-set-processor/base.ts @@ -1,7 +1,7 @@ import { GraphQLInterfaceType, GraphQLNamedType, GraphQLObjectType, GraphQLOutputType } from 'graphql'; import { AvoidOptionalsConfig, ConvertNameFn, ScalarsMap } from '../types.js'; -export type PrimitiveField = { isConditional: boolean; fieldName: string }; +export type PrimitiveField = { isConditional: boolean; isIncremental: boolean; fieldName: string }; export type PrimitiveAliasedFields = { alias: string; fieldName: string }; export type LinkField = { alias: string; name: string; type: string; selectionSet: string }; export type NameAndType = { name: string; type: string }; @@ -12,7 +12,12 @@ export type SelectionSetProcessorConfig = { convertName: ConvertNameFn; enumPrefix: boolean | null; scalars: ScalarsMap; - formatNamedField(name: string, type?: GraphQLOutputType | GraphQLNamedType | null, isConditional?: boolean): string; + formatNamedField( + name: string, + type?: GraphQLOutputType | GraphQLNamedType | null, + isConditional?: boolean, + isIncremental?: boolean + ): string; wrapTypeWithModifiers(baseType: string, type: GraphQLOutputType | GraphQLNamedType): string; avoidOptionals?: AvoidOptionalsConfig; }; diff --git a/packages/plugins/other/visitor-plugin-common/src/selection-set-processor/pre-resolve-types.ts b/packages/plugins/other/visitor-plugin-common/src/selection-set-processor/pre-resolve-types.ts index 0cf4075d6435..66042b0593ad 100644 --- a/packages/plugins/other/visitor-plugin-common/src/selection-set-processor/pre-resolve-types.ts +++ b/packages/plugins/other/visitor-plugin-common/src/selection-set-processor/pre-resolve-types.ts @@ -33,7 +33,7 @@ export class PreResolveTypesProcessor extends BaseSelectionSetProcessor; }; +type CollectedFragmentNode = (SelectionNode | FragmentSpreadUsage | DirectiveNode) & FragmentDirectives; + function isMetadataFieldName(name: string) { return ['__schema', '__type'].includes(name); } @@ -99,8 +108,8 @@ export class SelectionSetToObject> + nodes: (InlineFragmentNode & FragmentDirectives)[], + types: Map> ) { if (isListType(parentType) || isNonNullType(parentType)) { return this._collectInlineFragments(parentType.ofType as GraphQLNamedType, nodes, types); @@ -112,8 +121,18 @@ export class SelectionSetToObject ({ + ...field, + fragmentDirectives: field.fragmentDirectives || directives, + })); + if (isObjectType(typeOnSchema)) { - this._appendToTypeMap(types, typeOnSchema.name, fields); + this._appendToTypeMap(types, typeOnSchema.name, fieldsWithFragmentDirectives); this._appendToTypeMap(types, typeOnSchema.name, spreadsUsage[typeOnSchema.name]); this._appendToTypeMap(types, typeOnSchema.name, directives); this._collectInlineFragments(typeOnSchema, inlines, types); @@ -232,11 +251,18 @@ export class SelectionSetToObject { + return { + ...selectionNode, + fragmentDirectives: [...(spread.directives || [])], + }; + }); + selectionNodesByTypeName[possibleType.name].push({ fragmentName: spread.name.value, typeName: usage, onType: fragmentSpreadObject.onType, - selectionNodes: [...fragmentSpreadObject.node.selectionSet.selections], + selectionNodes, }); } } @@ -253,7 +279,6 @@ export class SelectionSetToObject(types: Map>, typeName: string, nodes: Array): void { + private _appendToTypeMap( + types: Map>, + typeName: string, + nodes: Array + ): void { if (!types.has(typeName)) { types.set(typeName, []); } @@ -442,7 +471,6 @@ export class SelectionSetToObject ({ isConditional: hasConditionalDirectives(field), + isIncremental: hasIncrementalDeliveryDirectives(field), fieldName: field.name.value, })) ), diff --git a/packages/plugins/other/visitor-plugin-common/src/types.ts b/packages/plugins/other/visitor-plugin-common/src/types.ts index ec32f658a19e..8e22a90daa92 100644 --- a/packages/plugins/other/visitor-plugin-common/src/types.ts +++ b/packages/plugins/other/visitor-plugin-common/src/types.ts @@ -1,4 +1,4 @@ -import { ASTNode, FragmentDefinitionNode } from 'graphql'; +import { ASTNode, FragmentDefinitionNode, DirectiveNode } from 'graphql'; import { ParsedMapper } from './mappers.js'; /** @@ -102,3 +102,7 @@ export interface ParsedImport { moduleName: string | null; propName: string; } + +export type FragmentDirectives = { + fragmentDirectives?: Array; +}; diff --git a/packages/plugins/other/visitor-plugin-common/src/utils.ts b/packages/plugins/other/visitor-plugin-common/src/utils.ts index b95234f40f44..803fb3316004 100644 --- a/packages/plugins/other/visitor-plugin-common/src/utils.ts +++ b/packages/plugins/other/visitor-plugin-common/src/utils.ts @@ -25,7 +25,7 @@ import { import { RawConfig } from './base-visitor.js'; import { parseMapper } from './mappers.js'; import { DEFAULT_SCALARS } from './scalars.js'; -import { NormalizedScalarsMap, ParsedScalarsMap, ScalarsMap } from './types.js'; +import { NormalizedScalarsMap, ParsedScalarsMap, ScalarsMap, FragmentDirectives } from './types.js'; export const getConfigValue = (value: T, defaultValue: T): T => { if (value === null || value === undefined) { @@ -408,7 +408,7 @@ export const getFieldNodeNameValue = (node: FieldNode): string => { }; export function separateSelectionSet(selections: ReadonlyArray): { - fields: FieldNode[]; + fields: (FieldNode & FragmentDirectives)[]; spreads: FragmentSpreadNode[]; inlines: InlineFragmentNode[]; } { @@ -438,6 +438,11 @@ export function hasConditionalDirectives(field: FieldNode): boolean { return field.directives?.some(directive => CONDITIONAL_DIRECTIVES.includes(directive.name.value)); } +export function hasIncrementalDeliveryDirectives(field: FieldNode & FragmentDirectives): boolean { + const INCREMENTAL_DELIVERY_DIRECTIVES = ['defer']; + return field.fragmentDirectives?.some(directive => INCREMENTAL_DELIVERY_DIRECTIVES.includes(directive.name.value)); +} + type WrapModifiersOptions = { wrapOptional(type: string): string; wrapArray(type: string): string; diff --git a/packages/plugins/typescript/operations/src/ts-selection-set-processor.ts b/packages/plugins/typescript/operations/src/ts-selection-set-processor.ts index 9829ba692d95..c0e2d6ba6642 100644 --- a/packages/plugins/typescript/operations/src/ts-selection-set-processor.ts +++ b/packages/plugins/typescript/operations/src/ts-selection-set-processor.ts @@ -23,19 +23,19 @@ export class TypeScriptSelectionSetProcessor extends BaseSelectionSetProcessor { - if (field.isConditional) { - hasConditionals = true; - conditilnalsList.push(field.fieldName); + if (field.isConditional || field.isIncremental) { + hasOptional = true; + optionalList.push(field.fieldName); } return `'${field.fieldName}'`; }) .join(' | ')}>`; - if (hasConditionals) { + if (hasOptional) { const avoidOptional = // TODO: check type and exec only if relevant this.config.avoidOptionals === true || @@ -45,7 +45,7 @@ export class TypeScriptSelectionSetProcessor extends BaseSelectionSetProcessor `'${field}'`).join(' | ')}>`; + }${transform}<${resString}, ${optionalList.map(field => `'${field}'`).join(' | ')}>`; } return [resString]; } diff --git a/packages/plugins/typescript/operations/src/visitor.ts b/packages/plugins/typescript/operations/src/visitor.ts index 34b37bcbe10c..47c13d26198f 100644 --- a/packages/plugins/typescript/operations/src/visitor.ts +++ b/packages/plugins/typescript/operations/src/visitor.ts @@ -66,9 +66,13 @@ export class TypeScriptDocumentsVisitor extends BaseDocumentsVisitor< const formatNamedField = ( name: string, type: GraphQLOutputType | GraphQLNamedType | null, - isConditional = false + isConditional = false, + isIncremental = false ): string => { - const optional = isConditional || (!this.config.avoidOptionals.field && Boolean(type) && !isNonNullType(type)); + const optional = + isConditional || + isIncremental || + (!this.config.avoidOptionals.field && Boolean(type) && !isNonNullType(type)); return (this.config.immutableTypes ? `readonly ${name}` : name) + (optional ? '?' : ''); }; diff --git a/packages/plugins/typescript/operations/tests/ts-documents.spec.ts b/packages/plugins/typescript/operations/tests/ts-documents.spec.ts index de9b5c5dfe82..b9b8016f03d3 100644 --- a/packages/plugins/typescript/operations/tests/ts-documents.spec.ts +++ b/packages/plugins/typescript/operations/tests/ts-documents.spec.ts @@ -6434,6 +6434,571 @@ function test(q: GetEntityBrandDataQuery): void { }); }); + describe('incremental delivery directive handling', () => { + it('should mark fields in deferred fragments as optional (preResolveTypes: true)', async () => { + const schema = buildSchema(` + type Address { + street1: String! + } + + type Phone { + home: String! + } + + type Employment { + title: String! + } + + type User { + name: String! + email: String! + address: Address! + phone: Phone! + employment: Employment! + widgetCount: Int! + widgetPreference: String! + clearanceLevel: String! + favoriteFood: String! + leastFavoriteFood: String! + } + + type Query { + user: User! + } + `); + + const fragment = parse(` + fragment WidgetFragment on User { + widgetCount + widgetPreference + } + + fragment FoodFragment on User { + favoriteFood + leastFavoriteFood + } + + fragment EmploymentFragment on User { + employment { + title + } + } + + query user { + user { + # Test inline fragment defer + ... @defer { + email + } + + # Test inline fragment defer with nested selection set + ... @defer { + address { + street1 + } + } + + # Test named fragment defer + ...WidgetFragment @defer + + # Test a secondary named fragment defer + ...FoodFragment @defer + + # Not deferred fields, fragments, selection sets, etc are left alone + name + phone { + home + } + ...EmploymentFragment + ... { + clearanceLevel + } + } + } + `); + + const { content } = await plugin( + schema, + [{ location: '', document: fragment }], + { preResolveTypes: true }, + { outputFile: 'graphql.ts' } + ); + + expect(content).toBeSimilarStringTo(` + export type UserQueryVariables = Exact<{ [key: string]: never; }>; + export type UserQuery = { + __typename?: 'Query', + user: { + __typename?: 'User', + email?: string, + clearanceLevel: string, + name: string, + widgetCount?: number, + widgetPreference?: string, + favoriteFood?: string, + leastFavoriteFood?: string, + address?: { + __typename?: 'Address', + street1: string + }, + phone: { + __typename?: 'Phone', + home: string + }, + employment: { + __typename?: 'Employment', + title: string + } + } + }; + `); + }); + + it('should mark fields in deferred fragments as optional using MakeOptional (preResolveTypes: false)', async () => { + const schema = buildSchema(` + type Address { + street1: String! + } + + type Phone { + home: String! + } + + type Employment { + title: String! + } + + type User { + name: String! + email: String! + address: Address! + phone: Phone! + employment: Employment! + widgetCount: Int! + clearanceLevel: String! + } + + type Query { + user: User! + } + `); + + const fragment = parse(` + fragment WidgetFragment on User { + widgetCount + } + + fragment EmploymentFragment on User { + employment { + title + } + } + + query user { + user { + # Test inline fragment defer + ... @defer { + email + } + + # Test inline fragment defer with nested selection set + ... @defer { + address { + street1 + } + } + + # Test named fragment defer + ...WidgetFragment @defer + + # Not deferred fields, fragments, selection sets, etc are left alone + name + phone { + home + } + ...EmploymentFragment + ... { + clearanceLevel + } + } + } + `); + + const { content } = await plugin( + schema, + [{ location: '', document: fragment }], + { preResolveTypes: false }, + { outputFile: 'graphql.ts' } + ); + + expect(content).toBeSimilarStringTo(` + export type WidgetFragmentFragment = ( + { __typename?: 'User' } + & Pick + ); + + export type EmploymentFragmentFragment = ( + { __typename?: 'User' } + & { employment: ( + { __typename?: 'Employment' } + & Pick + ) } + ); + + export type UserQueryVariables = Exact<{ [key: string]: never; }>; + + export type UserQuery = ( + { __typename?: 'Query' } + & { user: ( + { __typename?: 'User' } + & MakeOptional, 'email' | 'widgetCount'> + & { address?: ( + { __typename?: 'Address' } + & Pick + ), phone: ( + { __typename?: 'Phone' } + & Pick + ), employment: ( + { __typename?: 'Employment' } + & Pick + ) } + ) } + ); + `); + }); + + it('should mark fields in deferred fragments as optional using MakeMaybe (avoidOptionals: true)', async () => { + const schema = buildSchema(` + type Address { + street1: String! + } + + type Phone { + home: String! + } + + type Employment { + title: String! + } + + type User { + name: String! + email: String! + address: Address! + phone: Phone! + employment: Employment! + widgetCount: Int! + clearanceLevel: String! + } + + type Query { + user: User! + } + `); + + const fragment = parse(` + fragment WidgetFragment on User { + widgetCount + } + + fragment EmploymentFragment on User { + employment { + title + } + } + + query user { + user { + # Test inline fragment defer + ... @defer { + email + } + + # Test inline fragment defer with nested selection set + ... @defer { + address { + street1 + } + } + + # Test named fragment defer + ...WidgetFragment @defer + + # Not deferred fields, fragments, selection sets, etc are left alone + name + phone { + home + } + ...EmploymentFragment + ... { + clearanceLevel + } + } + } + `); + + const { content } = await plugin( + schema, + [{ location: '', document: fragment }], + { + avoidOptionals: true, + preResolveTypes: false, + }, + { outputFile: 'graphql.ts' } + ); + + expect(content).toBeSimilarStringTo(` + export type WidgetFragmentFragment = ( + { __typename?: 'User' } + & Pick + ); + + export type EmploymentFragmentFragment = ( + { __typename?: 'User' } + & { employment: ( + { __typename?: 'Employment' } + & Pick + ) } + ); + + export type UserQueryVariables = Exact<{ [key: string]: never; }>; + + export type UserQuery = ( + { __typename?: 'Query' } + & { user: ( + { __typename?: 'User' } + & MakeMaybe, 'email' | 'widgetCount'> + & { address?: ( + { __typename?: 'Address' } + & Pick + ), phone: ( + { __typename?: 'Phone' } + & Pick + ), employment: ( + { __typename?: 'Employment' } + & Pick + ) } + ) } + ); + `); + }); + + it('should support "preResolveTypes: true" and "avoidOptionals: true" together', async () => { + const schema = buildSchema(` + type Address { + street1: String! + } + + type Phone { + home: String! + } + + type Employment { + title: String! + } + + type User { + name: String! + email: String! + address: Address! + phone: Phone! + employment: Employment! + widgetCount: Int! + clearanceLevel: String! + } + + type Query { + user: User! + } + `); + + const fragment = parse(` + fragment WidgetFragment on User { + widgetCount + } + + fragment EmploymentFragment on User { + employment { + title + } + } + + query user { + user { + # Test inline fragment defer + ... @defer { + email + } + + # Test inline fragment defer with nested selection set + ... @defer { + address { + street1 + } + } + + # Test named fragment defer + ...WidgetFragment @defer + + # Not deferred fields, fragments, selection sets, etc are left alone + name + phone { + home + } + ...EmploymentFragment + ... { + clearanceLevel + } + } + } + `); + + const { content } = await plugin( + schema, + [{ location: '', document: fragment }], + { + avoidOptionals: true, + preResolveTypes: true, + }, + { outputFile: 'graphql.ts' } + ); + + expect(content).toBeSimilarStringTo(` + export type UserQueryVariables = Exact<{ [key: string]: never; }>; + export type UserQuery = { + __typename?: 'Query', + user: { + __typename?: 'User', + email?: string, + clearanceLevel: string, + name: string, + widgetCount?: number, + address?: { + __typename?: 'Address', + street1: string + }, + phone: { + __typename?: 'Phone', + home: string + }, + employment: { + __typename?: 'Employment', + title: string + } + } + }; + `); + }); + + it('should resolve optionals according to maybeValue together with avoidOptionals and deferred fragments', async () => { + const schema = buildSchema(` + type Address { + street1: String + } + + type Phone { + home: String! + } + + type Employment { + title: String! + } + + type User { + name: String! + email: String! + address: Address! + phone: Phone! + employment: Employment! + widgetCount: Int! + clearanceLevel: String! + } + + type Query { + user: User! + } + `); + + const fragment = parse(` + fragment WidgetFragment on User { + widgetCount + } + + fragment EmploymentFragment on User { + employment { + title + } + } + + query user { + user { + # Test inline fragment defer + ... @defer { + email + } + + # Test inline fragment defer with nested selection set + ... @defer { + address { + street1 + } + } + + # Test named fragment defer + ...WidgetFragment @defer + + # Not deferred fields, fragments, selection sets, etc are left alone + name + phone { + home + } + ...EmploymentFragment + ... { + clearanceLevel + } + } + } + `); + + const { content } = await plugin( + schema, + [{ location: '', document: fragment }], + { + preResolveTypes: true, + maybeValue: "T | 'specialType'", + avoidOptionals: true, + }, + { outputFile: 'graphql.ts' } + ); + + expect(content).toBeSimilarStringTo(` + export type UserQueryVariables = Exact<{ [key: string]: never; }>; + export type UserQuery = { + __typename?: 'Query', + user: { + __typename?: 'User', + email?: string, + clearanceLevel: string, + name: string, + widgetCount?: number, + address?: { + __typename?: 'Address', + street1: string | 'specialType' + }, + phone: { + __typename?: 'Phone', + home: string + }, + employment: { + __typename?: 'Employment', + title: string + } + } + }; + `); + }); + }); + it('handles unnamed queries', async () => { const ast = parse(/* GraphQL */ ` query {