Skip to content

Commit 43751b0

Browse files
authored
Allow to disable stream decompression for exec (#300)
1 parent 442392c commit 43751b0

File tree

13 files changed

+146
-52
lines changed

13 files changed

+146
-52
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
# 1.5.0 (Node.js)
2+
3+
## New features
4+
5+
- It is now possible to disable the automatic decompression of the response stream with the `exec` method. See `ExecParams.decompress_response_stream` for more details. ([#298](https://github.com/ClickHouse/clickhouse-js/issues/298)).
6+
17
# 1.4.1 (Node.js, Web)
28

39
## Improvements

packages/client-common/src/client.ts

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -66,10 +66,17 @@ export type ExecParams = BaseQueryParams & {
6666
* If {@link ExecParamsWithValues.values} are defined, the query is sent as a request parameter,
6767
* and the values are sent in the request body instead. */
6868
query: string
69+
/** If set to `false`, the client _will not_ decompress the response stream, even if the response compression
70+
* was requested by the client via the {@link BaseClickHouseClientConfigOptions.compression.response } setting.
71+
* This could be useful if the response stream is passed to another application as-is,
72+
* and the decompression is handled there.
73+
* @note 1) Node.js only. This setting will have no effect on the Web version.
74+
* @note 2) In case of an error, the stream will be decompressed anyway, regardless of this setting.
75+
* @default true */
76+
decompress_response_stream?: boolean
6977
}
7078
export type ExecParamsWithValues<Stream> = ExecParams & {
71-
/** If you have a custom INSERT statement to run with `exec`,
72-
* the data from this stream will be inserted.
79+
/** If you have a custom INSERT statement to run with `exec`, the data from this stream will be inserted.
7380
*
7481
* NB: the data in the stream is expected to be serialized accordingly to the FORMAT clause
7582
* used in {@link ExecParams.query} in this case.
@@ -170,11 +177,12 @@ export class ClickHouseClient<Stream = unknown> {
170177
}
171178

172179
/**
173-
* Used for most statements that can have a response, such as SELECT.
174-
* FORMAT clause should be specified separately via {@link QueryParams.format} (default is JSON)
175-
* Consider using {@link ClickHouseClient.insert} for data insertion,
176-
* or {@link ClickHouseClient.command} for DDLs.
180+
* Used for most statements that can have a response, such as `SELECT`.
181+
* FORMAT clause should be specified separately via {@link QueryParams.format} (default is `JSON`).
182+
* Consider using {@link ClickHouseClient.insert} for data insertion, or {@link ClickHouseClient.command} for DDLs.
177183
* Returns an implementation of {@link BaseResultSet}.
184+
*
185+
* See {@link DataFormat} for the formats supported by the client.
178186
*/
179187
async query<Format extends DataFormat = 'JSON'>(
180188
params: QueryParamsWithFormat<Format>,
@@ -211,7 +219,9 @@ export class ClickHouseClient<Stream = unknown> {
211219
* when the format clause is not applicable, or when you are not interested in the response at all.
212220
* Response stream is destroyed immediately as we do not expect useful information there.
213221
* Examples of such statements are DDLs or custom inserts.
214-
* If you are interested in the response data, consider using {@link ClickHouseClient.exec}
222+
*
223+
* @note if you have a custom query that does not work with {@link ClickHouseClient.query},
224+
* and you are interested in the response data, consider using {@link ClickHouseClient.exec}.
215225
*/
216226
async command(params: CommandParams): Promise<CommandResult> {
217227
const query = removeTrailingSemi(params.query.trim())
@@ -222,18 +232,23 @@ export class ClickHouseClient<Stream = unknown> {
222232
}
223233

224234
/**
225-
* Similar to {@link ClickHouseClient.command}, but for the cases where the output is expected,
226-
* but format clause is not applicable. The caller of this method is expected to consume the stream,
227-
* otherwise, the request will eventually be timed out.
235+
* Similar to {@link ClickHouseClient.command}, but for the cases where the output _is expected_,
236+
* but format clause is not applicable. The caller of this method _must_ consume the stream,
237+
* as the underlying socket will not be released until then, and the request will eventually be timed out.
238+
*
239+
* @note it is not intended to use this method to execute the DDLs, such as `CREATE TABLE` or similar;
240+
* use {@link ClickHouseClient.command} instead.
228241
*/
229242
async exec(
230243
params: ExecParams | ExecParamsWithValues<Stream>,
231244
): Promise<ExecResult<Stream>> {
232245
const query = removeTrailingSemi(params.query.trim())
233246
const values = 'values' in params ? params.values : undefined
247+
const decompress_response_stream = params.decompress_response_stream ?? true
234248
return await this.connection.exec({
235249
query,
236250
values,
251+
decompress_response_stream,
237252
...this.withClientQueryParams(params),
238253
})
239254
}
@@ -242,8 +257,10 @@ export class ClickHouseClient<Stream = unknown> {
242257
* The primary method for data insertion. It is recommended to avoid arrays in case of large inserts
243258
* to reduce application memory consumption and consider streaming for most of such use cases.
244259
* As the insert operation does not provide any output, the response stream is immediately destroyed.
245-
* In case of a custom insert operation, such as, for example, INSERT FROM SELECT,
246-
* consider using {@link ClickHouseClient.command}, passing the entire raw query there (including FORMAT clause).
260+
*
261+
* @note in case of a custom insert operation (e.g., `INSERT FROM SELECT`),
262+
* consider using {@link ClickHouseClient.command}, passing the entire raw query there
263+
* (including the `FORMAT` clause).
247264
*/
248265
async insert<T>(params: InsertParams<Stream, T>): Promise<InsertResult> {
249266
if (Array.isArray(params.values) && params.values.length === 0) {

packages/client-common/src/connection.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ export interface ConnInsertParams<Stream> extends ConnBaseQueryParams {
4141

4242
export interface ConnExecParams<Stream> extends ConnBaseQueryParams {
4343
values?: Stream
44+
decompress_response_stream?: boolean
4445
}
4546

4647
export interface ConnBaseResult extends WithResponseHeaders {

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

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,17 @@ export type HttpHeaders = Record<string, HttpHeader | undefined>
55

66
export function withCompressionHeaders({
77
headers,
8-
compress_request,
9-
decompress_response,
8+
enable_request_compression,
9+
enable_response_compression,
1010
}: {
1111
headers: HttpHeaders
12-
compress_request: boolean | undefined
13-
decompress_response: boolean | undefined
12+
enable_request_compression: boolean | undefined
13+
enable_response_compression: boolean | undefined
1414
}): Record<string, string> {
1515
return {
1616
...headers,
17-
...(decompress_response ? { 'Accept-Encoding': 'gzip' } : {}),
18-
...(compress_request ? { 'Content-Encoding': 'gzip' } : {}),
17+
...(enable_response_compression ? { 'Accept-Encoding': 'gzip' } : {}),
18+
...(enable_request_compression ? { 'Content-Encoding': 'gzip' } : {}),
1919
}
2020
}
2121

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
export default '1.4.1'
1+
export default '1.5.0'

packages/client-node/__tests__/integration/node_exec.test.ts

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import type { ClickHouseClient } from '@clickhouse/client-common'
2-
import { createTestClient } from '@test/utils'
3-
import { guid } from '@test/utils'
42
import { createSimpleTable } from '@test/fixtures/simple_table'
3+
import { createTestClient, guid } from '@test/utils'
54
import Stream from 'stream'
6-
import { getAsText } from '../../src/utils'
5+
import Zlib from 'zlib'
76
import { drainStream, ResultSet } from '../../src'
7+
import { getAsText } from '../../src/utils'
88

99
describe('[Node.js] exec', () => {
1010
let client: ClickHouseClient<Stream.Readable>
@@ -165,4 +165,44 @@ describe('[Node.js] exec', () => {
165165
expect(await rs.json()).toEqual(expected)
166166
}
167167
})
168+
169+
describe('disabled stream decompression', () => {
170+
beforeEach(() => {
171+
client = createTestClient({
172+
compression: {
173+
response: true,
174+
},
175+
})
176+
})
177+
178+
it('should get a compressed response stream without decompressing it', async () => {
179+
const result = await client.exec({
180+
query: 'SELECT 42 AS result FORMAT JSONEachRow',
181+
decompress_response_stream: false,
182+
})
183+
const text = await getAsText(decompress(result.stream))
184+
expect(text).toEqual('{"result":42}\n')
185+
})
186+
187+
it('should force decompress in case of an error', async () => {
188+
await expectAsync(
189+
client.exec({
190+
query: 'invalid',
191+
decompress_response_stream: false,
192+
}),
193+
).toBeRejectedWith(
194+
jasmine.objectContaining({
195+
message: jasmine.stringContaining('Syntax error'),
196+
}),
197+
)
198+
})
199+
200+
function decompress(stream: Stream.Readable) {
201+
return Stream.pipeline(stream, Zlib.createGunzip(), (err) => {
202+
if (err) {
203+
console.error(err)
204+
}
205+
})
206+
}
207+
})
168208
})

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

Lines changed: 49 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,10 @@ import type {
1515
LogWriter,
1616
ResponseHeaders,
1717
} from '@clickhouse/client-common'
18-
import { sleep } from '@clickhouse/client-common'
1918
import {
2019
isSuccessfulResponse,
2120
parseError,
21+
sleep,
2222
toSearchParams,
2323
transformUrl,
2424
withHttpSettings,
@@ -63,8 +63,10 @@ export interface RequestParams {
6363
body?: string | Stream.Readable
6464
// provided by the user and wrapped around internally
6565
abort_signal: AbortSignal
66-
decompress_response?: boolean
67-
compress_request?: boolean
66+
enable_response_compression?: boolean
67+
enable_request_compression?: boolean
68+
// if there are compression headers, attempt to decompress it
69+
try_decompress_response_stream?: boolean
6870
parse_summary?: boolean
6971
}
7072

@@ -73,7 +75,6 @@ export abstract class NodeBaseConnection
7375
{
7476
protected readonly defaultAuthHeader: string
7577
protected readonly defaultHeaders: Http.OutgoingHttpHeaders
76-
protected readonly additionalHTTPHeaders: Record<string, string>
7778

7879
private readonly logger: LogWriter
7980
private readonly knownSockets = new WeakMap<net.Socket, SocketInfo>()
@@ -83,12 +84,11 @@ export abstract class NodeBaseConnection
8384
protected readonly params: NodeConnectionParams,
8485
protected readonly agent: Http.Agent,
8586
) {
86-
this.additionalHTTPHeaders = params.http_headers ?? {}
8787
this.defaultAuthHeader = `Basic ${Buffer.from(
8888
`${params.username}:${params.password}`,
8989
).toString('base64')}`
9090
this.defaultHeaders = {
91-
...this.additionalHTTPHeaders,
91+
...(params.http_headers ?? {}),
9292
// KeepAlive agent for some reason does not set this on its own
9393
Connection: this.params.keep_alive.enabled ? 'keep-alive' : 'close',
9494
'User-Agent': getUserAgent(this.params.application_id),
@@ -137,21 +137,23 @@ export abstract class NodeBaseConnection
137137
)
138138
const searchParams = toSearchParams({
139139
database: this.params.database,
140-
clickhouse_settings,
141140
query_params: params.query_params,
142141
session_id: params.session_id,
142+
clickhouse_settings,
143143
query_id,
144144
})
145-
const decompressResponse = clickhouse_settings.enable_http_compression === 1
146145
const { controller, controllerCleanup } = this.getAbortController(params)
146+
// allows to enforce the compression via the settings even if the client instance has it disabled
147+
const enableResponseCompression =
148+
clickhouse_settings.enable_http_compression === 1
147149
try {
148150
const { stream, response_headers } = await this.request(
149151
{
150152
method: 'POST',
151153
url: transformUrl({ url: this.params.url, searchParams }),
152154
body: params.query,
153155
abort_signal: controller.signal,
154-
decompress_response: decompressResponse,
156+
enable_response_compression: enableResponseCompression,
155157
headers: this.buildRequestHeaders(params),
156158
},
157159
'Query',
@@ -170,7 +172,7 @@ export abstract class NodeBaseConnection
170172
search_params: searchParams,
171173
err: err as Error,
172174
extra_args: {
173-
decompress_response: decompressResponse,
175+
decompress_response: enableResponseCompression,
174176
clickhouse_settings,
175177
},
176178
})
@@ -200,7 +202,7 @@ export abstract class NodeBaseConnection
200202
url: transformUrl({ url: this.params.url, searchParams }),
201203
body: params.values,
202204
abort_signal: controller.signal,
203-
compress_request: this.params.compression.compress_request,
205+
enable_request_compression: this.params.compression.compress_request,
204206
parse_summary: true,
205207
headers: this.buildRequestHeaders(params),
206208
},
@@ -371,16 +373,28 @@ export abstract class NodeBaseConnection
371373
): Promise<ConnExecResult<Stream.Readable>> {
372374
const query_id = this.getQueryId(params.query_id)
373375
const sendQueryInParams = params.values !== undefined
376+
const clickhouse_settings = withHttpSettings(
377+
params.clickhouse_settings,
378+
this.params.compression.decompress_response,
379+
)
374380
const toSearchParamsOptions = {
375381
query: sendQueryInParams ? params.query : undefined,
376382
database: this.params.database,
377-
clickhouse_settings: params.clickhouse_settings,
378383
query_params: params.query_params,
379384
session_id: params.session_id,
385+
clickhouse_settings,
380386
query_id,
381387
}
382388
const searchParams = toSearchParams(toSearchParamsOptions)
383389
const { controller, controllerCleanup } = this.getAbortController(params)
390+
const tryDecompressResponseStream =
391+
params.op === 'Exec'
392+
? // allows to disable stream decompression for the `Exec` operation only
393+
params.decompress_response_stream ??
394+
this.params.compression.decompress_response
395+
: // there is nothing useful in the response stream for the `Command` operation,
396+
// and it is immediately destroyed; never decompress it
397+
false
384398
try {
385399
const { stream, summary, response_headers } = await this.request(
386400
{
@@ -389,6 +403,10 @@ export abstract class NodeBaseConnection
389403
body: sendQueryInParams ? params.values : params.query,
390404
abort_signal: controller.signal,
391405
parse_summary: true,
406+
enable_request_compression: this.params.compression.compress_request,
407+
enable_response_compression:
408+
this.params.compression.decompress_response,
409+
try_decompress_response_stream: tryDecompressResponseStream,
392410
headers: this.buildRequestHeaders(params),
393411
},
394412
params.op,
@@ -438,20 +456,30 @@ export abstract class NodeBaseConnection
438456
): Promise<void> => {
439457
this.logResponse(op, request, params, _response, start)
440458

441-
const decompressionResult = decompressResponse(_response)
442-
if (isDecompressionError(decompressionResult)) {
443-
return reject(decompressionResult.error)
459+
let responseStream: Stream.Readable
460+
const tryDecompressResponseStream =
461+
params.try_decompress_response_stream ?? true
462+
// even if the stream decompression is disabled, we have to decompress it in case of an error
463+
const isFailedResponse = !isSuccessfulResponse(_response.statusCode)
464+
if (tryDecompressResponseStream || isFailedResponse) {
465+
const decompressionResult = decompressResponse(_response)
466+
if (isDecompressionError(decompressionResult)) {
467+
return reject(decompressionResult.error)
468+
}
469+
responseStream = decompressionResult.response
470+
} else {
471+
responseStream = _response
444472
}
445-
if (isSuccessfulResponse(_response.statusCode)) {
473+
if (isFailedResponse) {
474+
reject(parseError(await getAsText(responseStream)))
475+
} else {
446476
return resolve({
447-
stream: decompressionResult.response,
477+
stream: responseStream,
448478
summary: params.parse_summary
449479
? this.parseSummary(op, _response)
450480
: undefined,
451481
response_headers: { ..._response.headers },
452482
})
453-
} else {
454-
reject(parseError(await getAsText(decompressionResult.response)))
455483
}
456484
}
457485

@@ -492,7 +520,7 @@ export abstract class NodeBaseConnection
492520
}
493521
}
494522

495-
if (params.compress_request) {
523+
if (params.enable_request_compression) {
496524
Stream.pipeline(bodyStream, Zlib.createGzip(), request, callback)
497525
} else {
498526
Stream.pipeline(bodyStream, request, callback)
@@ -626,4 +654,5 @@ interface SocketInfo {
626654
type RunExecParams = ConnBaseQueryParams & {
627655
op: 'Exec' | 'Command'
628656
values?: ConnExecParams<Stream.Readable>['values']
657+
decompress_response_stream?: boolean
629658
}

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@ export class NodeCustomAgentConnection extends NodeBaseConnection {
1919
protected createClientRequest(params: RequestParams): Http.ClientRequest {
2020
const headers = withCompressionHeaders({
2121
headers: params.headers,
22-
compress_request: params.compress_request,
23-
decompress_response: params.decompress_response,
22+
enable_request_compression: params.enable_request_compression,
23+
enable_response_compression: params.enable_response_compression,
2424
})
2525
return Http.request(params.url, {
2626
method: params.method,

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ export class NodeHttpConnection extends NodeBaseConnection {
1818
protected createClientRequest(params: RequestParams): Http.ClientRequest {
1919
const headers = withCompressionHeaders({
2020
headers: params.headers,
21-
compress_request: params.compress_request,
22-
decompress_response: params.decompress_response,
21+
enable_request_compression: params.enable_request_compression,
22+
enable_response_compression: params.enable_response_compression,
2323
})
2424
return Http.request(params.url, {
2525
method: params.method,

0 commit comments

Comments
 (0)