From 790635ba68fe5307a95b056483d26d050555451c Mon Sep 17 00:00:00 2001 From: Makisuo Date: Mon, 2 Nov 2026 16:25:14 +0100 Subject: [PATCH] fix(clickhouse-http): escape bound parameters as ClickHouse reads them Scalar HTTP parameters are read in the escaped (TabSeparated) format, so a newline failed to parse and a backslash changed the value. Array parameters are read as literals, where JSON's double-quoted strings do not parse. Escape scalars, and write arrays as ClickHouse literals. Fixes #38 Co-Authored-By: Claude Opus 5.5 --- .changeset/clickhouse-http-param-escaping.md | 8 ++++++++ src/clickhouse-http/client.ts | 18 +++++++++++++++++- tests/clickhouse-http.clickhouse.test.ts | 12 ++++++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 .changeset/clickhouse-http-param-escaping.md diff --git a/.changeset/clickhouse-http-param-escaping.md b/.changeset/clickhouse-http-param-escaping.md new file mode 100644 index 0000000..b74908d --- /dev/null +++ b/.changeset/clickhouse-http-param-escaping.md @@ -0,0 +1,8 @@ +--- +"@maple-dev/effect-orm": patch +--- + +`ClickhouseHttpClient` escapes bound parameters the way ClickHouse reads them. A string with a +newline, tab or NUL no longer fails with `BAD_QUERY_PARAMETER`, and a backslash is no longer +read as the start of an escape sequence, which silently changed the value. Arrays are sent as +ClickHouse literals, so arrays of strings now bind at all (#38). diff --git a/src/clickhouse-http/client.ts b/src/clickhouse-http/client.ts index 525be27..b1ae3ca 100644 --- a/src/clickhouse-http/client.ts +++ b/src/clickhouse-http/client.ts @@ -132,8 +132,24 @@ const compiler = Statement.makeCompiler({ onCustom: () => ["", []], }) +// ClickHouse reads a scalar HTTP query parameter in the escaped (TabSeparated) format: a raw +// newline fails to parse, and a backslash starts an escape sequence. An array parameter is +// read as a literal instead, so only its quoted strings are escaped. +const escapes: Record = { "\\": "\\\\", "\0": "\\0", "\b": "\\b", "\f": "\\f", "\n": "\\n", "\r": "\\r", "\t": "\\t" } + +const dateValue = (value: Date): string => value.toISOString().replace("T", " ").replace("Z", "") + +/** An array as a ClickHouse literal: strings single-quoted, since JSON's double quotes do not parse. */ +const arrayLiteral = (value: ReadonlyArray): string => + `[${value.map((item) => + item === null || item === undefined ? "NULL" + : Array.isArray(item) ? arrayLiteral(item) + : typeof item === "number" || typeof item === "bigint" || typeof item === "boolean" ? String(item) + : `'${(item instanceof Date ? dateValue(item) : String(item)).replace(/[\\']/g, (c) => `\\${c}`)}'`).join(",")}]` + const paramValue = (value: unknown): string => - value instanceof Date ? value.toISOString().replace("T", " ").replace("Z", "") : Array.isArray(value) ? JSON.stringify(value) : String(value) + Array.isArray(value) ? arrayLiteral(value) + : (value instanceof Date ? dateValue(value) : String(value)).replace(/[\\\0\b\f\n\r\t]/g, (c) => escapes[c]!) export const make = ( config: ClickhouseHttpClientConfig, diff --git a/tests/clickhouse-http.clickhouse.test.ts b/tests/clickhouse-http.clickhouse.test.ts index 909cc34..597db37 100644 --- a/tests/clickhouse-http.clickhouse.test.ts +++ b/tests/clickhouse-http.clickhouse.test.ts @@ -50,6 +50,18 @@ describe.skipIf(!endpoint)("ClickhouseHttpClient on ClickHouse", () => { }), ) + // Issue #38: these used to fail to parse or arrive changed; string arrays did not parse at all. + it.effect("binds strings with newlines, tabs and backslashes unchanged", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient + const values = ["a\nb", "a\\nb", "a\\\\b", "tab\there", "cr\r", "nul\0", "é é 🙂\n|\t\\n"] + for (const value of values) { + const rows = yield* sql`SELECT ${value} AS value, ${[value, "it's"]} AS list` + expect(rows).toEqual([{ value, list: [value, "it's"] }]) + } + }), + ) + it.effect("streams rows", () => Effect.gen(function* () { const sql = yield* SqlClient.SqlClient