From 91d58adf34dd10dc55ee1068787b78f922234aa9 Mon Sep 17 00:00:00 2001 From: unknown <> Date: Sun, 22 Feb 2026 02:17:19 +0000 Subject: [PATCH] fix(codegen): fix CLI codegen bugs - ORM signatures, String.call, type coercion, field filtering - Fix String.call() -> String() in infra-generator.ts buildSetTokenHandler (String.call() returns undefined, breaking token storage) - Fix update/delete ORM signatures in table-command-generator.ts to use { where: { id } } instead of flat { id } (matching ORM API) - Add type coercion for CLI string args -> proper GraphQL types (Boolean, Int, Float, JSON, UUID, Enum) via generated fieldSchema - Add field filtering to strip extra minimist fields (_, tty, etc.) via stripUndefined() with schema-aware filtering - Generate utils.ts with coerceAnswers(), stripUndefined(), parseMutationInput() runtime helpers for all generated CLI commands - Update test expectations and snapshots for new utils.ts file and updated generated output (301/301 tests pass) --- .../__snapshots__/cli-generator.test.ts.snap | 186 +++++++++++++----- .../__tests__/codegen/cli-generator.test.ts | 10 +- graphql/codegen/src/core/codegen/cli/index.ts | 12 +- .../src/core/codegen/cli/infra-generator.ts | 5 +- .../codegen/cli/table-command-generator.ts | 113 ++++++++++- .../src/core/codegen/cli/utils-generator.ts | 146 ++++++++++++++ 6 files changed, 401 insertions(+), 71 deletions(-) create mode 100644 graphql/codegen/src/core/codegen/cli/utils-generator.ts 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 280608677..910e6d24b 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 @@ -690,7 +690,7 @@ async function handleSetToken(argv: Partial>, prompter: tokenValue = answer.token; } store.setCredentials(current.name, { - token: String.call(tokenValue || "").trim() + token: String(tokenValue || "").trim() }); console.log(\`Token saved for context: \${current.name}\`); } @@ -741,6 +741,15 @@ exports[`cli-generator generates commands/car.ts 1`] = ` */ import { CLIOptions, Inquirerer, extractFirst } from "inquirerer"; import { getClient } from "../executor"; +import { coerceAnswers, stripUndefined } from "../utils"; +const fieldSchema = { + id: "uuid", + make: "string", + model: "string", + year: "int", + isElectric: "boolean", + createdAt: "string" +}; const usage = "\\ncar \\n\\nCommands:\\n list List all car records\\n get Get a car by ID\\n create Create a new car\\n update Update an existing car\\n delete Delete a car\\n\\n --help, -h Show this help message\\n"; export default async (argv: Partial>, prompter: Inquirerer, _options: CLIOptions) => { if (argv.help || argv.h) { @@ -832,7 +841,7 @@ async function handleGet(argv: Partial>, prompter: Inqui } async function handleCreate(argv: Partial>, prompter: Inquirerer) { try { - const answers = await prompter.prompt(argv, [{ + const rawAnswers = await prompter.prompt(argv, [{ type: "text", name: "make", message: "make", @@ -853,13 +862,15 @@ async function handleCreate(argv: Partial>, prompter: In message: "isElectric", required: true }]); + const answers = coerceAnswers(rawAnswers, fieldSchema); + const cleanedData = stripUndefined(answers, fieldSchema); const client = getClient(); const result = await client.car.create({ data: { - make: answers.make, - model: answers.model, - year: answers.year, - isElectric: answers.isElectric + make: cleanedData.make, + model: cleanedData.model, + year: cleanedData.year, + isElectric: cleanedData.isElectric }, select: { id: true, @@ -881,7 +892,7 @@ async function handleCreate(argv: Partial>, prompter: In } async function handleUpdate(argv: Partial>, prompter: Inquirerer) { try { - const answers = await prompter.prompt(argv, [{ + const rawAnswers = await prompter.prompt(argv, [{ type: "text", name: "id", message: "id", @@ -907,14 +918,18 @@ async function handleUpdate(argv: Partial>, prompter: In message: "isElectric", required: false }]); + const answers = coerceAnswers(rawAnswers, fieldSchema); + const cleanedData = stripUndefined(answers, fieldSchema); const client = getClient(); const result = await client.car.update({ - id: answers.id, + where: { + id: answers.id as string + }, data: { - make: answers.make, - model: answers.model, - year: answers.year, - isElectric: answers.isElectric + make: cleanedData.make, + model: cleanedData.model, + year: cleanedData.year, + isElectric: cleanedData.isElectric }, select: { id: true, @@ -936,15 +951,18 @@ async function handleUpdate(argv: Partial>, prompter: In } async function handleDelete(argv: Partial>, prompter: Inquirerer) { try { - const answers = await prompter.prompt(argv, [{ + const rawAnswers = await prompter.prompt(argv, [{ type: "text", name: "id", message: "id", required: true }]); + const answers = coerceAnswers(rawAnswers, fieldSchema); const client = getClient(); const result = await client.car.delete({ - id: answers.id, + where: { + id: answers.id as string + }, select: { id: true } @@ -1153,6 +1171,12 @@ exports[`cli-generator generates commands/driver.ts 1`] = ` */ import { CLIOptions, Inquirerer, extractFirst } from "inquirerer"; import { getClient } from "../executor"; +import { coerceAnswers, stripUndefined } from "../utils"; +const fieldSchema = { + id: "uuid", + name: "string", + licenseNumber: "string" +}; const usage = "\\ndriver \\n\\nCommands:\\n list List all driver records\\n get Get a driver by ID\\n create Create a new driver\\n update Update an existing driver\\n delete Delete a driver\\n\\n --help, -h Show this help message\\n"; export default async (argv: Partial>, prompter: Inquirerer, _options: CLIOptions) => { if (argv.help || argv.h) { @@ -1238,7 +1262,7 @@ async function handleGet(argv: Partial>, prompter: Inqui } async function handleCreate(argv: Partial>, prompter: Inquirerer) { try { - const answers = await prompter.prompt(argv, [{ + const rawAnswers = await prompter.prompt(argv, [{ type: "text", name: "name", message: "name", @@ -1249,11 +1273,13 @@ async function handleCreate(argv: Partial>, prompter: In message: "licenseNumber", required: true }]); + const answers = coerceAnswers(rawAnswers, fieldSchema); + const cleanedData = stripUndefined(answers, fieldSchema); const client = getClient(); const result = await client.driver.create({ data: { - name: answers.name, - licenseNumber: answers.licenseNumber + name: cleanedData.name, + licenseNumber: cleanedData.licenseNumber }, select: { id: true, @@ -1272,7 +1298,7 @@ async function handleCreate(argv: Partial>, prompter: In } async function handleUpdate(argv: Partial>, prompter: Inquirerer) { try { - const answers = await prompter.prompt(argv, [{ + const rawAnswers = await prompter.prompt(argv, [{ type: "text", name: "id", message: "id", @@ -1288,12 +1314,16 @@ async function handleUpdate(argv: Partial>, prompter: In message: "licenseNumber", required: false }]); + const answers = coerceAnswers(rawAnswers, fieldSchema); + const cleanedData = stripUndefined(answers, fieldSchema); const client = getClient(); const result = await client.driver.update({ - id: answers.id, + where: { + id: answers.id as string + }, data: { - name: answers.name, - licenseNumber: answers.licenseNumber + name: cleanedData.name, + licenseNumber: cleanedData.licenseNumber }, select: { id: true, @@ -1312,15 +1342,18 @@ async function handleUpdate(argv: Partial>, prompter: In } async function handleDelete(argv: Partial>, prompter: Inquirerer) { try { - const answers = await prompter.prompt(argv, [{ + const rawAnswers = await prompter.prompt(argv, [{ type: "text", name: "id", message: "id", required: true }]); + const answers = coerceAnswers(rawAnswers, fieldSchema); const client = getClient(); const result = await client.driver.delete({ - id: answers.id, + where: { + id: answers.id as string + }, select: { id: true } @@ -3166,7 +3199,7 @@ async function handleSetToken(argv: Partial>, prompter: tokenValue = answer.token; } store.setCredentials(current.name, { - token: String.call(tokenValue || "").trim() + token: String(tokenValue || "").trim() }); console.log(\`Token saved for context: \${current.name}\`); } @@ -3555,6 +3588,12 @@ exports[`multi-target cli generator generates target-prefixed table commands 1`] */ import { CLIOptions, Inquirerer, extractFirst } from "inquirerer"; import { getClient } from "../../executor"; +import { coerceAnswers, stripUndefined } from "../../utils"; +const fieldSchema = { + id: "uuid", + email: "string", + name: "string" +}; const usage = "\\nuser \\n\\nCommands:\\n list List all user records\\n get Get a user by ID\\n create Create a new user\\n update Update an existing user\\n delete Delete a user\\n\\n --help, -h Show this help message\\n"; export default async (argv: Partial>, prompter: Inquirerer, _options: CLIOptions) => { if (argv.help || argv.h) { @@ -3640,7 +3679,7 @@ async function handleGet(argv: Partial>, prompter: Inqui } async function handleCreate(argv: Partial>, prompter: Inquirerer) { try { - const answers = await prompter.prompt(argv, [{ + const rawAnswers = await prompter.prompt(argv, [{ type: "text", name: "email", message: "email", @@ -3651,11 +3690,13 @@ async function handleCreate(argv: Partial>, prompter: In message: "name", required: true }]); + const answers = coerceAnswers(rawAnswers, fieldSchema); + const cleanedData = stripUndefined(answers, fieldSchema); const client = getClient("auth"); const result = await client.user.create({ data: { - email: answers.email, - name: answers.name + email: cleanedData.email, + name: cleanedData.name }, select: { id: true, @@ -3674,7 +3715,7 @@ async function handleCreate(argv: Partial>, prompter: In } async function handleUpdate(argv: Partial>, prompter: Inquirerer) { try { - const answers = await prompter.prompt(argv, [{ + const rawAnswers = await prompter.prompt(argv, [{ type: "text", name: "id", message: "id", @@ -3690,12 +3731,16 @@ async function handleUpdate(argv: Partial>, prompter: In message: "name", required: false }]); + const answers = coerceAnswers(rawAnswers, fieldSchema); + const cleanedData = stripUndefined(answers, fieldSchema); const client = getClient("auth"); const result = await client.user.update({ - id: answers.id, + where: { + id: answers.id as string + }, data: { - email: answers.email, - name: answers.name + email: cleanedData.email, + name: cleanedData.name }, select: { id: true, @@ -3714,15 +3759,18 @@ async function handleUpdate(argv: Partial>, prompter: In } async function handleDelete(argv: Partial>, prompter: Inquirerer) { try { - const answers = await prompter.prompt(argv, [{ + const rawAnswers = await prompter.prompt(argv, [{ type: "text", name: "id", message: "id", required: true }]); + const answers = coerceAnswers(rawAnswers, fieldSchema); const client = getClient("auth"); const result = await client.user.delete({ - id: answers.id, + where: { + id: answers.id as string + }, select: { id: true } @@ -3746,6 +3794,11 @@ exports[`multi-target cli generator generates target-prefixed table commands 2`] */ import { CLIOptions, Inquirerer, extractFirst } from "inquirerer"; import { getClient } from "../../executor"; +import { coerceAnswers, stripUndefined } from "../../utils"; +const fieldSchema = { + id: "uuid", + role: "string" +}; const usage = "\\nmember \\n\\nCommands:\\n list List all member records\\n get Get a member by ID\\n create Create a new member\\n update Update an existing member\\n delete Delete a member\\n\\n --help, -h Show this help message\\n"; export default async (argv: Partial>, prompter: Inquirerer, _options: CLIOptions) => { if (argv.help || argv.h) { @@ -3829,16 +3882,18 @@ async function handleGet(argv: Partial>, prompter: Inqui } async function handleCreate(argv: Partial>, prompter: Inquirerer) { try { - const answers = await prompter.prompt(argv, [{ + const rawAnswers = await prompter.prompt(argv, [{ type: "text", name: "role", message: "role", required: true }]); + const answers = coerceAnswers(rawAnswers, fieldSchema); + const cleanedData = stripUndefined(answers, fieldSchema); const client = getClient("members"); const result = await client.member.create({ data: { - role: answers.role + role: cleanedData.role }, select: { id: true, @@ -3856,7 +3911,7 @@ async function handleCreate(argv: Partial>, prompter: In } async function handleUpdate(argv: Partial>, prompter: Inquirerer) { try { - const answers = await prompter.prompt(argv, [{ + const rawAnswers = await prompter.prompt(argv, [{ type: "text", name: "id", message: "id", @@ -3867,11 +3922,15 @@ async function handleUpdate(argv: Partial>, prompter: In message: "role", required: false }]); + const answers = coerceAnswers(rawAnswers, fieldSchema); + const cleanedData = stripUndefined(answers, fieldSchema); const client = getClient("members"); const result = await client.member.update({ - id: answers.id, + where: { + id: answers.id as string + }, data: { - role: answers.role + role: cleanedData.role }, select: { id: true, @@ -3889,15 +3948,18 @@ async function handleUpdate(argv: Partial>, prompter: In } async function handleDelete(argv: Partial>, prompter: Inquirerer) { try { - const answers = await prompter.prompt(argv, [{ + const rawAnswers = await prompter.prompt(argv, [{ type: "text", name: "id", message: "id", required: true }]); + const answers = coerceAnswers(rawAnswers, fieldSchema); const client = getClient("members"); const result = await client.member.delete({ - id: answers.id, + where: { + id: answers.id as string + }, select: { id: true } @@ -3921,6 +3983,15 @@ exports[`multi-target cli generator generates target-prefixed table commands 3`] */ import { CLIOptions, Inquirerer, extractFirst } from "inquirerer"; import { getClient } from "../../executor"; +import { coerceAnswers, stripUndefined } from "../../utils"; +const fieldSchema = { + id: "uuid", + make: "string", + model: "string", + year: "int", + isElectric: "boolean", + createdAt: "string" +}; const usage = "\\ncar \\n\\nCommands:\\n list List all car records\\n get Get a car by ID\\n create Create a new car\\n update Update an existing car\\n delete Delete a car\\n\\n --help, -h Show this help message\\n"; export default async (argv: Partial>, prompter: Inquirerer, _options: CLIOptions) => { if (argv.help || argv.h) { @@ -4012,7 +4083,7 @@ async function handleGet(argv: Partial>, prompter: Inqui } async function handleCreate(argv: Partial>, prompter: Inquirerer) { try { - const answers = await prompter.prompt(argv, [{ + const rawAnswers = await prompter.prompt(argv, [{ type: "text", name: "make", message: "make", @@ -4033,13 +4104,15 @@ async function handleCreate(argv: Partial>, prompter: In message: "isElectric", required: true }]); + const answers = coerceAnswers(rawAnswers, fieldSchema); + const cleanedData = stripUndefined(answers, fieldSchema); const client = getClient("app"); const result = await client.car.create({ data: { - make: answers.make, - model: answers.model, - year: answers.year, - isElectric: answers.isElectric + make: cleanedData.make, + model: cleanedData.model, + year: cleanedData.year, + isElectric: cleanedData.isElectric }, select: { id: true, @@ -4061,7 +4134,7 @@ async function handleCreate(argv: Partial>, prompter: In } async function handleUpdate(argv: Partial>, prompter: Inquirerer) { try { - const answers = await prompter.prompt(argv, [{ + const rawAnswers = await prompter.prompt(argv, [{ type: "text", name: "id", message: "id", @@ -4087,14 +4160,18 @@ async function handleUpdate(argv: Partial>, prompter: In message: "isElectric", required: false }]); + const answers = coerceAnswers(rawAnswers, fieldSchema); + const cleanedData = stripUndefined(answers, fieldSchema); const client = getClient("app"); const result = await client.car.update({ - id: answers.id, + where: { + id: answers.id as string + }, data: { - make: answers.make, - model: answers.model, - year: answers.year, - isElectric: answers.isElectric + make: cleanedData.make, + model: cleanedData.model, + year: cleanedData.year, + isElectric: cleanedData.isElectric }, select: { id: true, @@ -4116,15 +4193,18 @@ async function handleUpdate(argv: Partial>, prompter: In } async function handleDelete(argv: Partial>, prompter: Inquirerer) { try { - const answers = await prompter.prompt(argv, [{ + const rawAnswers = await prompter.prompt(argv, [{ type: "text", name: "id", message: "id", required: true }]); + const answers = coerceAnswers(rawAnswers, fieldSchema); const client = getClient("app"); const result = await client.car.delete({ - id: answers.id, + where: { + id: answers.id as string + }, select: { id: true } diff --git a/graphql/codegen/src/__tests__/codegen/cli-generator.test.ts b/graphql/codegen/src/__tests__/codegen/cli-generator.test.ts index 47bc8f53b..1a9b2c941 100644 --- a/graphql/codegen/src/__tests__/codegen/cli-generator.test.ts +++ b/graphql/codegen/src/__tests__/codegen/cli-generator.test.ts @@ -157,8 +157,8 @@ describe('cli-generator', () => { tables: 2, customQueries: 1, customMutations: 1, - infraFiles: 3, - totalFiles: 8, + infraFiles: 4, + totalFiles: 9, }); }); @@ -254,6 +254,7 @@ describe('cli-generator', () => { 'commands/driver.ts', 'commands/login.ts', 'executor.ts', + 'utils.ts', ]); }); }); @@ -537,8 +538,8 @@ describe('multi-target cli generator', () => { tables: 3, customQueries: 1, customMutations: 1, - infraFiles: 3, - totalFiles: 9, + infraFiles: 4, + totalFiles: 10, }); }); @@ -560,6 +561,7 @@ describe('multi-target cli generator', () => { 'commands/credentials.ts', 'commands/members/member.ts', 'executor.ts', + 'utils.ts', ]); }); diff --git a/graphql/codegen/src/core/codegen/cli/index.ts b/graphql/codegen/src/core/codegen/cli/index.ts index fd4c696ac..81c287d52 100644 --- a/graphql/codegen/src/core/codegen/cli/index.ts +++ b/graphql/codegen/src/core/codegen/cli/index.ts @@ -11,6 +11,7 @@ import { generateMultiTargetContextCommand, } from './infra-generator'; import { generateTableCommand } from './table-command-generator'; +import { generateUtilsFile } from './utils-generator'; export interface GenerateCliOptions { tables: CleanTable[]; @@ -45,6 +46,9 @@ export function generateCli(options: GenerateCliOptions): GenerateCliResult { const executorFile = generateExecutorFile(toolName); files.push(executorFile); + const utilsFile = generateUtilsFile(); + files.push(utilsFile); + const contextFile = generateContextCommand(toolName); files.push(contextFile); @@ -79,7 +83,7 @@ export function generateCli(options: GenerateCliOptions): GenerateCliResult { tables: tables.length, customQueries: customOperations?.queries.length ?? 0, customMutations: customOperations?.mutations.length ?? 0, - infraFiles: 3, + infraFiles: 4, totalFiles: files.length, }, }; @@ -137,6 +141,9 @@ export function generateMultiTargetCli( const executorFile = generateMultiTargetExecutorFile(toolName, executorInputs); files.push(executorFile); + const utilsFile = generateUtilsFile(); + files.push(utilsFile); + const contextFile = generateMultiTargetContextCommand( toolName, builtinNames.context, @@ -205,7 +212,7 @@ export function generateMultiTargetCli( tables: totalTables, customQueries: totalQueries, customMutations: totalMutations, - infraFiles: 3, + infraFiles: 4, totalFiles: files.length, }, }; @@ -234,4 +241,5 @@ export { export type { MultiTargetDocsInput } from './docs-generator'; export { resolveDocsConfig } from '../docs-utils'; export type { GeneratedDocFile, McpTool } from '../docs-utils'; +export { generateUtilsFile } from './utils-generator'; export type { GeneratedFile, MultiTargetExecutorInput } from './executor-generator'; diff --git a/graphql/codegen/src/core/codegen/cli/infra-generator.ts b/graphql/codegen/src/core/codegen/cli/infra-generator.ts index d3610b5ce..dbfbf44bb 100644 --- a/graphql/codegen/src/core/codegen/cli/infra-generator.ts +++ b/graphql/codegen/src/core/codegen/cli/infra-generator.ts @@ -1645,10 +1645,7 @@ function buildSetTokenHandler(): t.FunctionDeclaration { t.callExpression( t.memberExpression( t.callExpression( - t.memberExpression( - t.identifier('String'), - t.identifier('call'), - ), + t.identifier('String'), [ t.logicalExpression( '||', diff --git a/graphql/codegen/src/core/codegen/cli/table-command-generator.ts b/graphql/codegen/src/core/codegen/cli/table-command-generator.ts index 8a84ed5d0..37274861e 100644 --- a/graphql/codegen/src/core/codegen/cli/table-command-generator.ts +++ b/graphql/codegen/src/core/codegen/cli/table-command-generator.ts @@ -28,6 +28,46 @@ function createImportDeclaration( return decl; } +/** + * Build a field schema object that maps field names to their GraphQL types. + * This is used at runtime for type coercion (string CLI args → proper types). + * e.g., { name: 'string', isActive: 'boolean', position: 'int', status: 'enum' } + */ +function buildFieldSchemaObject(table: CleanTable): t.ObjectExpression { + const fields = getScalarFields(table); + return t.objectExpression( + fields.map((f) => { + const gqlType = f.type.gqlType.replace(/!/g, ''); + let schemaType: string; + switch (gqlType) { + case 'Boolean': + schemaType = 'boolean'; + break; + case 'Int': + case 'BigInt': + schemaType = 'int'; + break; + case 'Float': + schemaType = 'float'; + break; + case 'JSON': + case 'GeoJSON': + schemaType = 'json'; + break; + case 'UUID': + schemaType = 'uuid'; + break; + default: + schemaType = 'string'; + } + return t.objectProperty( + t.identifier(f.name), + t.stringLiteral(schemaType), + ); + }), + ); +} + function buildSelectObject(table: CleanTable): t.ObjectExpression { const fields = getScalarFields(table); return t.objectExpression( @@ -359,7 +399,7 @@ function buildMutationHandler( const dataProps = editableFields.map((f) => t.objectProperty( t.identifier(f.name), - t.memberExpression(t.identifier('answers'), t.identifier(f.name)), + t.memberExpression(t.identifier('cleanedData'), t.identifier(f.name)), false, true, ), @@ -372,15 +412,23 @@ function buildMutationHandler( const dataProps = editableFields.map((f) => t.objectProperty( t.identifier(f.name), - t.memberExpression(t.identifier('answers'), t.identifier(f.name)), + t.memberExpression(t.identifier('cleanedData'), t.identifier(f.name)), false, true, ), ); ormArgs = t.objectExpression([ t.objectProperty( - t.identifier(pk.name), - t.memberExpression(t.identifier('answers'), t.identifier(pk.name)), + t.identifier('where'), + t.objectExpression([ + t.objectProperty( + t.identifier(pk.name), + t.tsAsExpression( + t.memberExpression(t.identifier('answers'), t.identifier(pk.name)), + t.tsStringKeyword(), + ), + ), + ]), ), t.objectProperty(t.identifier('data'), t.objectExpression(dataProps)), t.objectProperty(t.identifier('select'), selectObj), @@ -388,8 +436,16 @@ function buildMutationHandler( } else { ormArgs = t.objectExpression([ t.objectProperty( - t.identifier(pk.name), - t.memberExpression(t.identifier('answers'), t.identifier(pk.name)), + t.identifier('where'), + t.objectExpression([ + t.objectProperty( + t.identifier(pk.name), + t.tsAsExpression( + t.memberExpression(t.identifier('answers'), t.identifier(pk.name)), + t.tsStringKeyword(), + ), + ), + ]), ), t.objectProperty(t.identifier('select'), selectObj), ]); @@ -398,7 +454,7 @@ function buildMutationHandler( const tryBody: t.Statement[] = [ t.variableDeclaration('const', [ t.variableDeclarator( - t.identifier('answers'), + t.identifier('rawAnswers'), t.awaitExpression( t.callExpression( t.memberExpression( @@ -410,6 +466,32 @@ function buildMutationHandler( ), ), ]), + t.variableDeclaration('const', [ + t.variableDeclarator( + t.identifier('answers'), + t.callExpression(t.identifier('coerceAnswers'), [ + t.identifier('rawAnswers'), + t.identifier('fieldSchema'), + ]), + ), + ]), + ]; + + if (operation !== 'delete') { + tryBody.push( + t.variableDeclaration('const', [ + t.variableDeclarator( + t.identifier('cleanedData'), + t.callExpression(t.identifier('stripUndefined'), [ + t.identifier('answers'), + t.identifier('fieldSchema'), + ]), + ), + ]), + ); + } + + tryBody.push( buildGetClientStatement(targetName), t.variableDeclaration('const', [ t.variableDeclarator( @@ -420,7 +502,7 @@ function buildMutationHandler( ), ]), buildJsonLog(t.identifier('result')), - ]; + ); const argvParam = t.identifier('argv'); argvParam.typeAnnotation = buildArgvType(); @@ -467,6 +549,21 @@ export function generateTableCommand(table: CleanTable, options?: TableCommandOp createImportDeclaration(executorPath, ['getClient']), ); + const utilsPath = options?.targetName ? '../../utils' : '../utils'; + statements.push( + createImportDeclaration(utilsPath, ['coerceAnswers', 'stripUndefined']), + ); + + // Generate field schema for type coercion + statements.push( + t.variableDeclaration('const', [ + t.variableDeclarator( + t.identifier('fieldSchema'), + buildFieldSchemaObject(table), + ), + ]), + ); + const subcommands = ['list', 'get', 'create', 'update', 'delete']; const usageLines = [ diff --git a/graphql/codegen/src/core/codegen/cli/utils-generator.ts b/graphql/codegen/src/core/codegen/cli/utils-generator.ts new file mode 100644 index 000000000..3d0e9df5e --- /dev/null +++ b/graphql/codegen/src/core/codegen/cli/utils-generator.ts @@ -0,0 +1,146 @@ +import { getGeneratedFileHeader } from '../utils'; +import type { GeneratedFile } from './executor-generator'; + +/** + * Generate a utils.ts file with runtime helpers for CLI commands. + * Includes type coercion (string CLI args -> proper GraphQL types), + * field filtering (strip extra minimist fields like _ and tty), + * and mutation input parsing. + */ +export function generateUtilsFile(): GeneratedFile { + const header = getGeneratedFileHeader( + 'CLI utility functions for type coercion and input handling', + ); + + const code = ` +export type FieldType = 'string' | 'boolean' | 'int' | 'float' | 'json' | 'uuid' | 'enum'; + +export interface FieldSchema { + [fieldName: string]: FieldType; +} + +/** + * Coerce CLI string arguments to their proper GraphQL types based on a field schema. + * CLI args always arrive as strings from minimist, but GraphQL expects + * Boolean, Int, Float, JSON, etc. + */ +export function coerceAnswers( + answers: Record, + schema: FieldSchema, +): Record { + const result: Record = { ...answers }; + + for (const [key, value] of Object.entries(result)) { + const fieldType = schema[key]; + if (!fieldType || value === undefined || value === null) continue; + + const strValue = String(value); + + // Empty strings become undefined for non-string types + if (strValue === '' && fieldType !== 'string') { + result[key] = undefined; + continue; + } + + switch (fieldType) { + case 'boolean': + if (typeof value === 'boolean') break; + result[key] = strValue === 'true' || strValue === '1' || strValue === 'yes'; + break; + case 'int': + if (typeof value === 'number') break; + { + const parsed = parseInt(strValue, 10); + result[key] = isNaN(parsed) ? undefined : parsed; + } + break; + case 'float': + if (typeof value === 'number') break; + { + const parsed = parseFloat(strValue); + result[key] = isNaN(parsed) ? undefined : parsed; + } + break; + case 'json': + if (typeof value === 'object') break; + if (strValue === '') { + result[key] = undefined; + } else { + try { + result[key] = JSON.parse(strValue); + } catch { + result[key] = undefined; + } + } + break; + case 'uuid': + // Empty UUIDs become undefined + if (strValue === '') { + result[key] = undefined; + } + break; + case 'enum': + // Enums stay as strings but empty ones become undefined + if (strValue === '') { + result[key] = undefined; + } + break; + default: + // String type: empty strings also become undefined to avoid + // sending empty strings for optional fields + if (strValue === '') { + result[key] = undefined; + } + break; + } + } + + return result; +} + +/** + * Strip undefined values and filter to only schema-defined keys. + * This removes extra fields injected by minimist (like _, tty, etc.) + * and any fields that were coerced to undefined. + */ +export function stripUndefined( + obj: Record, + schema?: FieldSchema, +): Record { + const result: Record = {}; + const allowedKeys = schema ? new Set(Object.keys(schema)) : null; + + for (const [key, value] of Object.entries(obj)) { + if (value === undefined) continue; + if (allowedKeys && !allowedKeys.has(key)) continue; + result[key] = value; + } + + return result; +} + +/** + * Parse mutation input from CLI. + * Custom mutation commands receive an \`input\` field as a JSON string + * from the CLI prompt. This parses it into a proper object. + */ +export function parseMutationInput( + answers: Record, +): Record { + if (typeof answers.input === 'string') { + try { + const parsed = JSON.parse(answers.input); + return { ...answers, input: parsed }; + } catch { + return answers; + } + } + return answers; +} +`; + + return { + fileName: 'utils.ts', + content: header + '\n' + code, + }; +}