Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/five-cases-sniff.md
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<
Expand Down Expand Up @@ -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),
});

Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(' | '),
Expand Down Expand Up @@ -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,
});
}

Expand All @@ -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")
Expand Down
223 changes: 223 additions & 0 deletions packages/plugins/typescript/operations/tests/extract-all-types.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});