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 8167cf57..ff72ea44 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 00000000..bb344fa1 --- /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 e10072f1..8fa30a37 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 7b64773b..5fa2acd2 100644 --- a/packages/client-node/CHANGELOG.md +++ b/packages/client-node/CHANGELOG.md @@ -3,8 +3,10 @@ ## Bug fixes - Fixed `Array(Date)` / `Array(Date32)` query-parameter binding (and other temporal element types nested in arrays, tuples, and maps). A JS `Date` inside a container was serialized as a bare Unix timestamp (e.g. `[1683244800]`), which the server's `Array(Date)` element parser rejects (`CANNOT_PARSE_INPUT_ASSERTION_FAILED`). Container-nested `Date` values are now emitted as a quoted UTC date string (e.g. `['2023-05-05']`), the one encoding every temporal element type accepts. Note: a `Date` used inside `Array(DateTime)` / `Array(DateTime64)` is now bound at day precision (the time-of-day is dropped), since date-only is the only form `Array(Date)` accepts; scalar `Date` / `DateTime` binding is unchanged. ([#947]) +- `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]) [#947]: https://github.com/ClickHouse/clickhouse-js/pull/947 +[#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 93ff5967..03b8fcfe 100644 --- a/packages/client-web/CHANGELOG.md +++ b/packages/client-web/CHANGELOG.md @@ -3,8 +3,10 @@ ## Bug fixes - Fixed `Array(Date)` / `Array(Date32)` query-parameter binding (and other temporal element types nested in arrays, tuples, and maps). A JS `Date` inside a container was serialized as a bare Unix timestamp (e.g. `[1683244800]`), which the server's `Array(Date)` element parser rejects (`CANNOT_PARSE_INPUT_ASSERTION_FAILED`). Container-nested `Date` values are now emitted as a quoted UTC date string (e.g. `['2023-05-05']`), the one encoding every temporal element type accepts. Note: a `Date` used inside `Array(DateTime)` / `Array(DateTime64)` is now bound at day precision (the time-of-day is dropped), since date-only is the only form `Array(Date)` accepts; scalar `Date` / `DateTime` binding is unchanged. ([#947]) +- `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]) [#947]: https://github.com/ClickHouse/clickhouse-js/pull/947 +[#949]: https://github.com/ClickHouse/clickhouse-js/pull/949 # 1.23.1