Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -1144,15 +1144,17 @@ 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<Record<string, unknown>>, prompter: Inquirerer, _options: CLIOptions) => {
try {
if (argv.help || argv.h) {
console.log("current-user - Get the currently authenticated user\\n\\nUsage: current-user [OPTIONS]\\n");
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) {
Expand Down Expand Up @@ -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<Record<string, unknown>>, prompter: Inquirerer, _options: CLIOptions) => {
try {
if (argv.help || argv.h) {
Expand All @@ -1397,10 +1400,9 @@ export default async (argv: Partial<Record<string, unknown>>, 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) {
Expand Down Expand Up @@ -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<Record<string, unknown>>, prompter: Inquirerer, _options: CLIOptions) => {
try {
if (argv.help || argv.h) {
Expand All @@ -3561,10 +3564,9 @@ export default async (argv: Partial<Record<string, unknown>>, 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;
Expand Down
88 changes: 61 additions & 27 deletions graphql/codegen/src/core/codegen/cli/custom-command-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -155,12 +162,7 @@ function buildOrmCustomCall(
),
t.identifier(opName),
),
[
argsExpr,
t.objectExpression([
t.objectProperty(t.identifier('select'), selectExpr),
]),
],
callArgs,
),
t.identifier('execute'),
),
Expand Down Expand Up @@ -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';
Expand All @@ -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),
);
}

Expand Down Expand Up @@ -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', [
Expand Down
61 changes: 59 additions & 2 deletions graphql/codegen/src/core/codegen/templates/cli-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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<string, unknown> {
const result: Record<string, unknown> = {};
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<string, unknown>;
if (!wrapper.select || typeof wrapper.select !== 'object') {
wrapper.select = {};
}
current = wrapper.select as Record<string, unknown>;
}
}
}
}

return result;
}