From 54a6d752d39233a621004ed90f294172f726b22d Mon Sep 17 00:00:00 2001 From: robert Date: Thu, 14 May 2026 21:38:57 +0800 Subject: [PATCH] re-fix splitSql function, and implement dry-run in sql subcommand --- packages/clickzetta-sdk/src/sql/split.ts | 16 +---- packages/clickzetta-sdk/test/split.test.ts | 78 +++++++++++++++++++++- packages/cz-cli/src/commands/exec.ts | 2 +- packages/cz-cli/src/commands/sql.ts | 22 +++++- 4 files changed, 98 insertions(+), 20 deletions(-) diff --git a/packages/clickzetta-sdk/src/sql/split.ts b/packages/clickzetta-sdk/src/sql/split.ts index 9dc2d2fc2f..26447f7a11 100644 --- a/packages/clickzetta-sdk/src/sql/split.ts +++ b/packages/clickzetta-sdk/src/sql/split.ts @@ -86,19 +86,5 @@ export function splitSql(query: string): string[] { } if (b < query.length) ret.push(query.slice(b)) - return ret.filter((statement) => { - let rest = statement.trim() - while (rest.startsWith("--") || rest.startsWith("/*")) { - if (rest.startsWith("--")) { - const newline = rest.indexOf("\n") - if (newline === -1) return false - rest = rest.slice(newline + 1).trim() - continue - } - const end = rest.indexOf("*/") - if (end === -1) return false - rest = rest.slice(end + 2).trim() - } - return rest.length > 0 - }) + return ret } diff --git a/packages/clickzetta-sdk/test/split.test.ts b/packages/clickzetta-sdk/test/split.test.ts index bac2af4d94..73cdd63017 100644 --- a/packages/clickzetta-sdk/test/split.test.ts +++ b/packages/clickzetta-sdk/test/split.test.ts @@ -3,12 +3,12 @@ import { splitSql } from "../src/sql/split.js" describe("splitSql", () => { test("drops trailing single-line comment after a terminated statement", () => { - expect(splitSql("select 'abc', 1 + 1; --comment")).toEqual(["select 'abc', 1 + 1"]) + expect(splitSql("select 'abc', 1 + 1; --comment")).toEqual(["select 'abc', 1 + 1", " --comment"]) }) test("drops comment-only input", () => { - expect(splitSql("--comment only")).toEqual([]) - expect(splitSql("/* comment only */")).toEqual([]) + expect(splitSql("--comment only")).toEqual(["--comment only"]) + expect(splitSql("/* comment only */")).toEqual(["/* comment only */"]) }) test("keeps fragments that still contain SQL after a leading comment", () => { @@ -18,4 +18,76 @@ describe("splitSql", () => { test("preserves double-quoted SQL text", () => { expect(splitSql('select "abc";')).toEqual(['select "abc"']) }) + + test("single statement without semicolon", () => { + expect(splitSql("select 1")).toHaveLength(1) + }) + + test("single statement with semicolon", () => { + expect(splitSql("select 1;")).toHaveLength(1) + }) + + test("two statements without trailing semicolon", () => { + expect(splitSql("select 1;select 2")).toHaveLength(2) + }) + + test("two statements with trailing semicolon", () => { + expect(splitSql("select 1;select 2;")).toHaveLength(2) + }) + + test("multiline single statement", () => { + expect(splitSql("select 1\n\n\nfrom table;")).toHaveLength(1) + }) + + test("lone semicolon produces empty", () => { + expect(splitSql(";")).toHaveLength(0) + }) + + test("double semicolons produce empty", () => { + expect(splitSql(";;")).toHaveLength(0) + }) + + test("semicolons with space between", () => { + expect(splitSql("; ;")).toHaveLength(1) + }) + + test("semicolons with newline between", () => { + expect(splitSql(";\n;")).toHaveLength(1) + }) + + test("empty string", () => { + expect(splitSql("")).toHaveLength(0) + }) + + test("single newline", () => { + expect(splitSql("\n")).toHaveLength(1) + }) + + test("single-line comment with semicolons inside", () => { + expect(splitSql("select *\n-- -- ;\nfrom world\n")).toHaveLength(1) + }) + + test("unclosed backtick identifier", () => { + expect(splitSql("select `aaaa")).toHaveLength(1) + }) + + test("single-quoted string with semicolons and newlines", () => { + expect(splitSql("select 'aaa;\nbbb'\n")).toHaveLength(1) + }) + + test("double-quoted string with escaped quote and semicolons", () => { + expect(splitSql('select "--\\"/*;\n*/"')).toHaveLength(1) + }) + + test("mixed comments and SQL", () => { + expect(splitSql("-- line 1\nselect\n/* comment -- -- ;\n****/*\nfrom foo")).toHaveLength(1) + }) + + test("multiple statements with block comments", () => { + expect(splitSql("/*/--/*/;\nselect /* -- 1; */\n1;-- sql 2")).toHaveLength(3) + }) + + test("double-quoted string with escaped backslash before semicolon", () => { + expect(splitSql('select "1\\\\";select2\n')).toHaveLength(2) + }) }) diff --git a/packages/cz-cli/src/commands/exec.ts b/packages/cz-cli/src/commands/exec.ts index 0f3fa39833..2ac5e7cf3d 100644 --- a/packages/cz-cli/src/commands/exec.ts +++ b/packages/cz-cli/src/commands/exec.ts @@ -58,7 +58,7 @@ export async function execSql( timeoutMs?: number }, ): Promise { - const normalizedSql = sql.trimEnd().endsWith(";") ? sql : sql + ";" + const normalizedSql = sql + "\n;" const jobId = newJobId(ctx.config.workspace, ctx.token.instanceId) const submitResp = await submitJob(ctx.clientOpts, { sql: normalizedSql, diff --git a/packages/cz-cli/src/commands/sql.ts b/packages/cz-cli/src/commands/sql.ts index 4c2326fb95..34787e76be 100644 --- a/packages/cz-cli/src/commands/sql.ts +++ b/packages/cz-cli/src/commands/sql.ts @@ -37,6 +37,7 @@ interface SqlArgs extends GlobalArgs { N?: boolean "limit": boolean batch: boolean + "dry-run": boolean } function truncateLargeFields(rows: Record[], maxLen: number): Record[] { @@ -394,11 +395,29 @@ async function handler(argv: SqlArgs): Promise { process.on("SIGINT", sigintHandler) try { - const ctx = await getExecContext(argv) const statements = splitSql(sql).map((s) => s.trim()).filter(Boolean) if (statements.length === 0) { error("USAGE_ERROR", "No SQL statements found.", { format, exitCode: 2 }); return } + if (argv["dry-run"]) { + const ctx = await getExecContext(argv) + const results = await Promise.all(statements.map(async (stmt) => { + try { + const r = await execSql(ctx, `EXPLAIN ${stmt}`, { timeoutMs: argv.timeout * 1000 }) + if (isQueryResult(r)) { + if (r.status === JobStatus.FAILED) + return { sql: stmt, status: "error", job_id: r.jobId, error: r.errorMessage ?? "EXPLAIN failed" } + return { sql: stmt, status: "ok", job_id: r.jobId } + } + return { sql: stmt, status: "ok", job_id: (r as { jobId?: string }).jobId } + } catch (err) { + return { sql: stmt, status: "error", error: err instanceof Error ? err.message : String(err) } + } + })) + success({ statements: results, count: statements.length }, { format }) + return + } + const ctx = await getExecContext(argv) // Multi-statement: execute all, return all results in batch mode or last result otherwise if (statements.length > 1) { const accumulatedHints = { ...hints } @@ -500,6 +519,7 @@ export function registerSqlCommand(cli: Argv): void { .option("N", { type: "boolean", hidden: true }) .option("limit", { type: "boolean", default: true, describe: "Auto-truncate results to 100 rows. Use --no-limit to fetch all rows." }) .option("batch", { alias: "B", type: "boolean", default: false, describe: "Batch mode: execute multiple semicolon-separated statements sequentially" }) + .option("dry-run", { type: "boolean", default: false, describe: "Split SQL and EXPLAIN each statement without executing. Reports ok/error per statement." }) .epilogue([ "Examples:", " cz-cli sql \"SELECT * FROM orders LIMIT 10\" --sync",