From f6948e9b99b5ced385c21d97aaccccaf898b0896 Mon Sep 17 00:00:00 2001 From: Polyglot AI <293096396+polyglotAI-bot@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:05:26 +0000 Subject: [PATCH 1/2] fix: back-quote insert() column identifiers with special characters Column names passed via `insert({ columns })` (both the array form and the `columns.except` form) were joined verbatim into the generated statement, so a name containing a space or other special character produced invalid SQL and a server-side syntax error (e.g. `['test id']` -> `INSERT INTO t (test id) ...`). Back-quote each identifier, escaping backslashes and backticks the same way the ClickHouse server's `backQuote` does. Identifiers that are already quoted (with backticks or double quotes) are passed through unchanged, preserving backward compatibility for callers that pre-quoted their column names as a workaround. The shared code lives in the common module bundled into both @clickhouse/client and @clickhouse/client-web, so the fix applies to both. Fixes: https://github.com/ClickHouse/clickhouse-js/issues/945 --- .../insert_specific_columns.test.ts | 52 +++++++ .../unit/insert_query_columns.test.ts | 147 ++++++++++++++++++ packages/client-common/src/client.ts | 30 +++- packages/client-node/CHANGELOG.md | 8 + packages/client-web/CHANGELOG.md | 8 + 5 files changed, 241 insertions(+), 4 deletions(-) create mode 100644 packages/client-common/__tests__/unit/insert_query_columns.test.ts diff --git a/packages/client-common/__tests__/integration/insert_specific_columns.test.ts b/packages/client-common/__tests__/integration/insert_specific_columns.test.ts index 8167cf57e..ff72ea446 100644 --- a/packages/client-common/__tests__/integration/insert_specific_columns.test.ts +++ b/packages/client-common/__tests__/integration/insert_specific_columns.test.ts @@ -243,6 +243,58 @@ describe("Insert with specific columns", () => { }); }); + describe("columns with special characters (#945)", () => { + it("should insert into columns whose names contain spaces", async () => { + table = await createTableWithFields( + client, + "`test id` String, name String", + ); + + await client.insert({ + table, + values: [{ "test id": "foo", name: "bar" }], + format: "JSONEachRow", + columns: ["test id", "name"], + }); + + const result = await client + .query({ + query: `SELECT \`test id\`, name + FROM ${table}`, + format: "JSONEachRow", + }) + .then((r) => r.json()); + + expect(result).toEqual([{ "test id": "foo", name: "bar" }]); + }); + + it("should exclude a column whose name contains spaces via EXCEPT", async () => { + table = await createTableWithFields( + client, + "`test col` String, keep String", + ); + + await client.insert({ + table, + values: [{ id: 1, keep: "kept" }], + format: "JSONEachRow", + columns: { + except: ["test col"], + }, + }); + + const result = await client + .query({ + query: `SELECT id, \`test col\`, keep + FROM ${table}`, + format: "JSONEachRow", + }) + .then((r) => r.json()); + + expect(result).toEqual([{ id: 1, "test col": "", keep: "kept" }]); + }); + }); + async function select() { const rs = await client.query({ query: `SELECT * diff --git a/packages/client-common/__tests__/unit/insert_query_columns.test.ts b/packages/client-common/__tests__/unit/insert_query_columns.test.ts new file mode 100644 index 000000000..bb344fa1c --- /dev/null +++ b/packages/client-common/__tests__/unit/insert_query_columns.test.ts @@ -0,0 +1,147 @@ +import { describe, it, expect } from "vitest"; +import { ClickHouseClient, type InsertParams } from "../../src/client"; + +// Builds a client whose connection records the generated `INSERT` statement, +// so we can assert the exact SQL produced by `client.insert(...)` (i.e. the +// real getInsertQuery code path) without a running server. +function createQueryCapturingClient(): { + client: ClickHouseClient; + getLastQuery: () => string | undefined; +} { + let lastQuery: string | undefined; + const connection = { + query: async () => ({ stream: {}, query_id: "q", response_headers: {} }), + command: async () => ({ query_id: "c", response_headers: {} }), + exec: async () => ({ stream: {}, query_id: "e", response_headers: {} }), + insert: async (params: { query: string }) => { + lastQuery = params.query; + return { query_id: "i", response_headers: {} }; + }, + ping: async () => ({ success: true }), + close: async () => {}, + }; + const client = new ClickHouseClient({ + url: "http://localhost:8123", + impl: { + make_connection: () => connection as any, + make_result_set: (() => ({})) as any, + values_encoder: () => + ({ + validateInsertValues: () => {}, + encodeValues: (v: any) => + typeof v === "string" ? v : JSON.stringify(v), + }) as any, + }, + } as any); + return { client, getLastQuery: () => lastQuery }; +} + +async function insertColumns( + columns: InsertParams["columns"], +): Promise { + const { client, getLastQuery } = createQueryCapturingClient(); + await client.insert({ + table: "t", + values: [{ id: 1 }], + format: "JSONEachRow", + columns, + }); + return getLastQuery()!; +} + +describe("getInsertQuery column identifier quoting", () => { + describe("list of columns", () => { + const cases: Array<{ + title: string; + columns: [string, ...string[]]; + expected: string; + }> = [ + { + title: "back-quotes plain identifiers", + columns: ["id", "name"], + expected: "INSERT INTO t (`id`, `name`) FORMAT JSONEachRow", + }, + { + title: "back-quotes identifiers containing spaces (#945)", + columns: ["test id", "name"], + expected: "INSERT INTO t (`test id`, `name`) FORMAT JSONEachRow", + }, + { + title: "escapes an embedded backtick with a backslash", + columns: ["a`b"], + expected: "INSERT INTO t (`a\\`b`) FORMAT JSONEachRow", + }, + { + title: "escapes an embedded backslash", + columns: ["a\\b"], + expected: "INSERT INTO t (`a\\\\b`) FORMAT JSONEachRow", + }, + { + // Both special chars in one name: pins that the backslash is escaped + // before the backtick (reversing the order would emit invalid SQL). + title: "escapes a name containing both a backslash and a backtick", + columns: ["a\\`b"], + expected: "INSERT INTO t (`a\\\\\\`b`) FORMAT JSONEachRow", + }, + { + // A lone backtick is a 1-char name, below the passthrough guard, so it + // is escaped rather than mistaken for an already-quoted identifier. + title: "escapes a single-backtick column name (passthrough boundary)", + columns: ["`"], + expected: "INSERT INTO t (`\\``) FORMAT JSONEachRow", + }, + { + title: "back-quotes a dotted (Nested) column as a single identifier", + columns: ["n.arr1", "n.arr2"], + expected: "INSERT INTO t (`n.arr1`, `n.arr2`) FORMAT JSONEachRow", + }, + { + // Contrast case: an already back-quoted identifier (the pre-#945 + // workaround) must be passed through unchanged, not double-quoted. + title: "passes through already back-quoted identifiers unchanged", + columns: ["`test id`", "name"], + expected: "INSERT INTO t (`test id`, `name`) FORMAT JSONEachRow", + }, + { + // Contrast case: double-quoted identifiers are also valid ClickHouse + // syntax and are passed through unchanged. + title: "passes through already double-quoted identifiers unchanged", + columns: ['"test id"', "name"], + expected: 'INSERT INTO t ("test id", `name`) FORMAT JSONEachRow', + }, + ]; + + it.each(cases)("$title", async ({ columns, expected }) => { + expect(await insertColumns(columns)).toBe(expected); + }); + }); + + describe("EXCEPT list of columns", () => { + const cases: Array<{ + title: string; + columns: { except: [string, ...string[]] }; + expected: string; + }> = [ + { + title: "back-quotes excepted identifiers containing spaces", + columns: { except: ["test col"] }, + expected: "INSERT INTO t (* EXCEPT (`test col`)) FORMAT JSONEachRow", + }, + { + title: "back-quotes multiple excepted identifiers", + columns: { except: ["id", "b"] }, + expected: "INSERT INTO t (* EXCEPT (`id`, `b`)) FORMAT JSONEachRow", + }, + { + // Contrast case: already back-quoted excepted identifier is unchanged. + title: "passes through already back-quoted excepted identifiers", + columns: { except: ["`test col`"] }, + expected: "INSERT INTO t (* EXCEPT (`test col`)) FORMAT JSONEachRow", + }, + ]; + + it.each(cases)("$title", async ({ columns, expected }) => { + expect(await insertColumns(columns)).toBe(expected); + }); + }); +}); diff --git a/packages/client-common/src/client.ts b/packages/client-common/src/client.ts index e10072f15..8fa30a37e 100644 --- a/packages/client-common/src/client.ts +++ b/packages/client-common/src/client.ts @@ -170,10 +170,13 @@ export interface InsertParams< /** * Allows specifying which columns the data will be inserted into. * Accepts either an array of strings (column names) or an object of {@link InsertColumnsExcept} type. + * Column names are automatically back-quoted, so names containing spaces or + * other special characters are handled correctly; a name that is already + * quoted (with backticks or double quotes) is used as-is. * Examples of generated queries: * - * - An array such as `['a', 'b']` will generate: `INSERT INTO table (a, b) FORMAT DataFormat` - * - An object such as `{ except: ['a', 'b'] }` will generate: `INSERT INTO table (* EXCEPT (a, b)) FORMAT DataFormat` + * - An array such as `['a', 'b']` generates: INSERT INTO table (`a`, `b`) FORMAT DataFormat + * - An object such as `{ except: ['a', 'b'] }` generates: INSERT INTO table (* EXCEPT (`a`, `b`)) FORMAT DataFormat * * By default, the data is inserted into all columns of the {@link InsertParams.table}, * and the generated statement will be: `INSERT INTO table FORMAT DataFormat`. @@ -655,6 +658,23 @@ function isInsertColumnsExcept(obj: unknown): obj is InsertColumnsExcept { ); } +/** Quotes a user-provided column identifier for an `INSERT` column list. + * Backslashes and backticks are escaped the same way the ClickHouse server + * does (see `backQuote`), so column names containing spaces or other special + * characters produce valid SQL. Identifiers that are already quoted (with + * backticks or double quotes) are passed through unchanged, preserving + * backward compatibility for callers that pre-quoted their column names. */ +function escapeColumn(column: string): string { + if ( + column.length >= 2 && + ((column.startsWith("`") && column.endsWith("`")) || + (column.startsWith('"') && column.endsWith('"'))) + ) { + return column; + } + return `\`${column.replace(/\\/g, "\\\\").replace(/`/g, "\\`")}\``; +} + function getInsertQuery( params: InsertParams, format: DataFormat, @@ -662,12 +682,14 @@ function getInsertQuery( let columnsPart = ""; if (params.columns !== undefined) { if (Array.isArray(params.columns) && params.columns.length > 0) { - columnsPart = ` (${params.columns.join(", ")})`; + columnsPart = ` (${params.columns.map(escapeColumn).join(", ")})`; } else if ( isInsertColumnsExcept(params.columns) && params.columns.except.length > 0 ) { - columnsPart = ` (* EXCEPT (${params.columns.except.join(", ")}))`; + columnsPart = ` (* EXCEPT (${params.columns.except + .map(escapeColumn) + .join(", ")}))`; } } return `INSERT INTO ${params.table.trim()}${columnsPart} FORMAT ${format}`; diff --git a/packages/client-node/CHANGELOG.md b/packages/client-node/CHANGELOG.md index faa822daa..ebafc9e67 100644 --- a/packages/client-node/CHANGELOG.md +++ b/packages/client-node/CHANGELOG.md @@ -1,3 +1,11 @@ +# 1.23.2 + +## Bug Fixes + +- `insert`: column names passed via the `columns` parameter (both the array form and the `columns.except` form) are now back-quoted, so names containing spaces or other special characters produce valid SQL instead of a server-side syntax error. Column names that are already quoted (with backticks or double quotes) are passed through unchanged, preserving backward compatibility for callers that pre-quoted them. ([#948]) + +[#948]: https://github.com/ClickHouse/clickhouse-js/pull/948 + # 1.23.1 ## Bug Fixes diff --git a/packages/client-web/CHANGELOG.md b/packages/client-web/CHANGELOG.md index 1883e1f6d..89bcc05bf 100644 --- a/packages/client-web/CHANGELOG.md +++ b/packages/client-web/CHANGELOG.md @@ -1,3 +1,11 @@ +# 1.23.2 + +## Bug Fixes + +- `insert`: column names passed via the `columns` parameter (both the array form and the `columns.except` form) are now back-quoted, so names containing spaces or other special characters produce valid SQL instead of a server-side syntax error. Column names that are already quoted (with backticks or double quotes) are passed through unchanged, preserving backward compatibility for callers that pre-quoted them. ([#948]) + +[#948]: https://github.com/ClickHouse/clickhouse-js/pull/948 + # 1.23.1 ## Bug Fixes From e099e95f52f0dc836f1a908240b5a8878e115760 Mon Sep 17 00:00:00 2001 From: Polyglot AI <293096396+polyglotAI-bot@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:06:40 +0000 Subject: [PATCH 2/2] docs(changelog): reference PR #949 --- packages/client-node/CHANGELOG.md | 4 ++-- packages/client-web/CHANGELOG.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/client-node/CHANGELOG.md b/packages/client-node/CHANGELOG.md index ebafc9e67..c1d6ca17b 100644 --- a/packages/client-node/CHANGELOG.md +++ b/packages/client-node/CHANGELOG.md @@ -2,9 +2,9 @@ ## Bug Fixes -- `insert`: column names passed via the `columns` parameter (both the array form and the `columns.except` form) are now back-quoted, so names containing spaces or other special characters produce valid SQL instead of a server-side syntax error. Column names that are already quoted (with backticks or double quotes) are passed through unchanged, preserving backward compatibility for callers that pre-quoted them. ([#948]) +- `insert`: column names passed via the `columns` parameter (both the array form and the `columns.except` form) are now back-quoted, so names containing spaces or other special characters produce valid SQL instead of a server-side syntax error. Column names that are already quoted (with backticks or double quotes) are passed through unchanged, preserving backward compatibility for callers that pre-quoted them. ([#949]) -[#948]: https://github.com/ClickHouse/clickhouse-js/pull/948 +[#949]: https://github.com/ClickHouse/clickhouse-js/pull/949 # 1.23.1 diff --git a/packages/client-web/CHANGELOG.md b/packages/client-web/CHANGELOG.md index 89bcc05bf..4709671c7 100644 --- a/packages/client-web/CHANGELOG.md +++ b/packages/client-web/CHANGELOG.md @@ -2,9 +2,9 @@ ## Bug Fixes -- `insert`: column names passed via the `columns` parameter (both the array form and the `columns.except` form) are now back-quoted, so names containing spaces or other special characters produce valid SQL instead of a server-side syntax error. Column names that are already quoted (with backticks or double quotes) are passed through unchanged, preserving backward compatibility for callers that pre-quoted them. ([#948]) +- `insert`: column names passed via the `columns` parameter (both the array form and the `columns.except` form) are now back-quoted, so names containing spaces or other special characters produce valid SQL instead of a server-side syntax error. Column names that are already quoted (with backticks or double quotes) are passed through unchanged, preserving backward compatibility for callers that pre-quoted them. ([#949]) -[#948]: https://github.com/ClickHouse/clickhouse-js/pull/948 +[#949]: https://github.com/ClickHouse/clickhouse-js/pull/949 # 1.23.1