From f49ff9421480bf5a5d88f9ce6fcc6b7cdaa51e09 Mon Sep 17 00:00:00 2001 From: suibianwanwan Date: Tue, 29 Sep 2026 10:30:33 +0800 Subject: [PATCH 1/2] fix(cli): return failure exit code for SQL batches --- packages/cz-cli/src/commands/sql.ts | 6 +- .../cz-cli/test/sql-batch-exit-code.test.ts | 57 +++++++++++++++++++ 2 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 packages/cz-cli/test/sql-batch-exit-code.test.ts diff --git a/packages/cz-cli/src/commands/sql.ts b/packages/cz-cli/src/commands/sql.ts index 97aebf4f1..b237b7fb0 100644 --- a/packages/cz-cli/src/commands/sql.ts +++ b/packages/cz-cli/src/commands/sql.ts @@ -3,7 +3,7 @@ import type { Argv } from "yargs" import { readFileSync, openSync, readSync, closeSync } from "node:fs" import { analyzeSql, isReadonlySqlSetting, splitSql, JobStatus, requestRaw, getCurrentUser, type JobID, type QueryResult } from "@clickzetta/sdk" import type { GlobalArgs } from "../cli.js" -import { success, successRows, error, handledError, parseOutputArgs, renderOutput, renderErrorOutput } from "../output/index.js" +import { success, successRows, error, handledError, parseOutputArgs, renderOutput, renderErrorOutput, EXIT_BIZ_ERROR } from "../output/index.js" import { maskRows } from "../output/masking.js" import { logOperation } from "../logger.js" import { type ExecContext, classifyExecError, execSql, execSqlWithRetry, getExecContext, isQueryResult, validateIdentifier } from "./exec.js" @@ -704,6 +704,7 @@ async function handler(argv: SqlArgs): Promise { try { const r = await execSqlWithRetry(ctx, stmt, { hints: accumulatedHints, timeoutMs: argv.timeout * 1000, configStatements }) if (isQueryResult(r) && r.status === JobStatus.FAILED) { + process.exitCode = EXIT_BIZ_ERROR const line = { index: i, sql: stmt, error: { code: r.errorCode ?? "SQL_ERROR", message: await formatQueryError(r, ctx, argv.profile) }, time_ms: Date.now() - t0, ...(r.jobId ? { job_id: r.jobId } : {}) } process.stdout.write(renderOutput(line, format, batchField) + "\n") logOperation("sql", { sql: stmt, ok: false, errorCode: r.errorCode }) @@ -715,6 +716,7 @@ async function handler(argv: SqlArgs): Promise { logOperation("sql", { sql: stmt, ok: true, rows: rows.length, timeMs: Date.now() - t0 }) } } catch (err) { + process.exitCode = EXIT_BIZ_ERROR const { code, message } = classifyExecError(err) const line = { index: i, sql: stmt, error: { code, message: await formatClassifiedError({ code, message, ctx, profileName: argv.profile }) }, time_ms: Date.now() - t0 } process.stdout.write(renderOutput(line, format, batchField) + "\n") @@ -858,7 +860,7 @@ export function registerSqlCommand(cli: Argv): void { .option("header", { type: "boolean", default: true, describe: "Include column names in output. Use --no-header or -N to suppress." }) .option("N", { type: "boolean", hidden: true }) .option("limit", { type: "number", default: 100, describe: "Max rows to return (0 for unlimited)" }) - .option("batch", { alias: "B", type: "boolean", default: false, describe: "Batch mode: execute multiple semicolon-separated statements sequentially" }) + .option("batch", { alias: "B", type: "boolean", default: false, describe: "Batch mode: execute all statements sequentially; exit non-zero if any fails" }) .option("dry-run", { type: "boolean", default: false, describe: "EXPLAIN recognized readonly queries. Multiple queries require sql_split=true; writes, session commands and unknown syntax are rejected." }) .epilogue([ "Examples:", diff --git a/packages/cz-cli/test/sql-batch-exit-code.test.ts b/packages/cz-cli/test/sql-batch-exit-code.test.ts new file mode 100644 index 000000000..094e1ecc4 --- /dev/null +++ b/packages/cz-cli/test/sql-batch-exit-code.test.ts @@ -0,0 +1,57 @@ +import { beforeEach, expect, test } from "bun:test" +import { join } from "node:path" +import { onFetch, requireTestHome, sqlFailure, sqlSuccess, stubStudioContext } from "./support/cz-fixtures.js" + +const { execute } = await import("../src/execute.ts") + +beforeEach(async () => { + stubStudioContext() + await Bun.write(join(requireTestHome(), ".clickzetta", "profiles.toml"), [ + 'default_profile = "test"', + "[profiles.test]", + 'pat = "pat"', + 'service = "uat-api.clickzetta.com"', + 'instance = "inst"', + 'workspace = "ws0"', + ].join("\n")) +}) + +// Exercise the real command and SDK with only the HTTP boundary substituted. +for (const input of ["execute", "file"] as const) { + for (const failure of ["none", "job", "http"] as const) { + test(`batch ${input}: ${failure} failure preserves results and exit status`, async () => { + const submitted: string[] = [] + onFetch({ + match: (url) => url.includes("/lh/submitJob"), + respond: (_url, _method, body) => { + const query = (body as { jobDesc: { sqlJob: { query: string[] } } }).jobDesc.sqlJob.query[0] + submitted.push(query) + if (query.includes("SELECT 2") && failure === "job") { + return sqlFailure("CZLH-42000", "Statement failed") + } + if (query.includes("SELECT 2") && failure === "http") { + return new Response("Submission rejected", { status: 400 }) + } + return sqlSuccess(["value"], [[1]]) + }, + }) + + const sql = "SELECT 1; SELECT 2; SELECT 3;" + const file = join(requireTestHome(), "batch.sql") + if (input === "file") await Bun.write(file, sql) + const result = await execute("sql --batch --sync", input === "file" ? ["-f", file] : ["-e", sql]) + const rows = result.output.trim().split("\n").map((line) => JSON.parse(line)) + + expect(submitted).toHaveLength(3) + expect(rows.map((row) => row.index)).toEqual([0, 1, 2]) + expect(rows[0].rows).toEqual([[1]]) + expect(rows[2].rows).toEqual([[1]]) + expect(result.exitCode).toBe(failure === "none" ? 0 : 1) + if (failure === "none") { + expect(rows.every((row) => !row.error)).toBe(true) + return + } + expect(rows[1].error.code).toBe(failure === "job" ? "CZLH-42000" : "HTTP_400") + }) + } +} From 7574edf8c4b1cb25c53f5ea85b6962a2a9b83d58 Mon Sep 17 00:00:00 2001 From: suibianwanwan Date: Thu, 8 Oct 2026 11:14:21 +0800 Subject: [PATCH 2/2] fix(cli): fail dry-run when any EXPLAIN fails --dry-run reported per-statement errors but exited 0. Batch and dry-run failures now also record lastError so telemetry carries the error. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/cz-cli/src/commands/sql.ts | 15 ++++++++++--- .../cz-cli/test/sql-batch-exit-code.test.ts | 21 +++++++++++++++++++ 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/packages/cz-cli/src/commands/sql.ts b/packages/cz-cli/src/commands/sql.ts index b237b7fb0..e2f84b132 100644 --- a/packages/cz-cli/src/commands/sql.ts +++ b/packages/cz-cli/src/commands/sql.ts @@ -502,6 +502,13 @@ async function resolveAccountDisplayName(ctx: ExecContext) { } } +// Per-statement failures are already rendered inline, so error() would print a +// second envelope; set the exit code and telemetry error it would have set. +function markStatementFailure(message: string) { + process.exitCode = EXIT_BIZ_ERROR + ;(process as unknown as Record).lastError = message +} + async function formatQueryError(r: QueryResult, ctx: ExecContext, profileName?: string, fallback = "Query failed") { return formatBillingError({ code: r.errorCode, @@ -669,6 +676,8 @@ async function handler(argv: SqlArgs): Promise { } })) success({ statements: results, count: statements.length }, { format, rowsKey: "statements" }) + const failed = results.find((r) => r.status === "error") + if (failed) markStatementFailure(String(failed.error)) return } ctx = await getExecContext(argv) @@ -704,8 +713,8 @@ async function handler(argv: SqlArgs): Promise { try { const r = await execSqlWithRetry(ctx, stmt, { hints: accumulatedHints, timeoutMs: argv.timeout * 1000, configStatements }) if (isQueryResult(r) && r.status === JobStatus.FAILED) { - process.exitCode = EXIT_BIZ_ERROR const line = { index: i, sql: stmt, error: { code: r.errorCode ?? "SQL_ERROR", message: await formatQueryError(r, ctx, argv.profile) }, time_ms: Date.now() - t0, ...(r.jobId ? { job_id: r.jobId } : {}) } + markStatementFailure(line.error.message) process.stdout.write(renderOutput(line, format, batchField) + "\n") logOperation("sql", { sql: stmt, ok: false, errorCode: r.errorCode }) } else if (isQueryResult(r)) { @@ -716,9 +725,9 @@ async function handler(argv: SqlArgs): Promise { logOperation("sql", { sql: stmt, ok: true, rows: rows.length, timeMs: Date.now() - t0 }) } } catch (err) { - process.exitCode = EXIT_BIZ_ERROR const { code, message } = classifyExecError(err) const line = { index: i, sql: stmt, error: { code, message: await formatClassifiedError({ code, message, ctx, profileName: argv.profile }) }, time_ms: Date.now() - t0 } + markStatementFailure(line.error.message) process.stdout.write(renderOutput(line, format, batchField) + "\n") logOperation("sql", { sql: stmt, ok: false, errorCode: code }) } @@ -861,7 +870,7 @@ export function registerSqlCommand(cli: Argv): void { .option("N", { type: "boolean", hidden: true }) .option("limit", { type: "number", default: 100, describe: "Max rows to return (0 for unlimited)" }) .option("batch", { alias: "B", type: "boolean", default: false, describe: "Batch mode: execute all statements sequentially; exit non-zero if any fails" }) - .option("dry-run", { type: "boolean", default: false, describe: "EXPLAIN recognized readonly queries. Multiple queries require sql_split=true; writes, session commands and unknown syntax are rejected." }) + .option("dry-run", { type: "boolean", default: false, describe: "EXPLAIN recognized readonly queries; exit non-zero if any fails. Multiple queries require sql_split=true; writes, session commands and unknown syntax are rejected." }) .epilogue([ "Examples:", " cz-cli sql \"SELECT * FROM orders LIMIT 10\"", diff --git a/packages/cz-cli/test/sql-batch-exit-code.test.ts b/packages/cz-cli/test/sql-batch-exit-code.test.ts index 094e1ecc4..4e1314f9b 100644 --- a/packages/cz-cli/test/sql-batch-exit-code.test.ts +++ b/packages/cz-cli/test/sql-batch-exit-code.test.ts @@ -55,3 +55,24 @@ for (const input of ["execute", "file"] as const) { }) } } + +for (const failure of ["none", "job", "http"] as const) { + test(`dry-run: ${failure} failure preserves results and exit status`, async () => { + await Bun.write(join(requireTestHome(), ".clickzetta", "czcli.json"), '{"sql_split":true}') + onFetch({ + match: (url) => url.includes("/lh/submitJob"), + respond: (_url, _method, body) => { + const query = (body as { jobDesc: { sqlJob: { query: string[] } } }).jobDesc.sqlJob.query[0] + if (query.includes("SELECT 2") && failure === "job") return sqlFailure("CZLH-42000", "Statement failed") + if (query.includes("SELECT 2") && failure === "http") return new Response("Submission rejected", { status: 400 }) + return sqlSuccess(["plan"], [["ok"]]) + }, + }) + + const result = await execute("sql", ["SELECT 1; SELECT 2", "--dry-run"]) + const statements = JSON.parse(result.output).data.statements as { status: string }[] + + expect(statements.map((s) => s.status)).toEqual(failure === "none" ? ["ok", "ok"] : ["ok", "error"]) + expect(result.exitCode).toBe(failure === "none" ? 0 : 1) + }) +}