Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,15 @@ Query input (one of):
Connection:
--host <host> ClickHouse host (env: CLICKHOUSE_HOST, default: localhost)
--port <port> HTTP port (env: CLICKHOUSE_PORT, default: 8123)
-u, --user <user> Username (env: CLICKHOUSE_USER, default: default)
-u, --user <user> Username (env: CLICKHOUSE_USER or CLICKHOUSE_USERNAME, default: default)
--password <pass> Password (env: CLICKHOUSE_PASSWORD, default: "")
-d, --database <db> Database (env: CLICKHOUSE_DATABASE, default: default)
-d, --database <db> 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 <fmt> Output format (json, jsonl, csv, tsv, pretty, vertical, markdown, sql)
-t, --time Print execution time to stderr
Expand Down
156 changes: 156 additions & 0 deletions src/client.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | undefined> = {};
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<typeof resolveConnectionConfig>[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<typeof resolveConnectionConfig>[0];

const result = resolveConnectionConfig(config);
expect(result).toEqual({
url: "https://flag-host:1234",
username: "flag_user",
password: "flag_pass",
database: "flag_db",
});
});
});
53 changes: 44 additions & 9 deletions src/client.ts
Original file line number Diff line number Diff line change
@@ -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));
}