Skip to content

Commit b6d864a

Browse files
ikusakov2ikusakov
authored andcommitted
Add config extract all fields to types field names only (same as apollo-tooling), v2 (#10568)
* add extractAllFieldsToTypesCompact config option * adding changeset * fixing duplication bug * duplication bug fix --------- Co-authored-by: Igor Kusakov <igor@kusakov.com>
1 parent 38d9cf4 commit b6d864a

4 files changed

Lines changed: 291 additions & 13 deletions

File tree

.changeset/five-cases-sniff.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@graphql-codegen/visitor-plugin-common': minor
3+
---
4+
5+
Adding config option extractAllFieldsToTypesCompact, which renders nested types names with field names only (without types)

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

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
OperationTypeNode,
88
VariableDefinitionNode,
99
} from 'graphql';
10-
import { BaseVisitor, type RawConfig, type ParsedConfig } from './base-visitor.js';
10+
import { BaseVisitor, type ParsedConfig, type RawConfig } from './base-visitor.js';
1111
import { DEFAULT_SCALARS } from './scalars.js';
1212
import { SelectionSetToObject } from './selection-set-to-object.js';
1313
import { CustomDirectivesConfig, NormalizedScalarsMap } from './types.js';
@@ -21,6 +21,7 @@ import { OperationVariablesToObject } from './variables-to-object.js';
2121

2222
export interface ParsedDocumentsConfig extends ParsedConfig {
2323
extractAllFieldsToTypes: boolean;
24+
extractAllFieldsToTypesCompact: boolean;
2425
operationResultSuffix: string;
2526
dedupeOperationSuffix: boolean;
2627
omitOperationSuffix: boolean;
@@ -224,6 +225,16 @@ export interface RawDocumentsConfig extends RawConfig {
224225
* and the typechecking time.
225226
*/
226227
extractAllFieldsToTypes?: boolean;
228+
/**
229+
* @default false
230+
* @description Generates type names using only field names, omitting GraphQL type names.
231+
* This matches the naming convention used by Apollo Tooling.
232+
* For example, instead of `Query_company_Company_office_Office_location_Location`,
233+
* it generates `Query_company_office_location`.
234+
*
235+
* When this option is enabled, `extractAllFieldsToTypes` is automatically enabled as well.
236+
*/
237+
extractAllFieldsToTypesCompact?: boolean;
227238
}
228239

229240
export class BaseDocumentsVisitor<
@@ -257,7 +268,13 @@ export class BaseDocumentsVisitor<
257268
}),
258269
generatesOperationTypes: getConfigValue(rawConfig.generatesOperationTypes, true),
259270
importSchemaTypesFrom: getConfigValue(rawConfig.importSchemaTypesFrom, ''),
260-
extractAllFieldsToTypes: getConfigValue(rawConfig.extractAllFieldsToTypes, false),
271+
extractAllFieldsToTypes:
272+
getConfigValue(rawConfig.extractAllFieldsToTypes, false) ||
273+
getConfigValue(rawConfig.extractAllFieldsToTypesCompact, false),
274+
extractAllFieldsToTypesCompact: getConfigValue(
275+
rawConfig.extractAllFieldsToTypesCompact,
276+
false,
277+
),
261278
...((additionalConfig || {}) as any),
262279
});
263280

@@ -371,15 +388,23 @@ export class BaseDocumentsVisitor<
371388
}),
372389
);
373390

374-
const operationResult = new DeclarationBlock(this._declarationBlockConfig)
375-
.export()
376-
.asKind('type')
377-
.withName(
378-
this.convertName(name, {
379-
suffix: operationTypeSuffix + this._parsedConfig.operationResultSuffix,
380-
}),
381-
)
382-
.withContent(selectionSetObjects.mergedTypeString).string;
391+
const operationResultName = this.convertName(name, {
392+
suffix: operationTypeSuffix + this._parsedConfig.operationResultSuffix,
393+
});
394+
395+
// When extractAllFieldsToTypes creates a root type with the same name as the operation result,
396+
// we only need the extracted type and can skip the alias to avoid duplicates
397+
const shouldSkipOperationResult =
398+
this._parsedConfig.extractAllFieldsToTypesCompact &&
399+
operationResultName === selectionSetObjects.mergedTypeString;
400+
401+
const operationResult = shouldSkipOperationResult
402+
? ''
403+
: new DeclarationBlock(this._declarationBlockConfig)
404+
.export()
405+
.asKind('type')
406+
.withName(operationResultName)
407+
.withContent(selectionSetObjects.mergedTypeString).string;
383408

