Skip to content
Open
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 @@ -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 *
Expand Down
147 changes: 147 additions & 0 deletions packages/client-common/__tests__/unit/insert_query_columns.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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);
});
});
});
30 changes: 26 additions & 4 deletions packages/client-common/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -655,19 +658,38 @@ 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<T>(
params: InsertParams<T>,
format: DataFormat,
): string {
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}`;
Expand Down
2 changes: 2 additions & 0 deletions packages/client-node/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions packages/client-web/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading