From f6c2a29d729701756c353ba6ad7c8883fccaf71e Mon Sep 17 00:00:00 2001 From: KeKs0r Date: Wed, 18 Feb 2026 22:04:46 +0800 Subject: [PATCH] Support Doppler-style ClickHouse env vars Add support for CLICKHOUSE_URL (for full connection string), CLICKHOUSE_USERNAME (fallback for CLICKHOUSE_USER), and CLICKHOUSE_DB (fallback for CLICKHOUSE_DATABASE). This enables Doppler integration without requiring bash wrappers to translate between env var names. Exported parseClickHouseUrl and resolveConnectionConfig for testability. Added 15 tests covering URL parsing and env var precedence. Co-Authored-By: Claude Haiku 4.5 --- src/cli.ts | 8 ++- src/client.test.ts | 156 +++++++++++++++++++++++++++++++++++++++++++++ src/client.ts | 53 ++++++++++++--- 3 files changed, 206 insertions(+), 11 deletions(-) create mode 100644 src/client.test.ts diff --git a/src/cli.ts b/src/cli.ts index 0b6d69d..7698ea4 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -40,11 +40,15 @@ Query input (one of): Connection: --host ClickHouse host (env: CLICKHOUSE_HOST, default: localhost) --port HTTP port (env: CLICKHOUSE_PORT, default: 8123) - -u, --user Username (env: CLICKHOUSE_USER, default: default) + -u, --user Username (env: CLICKHOUSE_USER or CLICKHOUSE_USERNAME, default: default) --password Password (env: CLICKHOUSE_PASSWORD, default: "") - -d, --database Database (env: CLICKHOUSE_DATABASE, default: default) + -d, --database Database (env: CLICKHOUSE_DATABASE or CLICKHOUSE_DB, default: default) -s, --secure Use HTTPS (env: CLICKHOUSE_SECURE) + CLICKHOUSE_URL is also supported (e.g. https://host:8443) and will be + used for host, port, secure, and password if the individual env vars + are not set. + Output: -F, --format Output format (json, jsonl, csv, tsv, pretty, vertical, markdown, sql) -t, --time Print execution time to stderr diff --git a/src/client.test.ts b/src/client.test.ts new file mode 100644 index 0000000..61b07ca --- /dev/null +++ b/src/client.test.ts @@ -0,0 +1,156 @@ +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +import { parseClickHouseUrl, resolveConnectionConfig } from "./client"; + +describe("parseClickHouseUrl", () => { + test("parses https URL with explicit port", () => { + const result = parseClickHouseUrl("https://ch.example.com:8443"); + expect(result).toEqual({ + host: "ch.example.com", + port: "8443", + secure: true, + password: undefined, + }); + }); + + test("defaults port to 8443 for https", () => { + const result = parseClickHouseUrl("https://ch.example.com"); + expect(result.port).toBe("8443"); + expect(result.secure).toBe(true); + }); + + test("defaults port to 8123 for http", () => { + const result = parseClickHouseUrl("http://ch.example.com"); + expect(result.port).toBe("8123"); + expect(result.secure).toBe(false); + }); + + test("extracts password from URL", () => { + const result = parseClickHouseUrl("https://user:s3cret@ch.example.com:8443"); + expect(result.password).toBe("s3cret"); + }); + + test("password is undefined when not in URL", () => { + const result = parseClickHouseUrl("https://ch.example.com:8443"); + expect(result.password).toBeUndefined(); + }); +}); + +describe("resolveConnectionConfig", () => { + const saved: Record = {}; + const envKeys = [ + "CLICKHOUSE_URL", + "CLICKHOUSE_HOST", + "CLICKHOUSE_PORT", + "CLICKHOUSE_SECURE", + "CLICKHOUSE_USER", + "CLICKHOUSE_USERNAME", + "CLICKHOUSE_PASSWORD", + "CLICKHOUSE_DATABASE", + "CLICKHOUSE_DB", + ]; + + beforeEach(() => { + for (const key of envKeys) { + saved[key] = process.env[key]; + delete process.env[key]; + } + }); + + afterEach(() => { + for (const key of envKeys) { + if (saved[key] !== undefined) { + process.env[key] = saved[key]; + } else { + delete process.env[key]; + } + } + }); + + const emptyConfig = {} as Parameters[0]; + + test("uses defaults when no env vars or flags set", () => { + const result = resolveConnectionConfig(emptyConfig); + expect(result).toEqual({ + url: "http://localhost:8123", + username: "default", + password: "", + database: "default", + }); + }); + + test("CLICKHOUSE_URL sets host, port, secure", () => { + process.env.CLICKHOUSE_URL = "https://ch.prod.com:8443"; + const result = resolveConnectionConfig(emptyConfig); + expect(result.url).toBe("https://ch.prod.com:8443"); + }); + + test("CLICKHOUSE_URL password is used as fallback", () => { + process.env.CLICKHOUSE_URL = "https://user:urlpass@ch.prod.com:8443"; + const result = resolveConnectionConfig(emptyConfig); + expect(result.password).toBe("urlpass"); + }); + + test("CLICKHOUSE_PASSWORD takes precedence over URL password", () => { + process.env.CLICKHOUSE_URL = "https://user:urlpass@ch.prod.com:8443"; + process.env.CLICKHOUSE_PASSWORD = "envpass"; + const result = resolveConnectionConfig(emptyConfig); + expect(result.password).toBe("envpass"); + }); + + test("CLICKHOUSE_HOST takes precedence over CLICKHOUSE_URL", () => { + process.env.CLICKHOUSE_URL = "https://from-url.com:8443"; + process.env.CLICKHOUSE_HOST = "from-host-env.com"; + const result = resolveConnectionConfig(emptyConfig); + expect(result.url).toBe("https://from-host-env.com:8443"); + }); + + test("CLICKHOUSE_USERNAME is used when CLICKHOUSE_USER is not set", () => { + process.env.CLICKHOUSE_USERNAME = "doppler_user"; + const result = resolveConnectionConfig(emptyConfig); + expect(result.username).toBe("doppler_user"); + }); + + test("CLICKHOUSE_USER takes precedence over CLICKHOUSE_USERNAME", () => { + process.env.CLICKHOUSE_USER = "primary"; + process.env.CLICKHOUSE_USERNAME = "fallback"; + const result = resolveConnectionConfig(emptyConfig); + expect(result.username).toBe("primary"); + }); + + test("CLICKHOUSE_DB is used when CLICKHOUSE_DATABASE is not set", () => { + process.env.CLICKHOUSE_DB = "doppler_db"; + const result = resolveConnectionConfig(emptyConfig); + expect(result.database).toBe("doppler_db"); + }); + + test("CLICKHOUSE_DATABASE takes precedence over CLICKHOUSE_DB", () => { + process.env.CLICKHOUSE_DATABASE = "primary"; + process.env.CLICKHOUSE_DB = "fallback"; + const result = resolveConnectionConfig(emptyConfig); + expect(result.database).toBe("primary"); + }); + + test("CLI flags take precedence over all env vars", () => { + process.env.CLICKHOUSE_URL = "https://from-url.com:9999"; + process.env.CLICKHOUSE_USER = "env_user"; + process.env.CLICKHOUSE_DATABASE = "env_db"; + process.env.CLICKHOUSE_PASSWORD = "env_pass"; + + const config = { + host: "flag-host", + port: "1234", + user: "flag_user", + password: "flag_pass", + database: "flag_db", + secure: true, + } as Parameters[0]; + + const result = resolveConnectionConfig(config); + expect(result).toEqual({ + url: "https://flag-host:1234", + username: "flag_user", + password: "flag_pass", + database: "flag_db", + }); + }); +}); diff --git a/src/client.ts b/src/client.ts index dbad278..2a98937 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,16 +1,51 @@ import { createClient } from "@clickhouse/client"; import type { CliConfig } from "./cli"; -export function createClickHouseClient(config: CliConfig) { - const host = config.host || process.env.CLICKHOUSE_HOST || "localhost"; - const port = config.port || process.env.CLICKHOUSE_PORT || "8123"; - const secure = config.secure || process.env.CLICKHOUSE_SECURE === "true"; +export function parseClickHouseUrl(raw: string) { + const url = new URL(raw); + return { + host: url.hostname, + port: url.port || (url.protocol === "https:" ? "8443" : "8123"), + secure: url.protocol === "https:", + password: url.password || undefined, + }; +} + +export function resolveConnectionConfig(config: CliConfig) { + const parsed = process.env.CLICKHOUSE_URL + ? parseClickHouseUrl(process.env.CLICKHOUSE_URL) + : undefined; + + const host = + config.host || process.env.CLICKHOUSE_HOST || parsed?.host || "localhost"; + const port = + config.port || process.env.CLICKHOUSE_PORT || parsed?.port || "8123"; + const secure = + config.secure || + process.env.CLICKHOUSE_SECURE === "true" || + (parsed?.secure ?? false); const protocol = secure ? "https" : "http"; - return createClient({ + return { url: `${protocol}://${host}:${port}`, - username: config.user || process.env.CLICKHOUSE_USER || "default", - password: config.password || process.env.CLICKHOUSE_PASSWORD || "", - database: config.database || process.env.CLICKHOUSE_DATABASE || "default", - }); + username: + config.user || + process.env.CLICKHOUSE_USER || + process.env.CLICKHOUSE_USERNAME || + "default", + password: + config.password || + process.env.CLICKHOUSE_PASSWORD || + parsed?.password || + "", + database: + config.database || + process.env.CLICKHOUSE_DATABASE || + process.env.CLICKHOUSE_DB || + "default", + }; +} + +export function createClickHouseClient(config: CliConfig) { + return createClient(resolveConnectionConfig(config)); }