384409
const operationVariables = new DeclarationBlock({
385410
...this._declarationBlockConfig,

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

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -914,7 +914,19 @@ export class SelectionSetToObject<
914914
.map(typeName => {
915915
const relevant = grouped[typeName].filter(Boolean);
916916
return relevant.map(objDefinition => {
917-
const name = fieldName ? `${fieldName}_${typeName}` : typeName;
917+
// In compact mode, we still need to keep the final concrete type name for union/interface types
918+
// to distinguish between different implementations, but we skip it for simple object types
919+
const hasMultipleTypes = Object.keys(grouped).length > 1;
920+
let name: string;
921+
if (fieldName) {
922+
if (this._config.extractAllFieldsToTypesCompact && !hasMultipleTypes) {
923+
name = fieldName;
924+
} else {
925+
name = `${fieldName}_${typeName}`;
926+
}
927+
} else {
928+
name = typeName;
929+
}
918930
return {
919931
name,
920932
content:
@@ -1047,9 +1059,17 @@ export class SelectionSetToObject<
10471059
}
10481060

10491061
protected buildFragmentTypeName(name: string, suffix: string, typeName = ''): string {
1062+
// In compact mode, omit typeName from fragment type names
1063+
let fragmentSuffix: string;
1064+
if (this._config.extractAllFieldsToTypesCompact) {
1065+
fragmentSuffix = suffix;
1066+
} else {
1067+
fragmentSuffix = typeName && suffix ? `_${typeName}_${suffix}` : typeName ? `_${typeName}` : suffix;
1068+
}
1069+
10501070
return this._convertName(name, {
10511071
useTypesPrefix: true,
1052-
suffix: typeName && suffix ? `_${typeName}_${suffix}` : typeName ? `_${typeName}` : suffix,
1072+
suffix: fragmentSuffix,
10531073
});
10541074
}
10551075

@@ -1060,6 +1080,11 @@ export class SelectionSetToObject<
10601080
return parentName;
10611081
}
10621082

1083+
// When compact mode is enabled, skip appending typeName
1084+
if (this._config.extractAllFieldsToTypesCompact) {
1085+
return parentName;
1086+
}
1087+
10631088
const schemaType = this._schema.getType(typeName);
10641089

10651090
// Check if current selection set has type-narrowing fragments.

packages/plugins/typescript/operations/tests/extract-all-types.spec.ts

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1600,3 +1600,226 @@ describe('extractAllFieldsToTypes: true', () => {
16001600
await validate(content);
16011601
});
16021602
});
1603+
1604+
describe('extractAllFieldsToTypesCompact: true', () => {
1605+
const validate = async (content: Types.PluginOutput) => {
1606+
const m = mergeOutputs([content]);
1607+
validateTs(m, undefined, undefined, undefined, []);
1608+
1609+
return m;
1610+
};
1611+
1612+
const companySchema = buildSchema(/* GraphQL */ `
1613+
type Query {
1614+
company(id: ID!): Company
1615+
}
1616+
type Company {
1617+
id: ID!
1618+
name: String!
1619+
score: Float
1620+
reviewCount: Int
1621+
office: Office
1622+
}
1623+
type Office {
1624+
id: ID!
1625+
location: Location
1626+
}
1627+
type Location {
1628+
formatted: String
1629+
}
1630+
`);
1631+
1632+
const companyDoc = parse(/* GraphQL */ `
1633+
query GetCompanyInfo($id: ID!) {
1634+
company(id: $id) {
1635+
id
1636+
name
1637+
score
1638+
reviewCount
1639+
office {
1640+
id
1641+
location {
1642+
formatted
1643+
}
1644+
}
1645+
}
1646+
}
1647+
`);
1648+
1649+
it('should generate compact type names without GraphQL type names (Apollo Tooling style)', async () => {
1650+
const config: TypeScriptDocumentsPluginConfig = {
1651+
extractAllFieldsToTypesCompact: true,
1652+
nonOptionalTypename: true,
1653+
omitOperationSuffix: true,
1654+
};
1655+
const { content } = await plugin(companySchema, [{ location: 'test-file.ts', document: companyDoc }], config, {
1656+
outputFile: '',
1657+
});
1658+
expect(content).toMatchInlineSnapshot(`
1659+
"export type GetCompanyInfo_company_office_location = { __typename: 'Location', formatted: string | null };
1660+
1661+
export type GetCompanyInfo_company_office = { __typename: 'Office', id: string, location: GetCompanyInfo_company_office_location | null };
1662+
1663+
export type GetCompanyInfo_company = { __typename: 'Company', id: string, name: string, score: number | null, reviewCount: number | null, office: GetCompanyInfo_company_office | null };
1664+
1665+
export type GetCompanyInfo = { __typename: 'Query', company: GetCompanyInfo_company | null };
1666+
1667+
1668+
export type GetCompanyInfoVariables = Exact<{
1669+
id: string;
1670+
}>;
1671+
"
1672+
`);
1673+
1674+
await validate(content);
1675+
});
1676+
1677+
it('should work with unions and interfaces in compact mode', async () => {
1678+
const schema = buildSchema(/* GraphQL */ `
1679+
type Query {
1680+
animals: [Animal!]!
1681+
}
1682+
interface Animal {
1683+
name: String!
1684+
owner: Person!
1685+
}
1686+
type Cat implements Animal {
1687+
name: String!
1688+
owner: Person!
1689+
}
1690+
type Dog implements Animal {
1691+
name: String!
1692+
owner: Person!
1693+
}
1694+
union Person = Trainer | Veterinarian
1695+
type Trainer {
1696+
name: String!
1697+
}
1698+
type Veterinarian {
1699+
name: String!
1700+
}
1701+
`);
1702+
1703+
const doc = parse(/* GraphQL */ `
1704+
query GetAnimals {
1705+
animals {
1706+
name
1707+
owner {
1708+
... on Trainer {
1709+
name
1710+
}
1711+
... on Veterinarian {
1712+
name
1713+
}
1714+
}
1715+
}
1716+
}
1717+
`);
1718+
1719+
const config: TypeScriptDocumentsPluginConfig = {
1720+
extractAllFieldsToTypesCompact: true,
1721+
nonOptionalTypename: true,
1722+
omitOperationSuffix: true,
1723+
};
1724+
const { content } = await plugin(schema, [{ location: 'test-file.ts', document: doc }], config, { outputFile: '' });
1725+
1726+
// Verify the naming follows Apollo Tooling style (field names only, no intermediate type names)
1727+
expect(content).toContain('GetAnimals_animals_owner_Trainer');
1728+
expect(content).toContain('GetAnimals_animals_owner_Veterinarian');
1729+
expect(content).toContain('GetAnimals_animals_owner');
1730+
expect(content).toContain('GetAnimals_animals_Cat');
1731+
expect(content).toContain('GetAnimals_animals_Dog');
1732+
expect(content).toContain('GetAnimals_animals');
1733+
1734+
// Should NOT contain intermediate type names in the field paths (like Animal between animals and owner)
1735+
expect(content).not.toContain('GetAnimals_animals_Animal_owner');
1736+
1737+
await validate(content);
1738+
});
1739+
1740+
it('should automatically enable extractAllFieldsToTypes when extractAllFieldsToTypesCompact is true', async () => {
1741+
const config: TypeScriptDocumentsPluginConfig = {
1742+
extractAllFieldsToTypes: false,
1743+
extractAllFieldsToTypesCompact: true,
1744+
nonOptionalTypename: true,
1745+
omitOperationSuffix: true,
1746+
};
1747+
const { content } = await plugin(companySchema, [{ location: 'test-file.ts', document: companyDoc }], config, {
1748+
outputFile: '',
1749+
});
1750+
1751+
// When extractAllFieldsToTypesCompact is true, extractAllFieldsToTypes should be automatically enabled
1752+
// So types should be extracted, not inlined
1753+
expect(content).toContain('GetCompanyInfo_company_office_location');
1754+
expect(content).toContain('GetCompanyInfo_company_office');
1755+
expect(content).toContain('GetCompanyInfo_company');
1756+
expect(content).toContain('export type GetCompanyInfo');
1757+
1758+
await validate(content);
1759+
});
1760+
1761+
it('should apply compact naming to fragments', async () => {
1762+
const schema = buildSchema(/* GraphQL */ `
1763+
type Query {
1764+
user(id: ID!): User
1765+
}
1766+
interface User {
1767+
id: ID!
1768+
profile: Profile
1769+
}
1770+
type AdminUser implements User {
1771+
id: ID!
1772+
profile: Profile
1773+
permissions: [String!]!
1774+
}
1775+
type RegularUser implements User {
1776+
id: ID!
1777+
profile: Profile
1778+
}
1779+
type Profile {
1780+
name: String!
1781+
contact: Contact
1782+
}
1783+
type Contact {
1784+
email: String
1785+
}
1786+
`);
1787+
1788+
const doc = parse(/* GraphQL */ `
1789+
fragment UserProfile on User {
1790+
id
1791+
profile {
1792+
name
1793+
contact {
1794+
email
1795+
}
1796+
}
1797+
}
1798+
query GetUser($id: ID!) {
1799+
user(id: $id) {
1800+
...UserProfile
1801+
... on AdminUser {
1802+
permissions
1803+
}
1804+
}
1805+
}
1806+
`);
1807+
1808+
const config: TypeScriptDocumentsPluginConfig = {
1809+
extractAllFieldsToTypesCompact: true,
1810+
nonOptionalTypename: true,
1811+
omitOperationSuffix: true,
1812+
};
1813+
const { content } = await plugin(schema, [{ location: 'test-file.ts', document: doc }], config, { outputFile: '' });
1814+
1815+
// Fragment types should use compact naming (no intermediate type names)
1816+
expect(content).toContain('UserProfile_profile_contact');
1817+
expect(content).toContain('UserProfile_profile');
1818+
1819+
// Should NOT contain type names in fragment paths
1820+
expect(content).not.toContain('UserProfile_profile_Profile_contact');
1821+
expect(content).not.toContain('UserProfile_profile_Profile_contact_Contact');
1822+
1823+
await validate(content);
1824+
});
1825+
});

0 commit comments

Comments
 (0)