Skip to content

Commit 1f8222e

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 5f7c18c commit 1f8222e

4 files changed

Lines changed: 286 additions & 12 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: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { OperationVariablesToObject } from './variables-to-object.js';
1616

1717
export interface ParsedDocumentsConfig extends ParsedConfig {
1818
extractAllFieldsToTypes: boolean;
19+
extractAllFieldsToTypesCompact: boolean;
1920
operationResultSuffix: string;
2021
dedupeOperationSuffix: boolean;
2122
omitOperationSuffix: boolean;
@@ -219,6 +220,16 @@ export interface RawDocumentsConfig extends RawConfig {
219220
* and the typechecking time.
220221
*/
221222
extractAllFieldsToTypes?: boolean;
223+
/**
224+
* @default false
225+
* @description Generates type names using only field names, omitting GraphQL type names.
226+
* This matches the naming convention used by Apollo Tooling.
227+
* For example, instead of `Query_company_Company_office_Office_location_Location`,
228+
* it generates `Query_company_office_location`.
229+
*
230+
* When this option is enabled, `extractAllFieldsToTypes` is automatically enabled as well.
231+
*/
232+
extractAllFieldsToTypesCompact?: boolean;
222233
}
223234

224235
export class BaseDocumentsVisitor<
@@ -250,7 +261,10 @@ export class BaseDocumentsVisitor<
250261
customDirectives: getConfigValue(rawConfig.customDirectives, { apolloUnmask: false }),
251262
generatesOperationTypes: getConfigValue(rawConfig.generatesOperationTypes, true),
252263
importSchemaTypesFrom: getConfigValue(rawConfig.importSchemaTypesFrom, ''),
253-
extractAllFieldsToTypes: getConfigValue(rawConfig.extractAllFieldsToTypes, false),
264+
extractAllFieldsToTypes:
265+
getConfigValue(rawConfig.extractAllFieldsToTypes, false) ||
266+
getConfigValue(rawConfig.extractAllFieldsToTypesCompact, false),
267+
extractAllFieldsToTypesCompact: getConfigValue(rawConfig.extractAllFieldsToTypesCompact, false),
254268
...((additionalConfig || {}) as any),
255269
});
256270

@@ -357,15 +371,22 @@ export class BaseDocumentsVisitor<
357371
})
358372
);
359373

360-
const operationResult = new DeclarationBlock(this._declarationBlockConfig)
361-
.export()
362-
.asKind('type')
363-
.withName(
364-
this.convertName(name, {
365-
suffix: operationTypeSuffix + this._parsedConfig.operationResultSuffix,
366-
})
367-
)
368-
.withContent(selectionSetObjects.mergedTypeString).string;
374+
const operationResultName = this.convertName(name, {
375+
suffix: operationTypeSuffix + this._parsedConfig.operationResultSuffix,
376+
});
377+
378+
// When extractAllFieldsToTypes creates a root type with the same name as the operation result,
379+
// we only need the extracted type and can skip the alias to avoid duplicates
380+
const shouldSkipOperationResult =
381+
this._parsedConfig.extractAllFieldsToTypesCompact && operationResultName === selectionSetObjects.mergedTypeString;
382+
383+
const operationResult = shouldSkipOperationResult
384+
? ''
385+
: new DeclarationBlock(this._declarationBlockConfig)
386+
.export()
387+
.asKind('type')
388+
.withName(operationResultName)
389+
.withContent(selectionSetObjects.mergedTypeString).string;
369390

370391
const operationVariables = new DeclarationBlock({
371392
...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
@@ -834,7 +834,19 @@ export class SelectionSetToObject<
834834
.map(typeName => {
835835
const relevant = grouped[typeName].filter(Boolean);
836836
return relevant.map(objDefinition => {
837-
const name = fieldName ? `${fieldName}_${typeName}` : typeName;
837+
// In compact mode, we still need to keep the final concrete type name for union/interface types
838+
// to distinguish between different implementations, but we skip it for simple object types
839+
const hasMultipleTypes = Object.keys(grouped).length > 1;
840+
let name: string;
841+
if (fieldName) {
842+
if (this._config.extractAllFieldsToTypesCompact && !hasMultipleTypes) {
843+
name = fieldName;
844+
} else {
845+
name = `${fieldName}_${typeName}`;
846+
}
847+
} else {
848+
name = typeName;
849+
}
838850
return {
839851
name,
840852
content: typeof objDefinition === 'string' ? objDefinition : objDefinition.union.join(' | '),
@@ -957,9 +969,17 @@ export class SelectionSetToObject<
957969
}
958970

959971
protected buildFragmentTypeName(name: string, suffix: string, typeName = ''): string {
972+
// In compact mode, omit typeName from fragment type names
973+
let fragmentSuffix: string;
974+
if (this._config.extractAllFieldsToTypesCompact) {
975+
fragmentSuffix = suffix;
976+
} else {
977+
fragmentSuffix = typeName && suffix ? `_${typeName}_${suffix}` : typeName ? `_${typeName}` : suffix;
978+
}
979+
960980
return this._convertName(name, {
961981
useTypesPrefix: true,
962-
suffix: typeName && suffix ? `_${typeName}_${suffix}` : typeName ? `_${typeName}` : suffix,
982+
suffix: fragmentSuffix,
963983
});
964984
}
965985

@@ -970,6 +990,11 @@ export class SelectionSetToObject<
970990
return parentName;
971991
}
972992

993+
// When compact mode is enabled, skip appending typeName
994+
if (this._config.extractAllFieldsToTypesCompact) {
995+
return parentName;
996+
}
997+
973998
const schemaType = this._schema.getType(typeName);
974999

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

0 commit comments

Comments
 (0)