Skip to content

Commit 85c2b94

Browse files
authored
Merge pull request #132 from MapleTechLabs/fix/clickhouse-http-param-escaping
fix(clickhouse-http): escape bound parameters as ClickHouse reads them
2 parents 8a5d907 + 790635b commit 85c2b94

3 files changed

Lines changed: 37 additions & 1 deletion

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"@maple-dev/effect-orm": patch
3+
---
4+
5+
`ClickhouseHttpClient` escapes bound parameters the way ClickHouse reads them. A string with a
6+
newline, tab or NUL no longer fails with `BAD_QUERY_PARAMETER`, and a backslash is no longer
7+
read as the start of an escape sequence, which silently changed the value. Arrays are sent as
8+
ClickHouse literals, so arrays of strings now bind at all (#38).

‎src/clickhouse-http/client.ts‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,8 +132,24 @@ const compiler = Statement.makeCompiler({
132132
onCustom: () => ["", []],
133133
})
134134

135+
// ClickHouse reads a scalar HTTP query parameter in the escaped (TabSeparated) format: a raw
136+
// newline fails to parse, and a backslash starts an escape sequence. An array parameter is
137+
// read as a literal instead, so only its quoted strings are escaped.
138+
const escapes: Record<string, string> = { "\\": "\\\\", "\0": "\\0", "\b": "\\b", "\f": "\\f", "\n": "\\n", "\r": "\\r", "\t": "\\t" }
139+
140+
const dateValue = (value: Date): string => value.toISOString().replace("T", " ").replace("Z", "")
141+
142+
/** An array as a ClickHouse literal: strings single-quoted, since JSON's double quotes do not parse. */
143+
const arrayLiteral = (value: ReadonlyArray<unknown>): string =>
144+
`[${value.map((item) =>
145+
item === null || item === undefined ? "NULL"
146+
: Array.isArray(item) ? arrayLiteral(item)
147+
: typeof item === "number" || typeof item === "bigint" || typeof item === "boolean" ? String(item)
148+
: `'${(item instanceof Date ? dateValue(item) : String(item)).replace(/[\\']/g, (c) => `\\${c}`)}'`).join(",")}]`
149+
135150
const paramValue = (value: unknown): string =>
136-
value instanceof Date ? value.toISOString().replace("T", " ").replace("Z", "") : Array.isArray(value) ? JSON.stringify(value) : String(value)
151+
Array.isArray(value) ? arrayLiteral(value)
152+
: (value instanceof Date ? dateValue(value) : String(value)).replace(/[\\\0\b\f\n\r\t]/g, (c) => escapes[c]!)
137153

138154
export const make = (
139155
config: ClickhouseHttpClientConfig,

‎tests/clickhouse-http.clickhouse.test.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,18 @@ describe.skipIf(!endpoint)("ClickhouseHttpClient on ClickHouse", () => {
5050
}),
5151
)
5252

53+
// Issue #38: these used to fail to parse or arrive changed; string arrays did not parse at all.
54+
it.effect("binds strings with newlines, tabs and backslashes unchanged", () =>
55+
Effect.gen(function* () {
56+
const sql = yield* SqlClient.SqlClient
57+
const values = ["a\nb", "a\\nb", "a\\\\b", "tab\there", "cr\r", "nul\0", "é é 🙂\n|\t\\n"]
58+
for (const value of values) {
59+
const rows = yield* sql`SELECT ${value} AS value, ${[value, "it's"]} AS list`
60+
expect(rows).toEqual([{ value, list: [value, "it's"] }])
61+
}
62+
}),
63+
)
64+
5365
it.effect("streams rows", () =>
5466
Effect.gen(function* () {
5567
const sql = yield* SqlClient.SqlClient

0 commit comments

Comments
 (0)