From e4095ee9af14900a16bda35cd482e7ff86c46e95 Mon Sep 17 00:00:00 2001 From: unknown <> Date: Mon, 23 Feb 2026 11:01:31 +0000 Subject: [PATCH] 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 --- .../__snapshots__/cli-generator.test.ts.snap | 16 ++-- .../codegen/cli/custom-command-generator.ts | 88 +++++++++++++------ .../src/core/codegen/templates/cli-utils.ts | 61 ++++++++++++- 3 files changed, 129 insertions(+), 36 deletions(-) diff --git a/graphql/codegen/src/__tests__/codegen/__snapshots__/cli-generator.test.ts.snap b/graphql/codegen/src/__tests__/codegen/__snapshots__/cli-generator.test.ts.snap index ee350fab1..6deecd43d 100644 --- a/graphql/codegen/src/__tests__/codegen/__snapshots__/cli-generator.test.ts.snap +++ b/graphql/codegen/src/__tests__/codegen/__snapshots__/cli-generator.test.ts.snap @@ -1144,6 +1144,7 @@ exports[`cli-generator generates commands/current-user.ts (custom query) 1`] = ` */ import { CLIOptions, Inquirerer } from "inquirerer"; import { getClient } from "../executor"; +import { buildSelectFromPaths } from "../utils"; export default async (argv: Partial>, prompter: Inquirerer, _options: CLIOptions) => { try { if (argv.help || argv.h) { @@ -1151,8 +1152,9 @@ export default async (argv: Partial>, prompter: Inquirer process.exit(0); } const client = getClient(); + const selectFields = buildSelectFromPaths(argv.select ?? ""); const result = await client.query.currentUser({}, { - select: {} + select: selectFields }).execute(); console.log(JSON.stringify(result, null, 2)); } catch (error) { @@ -1379,6 +1381,7 @@ exports[`cli-generator generates commands/login.ts (custom mutation) 1`] = ` */ import { CLIOptions, Inquirerer } from "inquirerer"; import { getClient } from "../executor"; +import { buildSelectFromPaths } from "../utils"; export default async (argv: Partial>, prompter: Inquirerer, _options: CLIOptions) => { try { if (argv.help || argv.h) { @@ -1397,10 +1400,9 @@ export default async (argv: Partial>, prompter: Inquirer required: true }]); const client = getClient(); + const selectFields = buildSelectFromPaths(argv.select ?? "clientMutationId"); const result = await client.mutation.login(answers, { - select: { - clientMutationId: true - } + select: selectFields }).execute(); console.log(JSON.stringify(result, null, 2)); } catch (error) { @@ -3543,6 +3545,7 @@ exports[`multi-target cli generator generates target-prefixed custom commands wi */ import { CLIOptions, Inquirerer } from "inquirerer"; import { getClient, getStore } from "../../executor"; +import { buildSelectFromPaths } from "../../utils"; export default async (argv: Partial>, prompter: Inquirerer, _options: CLIOptions) => { try { if (argv.help || argv.h) { @@ -3561,10 +3564,9 @@ export default async (argv: Partial>, prompter: Inquirer required: true }]); const client = getClient("auth"); + const selectFields = buildSelectFromPaths(argv.select ?? "clientMutationId"); const result = await client.mutation.login(answers, { - select: { - clientMutationId: true - } + select: selectFields }).execute(); if (argv.saveToken && result) { const tokenValue = result.token || result.jwtToken || result.accessToken; diff --git a/graphql/codegen/src/core/codegen/cli/custom-command-generator.ts b/graphql/codegen/src/core/codegen/cli/custom-command-generator.ts index f458aa7f1..f374361e6 100644 --- a/graphql/codegen/src/core/codegen/cli/custom-command-generator.ts +++ b/graphql/codegen/src/core/codegen/cli/custom-command-generator.ts @@ -111,40 +111,47 @@ function unwrapType(ref: CleanTypeRef): CleanTypeRef { } /** - * Build a select object expression from return-type fields. - * If the return type has known fields, generates { field1: true, field2: true, ... }. - * Falls back to { clientMutationId: true } for mutations without known fields. + * Check if the return type (after unwrapping) is an OBJECT type. */ -function buildSelectObject( +function hasObjectReturnType(returnType: CleanTypeRef): boolean { + const base = unwrapType(returnType); + return base.kind === 'OBJECT'; +} + +/** + * Build a default select string from the return type's top-level scalar fields. + * For OBJECT return types with known fields, generates a comma-separated list + * of all top-level field names (e.g. 'clientMutationId,result'). + * Falls back to 'clientMutationId' for mutations without known fields. + */ +function buildDefaultSelectString( returnType: CleanTypeRef, isMutation: boolean, -): t.ObjectExpression { +): string { const base = unwrapType(returnType); if (base.fields && base.fields.length > 0) { - return t.objectExpression( - base.fields.map((f) => - t.objectProperty(t.identifier(f.name), t.booleanLiteral(true)), - ), - ); + return base.fields.map((f) => f.name).join(','); } - // Fallback: all PostGraphile mutation payloads have clientMutationId if (isMutation) { - return t.objectExpression([ - t.objectProperty( - t.identifier('clientMutationId'), - t.booleanLiteral(true), - ), - ]); + return 'clientMutationId'; } - return t.objectExpression([]); + return ''; } function buildOrmCustomCall( opKind: 'query' | 'mutation', opName: string, argsExpr: t.Expression, - selectExpr: t.ObjectExpression, + selectExpr?: t.Expression, ): t.Expression { + const callArgs: t.Expression[] = [argsExpr]; + if (selectExpr) { + callArgs.push( + t.objectExpression([ + t.objectProperty(t.identifier('select'), selectExpr), + ]), + ); + } return t.callExpression( t.memberExpression( t.callExpression( @@ -155,12 +162,7 @@ function buildOrmCustomCall( ), t.identifier(opName), ), - [ - argsExpr, - t.objectExpression([ - t.objectProperty(t.identifier('select'), selectExpr), - ]), - ], + callArgs, ), t.identifier('execute'), ), @@ -190,6 +192,9 @@ export function generateCustomCommand(op: CleanOperation, options?: CustomComman return base.kind === 'INPUT_OBJECT'; }); + // Check if return type is OBJECT (needs --select flag) + const isObjectReturn = hasObjectReturnType(op.returnType); + const utilsPath = options?.executorImportPath ? options.executorImportPath.replace(/\/executor$/, '/utils') : '../utils'; @@ -201,9 +206,17 @@ export function generateCustomCommand(op: CleanOperation, options?: CustomComman createImportDeclaration(executorPath, imports), ); + // Build the list of utils imports needed + const utilsImports: string[] = []; if (hasInputObjectArg) { + utilsImports.push('parseMutationInput'); + } + if (isObjectReturn) { + utilsImports.push('buildSelectFromPaths'); + } + if (utilsImports.length > 0) { statements.push( - createImportDeclaration(utilsPath, ['parseMutationInput']), + createImportDeclaration(utilsPath, utilsImports), ); } @@ -307,7 +320,28 @@ export function generateCustomCommand(op: CleanOperation, options?: CustomComman : t.identifier('answers')) : t.objectExpression([]); - const selectExpr = buildSelectObject(op.returnType, op.kind === 'mutation'); + // For OBJECT return types, generate runtime select from --select flag + // For scalar return types, no select is needed + let selectExpr: t.Expression | undefined; + if (isObjectReturn) { + const defaultSelect = buildDefaultSelectString(op.returnType, op.kind === 'mutation'); + // Generate: const selectFields = buildSelectFromPaths(argv.select ?? 'defaultFields') + bodyStatements.push( + t.variableDeclaration('const', [ + t.variableDeclarator( + t.identifier('selectFields'), + t.callExpression(t.identifier('buildSelectFromPaths'), [ + t.logicalExpression( + '??', + t.memberExpression(t.identifier('argv'), t.identifier('select')), + t.stringLiteral(defaultSelect), + ), + ]), + ), + ]), + ); + selectExpr = t.identifier('selectFields'); + } bodyStatements.push( t.variableDeclaration('const', [ diff --git a/graphql/codegen/src/core/codegen/templates/cli-utils.ts b/graphql/codegen/src/core/codegen/templates/cli-utils.ts index 5e7646b40..cd00e0b0c 100644 --- a/graphql/codegen/src/core/codegen/templates/cli-utils.ts +++ b/graphql/codegen/src/core/codegen/templates/cli-utils.ts @@ -3,13 +3,15 @@ * * This is the RUNTIME code that gets copied to generated output. * Provides helpers for CLI commands: type coercion (string CLI args -> proper - * GraphQL types), field filtering (strip extra minimist fields), and - * mutation input parsing. + * GraphQL types), field filtering (strip extra minimist fields), + * mutation input parsing, and select field parsing. * * NOTE: This file is read at codegen time and written to output. * Any changes here will affect all generated CLI utils. */ +import objectPath from 'nested-obj'; + export type FieldType = | 'string' | 'boolean' @@ -142,3 +144,58 @@ export function parseMutationInput( } return answers; } + +/** + * Build a select object from a comma-separated list of dot-notation paths. + * Uses `nested-obj` to parse paths like 'clientMutationId,result.accessToken,result.userId' + * into the nested structure expected by the ORM: + * + * { clientMutationId: true, result: { select: { accessToken: true, userId: true } } } + * + * Paths without dots set the key to `true` (scalar select). + * Paths with dots create nested `{ select: { ... } }` wrappers, matching the + * ORM's expected structure for OBJECT sub-fields (e.g. `SignUpPayloadSelect.result`). + * + * @param paths - Comma-separated dot-notation field paths (e.g. 'clientMutationId,result.accessToken') + * @returns The nested select object for the ORM + */ +export function buildSelectFromPaths( + paths: string, +): Record { + const result: Record = {}; + const trimmedPaths = paths + .split(',') + .map((p) => p.trim()) + .filter((p) => p.length > 0); + + for (const path of trimmedPaths) { + if (!path.includes('.')) { + // Simple scalar field: clientMutationId -> { clientMutationId: true } + result[path] = true; + } else { + // Nested path: result.accessToken -> { result: { select: { accessToken: true } } } + // Convert dot-notation to ORM's { select: { ... } } nesting pattern + const parts = path.split('.'); + let current = result; + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + if (i === parts.length - 1) { + // Leaf node: set to true + objectPath.set(current, part, true); + } else { + // Intermediate node: ensure { select: { ... } } wrapper exists + if (!current[part] || typeof current[part] !== 'object') { + current[part] = { select: {} }; + } + const wrapper = current[part] as Record; + if (!wrapper.select || typeof wrapper.select !== 'object') { + wrapper.select = {}; + } + current = wrapper.select as Record; + } + } + } + } + + return result; +}