Skip to content

Commit 2f96232

Browse files
authored
Custom json handling (#481)
1 parent 2e55de0 commit 2f96232

File tree

22 files changed

+663
-33
lines changed

22 files changed

+663
-33
lines changed

examples/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ If something is missing, or you found a mistake in one of these examples, please
2222
- [read_only_user.ts](read_only_user.ts) - an example of using the client with a read-only user, with possible read-only user limitations highlights.
2323
- [basic_tls.ts](node/basic_tls.ts) - (Node.js only) using certificates for basic TLS authentication.
2424
- [mutual_tls.ts](node/mutual_tls.ts) - (Node.js only) using certificates for mutual TLS authentication.
25+
- [custom_json_handling.ts](custom_json_handling.ts) - Customize JSON serialization/deserialization by providing a custom `parse` and `stringify` function. This is particularly useful when working with obscure data formats like `bigint`s.
2526

2627
#### Creating tables
2728

examples/custom_json_handling.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { createClient } from '@clickhouse/client' // or '@clickhouse/client-web'
2+
3+
/**
4+
* Similar to `examples/insert_js_dates.ts` but testing custom JSON handling
5+
*
6+
* JSON.stringify does not handle BigInt data types by default, so we'll provide
7+
* a custom serializer before passing it to the JSON.stringify function.
8+
*
9+
* This example also shows how you can serialize Date objects in a custom way.
10+
*/
11+
void (async () => {
12+
const valueSerializer = (value: unknown): unknown => {
13+
if (value instanceof Date) {
14+
// if you would have put this in the `replacer` parameter of JSON.stringify, (e.x: JSON.stringify(obj, replacerFn))
15+
// it would have been an ISO string, but since we are serializing before `stringify`ing,
16+
// it will convert it before the `.toJSON()` method has been called
17+
return value.getTime()
18+
}
19+
20+
if (typeof value === 'bigint') {
21+
return value.toString()
22+
}
23+
24+
if (Array.isArray(value)) {
25+
return value.map(valueSerializer)
26+
}
27+
28+
return value
29+
}
30+
31+
const tableName = 'inserts_custom_json_handling'
32+
const client = createClient({
33+
json: {
34+
parse: JSON.parse,
35+
stringify: (obj: unknown) => JSON.stringify(valueSerializer(obj)),
36+
},
37+
})
38+
await client.command({
39+
query: `DROP TABLE IF EXISTS ${tableName}`,
40+
})
41+
await client.command({
42+
query: `
43+
CREATE TABLE ${tableName}
44+
(id UInt64, dt DateTime64(3, 'UTC'))
45+
ENGINE MergeTree()
46+
ORDER BY (id)
47+
`,
48+
})
49+
await client.insert({
50+
table: tableName,
51+
values: [
52+
{
53+
id: BigInt(250000000000000200),
54+
dt: new Date(),
55+
},
56+
],
57+
format: 'JSONEachRow',
58+
})
59+
const rows = await client.query({
60+
query: `SELECT * FROM ${tableName}`,
61+
format: 'JSONEachRow',
62+
})
63+
console.info(await rows.json())
64+
await client.close()
65+
})()

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

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,58 @@ describe('data types', () => {
216216
})
217217
})
218218

