Skip to content

Commit 66c46be

Browse files
committed
fix: bind scalar Date/Date32 query parameters as date strings
1 parent 0f8458b commit 66c46be

11 files changed

Lines changed: 114 additions & 21 deletions

File tree

packages/client-common/__tests__/integration/select_query_binding.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,19 @@ describe("select with query binding", () => {
217217
expect(response).toBe('"2022-05-02"\n');
218218
});
219219

220+
it("binds a JS Date to a scalar Date/Date32 parameter", async () => {
221+
const rs = await client.query({
222+
query: "SELECT {d: Date} AS d, {d32: Date32} AS d32",
223+
format: "JSONEachRow",
224+
query_params: {
225+
d: new Date(Date.UTC(2022, 4, 2, 13, 25, 55)),
226+
d32: new Date(Date.UTC(2022, 4, 2, 13, 25, 55)),
227+
},
228+
});
229+
230+
expect(await rs.json()).toEqual([{ d: "2022-05-02", d32: "2022-05-02" }]);
231+
});
232+
220233
it("handles DateTime in a parameterized query", async () => {
221234
const rs = await client.query({
222235
query: "SELECT toDateTime({min_time: DateTime})",

packages/client-common/__tests__/unit/format_query_params.test.ts

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ describe("formatQueryParams", () => {
9191
expect(formatQueryParams({ value: [] })).toBe("[]");
9292
});
9393

94-
it("formats a date without timezone", () => {
94+
it("formats a scalar date as a Unix timestamp when the type is unknown", () => {
9595
const date = new Date(Date.UTC(2022, 6, 29, 7, 52, 14));
9696

9797
expect(
@@ -117,18 +117,40 @@ describe("formatQueryParams", () => {
117117
value: new Date(Date.UTC(2022, 6, 29, 7, 52, 14, 123)),
118118
}),
119119
).toBe("1659081134.123");
120-
expect(
121-
formatQueryParams({
122-
value: new Date(Date.UTC(2022, 6, 29, 7, 52, 14, 42)),
123-
}),
124-
).toBe("1659081134.042");
125120
expect(
126121
formatQueryParams({
127122
value: new Date(Date.UTC(2022, 6, 29, 7, 52, 14, 5)),
128123
}),
129124
).toBe("1659081134.005");
130125
});
131126

127+
it("formats a Date/Date32 scalar parameter as a UTC date string", () => {
128+
const date = new Date(Date.UTC(2022, 6, 29, 7, 52, 14));
129+
130+
for (const columnType of ["Date", "Date32", "Nullable(Date)"]) {
131+
expect(formatQueryParams({ value: date, columnType })).toBe("2022-07-29");
132+
}
133+
});
134+
135+
it("formats a pre-epoch Date scalar parameter without corruption", () => {
136+
const date = new Date(Date.UTC(1969, 11, 31, 23, 59, 59));
137+
138+
expect(formatQueryParams({ value: date, columnType: "Date" })).toBe(
139+
"1969-12-31",
140+
);
141+
});
142+
143+
it("keeps the Unix timestamp for a DateTime/DateTime64 scalar parameter", () => {
144+
const date = new Date(Date.UTC(2022, 6, 29, 7, 52, 14, 123));
145+
146+
expect(formatQueryParams({ value: date, columnType: "DateTime" })).toBe(
147+
"1659081134.123",
148+
);
149+
expect(
150+
formatQueryParams({ value: date, columnType: "Nullable(DateTime64(3))" }),
151+
).toBe("1659081134.123");
152+
});
153+
132154
it("does not wrap a string in quotes", () => {
133155
expect(
134156
formatQueryParams({

packages/client-common/src/data_formatter/format_query_params.ts

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,35 @@ export class TupleParam {
55
}
66
}
77

8+
// Matches a `Date` or `Date32` server type (optionally wrapped, e.g. `Nullable(Date)`),
9+
// but not `DateTime`/`DateTime64`: the `\b` after `Date` fails on the `T` of `DateTime`.
10+
const DATE_OR_DATE32_PARAM_RE = /\bDate(32)?\b/;
11+
12+
// Extracts the ClickHouse type of a bound parameter from its `{name:Type}` placeholder in the
13+
// query, so its value can be formatted for that specific type. Returns undefined when unknown.
14+
export function extractQueryParamType(
15+
query: string | undefined,
16+
key: string,
17+
): string | undefined {
18+
if (query === undefined) return undefined;
19+
const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
20+
const match = query.match(
21+
new RegExp(`\\{\\s*${escapedKey}\\s*:\\s*([^}]+?)\\s*\\}`),
22+
);
23+
return match?.[1];
24+
}
25+
826
export function formatQueryParams({
927
value,
1028
wrapStringInQuotes,
1129
printNullAsKeyword,
30+
columnType,
1231
}: FormatQueryParamsOptions): string {
1332
return formatQueryParamsInternal({
1433
value,
1534
wrapStringInQuotes,
1635
printNullAsKeyword,
36+
columnType,
1737
isInArrayOrTuple: false,
1838
});
1939
}
@@ -22,6 +42,7 @@ function formatQueryParamsInternal({
2242
value,
2343
wrapStringInQuotes,
2444
printNullAsKeyword,
45+
columnType,
2546
isInArrayOrTuple,
2647
}: FormatQueryParamsOptions & { isInArrayOrTuple: boolean }): string {
2748
if (value === null || value === undefined) {
@@ -80,15 +101,17 @@ function formatQueryParamsInternal({
80101
}
81102

82103
if (value instanceof Date) {
104+
// Array(Date)/Array(Date32) reject a bare Unix timestamp, so container elements are
105+
// always a quoted 'YYYY-MM-DD' string. A scalar is type-directed: Date/Date32 only accept
106+
// a date string (a Unix timestamp is rejected with BAD_QUERY_PARAMETER), while
107+
// DateTime/DateTime64 keep the timezone-agnostic Unix timestamp so the time of day is
108+
// preserved.
83109
if (isInArrayOrTuple) {
84-
// Inside a container each element is parsed by the element type's own
85-
// parser: Array(Date)/Array(Date32) reject a bare Unix timestamp and only
86-
// accept a quoted date string. A quoted UTC 'YYYY-MM-DD' is the single
87-
// representation accepted by every temporal element type (Date, Date32,
88-
// DateTime, DateTime64).
89110
return `'${value.toISOString().slice(0, 10)}'`;
90111
}
91-
// The ClickHouse server parses numbers as time-zone-agnostic Unix timestamps
112+
if (columnType !== undefined && DATE_OR_DATE32_PARAM_RE.test(columnType)) {
113+
return value.toISOString().slice(0, 10);
114+
}
92115
const unixTimestamp = Math.floor(value.getTime() / 1000)
93116
.toString()
94117
.padStart(10, "0");
@@ -152,6 +175,9 @@ interface FormatQueryParamsOptions {
152175
wrapStringInQuotes?: boolean;
153176
// For tuples/arrays, it is required to print NULL instead of \N
154177
printNullAsKeyword?: boolean;
178+
// The ClickHouse type of the bound parameter (from `{name:Type}` in the query), used to
179+
// pick a type-correct representation for a scalar `Date` value. Undefined when unknown.
180+
columnType?: string;
155181
}
156182

157183
const TabASCII = 9;
Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
11
export * from "./formatter";
2-
export { TupleParam, formatQueryParams } from "./format_query_params";
2+
export {
3+
TupleParam,
4+
formatQueryParams,
5+
extractQueryParamType,
6+
} from "./format_query_params";
37
export { formatQuerySettings } from "./format_query_settings";

packages/client-common/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ export {
141141
export {
142142
formatQuerySettings,
143143
formatQueryParams,
144+
extractQueryParamType,
144145
encodeJSON,
145146
isSupportedRawFormat,
146147
isStreamableJSONFamily,

packages/client-common/src/utils/multipart.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { formatQueryParams } from "../data_formatter";
1+
import { extractQueryParamType, formatQueryParams } from "../data_formatter";
22

33
const SAFE_PART_NAME = /^[A-Za-z0-9_.-]+$/;
44

@@ -22,14 +22,16 @@ export const MAX_URL_BIND_PARAM_LENGTH = 4096;
2222
*/
2323
export function serializeQueryParamsForUrl(
2424
query_params: Record<string, unknown>,
25+
query?: string,
2526
): [string, string][] | null {
2627
const entries: [string, string][] = [];
2728
// Raw length is a lower bound on the encoded length, so large payloads
2829
// short-circuit without materializing the encoded string.
2930
let rawLength = 0;
3031
for (const [key, value] of Object.entries(query_params)) {
3132
const name = `param_${key}`;
32-
const formatted = formatQueryParams({ value });
33+
const columnType = extractQueryParamType(query, key);
34+
const formatted = formatQueryParams({ value, columnType });
3335
rawLength += name.length + formatted.length;
3436
if (rawLength > MAX_URL_BIND_PARAM_LENGTH) {
3537
return null;

packages/client-common/src/utils/url.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import { formatQueryParams, formatQuerySettings } from "../data_formatter";
1+
import {
2+
extractQueryParamType,
3+
formatQueryParams,
4+
formatQuerySettings,
5+
} from "../data_formatter";
26
import type { ClickHouseSettings } from "../settings";
37

48
export function transformUrl({
@@ -38,6 +42,9 @@ interface ToSearchParamsOptions {
3842
* used as-is instead of serializing {@link query_params} again. */
3943
param_entries?: [string, string][];
4044
query?: string;
45+
/** The query text used only to read `{name:Type}` param types when serializing
46+
* {@link query_params}; unlike {@link query} it is never added to the URL. */
47+
param_types_query?: string;
4148
session_id?: string;
4249
query_id: string;
4350
role?: string | Array<string>;
@@ -48,6 +55,7 @@ export function toSearchParams({
4855
query,
4956
query_params,
5057
param_entries,
58+
param_types_query,
5159
clickhouse_settings,
5260
session_id,
5361
query_id,
@@ -61,7 +69,11 @@ export function toSearchParams({
6169
entries = [["query_id", query_id]];
6270
if (query_params !== undefined) {
6371
for (const [key, value] of Object.entries(query_params)) {
64-
const formattedParam = formatQueryParams({ value });
72+
const columnType = extractQueryParamType(
73+
param_types_query ?? query,
74+
key,
75+
);
76+
const formattedParam = formatQueryParams({ value, columnType });
6577
entries.push([`param_${key}`, formattedParam]);
6678
}
6779
}

packages/client-node/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,10 @@
1515
## Bug fixes
1616

1717
- 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])
18+
- Fixed scalar `Date` / `Date32` query-parameter binding. A JS `Date` bound to a scalar `{name:Date}` / `{name:Date32}` parameter was serialized as a bare Unix timestamp, which the server rejects with `BAD_QUERY_PARAMETER` (only `DateTime` / `DateTime64` accept a numeric timestamp). The bound parameter's type is now read from the query, so a `Date` value is formatted as a UTC date string for `Date` / `Date32` while `DateTime` / `DateTime64` keep the Unix timestamp and preserve the time of day. ([#955])
1819

1920
[#947]: https://github.com/ClickHouse/clickhouse-js/pull/947
21+
[#955]: https://github.com/ClickHouse/clickhouse-js/issues/955
2022

2123
# 1.23.1
2224

packages/client-node/src/connection/node_base_connection.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
buildMultipartBody,
1818
serializeQueryParamsForUrl,
1919
formatQueryParams,
20+
extractQueryParamType,
2021
isCredentialsAuth,
2122
isJWTAuth,
2223
toSearchParams,
@@ -204,7 +205,7 @@ export abstract class NodeBaseConnection implements Connection<Stream.Readable>
204205
(params.use_multipart_params_auto ??
205206
this.params.use_multipart_params_auto)
206207
) {
207-
const entries = serializeQueryParamsForUrl(queryParams);
208+
const entries = serializeQueryParamsForUrl(queryParams, params.query);
208209
if (entries === null) {
209210
useMultipart = true;
210211
} else {
@@ -217,6 +218,7 @@ export abstract class NodeBaseConnection implements Connection<Stream.Readable>
217218
// When using multipart, query_params are sent in the multipart body
218219
query_params: useMultipart ? undefined : params.query_params,
219220
param_entries: urlParamEntries,
221+
param_types_query: params.query,
220222
session_id: params.session_id,
221223
clickhouse_settings,
222224
query_id,
@@ -237,7 +239,8 @@ export abstract class NodeBaseConnection implements Connection<Stream.Readable>
237239
const boundary = `----clickhouse-js-${crypto.randomUUID()}`;
238240
const parts: Record<string, string> = { query: params.query };
239241
for (const [key, value] of Object.entries(params.query_params)) {
240-
parts[`param_${key}`] = formatQueryParams({ value });
242+
const columnType = extractQueryParamType(params.query, key);
243+
parts[`param_${key}`] = formatQueryParams({ value, columnType });
241244
}
242245
body = buildMultipartBody(parts, boundary);
243246
headers["Content-Type"] = `multipart/form-data; boundary=${boundary}`;

packages/client-web/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,10 @@
1515
## Bug fixes
1616

1717
- 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])
18+
- Fixed scalar `Date` / `Date32` query-parameter binding. A JS `Date` bound to a scalar `{name:Date}` / `{name:Date32}` parameter was serialized as a bare Unix timestamp, which the server rejects with `BAD_QUERY_PARAMETER` (only `DateTime` / `DateTime64` accept a numeric timestamp). The bound parameter's type is now read from the query, so a `Date` value is formatted as a UTC date string for `Date` / `Date32` while `DateTime` / `DateTime64` keep the Unix timestamp and preserve the time of day. ([#955])
1819

1920
[#947]: https://github.com/ClickHouse/clickhouse-js/pull/947
21+
[#955]: https://github.com/ClickHouse/clickhouse-js/issues/955
2022

2123
# 1.23.1
2224

0 commit comments

Comments
 (0)