Skip to content

Commit e1d4180

Browse files
ikusakov2ikusakov
andauthored
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 cda43e2 commit e1d4180

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 fragments (e.g., "... AppNotificationFragment" or "... on AppNotification")

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

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1494,3 +1494,226 @@ describe('extractAllFieldsToTypes: true', () => {
14941494
await validate(content);
14951495
});
14961496
});
1497+
1498+
describe('extractAllFieldsToTypesCompact: true', () => {
1499+
const validate = async (content: Types.PluginOutput) => {
1500+
const m = mergeOutputs([content]);
1501+
validateTs(m, undefined, undefined, undefined, []);
1502+
1503+
return m;
1504+
};
1505+
1506+
const companySchema = buildSchema(/* GraphQL */ `
1507+
type Query {
1508+
company(id: ID!): Company
1509+
}
1510+
type Company {
1511+
id: ID!
1512+
name: String!
1513+
score: Float
1514+
reviewCount: Int
1515+
office: Office
1516+
}
1517+
type Office {
1518+
id: ID!
1519+
location: Location
1520+
}
1521+
type Location {
1522+
formatted: String
1523+
}
1524+
`);
1525+
1526+
const companyDoc = parse(/* GraphQL */ `
1527+
query GetCompanyInfo($id: ID!) {
1528+
company(id: $id) {
1529+
id
1530+
name
1531+
score
1532+
reviewCount
1533+
office {
1534+
id
1535+
location {
1536+
formatted
1537+
}
1538+
}
1539+
}
1540+
}
1541+
`);
1542+
1543+
it('should generate compact type names without GraphQL type names (Apollo Tooling style)', async () => {
1544+
const config: TypeScriptDocumentsPluginConfig = {
1545+
extractAllFieldsToTypesCompact: true,
1546+
nonOptionalTypename: true,
1547+
omitOperationSuffix: true,
1548+
};
1549+
const { content } = await plugin(companySchema, [{ location: 'test-file.ts', document: companyDoc }], config, {
1550+
outputFile: '',
1551+
});
1552+
expect(content).toMatchInlineSnapshot(`
1553+
"export type GetCompanyInfo_company_office_location = { __typename: 'Location', formatted: string | null };
1554+
1555+
export type GetCompanyInfo_company_office = { __typename: 'Office', id: string, location: GetCompanyInfo_company_office_location | null };
1556+
1557+
export type GetCompanyInfo_company = { __typename: 'Company', id: string, name: string, score: number | null, reviewCount: number | null, office: GetCompanyInfo_company_office | null };
1558+
1559+
export type GetCompanyInfo = { __typename: 'Query', company: GetCompanyInfo_company | null };
1560+
1561+
1562+
export type GetCompanyInfoVariables = Exact<{
1563+
id: string;
1564+
}>;
1565+
"
1566+
`);
1567+
1568+
await validate(content);
1569+
});
1570+
1571+
it('should work with unions and interfaces in compact mode', async () => {
1572+
const schema = buildSchema(/* GraphQL */ `
1573+
type Query {
1574+
animals: [Animal!]!
1575+
}
1576+
interface Animal {
1577+
name: String!
1578+
owner: Person!
1579+
}
1580+
type Cat implements Animal {
1581+
name: String!
1582+
owner: Person!
1583+
}
1584+
type Dog implements Animal {
1585+
name: String!
1586+
owner: Person!
1587+
}
1588+
union Person = Trainer | Veterinarian
1589+
type Trainer {
1590+
name: String!
1591+
}
1592+
type Veterinarian {
1593+
name: String!
1594+
}
1595+
`);
1596+
1597+
const doc = parse(/* GraphQL */ `
1598+
query GetAnimals {
1599+
animals {
1600+
name
1601+
owner {
1602+
... on Trainer {
1603+
name
1604+
}
1605+
... on Veterinarian {
1606+
name
1607+
}
1608+
}
1609+
}
1610+
}
1611+
`);
1612+
1613+
const config: TypeScriptDocumentsPluginConfig = {
1614+
extractAllFieldsToTypesCompact: true,
1615+
nonOptionalTypename: true,
1616+
omitOperationSuffix: true,
1617+
};
1618+
const { content } = await plugin(schema, [{ location: 'test-file.ts', document: doc }], config, { outputFile: '' });
1619+
1620+
// Verify the naming follows Apollo Tooling style (field names only, no intermediate type names)
1621+
expect(content).toContain('GetAnimals_animals_owner_Trainer');
1622+
expect(content).toContain('GetAnimals_animals_owner_Veterinarian');
1623+
expect(content).toContain('GetAnimals_animals_owner');
1624+
expect(content).toContain('GetAnimals_animals_Cat');
1625+
expect(content).toContain('GetAnimals_animals_Dog');
1626+
expect(content).toContain('GetAnimals_animals');
1627+
1628+
// Should NOT contain intermediate type names in the field paths (like Animal between animals and owner)
1629+
expect(content).not.toContain('GetAnimals_animals_Animal_owner');
1630+
1631+
await validate(content);
1632+
});
1633+
1634+
it('should automatically enable extractAllFieldsToTypes when extractAllFieldsToTypesCompact is true', async () => {
1635+
const config: TypeScriptDocumentsPluginConfig = {
1636+
extractAllFieldsToTypes: false,
1637+
extractAllFieldsToTypesCompact: true,
1638+
nonOptionalTypename: true,
1639+
omitOperationSuffix: true,
1640+
};
1641+
const { content } = await plugin(companySchema, [{ location: 'test-file.ts', document: companyDoc }], config, {
1642+
outputFile: '',
1643+
});
1644+
1645+
// When extractAllFieldsToTypesCompact is true, extractAllFieldsToTypes should be automatically enabled
1646+
// So types should be extracted, not inlined
1647+
expect(content).toContain('GetCompanyInfo_company_office_location');
1648+
expect(content).toContain('GetCompanyInfo_company_office');
1649+
expect(content).toContain('GetCompanyInfo_company');
1650+
expect(content).toContain('export type GetCompanyInfo');
1651+
1652+
await validate(content);
1653+
});
1654+
1655+
it('should apply compact naming to fragments', async () => {
1656+
const schema = buildSchema(/* GraphQL */ `
1657+
type Query {
1658+
user(id: ID!): User
1659+
}
1660+
interface User {
1661+
id: ID!
1662+
profile: Profile
1663+
}
1664+
type AdminUser implements User {
1665+
id: ID!
1666+
profile: Profile
1667+
permissions: [String!]!
1668+
}
1669+
type RegularUser implements User {
1670+
id: ID!
1671+
profile: Profile
1672+
}
1673+
type Profile {
1674+
name: String!
1675+
contact: Contact
1676+
}
1677+
type Contact {
1678+
email: String
1679+
}
1680+
`);
1681+
1682+
const doc = parse(/* GraphQL */ `
1683+
fragment UserProfile on User {
1684+
id
1685+
profile {
1686+
name
1687+
contact {
1688+
email
1689+
}
1690+
}
1691+
}
1692+
query GetUser($id: ID!) {
1693+
user(id: $id) {
1694+
...UserProfile
1695+
... on AdminUser {
1696+
permissions
1697+
}
1698+
}
1699+
}
1700+
`);
1701+
1702+
const config: TypeScriptDocumentsPluginConfig = {
1703+
extractAllFieldsToTypesCompact: true,
1704+
nonOptionalTypename: true,
1705+
omitOperationSuffix: true,
1706+
};
1707+
const { content } = await plugin(schema, [{ location: 'test-file.ts', document: doc }], config, { outputFile: '' });
1708+
1709+
// Fragment types should use compact naming (no intermediate type names)
1710+
expect(content).toContain('UserProfile_profile_contact');
1711+
expect(content).toContain('UserProfile_profile');
1712+
1713+
// Should NOT contain type names in fragment paths
1714+
expect(content).not.toContain('UserProfile_profile_Profile_contact');
1715+
expect(content).not.toContain('UserProfile_profile_Profile_contact_Contact');
1716+
1717+
await validate(content);
1718+
});
1719+
});

0 commit comments

Comments
 (0)