diff --git a/.changeset/five-cases-sniff.md b/.changeset/five-cases-sniff.md new file mode 100644 index 00000000000..148366947bc --- /dev/null +++ b/.changeset/five-cases-sniff.md @@ -0,0 +1,5 @@ +--- +'@graphql-codegen/visitor-plugin-common': minor +--- + +Adding config option extractAllFieldsToTypesCompact, which renders nested types names with field names only (without 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 e0c512dc7c4..30184acb31b 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 @@ -16,6 +16,7 @@ import { OperationVariablesToObject } from './variables-to-object.js'; export interface ParsedDocumentsConfig extends ParsedConfig { extractAllFieldsToTypes: boolean; + extractAllFieldsToTypesCompact: boolean; operationResultSuffix: string; dedupeOperationSuffix: boolean; omitOperationSuffix: boolean; @@ -219,6 +220,16 @@ export interface RawDocumentsConfig extends RawConfig { * and the typechecking time. */ extractAllFieldsToTypes?: boolean; + /** + * @default false + * @description Generates type names using only field names, omitting GraphQL type names. + * This matches the naming convention used by Apollo Tooling. + * For example, instead of `Query_company_Company_office_Office_location_Location`, + * it generates `Query_company_office_location`. + * + * When this option is enabled, `extractAllFieldsToTypes` is automatically enabled as well. + */ + extractAllFieldsToTypesCompact?: boolean; } export class BaseDocumentsVisitor< @@ -250,7 +261,10 @@ export class BaseDocumentsVisitor< customDirectives: getConfigValue(rawConfig.customDirectives, { apolloUnmask: false }), generatesOperationTypes: getConfigValue(rawConfig.generatesOperationTypes, true), importSchemaTypesFrom: getConfigValue(rawConfig.importSchemaTypesFrom, ''), - extractAllFieldsToTypes: getConfigValue(rawConfig.extractAllFieldsToTypes, false), + extractAllFieldsToTypes: + getConfigValue(rawConfig.extractAllFieldsToTypes, false) || + getConfigValue(rawConfig.extractAllFieldsToTypesCompact, false), + extractAllFieldsToTypesCompact: getConfigValue(rawConfig.extractAllFieldsToTypesCompact, false), ...((additionalConfig || {}) as any), }); @@ -357,15 +371,22 @@ export class BaseDocumentsVisitor< }) ); - const operationResult = new DeclarationBlock(this._declarationBlockConfig) - .export() - .asKind('type') - .withName( - this.convertName(name, { - suffix: operationTypeSuffix + this._parsedConfig.operationResultSuffix, - }) - ) - .withContent(selectionSetObjects.mergedTypeString).string; + const operationResultName = this.convertName(name, { + suffix: operationTypeSuffix + this._parsedConfig.operationResultSuffix, + }); + + // When extractAllFieldsToTypes creates a root type with the same name as the operation result, + // we only need the extracted type and can skip the alias to avoid duplicates + const shouldSkipOperationResult = + this._parsedConfig.extractAllFieldsToTypesCompact && operationResultName === selectionSetObjects.mergedTypeString; + + const operationResult = shouldSkipOperationResult + ? '' + : new DeclarationBlock(this._declarationBlockConfig) + .export() + .asKind('type') + .withName(operationResultName) + .withContent(selectionSetObjects.mergedTypeString).string; const operationVariables = new DeclarationBlock({ ...this._declarationBlockConfig, 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 9ddb6756409..77555e81019 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 @@ -834,7 +834,19 @@ export class SelectionSetToObject< .map(typeName => { const relevant = grouped[typeName].filter(Boolean); return relevant.map(objDefinition => { - const name = fieldName ? `${fieldName}_${typeName}` : typeName; + // In compact mode, we still need to keep the final concrete type name for union/interface types + // to distinguish between different implementations, but we skip it for simple object types + const hasMultipleTypes = Object.keys(grouped).length > 1; + let name: string; + if (fieldName) { + if (this._config.extractAllFieldsToTypesCompact && !hasMultipleTypes) { + name = fieldName; + } else { + name = `${fieldName}_${typeName}`; + } + } else { + name = typeName; + } return { name, content: typeof objDefinition === 'string' ? objDefinition : objDefinition.union.join(' | '), @@ -957,9 +969,17 @@ export class SelectionSetToObject< } protected buildFragmentTypeName(name: string, suffix: string, typeName = ''): string { + // In compact mode, omit typeName from fragment type names + let fragmentSuffix: string; + if (this._config.extractAllFieldsToTypesCompact) { + fragmentSuffix = suffix; + } else { + fragmentSuffix = typeName && suffix ? `_${typeName}_${suffix}` : typeName ? `_${typeName}` : suffix; + } + return this._convertName(name, { useTypesPrefix: true, - suffix: typeName && suffix ? `_${typeName}_${suffix}` : typeName ? `_${typeName}` : suffix, + suffix: fragmentSuffix, }); } @@ -970,6 +990,11 @@ export class SelectionSetToObject< return parentName; } + // When compact mode is enabled, skip appending typeName + if (this._config.extractAllFieldsToTypesCompact) { + return parentName; + } + const schemaType = this._schema.getType(typeName); // Check if current selection set has fragments (e.g., "... AppNotificationFragment" or "... on AppNotification") diff --git a/packages/plugins/typescript/operations/tests/extract-all-types.spec.ts b/packages/plugins/typescript/operations/tests/extract-all-types.spec.ts index 0a247b7d388..e371099abe9 100644 --- a/packages/plugins/typescript/operations/tests/extract-all-types.spec.ts +++ b/packages/plugins/typescript/operations/tests/extract-all-types.spec.ts @@ -1494,3 +1494,226 @@ describe('extractAllFieldsToTypes: true', () => { await validate(content); }); }); + +describe('extractAllFieldsToTypesCompact: true', () => { + const validate = async (content: Types.PluginOutput) => { + const m = mergeOutputs([content]); + validateTs(m, undefined, undefined, undefined, []); + + return m; + }; + + const companySchema = buildSchema(/* GraphQL */ ` + type Query { + company(id: ID!): Company + } + type Company { + id: ID! + name: String! + score: Float + reviewCount: Int + office: Office + } + type Office { + id: ID! + location: Location + } + type Location { + formatted: String + } + `); + + const companyDoc = parse(/* GraphQL */ ` + query GetCompanyInfo($id: ID!) { + company(id: $id) { + id + name + score + reviewCount + office { + id + location { + formatted + } + } + } + } + `); + + it('should generate compact type names without GraphQL type names (Apollo Tooling style)', async () => { + const config: TypeScriptDocumentsPluginConfig = { + extractAllFieldsToTypesCompact: true, + nonOptionalTypename: true, + omitOperationSuffix: true, + }; + const { content } = await plugin(companySchema, [{ location: 'test-file.ts', document: companyDoc }], config, { + outputFile: '', + }); + expect(content).toMatchInlineSnapshot(` + "export type GetCompanyInfo_company_office_location = { __typename: 'Location', formatted: string | null }; + + export type GetCompanyInfo_company_office = { __typename: 'Office', id: string, location: GetCompanyInfo_company_office_location | null }; + + export type GetCompanyInfo_company = { __typename: 'Company', id: string, name: string, score: number | null, reviewCount: number | null, office: GetCompanyInfo_company_office | null }; + + export type GetCompanyInfo = { __typename: 'Query', company: GetCompanyInfo_company | null }; + + + export type GetCompanyInfoVariables = Exact<{ + id: string; + }>; + " + `); + + await validate(content); + }); + + it('should work with unions and interfaces in compact mode', async () => { + const schema = buildSchema(/* GraphQL */ ` + type Query { + animals: [Animal!]! + } + interface Animal { + name: String! + owner: Person! + } + type Cat implements Animal { + name: String! + owner: Person! + } + type Dog implements Animal { + name: String! + owner: Person! + } + union Person = Trainer | Veterinarian + type Trainer { + name: String! + } + type Veterinarian { + name: String! + } + `); + + const doc = parse(/* GraphQL */ ` + query GetAnimals { + animals { + name + owner { + ... on Trainer { + name + } + ... on Veterinarian { + name + } + } + } + } + `); + + const config: TypeScriptDocumentsPluginConfig = { + extractAllFieldsToTypesCompact: true, + nonOptionalTypename: true, + omitOperationSuffix: true, + }; + const { content } = await plugin(schema, [{ location: 'test-file.ts', document: doc }], config, { outputFile: '' }); + + // Verify the naming follows Apollo Tooling style (field names only, no intermediate type names) + expect(content).toContain('GetAnimals_animals_owner_Trainer'); + expect(content).toContain('GetAnimals_animals_owner_Veterinarian'); + expect(content).toContain('GetAnimals_animals_owner'); + expect(content).toContain('GetAnimals_animals_Cat'); + expect(content).toContain('GetAnimals_animals_Dog'); + expect(content).toContain('GetAnimals_animals'); + + // Should NOT contain intermediate type names in the field paths (like Animal between animals and owner) + expect(content).not.toContain('GetAnimals_animals_Animal_owner'); + + await validate(content); + }); + + it('should automatically enable extractAllFieldsToTypes when extractAllFieldsToTypesCompact is true', async () => { + const config: TypeScriptDocumentsPluginConfig = { + extractAllFieldsToTypes: false, + extractAllFieldsToTypesCompact: true, + nonOptionalTypename: true, + omitOperationSuffix: true, + }; + const { content } = await plugin(companySchema, [{ location: 'test-file.ts', document: companyDoc }], config, { + outputFile: '', + }); + + // When extractAllFieldsToTypesCompact is true, extractAllFieldsToTypes should be automatically enabled + // So types should be extracted, not inlined + expect(content).toContain('GetCompanyInfo_company_office_location'); + expect(content).toContain('GetCompanyInfo_company_office'); + expect(content).toContain('GetCompanyInfo_company'); + expect(content).toContain('export type GetCompanyInfo'); + + await validate(content); + }); + + it('should apply compact naming to fragments', async () => { + const schema = buildSchema(/* GraphQL */ ` + type Query { + user(id: ID!): User + } + interface User { + id: ID! + profile: Profile + } + type AdminUser implements User { + id: ID! + profile: Profile + permissions: [String!]! + } + type RegularUser implements User { + id: ID! + profile: Profile + } + type Profile { + name: String! + contact: Contact + } + type Contact { + email: String + } + `); + + const doc = parse(/* GraphQL */ ` + fragment UserProfile on User { + id + profile { + name + contact { + email + } + } + } + query GetUser($id: ID!) { + user(id: $id) { + ...UserProfile + ... on AdminUser { + permissions + } + } + } + `); + + const config: TypeScriptDocumentsPluginConfig = { + extractAllFieldsToTypesCompact: true, + nonOptionalTypename: true, + omitOperationSuffix: true, + }; + const { content } = await plugin(schema, [{ location: 'test-file.ts', document: doc }], config, { outputFile: '' }); + + // Fragment types should use compact naming (no intermediate type names) + expect(content).toContain('UserProfile_profile_contact'); + expect(content).toContain('UserProfile_profile'); + + // Should NOT contain type names in fragment paths + expect(content).not.toContain('UserProfile_profile_Profile_contact'); + expect(content).not.toContain('UserProfile_profile_Profile_contact_Contact'); + + await validate(content); + }); +});