219+
it('should work with custom JSON handling (BigInt and Date)', async () => {
220+
const TEST_BIGINT = BigInt(25000000000000000)
221+
const TEST_DATE = new Date('2023-12-06T10:54:48.123Z')
222+
const values = [
223+
{
224+
big_id: TEST_BIGINT,
225+
dt: TEST_DATE,
226+
},
227+
]
228+
229+
const valueSerializer = (value: unknown): unknown => {
230+
if (value instanceof Date) {
231+
return value.getTime()
232+
}
233+
if (typeof value === 'bigint') {
234+
return value.toString()
235+
}
236+
if (Array.isArray(value)) {
237+
return value.map(valueSerializer)
238+
}
239+
if (value && typeof value === 'object') {
240+
return Object.fromEntries(
241+
Object.entries(value).map(([k, v]) => [k, valueSerializer(v)]),
242+
)
243+
}
244+
return value
245+
}
246+
247+
// modify the client to handle BigInt and Date serialization
248+
client = createTestClient({
249+
json: {
250+
parse: JSON.parse,
251+
stringify: (obj: unknown) => {
252+
const seralized = valueSerializer(obj)
253+
return JSON.stringify(seralized)
254+
},
255+
},
256+
})
257+
258+
const table = await createTableWithFields(
259+
client,
260+
"big_id UInt64, dt DateTime64(3, 'UTC')",
261+
)
262+
263+
await insertAndAssert(table, values, {}, [
264+
{
265+
dt: TEST_DATE.toISOString().replace('T', ' ').replace('Z', ''), // clickhouse returns DateTime64 in UTC without timezone info
266+
big_id: TEST_BIGINT.toString(), // clickhouse by default returns UInt64 as string to be safe
267+
},
268+
])
269+
})
270+
219271
it('should work with string enums', async () => {
220272
const values = [
221273
{ e1: 'Foo', e2: 'Qaz' },
@@ -753,8 +805,9 @@ describe('data types', () => {
753805
table: string,
754806
data: T[],
755807
clickhouse_settings: ClickHouseSettings = {},
808+
expectedDataBack?: unknown[],
756809
) {
757810
await insertData(table, data, clickhouse_settings)
758-
await assertData(table, data, clickhouse_settings)
811+
await assertData(table, expectedDataBack ?? data, clickhouse_settings)
759812
}
760813
})

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,10 @@ describe('config', () => {
376376
keep_alive: { enabled: true },
377377
application_id: undefined,
378378
http_headers: {},
379+
json: {
380+
parse: JSON.parse,
381+
stringify: JSON.stringify,
382+
},
379383
})
380384
})
381385

@@ -426,6 +430,10 @@ describe('config', () => {
426430
log_writer: jasmine.any(LogWriter),
427431
keep_alive: { enabled: false },
428432
application_id: 'my_app',
433+
json: {
434+
parse: JSON.parse,
435+
stringify: JSON.stringify,
436+
},
429437
})
430438
})
431439

@@ -496,6 +504,10 @@ describe('config', () => {
496504
keep_alive: { enabled: true },
497505
application_id: undefined,
498506
http_headers: {},
507+
json: {
508+
parse: JSON.parse,
509+
stringify: JSON.stringify,
510+
},
499511
})
500512
})
501513
})

packages/client-common/src/client.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,16 @@ import type {
1010
WithClickHouseSummary,
1111
WithResponseHeaders,
1212
} from '@clickhouse/client-common'
13-
import { type DataFormat, DefaultLogger } from '@clickhouse/client-common'
13+
import {
14+
type DataFormat,
15+
defaultJSONHandling,
16+
DefaultLogger,
17+
} from '@clickhouse/client-common'
1418
import type { InsertValues, NonEmptyArray } from './clickhouse_types'
1519
import type { ImplementationDetails, ValuesEncoder } from './config'
1620
import { getConnectionParams, prepareConfigWithURL } from './config'
1721
import type { ConnPingResult } from './connection'
22+
import type { JSONHandling } from './parse/json_handling'
1823
import type { BaseResultSet } from './result'
1924

2025
export interface BaseQueryParams {
@@ -170,6 +175,7 @@ export class ClickHouseClient<Stream = unknown> {
170175
private readonly sessionId?: string
171176
private readonly role?: string | Array<string>
172177
private readonly logWriter: LogWriter
178+
private readonly jsonHandling: JSONHandling
173179

174180
constructor(
175181
config: BaseClickHouseClientConfigOptions & ImplementationDetails<Stream>,
@@ -192,7 +198,12 @@ export class ClickHouseClient<Stream = unknown> {
192198
this.connectionParams,
193199
)
194200
this.makeResultSet = config.impl.make_result_set
195-
this.valuesEncoder = config.impl.values_encoder
201+
this.jsonHandling = {
202+
...defaultJSONHandling,
203+
...config.json,
204+
}
205+
206+
this.valuesEncoder = config.impl.values_encoder(this.jsonHandling)
196207
}
197208

198209
/**
@@ -231,6 +242,7 @@ export class ClickHouseClient<Stream = unknown> {
231242
})
232243
},
233244
response_headers,
245+
this.jsonHandling,
234246
)
235247
}
236248

packages/client-common/src/config.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { Connection, ConnectionParams } from './connection'
33
import type { DataFormat } from './data_formatter'
44
import type { Logger } from './logger'
55
import { ClickHouseLogLevel, LogWriter } from './logger'
6+
import { defaultJSONHandling, type JSONHandling } from './parse/json_handling'
67
import type { BaseResultSet } from './result'
78
import type { ClickHouseSettings } from './settings'
89

@@ -86,6 +87,12 @@ export interface BaseClickHouseClientConfigOptions {
8687
* @default true */
8788
enabled?: boolean
8889
}
90+
/**
91+
* Custom parsing when handling with JSON objects
92+
*
93+
* Defaults to using standard `JSON.parse` and `JSON.stringify`
94+
*/
95+
json?: Partial<JSONHandling>
8996
}
9097

