Skip to content

Commit

Permalink
Merge branch 'main' into jwt-auth
Browse files Browse the repository at this point in the history
  • Loading branch information
slvrtrn authored Dec 11, 2024
2 parents 2c2510b + ad54291 commit 7cc3d73
Show file tree
Hide file tree
Showing 9 changed files with 162 additions and 21 deletions.
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,18 @@
# 1.9.1 (Node.js only)

- Fixed an uncaught exception that could happen in case of malformed ClickHouse response when response compression is enabled ([#363](https://github.com/ClickHouse/clickhouse-js/issues/363))

# 1.9.0 (Common, Node.js, Web)

## New features

- Added `input_format_json_throw_on_bad_escape_sequence` to the `ClickhouseSettings` type. ([#355](https://github.com/ClickHouse/clickhouse-js/pull/355), [@emmanuel-bonin](https://github.com/emmanuel-bonin))
- The client now exports `TupleParam` wrapper class, allowing tuples to be properly used as query parameters. Added support for JS Map as a query parameter. ([#359](https://github.com/ClickHouse/clickhouse-js/pull/359))

## Improvements

- The client will throw a more informative error if the buffered response is larger than the max allowed string length in V8, which is `2**29 - 24` bytes. ([#357](https://github.com/ClickHouse/clickhouse-js/pull/357))

# 1.8.1 (Node.js)

## Bug fixes
Expand Down
48 changes: 42 additions & 6 deletions examples/query_with_parameter_binding.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,52 @@
import { createClient } from '@clickhouse/client' // or '@clickhouse/client-web'
import { createClient, TupleParam } from '@clickhouse/client' // or '@clickhouse/client-web'

void (async () => {
const client = createClient()
const resultSet = await client.query({
query: 'SELECT plus({val1: Int32}, {val2: Int32}) AS result',
format: 'CSV',
query: `
SELECT
{var_int: Int32} AS var_int,
{var_float: Float32} AS var_float,
{var_str: String} AS var_str,
{var_array: Array(Int32)} AS var_arr,
{var_tuple: Tuple(Int32, String)} AS var_tuple,
{var_map: Map(Int, Array(String))} AS var_map,
{var_date: Date} AS var_date,
{var_datetime: DateTime} AS var_datetime,
{var_datetime64_3: DateTime64(3)} AS var_datetime64_3,
{var_datetime64_9: DateTime64(9)} AS var_datetime64_9,
{var_datetime64_9_ts: DateTime64(9)} AS var_datetime64_9_ts,
{var_decimal: Decimal(9, 2)} AS var_decimal,
{var_uuid: UUID} AS var_uuid,
{var_ipv4: IPv4} AS var_ipv4,
{var_null: Nullable(String)} AS var_null
`,
format: 'JSONEachRow',
query_params: {
val1: 10,
val2: 20,
var_int: 10,
var_float: '10.557',
var_str: 20,
var_array: [42, 144],
var_tuple: new TupleParam([42, 'foo']),
var_map: new Map([
[42, ['a', 'b']],
[144, ['c', 'd']],
]),
var_date: '2022-01-01',
var_datetime: '2022-01-01 12:34:56', // or a Date object
var_datetime64_3: '2022-01-01 12:34:56.789', // or a Date object
// NB: Date object with DateTime64(9) is still possible,
// but there will be precision loss, as JS Date has only milliseconds.
var_datetime64_9: '2022-01-01 12:34:56.123456789',
// It is also possible to provide DateTime64 as a timestamp.
var_datetime64_9_ts: '1651490755.123456789',
var_decimal: '123.45',
var_uuid: '01234567-89ab-cdef-0123-456789abcdef',
var_ipv4: '192.168.0.1',
var_null: null,
},
})
console.info('Result (val1 + val2):', await resultSet.text())
console.info('Result (different data types):', await resultSet.json())

// (0.3.1+) It is also possible to bind parameters with special characters.
const resultSet2 = await client.query({
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@
"apache-arrow": "^18.0.0",
"eslint": "^8.57.1",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-expect-type": "^0.4.3",
"eslint-plugin-expect-type": "^0.6.2",
"eslint-plugin-prettier": "^5.2.1",
"husky": "^9.1.6",
"jasmine": "^5.3.0",
Expand All @@ -72,7 +72,7 @@
"lint-staged": "^15.2.10",
"nyc": "^17.1.0",
"parquet-wasm": "0.6.1",
"prettier": "3.3.3",
"prettier": "3.4.2",
"sinon": "^19.0.2",
"source-map-support": "^0.5.21",
"split2": "^4.2.0",
Expand Down
2 changes: 1 addition & 1 deletion packages/client-common/src/version.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export default '1.8.1'
export default '1.9.1'
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import type { ClickHouseClient } from '@clickhouse/client-common'
import { createTestClient } from '@test/utils'
import http from 'http'
import type Stream from 'stream'
import type { NodeClickHouseClientConfigOptions } from '../../src/config'

describe('[Node.js] Compression', () => {
const port = 18123

let client: ClickHouseClient<Stream.Readable>
let server: http.Server

describe('Malformed compression response', () => {
const logAndQuit = (err: Error | unknown, prefix: string) => {
console.error(prefix, err)
process.exit(1)
}
const uncaughtExceptionListener = (err: Error) =>
logAndQuit(err, 'uncaughtException:')
const unhandledRejectionListener = (err: unknown) =>
logAndQuit(err, 'unhandledRejection:')

beforeEach(async () => {
process.on('uncaughtException', uncaughtExceptionListener)
process.on('unhandledRejection', unhandledRejectionListener)
client = createTestClient({
url: `http://localhost:${port}`,
compression: {
response: true,
},
} as NodeClickHouseClientConfigOptions)
})
afterEach(async () => {
process.off('uncaughtException', uncaughtExceptionListener)
process.off('unhandledRejection', unhandledRejectionListener)
await client.close()
server.close()
})

it('should not propagate the exception to the global context if a failed response is malformed', async () => {
server = http.createServer(async (_req, res) => {
return makeResponse(res, 500)
})
server.listen(port)

// The request fails completely (and the error message cannot be decompressed)
await expectAsync(
client.query({
query: 'SELECT 1',
format: 'JSONEachRow',
}),
).toBeRejectedWith(
jasmine.objectContaining({
code: 'Z_DATA_ERROR',
}),
)
})

it('should not propagate the exception to the global context if a successful response is malformed', async () => {
server = http.createServer(async (_req, res) => {
return makeResponse(res, 200)
})
server.listen(port)

const rs = await client.query({
query: 'SELECT 1',
format: 'JSONEachRow',
})

// Fails during the response streaming
await expectAsync(rs.text()).toBeRejectedWithError()
})
})

function makeResponse(res: http.ServerResponse, status: 200 | 500) {
res.appendHeader('Content-Encoding', 'gzip')
res.statusCode = status
res.write('A malformed response without compression')
return res.end()
}
})
19 changes: 11 additions & 8 deletions packages/client-node/src/connection/compression.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import type { LogWriter } from '@clickhouse/client-common'
import type Http from 'http'
import Stream from 'stream'
import Zlib from 'zlib'

export function decompressResponse(response: Http.IncomingMessage):
| {
response: Stream.Readable
}
| { error: Error } {
type DecompressResponseResult = { response: Stream.Readable } | { error: Error }

export function decompressResponse(
response: Http.IncomingMessage,
logWriter: LogWriter,
): DecompressResponseResult {
const encoding = response.headers['content-encoding']

if (encoding === 'gzip') {
Expand All @@ -16,9 +18,10 @@ export function decompressResponse(response: Http.IncomingMessage):
Zlib.createGunzip(),
function pipelineCb(err) {
if (err) {
// FIXME: use logger instead
// eslint-disable-next-line no-console
console.error(err)
logWriter.error({
message: 'An error occurred while decompressing the response',
err,
})
}
},
),
Expand Down
10 changes: 8 additions & 2 deletions packages/client-node/src/connection/node_base_connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -481,7 +481,7 @@ export abstract class NodeBaseConnection
// even if the stream decompression is disabled, we have to decompress it in case of an error
const isFailedResponse = !isSuccessfulResponse(_response.statusCode)
if (tryDecompressResponseStream || isFailedResponse) {
const decompressionResult = decompressResponse(_response)
const decompressionResult = decompressResponse(_response, this.logger)
if (isDecompressionError(decompressionResult)) {
return reject(decompressionResult.error)
}
Expand All @@ -490,7 +490,13 @@ export abstract class NodeBaseConnection
responseStream = _response
}
if (isFailedResponse) {
reject(parseError(await getAsText(responseStream)))
try {
const errorMessage = await getAsText(responseStream)
reject(parseError(errorMessage))
} catch (err) {
// If the ClickHouse response is malformed
reject(err)
}
} else {
return resolve({
stream: responseStream,
Expand Down
2 changes: 1 addition & 1 deletion packages/client-node/src/version.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export default '1.8.1'
export default '1.9.1'
2 changes: 1 addition & 1 deletion packages/client-web/src/version.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export default '1.8.1'
export default '1.9.1'

0 comments on commit 7cc3d73

Please sign in to comment.