diff --git a/examples/async_insert.ts b/examples/async_insert.ts index 7fc8f7ef..fea45911 100644 --- a/examples/async_insert.ts +++ b/examples/async_insert.ts @@ -6,7 +6,7 @@ import { ClickHouseError } from '@clickhouse/client-common' // or '@clickhouse/c // See https://clickhouse.com/docs/en/optimize/asynchronous-inserts void (async () => { const client = createClient({ - host: process.env['CLICKHOUSE_HOST'], // defaults to 'http://localhost:8123' + url: process.env['CLICKHOUSE_URL'], // defaults to 'http://localhost:8123' password: process.env['CLICKHOUSE_PASSWORD'], // defaults to an empty string max_open_connections: 10, clickhouse_settings: { diff --git a/examples/async_insert_without_waiting.ts b/examples/async_insert_without_waiting.ts index 9f656795..75ee1a64 100644 --- a/examples/async_insert_without_waiting.ts +++ b/examples/async_insert_without_waiting.ts @@ -11,7 +11,7 @@ import { EventEmitter } from 'events' // See https://clickhouse.com/docs/en/optimize/asynchronous-inserts void (async () => { const client = createClient({ - host: process.env['CLICKHOUSE_HOST'], // defaults to 'http://localhost:8123' + url: process.env['CLICKHOUSE_URL'], // defaults to 'http://localhost:8123' password: process.env['CLICKHOUSE_PASSWORD'], // defaults to an empty string max_open_connections: 10, clickhouse_settings: { diff --git a/examples/ping.ts b/examples/ping.ts index d159fefe..8fa36250 100644 --- a/examples/ping.ts +++ b/examples/ping.ts @@ -19,7 +19,7 @@ void (async () => { async function existingHostPing() { const client = createClient({ - host: process.env['CLICKHOUSE_HOST'], // defaults to 'http://localhost:8123' + url: process.env['CLICKHOUSE_URL'], // defaults to 'http://localhost:8123' password: process.env['CLICKHOUSE_PASSWORD'], // defaults to an empty string }) const pingResult = await client.ping() @@ -36,7 +36,7 @@ async function existingHostPing() { async function nonExistingHostPing() { const client = createClient({ - host: 'http://localhost:8100', // non-existing host + url: 'http://localhost:8100', // non-existing host request_timeout: 50, // low request_timeout to speed up the example }) // Ping does not throw an error; instead, { success: false; error: Error } is returned. @@ -56,7 +56,7 @@ async function nonExistingHostPing() { async function timeoutPing() { const server = startSlowHTTPServer() const client = createClient({ - host: 'http://localhost:18123', + url: 'http://localhost:18123', request_timeout: 50, // low request_timeout to speed up the example }) // Ping does not throw an error; instead, { success: false; error: Error } is returned. diff --git a/packages/client-common/src/client.ts b/packages/client-common/src/client.ts index 6f3542bc..0a6771f2 100644 --- a/packages/client-common/src/client.ts +++ b/packages/client-common/src/client.ts @@ -102,7 +102,7 @@ export type InsertResult = { /** * Indicates whether the INSERT statement was executed on the server. * Will be `false` if there was no data to insert. - * For example: if {@link InsertParams.values} was an empty array, + * For example, if {@link InsertParams.values} was an empty array, * the client does not send any requests to the server, and {@link executed} is false. */ executed: boolean @@ -115,7 +115,6 @@ export type InsertResult = { WithResponseHeaders export type ExecResult = ConnExecResult -export type PingResult = ConnPingResult /** {@link except} field contains a non-empty list of columns to exclude when generating `(* EXCEPT (...))` clause */ export interface InsertColumnsExcept { @@ -145,6 +144,19 @@ export interface InsertParams columns?: NonEmptyArray | InsertColumnsExcept } +/** Parameters for the health-check request - using the built-in `/ping` endpoint. */ +export type PingParamsWithEndpoint = { select: false } & Pick< + BaseQueryParams, + 'abort_signal' | 'http_headers' +> +/** Parameters for the health-check request - using a SELECT query. */ +export type PingParamsWithSelectQuery = { select: true } & Omit< + BaseQueryParams, + 'query_params' +> +export type PingParams = PingParamsWithEndpoint | PingParamsWithSelectQuery +export type PingResult = ConnPingResult + export class ClickHouseClient { private readonly clientClickHouseSettings: ClickHouseSettings private readonly connectionParams: ConnectionParams @@ -221,7 +233,7 @@ export class ClickHouseClient { /** * It should be used for statements that do not have any output, * when the format clause is not applicable, or when you are not interested in the response at all. - * Response stream is destroyed immediately as we do not expect useful information there. + * The response stream is destroyed immediately as we do not expect useful information there. * Examples of such statements are DDLs or custom inserts. * * @note if you have a custom query that does not work with {@link ClickHouseClient.query}, @@ -284,11 +296,16 @@ export class ClickHouseClient { } /** - * Health-check request. It does not throw if an error occurs - - * the error is returned inside the result object. + * A health-check request. It does not throw if an error occurs - the error is returned inside the result object. + * + * By default, Node.js version uses the built-in `/ping` endpoint, which does not verify credentials. + * Optionally, it can be switched to a `SELECT` query (see {@link PingParamsWithSelectQuery}). + * In that case, the server will verify the credentials. + * + * **NOTE**: Since the `/ping` endpoint does not support CORS, the Web version always uses a `SELECT` query. */ - async ping(): Promise { - return await this.connection.ping() + async ping(params?: PingParams): Promise { + return await this.connection.ping(params ?? { select: false }) } /** diff --git a/packages/client-common/src/connection.ts b/packages/client-common/src/connection.ts index 3fb1bdb1..24dcb859 100644 --- a/packages/client-common/src/connection.ts +++ b/packages/client-common/src/connection.ts @@ -40,6 +40,11 @@ export interface ConnBaseQueryParams { http_headers?: Record } +export type ConnPingParams = { select: boolean } & Omit< + ConnBaseQueryParams, + 'query' | 'query_params' +> + export interface ConnInsertParams extends ConnBaseQueryParams { values: string | Stream } @@ -72,10 +77,10 @@ export type ConnPingResult = export type ConnOperation = 'Ping' | 'Query' | 'Insert' | 'Exec' | 'Command' export interface Connection { - ping(): Promise - close(): Promise + ping(params: ConnPingParams): Promise query(params: ConnBaseQueryParams): Promise> insert(params: ConnInsertParams): Promise - exec(params: ConnExecParams): Promise> command(params: ConnBaseQueryParams): Promise + exec(params: ConnExecParams): Promise> + close(): Promise } diff --git a/packages/client-common/src/index.ts b/packages/client-common/src/index.ts index 4812a178..2e476a5b 100644 --- a/packages/client-common/src/index.ts +++ b/packages/client-common/src/index.ts @@ -11,6 +11,9 @@ export { type ExecResult, type InsertResult, type PingResult, + type PingParams, + type PingParamsWithSelectQuery, + type PingParamsWithEndpoint, } from './client' export { type BaseClickHouseClientConfigOptions } from './config' export type { @@ -127,6 +130,7 @@ export type { ConnPingResult, ConnCommandResult, ConnOperation, + ConnPingParams, } from './connection' export type { QueryParamsWithFormat } from './client' export type { IsSame } from './ts_utils' diff --git a/packages/client-common/src/utils/url.ts b/packages/client-common/src/utils/url.ts index 00960335..a33c4f26 100644 --- a/packages/client-common/src/utils/url.ts +++ b/packages/client-common/src/utils/url.ts @@ -31,7 +31,7 @@ export function transformUrl({ } type ToSearchParamsOptions = { - database: string + database: string | undefined clickhouse_settings?: ClickHouseSettings query_params?: Record query?: string @@ -69,7 +69,7 @@ export function toSearchParams({ } } - if (database !== 'default') { + if (database !== undefined && database !== 'default') { params.set('database', database) } diff --git a/packages/client-node/__tests__/integration/node_logger_support.test.ts b/packages/client-node/__tests__/integration/node_logger_support.test.ts index 701a4f52..6a0b97db 100644 --- a/packages/client-node/__tests__/integration/node_logger_support.test.ts +++ b/packages/client-node/__tests__/integration/node_logger_support.test.ts @@ -63,7 +63,7 @@ describe('[Node.js] logger support', () => { }, }) await client.ping() - // logs[0] are about current log level + // logs[0] are about the current log level expect(logs[1]).toEqual( jasmine.objectContaining({ message: 'Ping: got a response from ClickHouse', diff --git a/packages/client-node/__tests__/integration/node_ping.test.ts b/packages/client-node/__tests__/integration/node_ping.test.ts index a4f72e9d..3877c068 100644 --- a/packages/client-node/__tests__/integration/node_ping.test.ts +++ b/packages/client-node/__tests__/integration/node_ping.test.ts @@ -1,11 +1,16 @@ -import type { ClickHouseClient } from '@clickhouse/client-common' +import type { + ClickHouseClient, + ClickHouseError, +} from '@clickhouse/client-common' import { createTestClient } from '@test/utils' describe('[Node.js] ping', () => { let client: ClickHouseClient + afterEach(async () => { await client.close() }) + it('does not swallow a client error', async () => { client = createTestClient({ url: 'http://localhost:3333', @@ -20,4 +25,29 @@ describe('[Node.js] ping', () => { }), ) }) + + it('ignores credentials by default', async () => { + client = createTestClient({ + username: 'wrong', + }) + const response = await client.ping() + expect(response.success).toBe(true) + }) + + it('checks credentials when select query mode is enabled', async () => { + client = createTestClient({ + username: 'wrong', + }) + const response = await client.ping({ + select: true, + }) + expect(response.success).toBe(false) + + const err = (response as unknown as { error: ClickHouseError }).error + expect(err.code).toEqual('516') + expect(err.type).toEqual('AUTHENTICATION_FAILED') + expect(err.message).toEqual( + jasmine.stringContaining('Authentication failed'), + ) + }) }) diff --git a/packages/client-node/src/connection/node_base_connection.ts b/packages/client-node/src/connection/node_base_connection.ts index 9f2c5027..2728057d 100644 --- a/packages/client-node/src/connection/node_base_connection.ts +++ b/packages/client-node/src/connection/node_base_connection.ts @@ -26,6 +26,7 @@ import { transformUrl, withHttpSettings, } from '@clickhouse/client-common' +import { type ConnPingParams } from '@clickhouse/client-common' import crypto from 'crypto' import type Http from 'http' import type * as net from 'net' @@ -107,34 +108,59 @@ export abstract class NodeBaseConnection this.idleSocketTTL = params.keep_alive.idle_socket_ttl } - async ping(): Promise { - const abortController = new AbortController() + async ping(params: ConnPingParams): Promise { + const query_id = this.getQueryId(params.query_id) + const { controller, controllerCleanup } = this.getAbortController(params) + let result: RequestResult try { - const { stream } = await this.request( - { - method: 'GET', - url: transformUrl({ url: this.params.url, pathname: '/ping' }), - abort_signal: abortController.signal, - headers: this.buildRequestHeaders(), - query: 'ping', - }, - 'Ping', - ) - await drainStream(stream) + if (params.select) { + const searchParams = toSearchParams({ + database: undefined, + query: PingQuery, + query_id, + }) + result = await this.request( + { + method: 'GET', + url: transformUrl({ url: this.params.url, searchParams }), + query: PingQuery, + abort_signal: controller.signal, + headers: this.buildRequestHeaders(), + }, + 'Ping', + ) + } else { + result = await this.request( + { + method: 'GET', + url: transformUrl({ url: this.params.url, pathname: '/ping' }), + abort_signal: controller.signal, + headers: this.buildRequestHeaders(), + query: 'ping', + }, + 'Ping', + ) + } + await drainStream(result.stream) return { success: true } } catch (error) { // it is used to ensure that the outgoing request is terminated, // and we don't get unhandled error propagation later - abortController.abort('Ping failed') + controller.abort('Ping failed') // not an error, as this might be semi-expected this.logger.warn({ message: this.httpRequestErrorMessage('Ping'), err: error as Error, + args: { + query_id, + }, }) return { success: false, error: error as Error, // should NOT be propagated to the user } + } finally { + controllerCleanup() } } @@ -320,7 +346,7 @@ export abstract class NodeBaseConnection } // a wrapper over the user's Signal to terminate the failed requests - private getAbortController(params: ConnBaseQueryParams): { + private getAbortController(params: { abort_signal?: AbortSignal }): { controller: AbortController controllerCleanup: () => void } { @@ -764,3 +790,5 @@ type RunExecParams = ConnBaseQueryParams & { values?: ConnExecParams['values'] decompress_response_stream?: boolean } + +const PingQuery = `SELECT 'ping'` diff --git a/packages/client-web/__tests__/integration/web_ping.test.ts b/packages/client-web/__tests__/integration/web_ping.test.ts index 79558eee..38d78ee8 100644 --- a/packages/client-web/__tests__/integration/web_ping.test.ts +++ b/packages/client-web/__tests__/integration/web_ping.test.ts @@ -1,11 +1,16 @@ -import type { ClickHouseClient } from '@clickhouse/client-common' +import type { + ClickHouseClient, + ClickHouseError, +} from '@clickhouse/client-common' import { createTestClient } from '@test/utils' describe('[Web] ping', () => { let client: ClickHouseClient + afterEach(async () => { await client.close() }) + it('does not swallow a client error', async () => { client = createTestClient({ url: 'http://localhost:3333', @@ -21,4 +26,21 @@ describe('[Web] ping', () => { }), ) }) + + it('checks credentials by default', async () => { + client = createTestClient({ + username: 'wrong', + }) + const response = await client.ping({ + select: false, // ignored + }) + expect(response.success).toBe(false) + + const err = (response as unknown as { error: ClickHouseError }).error + expect(err.code).toEqual('516') + expect(err.type).toEqual('AUTHENTICATION_FAILED') + expect(err.message).toEqual( + jasmine.stringContaining('Authentication failed'), + ) + }) }) diff --git a/packages/client-web/src/connection/web_connection.ts b/packages/client-web/src/connection/web_connection.ts index 9925563b..e6c94c37 100644 --- a/packages/client-web/src/connection/web_connection.ts +++ b/packages/client-web/src/connection/web_connection.ts @@ -61,7 +61,7 @@ export class WebConnection implements Connection { query_id, }) const response = await this.request({ - values: params.query, + body: params.query, params, searchParams, }) @@ -105,7 +105,7 @@ export class WebConnection implements Connection { query_id, }) const response = await this.request({ - values: params.values, + body: params.values, params, searchParams, }) @@ -123,7 +123,13 @@ export class WebConnection implements Connection { // so we are using a simple SELECT as a workaround try { const response = await this.request({ - values: 'SELECT 1 FORMAT CSV', + body: null, + searchParams: toSearchParams({ + database: undefined, + query: `SELECT 'ping'`, + query_id: getQueryId(undefined), + }), + method: 'GET', }) if (response.body !== null) { await response.body.cancel() @@ -145,13 +151,13 @@ export class WebConnection implements Connection { } private async request({ - values, + body, params, searchParams, pathname, method, }: { - values: string | null + body: string | null params?: ConnBaseQueryParams searchParams?: URLSearchParams pathname?: string @@ -191,7 +197,7 @@ export class WebConnection implements Connection { // avoiding "fetch called on an object that does not implement interface Window" error const fetchFn = this.params.fetch ?? fetch const response = await fetchFn(url, { - body: values, + body, headers, keepalive: this.params.keep_alive.enabled, method: method ?? 'POST', @@ -235,7 +241,7 @@ export class WebConnection implements Connection { query_id, }) const response = await this.request({ - values: params.query, + body: params.query, params, searchParams, })