9198
export type MakeConnection<
@@ -102,8 +109,13 @@ export type MakeResultSet<Stream> = <
102109
query_id: string,
103110
log_error: (err: Error) => void,
104111
response_headers: ResponseHeaders,
112+
jsonHandling: JSONHandling,
105113
) => ResultSet
106114

115+
export type MakeValuesEncoder<Stream> = (
116+
jsonHandling: JSONHandling,
117+
) => ValuesEncoder<Stream>
118+
107119
export interface ValuesEncoder<Stream> {
108120
validateInsertValues<T = unknown>(
109121
values: InsertValues<Stream, T>,
@@ -150,7 +162,7 @@ export interface ImplementationDetails<Stream> {
150162
impl: {
151163
make_connection: MakeConnection<Stream>
152164
make_result_set: MakeResultSet<Stream>
153-
values_encoder: ValuesEncoder<Stream>
165+
values_encoder: MakeValuesEncoder<Stream>
154166
handle_specific_url_params?: HandleImplSpecificURLParams
155167
}
156168
}
@@ -241,6 +253,10 @@ export function getConnectionParams(
241253
keep_alive: { enabled: config.keep_alive?.enabled ?? true },
242254
clickhouse_settings: config.clickhouse_settings ?? {},
243255
http_headers: config.http_headers ?? {},
256+
json: {
257+
...defaultJSONHandling,
258+
...config.json,
259+
},
244260
}
245261
}
246262

packages/client-common/src/connection.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { JSONHandling } from '.'
12
import type {
23
WithClickHouseSummary,
34
WithResponseHeaders,
@@ -21,6 +22,7 @@ export interface ConnectionParams {
2122
application_id?: string
2223
http_headers?: Record<string, string>
2324
auth: ConnectionAuth
25+
json?: JSONHandling
2426
}
2527

2628
export interface CompressionSettings {

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type { JSONHandling } from '../parse'
2+
13
export const StreamableJSONFormats = [
24
'JSONEachRow',
35
'JSONStringsEachRow',
@@ -112,9 +114,13 @@ export function validateStreamFormat(
112114
* @param format One of the supported JSON formats: https://clickhouse.com/docs/en/interfaces/formats/
113115
* @returns string
114116
*/
115-
export function encodeJSON(value: any, format: DataFormat): string {
117+
export function encodeJSON(
118+
value: any,
119+
format: DataFormat,
120+
stringifyFn: JSONHandling['stringify'],
121+
): string {
116122
if ((SupportedJSONFormats as readonly string[]).includes(format)) {
117-
return JSON.stringify(value) + '\n'
123+
return stringifyFn(value) + '\n'
118124
}
119125
throw new Error(
120126
`The client does not support JSON encoding in [${format}] format.`,

packages/client-common/src/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,13 @@ export type {
8080
ParsedColumnTuple,
8181
ParsedColumnMap,
8282
ParsedColumnType,
83+
JSONHandling,
84+
} from './parse'
85+
export {
86+
SimpleColumnTypes,
87+
parseColumnType,
88+
defaultJSONHandling,
8389
} from './parse'
84-
export { SimpleColumnTypes, parseColumnType } from './parse'
8590

8691
/** For implementation usage only - should not be re-exported */
8792
export {
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export * from './column_types'
2+
export * from './json_handling'

0 commit comments

Comments
 (0)