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
34 changes: 32 additions & 2 deletions src/serialize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,8 @@ function serializeNumber(value: number): string {
* - string -> escaped single-quoted literal
* - Date -> 'YYYY-MM-DD HH:MM:SS' (UTC)
* - Array / TypedArray -> [a, b, c]
* - Map -> {k: v, ...} (keys escaped as string literals)
* - TupleParam -> (a, b, c) (tuple literal, elements quoted)
* - Map -> {k: v, ...} (keys serialized by JS type)
* - plain object -> {k: v, ...} (keys escaped as string literals)
*
* @throws ChdbBindError on non-finite numbers, invalid Dates, or unsupported types.
Expand Down Expand Up @@ -174,10 +175,15 @@ export function serializeValue(value: unknown): string {

if (Array.isArray(value)) return serializeArray(value)

if (isTupleParam(value)) return serializeTuple(value.values)

if (value instanceof Map) {
const parts: string[] = []
for (const [k, v] of value) {
parts.push(`${escapeStringLiteral(String(k))}:${serializeValue(v)}`)
// Serialize the key by its JS type, not force-quoted: a numeric key binds
// as `42` (an `{'42':…}` string-quoted key is rejected for `Map(Int32,…)`),
// a string key as `'k'`. Matches @clickhouse/client-common formatting.
parts.push(`${serializeValue(k)}:${serializeValue(v)}`)
}
return `{${parts.join(',')}}`
}
Expand Down Expand Up @@ -205,6 +211,30 @@ function serializeArray(arr: ReadonlyArray<unknown>): string {
return `[${arr.map((x) => serializeValue(x)).join(',')}]`
}

function serializeTuple(values: ReadonlyArray<unknown>): string {
return `(${values.map((x) => serializeValue(x)).join(',')})`
}

/**
* Detect `@clickhouse/client-common`'s `TupleParam` (members in `.values`, binds
* as the tuple literal `(a,b,c)` — not the `{k:v}` form the generic-object
* branch would otherwise produce).
*
* Matched by shape, not `instanceof`: the instance a caller passes originates
* from their own `@clickhouse/client(-common)` copy, which pnpm (or any nested
* install) may resolve to a different module instance than ours, so an
* `instanceof` against our imported class would spuriously miss it. The class is
* shipped un-minified with its name intact, so the constructor-name check is
* reliable for real instances while staying a private, non-`TupleParam` object
* (which keeps the `{k:v}` map form) untouched.
*/
function isTupleParam(value: object): value is { values: ReadonlyArray<unknown> } {
return (
value.constructor?.name === 'TupleParam' &&
Array.isArray((value as { values?: unknown }).values)
)
}

/**
* Format a JS value as a ClickHouse **parameter** string for server-side
* binding via `chdb_query_with_params` ({name:Type} placeholders). This is NOT
Expand Down
34 changes: 34 additions & 0 deletions test/v3/querybind.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,40 @@ describe('queryBind path A (server-side chdb_query_with_params)', () => {
expect(row.m).toEqual({ abc: [1, 2, 3] })
})

it('binds Tuple and Array(Tuple) params via a TupleParam-shaped value', () => {
// @clickhouse/client-common's TupleParam { values }, reproduced by shape so
// the test needs no dependency (serializeValue matches it structurally).
class TupleParam {
constructor(readonly values: unknown[]) {}
}
expect(
t('SELECT ({pair:Tuple(String, String)}).1', {
pair: new TupleParam(['main*', '^main.*$']),
}),
).toBe('main*')
// The Array(Tuple(String, String)) case from the trunk check-branch-patterns
// query — previously failed with "cannot be parsed as Array(Tuple(...))".
const rows = queryBind(
`SELECT pair.1 AS glob, pair.2 AS re
FROM (SELECT arrayJoin({pairs:Array(Tuple(String, String))}) AS pair)
ORDER BY glob`,
{
pairs: [
new TupleParam(['dev*', '^dev.*$']),
new TupleParam(['main*', '^main.*$']),
],
},
'JSONEachRow',
)
.trim()
.split('\n')
.map((l) => JSON.parse(l))
expect(rows).toEqual([
{ glob: 'dev*', re: '^dev.*$' },
{ glob: 'main*', re: '^main.*$' },
])
})

it('treats injection payloads as inert bound data (no interpolation)', () => {
const payloads = ["'; DROP TABLE x; --", 'a\\b', "a' OR 1=1 --", 'tab\tend']
for (const p of payloads) {
Expand Down
20 changes: 20 additions & 0 deletions test/v3/serialize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,9 +96,29 @@ describe('serializeValue (string assertions)', () => {
expect(serializeValue(['a', "b'c"])).toBe("['a','b\\'c']")
expect(serializeValue(new Int32Array([1, 2]))).toBe('[1,2]')
expect(serializeValue(new Map<string, unknown>([['k', 1]]))).toBe("{'k':1}")
// Numeric Map key binds unquoted (Map(Int32, …)), string key stays quoted.
expect(serializeValue(new Map<number, unknown>([[42, 'v']]))).toBe("{42:'v'}")
expect(serializeValue({ a: 1, b: 'x' })).toBe("{'a':1,'b':'x'}")
expect(serializeValue([[1, 2], [3]])).toBe('[[1,2],[3]]')
})

it('serializes a TupleParam as a tuple literal, not an object', () => {
// @clickhouse/client-common's TupleParam { values }. Reproduce its shape
// (matched structurally, not by instanceof) so the test needs no dependency.
class TupleParam {
constructor(readonly values: unknown[]) {}
}
expect(serializeValue(new TupleParam(['main*', '^main.*$']))).toBe(
"('main*','^main.*$')",
)
expect(serializeValue(new TupleParam([42, 'foo', null]))).toBe("(42,'foo',NULL)")
// Nested inside an Array — the Array(Tuple(...)) param case.
expect(
serializeValue([new TupleParam(['a', 'b']), new TupleParam(['c', 'd'])]),
).toBe("[('a','b'),('c','d')]")
// A private object literally named-differently keeps the {k:v} map form.
expect(serializeValue({ values: ['a', 'b'] })).toBe("{'values':['a','b']}")
})
})

describe('formatParamValue (server-side {name:Type} binding)', () => {
Expand Down
35 changes: 0 additions & 35 deletions tests/clickhouse-js/skip_list.json
Original file line number Diff line number Diff line change
Expand Up @@ -226,41 +226,6 @@
"file": "packages/client-node/__tests__/integration/node_streaming_e2e.test.ts",
"test": "[Node.js] streaming e2e > should stream a Parquet file",
"reason": "Streams a Parquet file into chdb via `INSERT FORMAT Parquet`; chdb's `ParquetV3BlockInputFormat` rejects this specific fixture with `apache::thrift::protocol::TProtocolException: invalid TType`. The server's older Parquet input format accepts it. Engine-version divergence in Parquet decoder."
},
{
"file": "packages/client-common/__tests__/integration/select_query_binding.test.ts",
"test": "select with query binding > handles tuples in a parametrized query",
"reason": "chdb's `queryBindAsync` serializes Tuple parameter values using `{'field':value}` map-literal form. The engine's parser expects `(value1, value2, ...)` positional form. Real chdb-engine parameter-binding gap for compound types."
},
{
"file": "packages/client-common/__tests__/integration/select_query_binding.test.ts",
"test": "select with query binding > handles arrays of tuples in a parametrized query",
"reason": "Same chdb parameter-binding gap for Tuple — applies to Array(Tuple(...))."
},
{
"file": "packages/client-common/__tests__/integration/select_query_binding.test.ts",
"test": "select with query binding > handles maps with tuples in a parametrized query",
"reason": "Same chdb parameter-binding gap for Tuple — applies to Map(K, Tuple(...))."
},
{
"file": "packages/client-common/__tests__/integration/select_query_binding.test.ts",
"test": "select with query binding > handles maps with nested arrays in a parametrized query",
"reason": "chdb parameter-binding for Map(K, Array(...)) generates a serialization the engine parser rejects."
},
{
"file": "packages/client-common/__tests__/integration/select_query_binding.test.ts",
"test": "select with query binding > handles maps with nullable values in a parametrized query",
"reason": "chdb parameter-binding for Map(K, Nullable(V)) generates a serialization the engine parser rejects."
},
{
"file": "packages/client-common/__tests__/integration/select_query_binding.test.ts",
"test": "select with query binding > Nested boolean types > handles boolean in a tuple",
"reason": "Boolean inside Tuple parameter binding hits the same Tuple-serialization divergence above."
},
{
"file": "packages/client-common/__tests__/integration/select_query_binding.test.ts",
"test": "select with query binding > Nested boolean types > handles boolean in a mixed nested structure",
"reason": "Boolean inside nested Tuple/Map parameter binding — same root cause."
}
]
}
Loading