Skip to content

Commit e4095ee

Browse files
author
unknown
committed
feat(codegen): add --select flag to custom CLI commands using nested-obj
Replace hardcoded buildSelectObject() with runtime --select flag for custom mutation/query commands. When return type is OBJECT, generated commands now accept --select 'field1,field2.nested' which uses buildSelectFromPaths() (powered by nested-obj) to build the proper nested { select: { ... } } structure at runtime. - Add buildSelectFromPaths() to cli-utils.ts template - Import nested-obj for dot-notation path parsing - Remove hardcoded buildSelectObject() from custom-command-generator - Add hasObjectReturnType() and buildDefaultSelectString() helpers - For OBJECT return types: generate runtime select via --select flag - For scalar return types: skip select entirely (no change needed) - Default select falls back to all top-level fields when --select omitted - Update 3 snapshots to reflect new generated code pattern
1 parent f80e2e5 commit e4095ee

3 files changed

Lines changed: 129 additions & 36 deletions

File tree

graphql/codegen/src/__tests__/codegen/__snapshots__/cli-generator.test.ts.snap

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1144,15 +1144,17 @@ exports[`cli-generator generates commands/current-user.ts (custom query) 1`] = `
11441144
*/
11451145
import { CLIOptions, Inquirerer } from "inquirerer";
11461146
import { getClient } from "../executor";
1147+
import { buildSelectFromPaths } from "../utils";
11471148
export default async (argv: Partial<Record<string, unknown>>, prompter: Inquirerer, _options: CLIOptions) => {
11481149
try {
11491150
if (argv.help || argv.h) {
11501151
console.log("current-user - Get the currently authenticated user\\n\\nUsage: current-user [OPTIONS]\\n");
11511152
process.exit(0);
11521153
}
11531154
const client = getClient();
1155+
const selectFields = buildSelectFromPaths(argv.select ?? "");
11541156
const result = await client.query.currentUser({}, {
1155-
select: {}
1157+
select: selectFields
11561158
}).execute();
11571159
console.log(JSON.stringify(result, null, 2));
11581160
} catch (error) {
@@ -1379,6 +1381,7 @@ exports[`cli-generator generates commands/login.ts (custom mutation) 1`] = `
13791381
*/
13801382
import { CLIOptions, Inquirerer } from "inquirerer";
13811383
import { getClient } from "../executor";
1384+
import { buildSelectFromPaths } from "../utils";
13821385
export default async (argv: Partial<Record<string, unknown>>, prompter: Inquirerer, _options: CLIOptions) => {
13831386
try {
13841387
if (argv.help || argv.h) {
@@ -1397,10 +1400,9 @@ export default async (argv: Partial<Record<string, unknown>>, prompter: Inquirer
13971400
required: true
13981401
}]);
13991402
const client = getClient();
1403+
const selectFields = buildSelectFromPaths(argv.select ?? "clientMutationId");
14001404
const result = await client.mutation.login(answers, {
1401-
select: {
1402-
clientMutationId: true
1403-
}
1405+
select: selectFields
14041406
}).execute();
14051407
console.log(JSON.stringify(result, null, 2));
14061408
} catch (error) {
@@ -3543,6 +3545,7 @@ exports[`multi-target cli generator generates target-prefixed custom commands wi
35433545
*/
35443546
import { CLIOptions, Inquirerer } from "inquirerer";
35453547
import { getClient, getStore } from "../../executor";
3548+
import { buildSelectFromPaths } from "../../utils";
35463549
export default async (argv: Partial<Record<string, unknown>>, prompter: Inquirerer, _options: CLIOptions) => {
35473550
try {
35483551
if (argv.help || argv.h) {
@@ -3561,10 +3564,9 @@ export default async (argv: Partial<Record<string, unknown>>, prompter: Inquirer
35613564
required: true
35623565
}]);
35633566
const client = getClient("auth");
3567+
const selectFields = buildSelectFromPaths(argv.select ?? "clientMutationId");
35643568
const result = await client.mutation.login(answers, {
3565-
select: {
3566-
clientMutationId: true
3567-
}
3569+
select: selectFields
35683570
}).execute();
35693571
if (argv.saveToken && result) {
35703572
const tokenValue = result.token || result.jwtToken || result.accessToken;

graphql/codegen/src/core/codegen/cli/custom-command-generator.ts

Lines changed: 61 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -111,40 +111,47 @@ function unwrapType(ref: CleanTypeRef): CleanTypeRef {
111111
}
112112

113113
/**
114-
* Build a select object expression from return-type fields.
115-
* If the return type has known fields, generates { field1: true, field2: true, ... }.
116-
* Falls back to { clientMutationId: true } for mutations without known fields.
114+
* Check if the return type (after unwrapping) is an OBJECT type.
117115
*/
118-
function buildSelectObject(
116+
function hasObjectReturnType(returnType: CleanTypeRef): boolean {
117+
const base = unwrapType(returnType);
118+
return base.kind === 'OBJECT';
119+
}
120+
121+
/**
122+
* Build a default select string from the return type's top-level scalar fields.
123+
* For OBJECT return types with known fields, generates a comma-separated list
124+
* of all top-level field names (e.g. 'clientMutationId,result').
125+
* Falls back to 'clientMutationId' for mutations without known fields.
126+
*/
127+
function buildDefaultSelectString(
119128
returnType: CleanTypeRef,
120129
isMutation: boolean,
121-
): t.ObjectExpression {
130+
): string {
122131
const base = unwrapType(returnType);
123132
if (base.fields && base.fields.length > 0) {
124-
return t.objectExpression(
125-
base.fields.map((f) =>
126-
t.objectProperty(t.identifier(f.name), t.booleanLiteral(true)),
127-
),
128-
);
133+
return base.fields.map((f) => f.name).join(',');
129134
}
130-
// Fallback: all PostGraphile mutation payloads have clientMutationId
131135
if (isMutation) {
132-
return t.objectExpression([
133-
t.objectProperty(
134-
t.identifier('clientMutationId'),
135-
t.booleanLiteral(true),
136-
),
137-
]);
136+
return 'clientMutationId';
138137
}
139-
return t.objectExpression([]);
138+
return '';
140139
}
141140

142141
function buildOrmCustomCall(
143142
opKind: 'query' | 'mutation',
144143
opName: string,
145144
argsExpr: t.Expression,
146-
selectExpr: t.ObjectExpression,
145+
selectExpr?: t.Expression,
147146
): t.Expression {
147+
const callArgs: t.Expression[] = [argsExpr];
148+
if (selectExpr) {
149+
callArgs.push(
150+
t.objectExpression([
151+
t.objectProperty(t.identifier('select'), selectExpr),
152+
]),
153+
);
154+
}
148155
return t.callExpression(
149156
t.memberExpression(
150157
t.callExpression(
@@ -155,12 +162,7 @@ function buildOrmCustomCall(
155162
),
156163
t.identifier(opName),
157164
),
158-
[
159-
argsExpr,
160-
t.objectExpression([
161-
t.objectProperty(t.identifier('select'), selectExpr),
162-
]),
163-
],
165+
callArgs,
164166
),
165167
t.identifier('execute'),
166168
),
@@ -190,6 +192,9 @@ export function generateCustomCommand(op: CleanOperation, options?: CustomComman
190192
return base.kind === 'INPUT_OBJECT';
191193
});
192194

195+
// Check if return type is OBJECT (needs --select flag)
196+
const isObjectReturn = hasObjectReturnType(op.returnType);
197+
193198
const utilsPath = options?.executorImportPath
194199
? options.executorImportPath.replace(/\/executor$/, '/utils')
195200
: '../utils';
@@ -201,9 +206,17 @@ export function generateCustomCommand(op: CleanOperation, options?: CustomComman
201206
createImportDeclaration(executorPath, imports),
202207
);
203208

209+
// Build the list of utils imports needed
210+
const utilsImports: string[] = [];
204211
if (hasInputObjectArg) {
212+
utilsImports.push('parseMutationInput');
213+
}
214+
if (isObjectReturn) {
215+
utilsImports.push('buildSelectFromPaths');
216+
}
217+
if (utilsImports.length > 0) {
205218
statements.push(
206-
createImportDeclaration(utilsPath, ['parseMutationInput']),
219+
createImportDeclaration(utilsPath, utilsImports),
207220
);
208221
}
209222

@@ -307,7 +320,28 @@ export function generateCustomCommand(op: CleanOperation, options?: CustomComman
307320
: t.identifier('answers'))
308321
: t.objectExpression([]);
309322

310-
const selectExpr = buildSelectObject(op.returnType, op.kind === 'mutation');
323+
// For OBJECT return types, generate runtime select from --select flag
324+
// For scalar return types, no select is needed
325+
let selectExpr: t.Expression | undefined;
326+
if (isObjectReturn) {
327+
const defaultSelect = buildDefaultSelectString(op.returnType, op.kind === 'mutation');
328+
// Generate: const selectFields = buildSelectFromPaths(argv.select ?? 'defaultFields')
329+
bodyStatements.push(
330+
t.variableDeclaration('const', [
331+
t.variableDeclarator(
332+
t.identifier('selectFields'),
333+
t.callExpression(t.identifier('buildSelectFromPaths'), [
334+
t.logicalExpression(
335+
'??',
336+
t.memberExpression(t.identifier('argv'), t.identifier('select')),
337+
t.stringLiteral(defaultSelect),
338+
),
339+
]),
340+
),
341+
]),
342+
);
343+
selectExpr = t.identifier('selectFields');
344+
}
311345

312346
bodyStatements.push(
313347
t.variableDeclaration('const', [

graphql/codegen/src/core/codegen/templates/cli-utils.ts

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@
33
*
44
* This is the RUNTIME code that gets copied to generated output.
55
* Provides helpers for CLI commands: type coercion (string CLI args -> proper
6-
* GraphQL types), field filtering (strip extra minimist fields), and
7-
* mutation input parsing.
6+
* GraphQL types), field filtering (strip extra minimist fields),
7+
* mutation input parsing, and select field parsing.
88
*
99
* NOTE: This file is read at codegen time and written to output.
1010
* Any changes here will affect all generated CLI utils.
1111
*/
1212

13+
import objectPath from 'nested-obj';
14+
1315
export type FieldType =
1416
| 'string'
1517
| 'boolean'
@@ -142,3 +144,58 @@ export function parseMutationInput(
142144
}
143145
return answers;
144146
}
147+
148+
/**
149+
* Build a select object from a comma-separated list of dot-notation paths.
150+
* Uses `nested-obj` to parse paths like 'clientMutationId,result.accessToken,result.userId'
151+
* into the nested structure expected by the ORM:
152+
*
153+
* { clientMutationId: true, result: { select: { accessToken: true, userId: true } } }
154+
*
155+
* Paths without dots set the key to `true` (scalar select).
156+
* Paths with dots create nested `{ select: { ... } }` wrappers, matching the
157+
* ORM's expected structure for OBJECT sub-fields (e.g. `SignUpPayloadSelect.result`).
158+
*
159+
* @param paths - Comma-separated dot-notation field paths (e.g. 'clientMutationId,result.accessToken')
160+
* @returns The nested select object for the ORM
161+
*/
162+
export function buildSelectFromPaths(
163+
paths: string,
164+
): Record<string, unknown> {
165+
const result: Record<string, unknown> = {};
166+
const trimmedPaths = paths
167+
.split(',')
168+
.map((p) => p.trim())
169+
.filter((p) => p.length > 0);
170+
171+
for (const path of trimmedPaths) {
172+
if (!path.includes('.')) {
173+
// Simple scalar field: clientMutationId -> { clientMutationId: true }
174+
result[path] = true;
175+
} else {
176+
// Nested path: result.accessToken -> { result: { select: { accessToken: true } } }
177+
// Convert dot-notation to ORM's { select: { ... } } nesting pattern
178+
const parts = path.split('.');
179+
let current = result;
180+
for (let i = 0; i < parts.length; i++) {
181+
const part = parts[i];
182+
if (i === parts.length - 1) {
183+
// Leaf node: set to true
184+
objectPath.set(current, part, true);
185+
} else {
186+
// Intermediate node: ensure { select: { ... } } wrapper exists
187+
if (!current[part] || typeof current[part] !== 'object') {
188+
current[part] = { select: {} };
189+
}
190+
const wrapper = current[part] as Record<string, unknown>;
191+
if (!wrapper.select || typeof wrapper.select !== 'object') {
192+
wrapper.select = {};
193+
}
194+
current = wrapper.select as Record<string, unknown>;
195+
}
196+
}
197+
}
198+
}
199+
200+
return result;
201+
}

0 commit comments

Comments
 (0)