Skip to content

Commit 54d715f

Browse files
committed
fix: address 7 P1 findings from v0.5.16 release evaluation (#590)
ClickHouse driver: - Silently ignore `binds` param (was throwing, inconsistent with other drivers) - Strip SQL comments before LIMIT check to prevent `-- LIMIT` bypass - Remove redundant `hasDML` regex (dead code after `isDDL` routing) - Replace fragile `position(type, 'Nullable')` SQL with TS regex on type string - Add connection guard: `execute()` before `connect()` throws clear error Query history: - Rename `{days:UInt32}` placeholders to `__DAYS__`/`__LIMIT__` to avoid confusion with ClickHouse native query parameter syntax Docs: - Update warehouse count from 10 to 12 Tests: - Add 39 unit tests for ClickHouse driver (DDL routing, LIMIT injection, truncation, nullable, connection lifecycle, binds, column mapping) - Remove stale "ClickHouse unsupported" test
1 parent 99270e5 commit 54d715f

5 files changed

Lines changed: 338 additions & 24 deletions

File tree

docs/docs/configure/warehouses.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Warehouses
22

3-
Altimate Code connects to 10 warehouse types. Configure them in `.altimate-code/connections.json` (project-local) or `~/.altimate-code/connections.json` (global).
3+
Altimate Code connects to 12 warehouse types. Configure them in `.altimate-code/connections.json` (project-local) or `~/.altimate-code/connections.json` (global).
44

55
## Configuration
66

packages/drivers/src/clickhouse.ts

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -60,17 +60,16 @@ export async function connect(config: ConnectionConfig): Promise<Connector> {
6060
client = createClient(clientConfig)
6161
},
6262

63-
async execute(sql: string, limit?: number, binds?: any[]): Promise<ConnectorResult> {
64-
if (binds && binds.length > 0) {
65-
throw new Error("ClickHouse driver does not support parameterized binds — use ClickHouse query parameters instead")
63+
async execute(sql: string, limit?: number, _binds?: any[]): Promise<ConnectorResult> {
64+
if (!client) {
65+
throw new Error("ClickHouse client not connected — call connect() first")
6666
}
6767
const effectiveLimit = limit === undefined ? 1000 : limit
6868
let query = sql
6969
// Only SELECT and WITH...SELECT support LIMIT — SHOW/DESCRIBE/EXPLAIN/EXISTS do not
7070
const supportsLimit = /^\s*(SELECT|WITH)\b/i.test(sql)
7171
const isDDL =
7272
/^\s*(INSERT|CREATE|DROP|ALTER|TRUNCATE|RENAME|ATTACH|DETACH|OPTIMIZE|SYSTEM|SET|USE|GRANT|REVOKE)\b/i.test(sql)
73-
const hasDML = /\b(INSERT|CREATE|DROP|ALTER|TRUNCATE|RENAME|ATTACH|DETACH|OPTIMIZE|SYSTEM)\b/i.test(sql)
7473

7574
// DDL/DML: use client.command() — no result set expected
7675
if (isDDL) {
@@ -79,8 +78,10 @@ export async function connect(config: ConnectionConfig): Promise<Connector> {
7978
}
8079

8180
// Read queries: use client.query() with JSONEachRow format
82-
// Only append LIMIT for SELECT/WITH queries (not SHOW/DESCRIBE/EXPLAIN/EXISTS)
83-
if (supportsLimit && !hasDML && effectiveLimit > 0 && !/\bLIMIT\b/i.test(sql)) {
81+
// Only append LIMIT for SELECT/WITH queries that don't already have one.
82+
// Strip SQL comments before checking for LIMIT to prevent bypass via `-- LIMIT`.
83+
const sqlNoComments = sql.replace(/--[^\n]*/g, "").replace(/\/\*[\s\S]*?\*\//g, "")
84+
if (supportsLimit && effectiveLimit > 0 && !/\bLIMIT\b/i.test(sqlNoComments)) {
8485
query = `${sql.replace(/;\s*$/, "")} LIMIT ${effectiveLimit + 1}`
8586
}
8687

@@ -134,8 +135,7 @@ export async function connect(config: ConnectionConfig): Promise<Connector> {
134135

135136
async describeTable(schema: string, table: string): Promise<SchemaColumn[]> {
136137
const resultSet = await client.query({
137-
query: `SELECT name, type,
138-
position(type, 'Nullable') > 0 AS is_nullable
138+
query: `SELECT name, type
139139
FROM system.columns
140140
WHERE database = {db:String}
141141
AND table = {tbl:String}
@@ -147,7 +147,8 @@ export async function connect(config: ConnectionConfig): Promise<Connector> {
147147
return rows.map((r) => ({
148148
name: r.name as string,
149149
data_type: r.type as string,
150-
nullable: r.is_nullable === 1 || r.is_nullable === true || r.is_nullable === "1",
150+
// Detect Nullable from the type string directly — stable across all versions
151+
nullable: /^Nullable\b/i.test((r.type as string) ?? ""),
151152
}))
152153
},
153154

Lines changed: 319 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,319 @@
1+
/**
2+
* Unit tests for ClickHouse driver logic:
3+
* - DDL vs SELECT routing (command vs query)
4+
* - LIMIT injection and bypass prevention
5+
* - Truncation detection
6+
* - Nullable detection from type string
7+
* - Connection guard (execute before connect)
8+
* - Binds parameter is silently ignored
9+
*/
10+
import { describe, test, expect, mock, beforeEach } from "bun:test"
11+
12+
// --- Mock @clickhouse/client ---
13+
14+
let mockCommandCalls: any[] = []
15+
let mockQueryCalls: any[] = []
16+
let mockQueryResult: any[] = []
17+
let mockCloseCalls = 0
18+
19+
function resetMocks() {
20+
mockCommandCalls = []
21+
mockQueryCalls = []
22+
mockQueryResult = []
23+
mockCloseCalls = 0
24+
}
25+
26+
mock.module("@clickhouse/client", () => ({
27+
createClient: (_config: any) => ({
28+
command: async (opts: any) => {
29+
mockCommandCalls.push(opts)
30+
},
31+
query: async (opts: any) => {
32+
mockQueryCalls.push(opts)
33+
return { json: async () => mockQueryResult }
34+
},
35+
close: async () => {
36+
mockCloseCalls++
37+
},
38+
}),
39+
}))
40+
41+
// Import after mocking
42+
const { connect } = await import("../src/clickhouse")
43+
44+
describe("ClickHouse driver unit tests", () => {
45+
let connector: Awaited<ReturnType<typeof connect>>
46+
47+
beforeEach(async () => {
48+
resetMocks()
49+
connector = await connect({ host: "localhost", port: 8123 })
50+
await connector.connect()
51+
})
52+
53+
// --- DDL vs SELECT routing ---
54+
55+
describe("DDL routing via client.command()", () => {
56+
const ddlStatements = [
57+
"INSERT INTO t VALUES (1, 'a')",
58+
"CREATE TABLE t (id UInt32) ENGINE = MergeTree()",
59+
"DROP TABLE t",
60+
"ALTER TABLE t ADD COLUMN x String",
61+
"TRUNCATE TABLE t",
62+
"OPTIMIZE TABLE t FINAL",
63+
"SYSTEM RELOAD DICTIONARY",
64+
"SET max_memory_usage = 1000000",
65+
]
66+
67+
for (const sql of ddlStatements) {
68+
test(`routes "${sql.slice(0, 40)}..." to client.command()`, async () => {
69+
const result = await connector.execute(sql)
70+
expect(mockCommandCalls.length).toBe(1)
71+
expect(mockQueryCalls.length).toBe(0)
72+
expect(result.row_count).toBe(0)
73+
})
74+
}
75+
76+
test("strips trailing semicolons from DDL", async () => {
77+
await connector.execute("DROP TABLE t; ")
78+
expect(mockCommandCalls[0].query).toBe("DROP TABLE t")
79+
})
80+
})
81+
82+
describe("SELECT routing via client.query()", () => {
83+
test("routes SELECT to client.query()", async () => {
84+
mockQueryResult = [{ id: 1, name: "test" }]
85+
await connector.execute("SELECT id, name FROM t")
86+
expect(mockQueryCalls.length).toBe(1)
87+
expect(mockCommandCalls.length).toBe(0)
88+
})
89+
90+
test("routes SHOW to client.query()", async () => {
91+
mockQueryResult = [{ name: "db1" }]
92+
await connector.execute("SHOW DATABASES")
93+
expect(mockQueryCalls.length).toBe(1)
94+
})
95+
96+
test("routes DESCRIBE to client.query()", async () => {
97+
mockQueryResult = [{ name: "col1", type: "String" }]
98+
await connector.execute("DESCRIBE TABLE t")
99+
expect(mockQueryCalls.length).toBe(1)
100+
})
101+
102+
test("routes EXPLAIN to client.query()", async () => {
103+
mockQueryResult = [{ explain: "ReadFromMergeTree" }]
104+
await connector.execute("EXPLAIN SELECT 1")
105+
expect(mockQueryCalls.length).toBe(1)
106+
})
107+
})
108+
109+
// --- LIMIT injection ---
110+
111+
describe("LIMIT injection", () => {
112+
test("appends LIMIT to SELECT without one", async () => {
113+
mockQueryResult = [{ id: 1 }]
114+
await connector.execute("SELECT * FROM t", 10)
115+
expect(mockQueryCalls[0].query).toContain("LIMIT 11")
116+
})
117+
118+
test("does NOT append LIMIT to SELECT that already has one", async () => {
119+
mockQueryResult = [{ id: 1 }]
120+
await connector.execute("SELECT * FROM t LIMIT 5", 10)
121+
expect(mockQueryCalls[0].query).not.toContain("LIMIT 11")
122+
})
123+
124+
test("does NOT append LIMIT to SHOW/DESCRIBE/EXPLAIN/EXISTS", async () => {
125+
mockQueryResult = [{ name: "t" }]
126+
127+
await connector.execute("SHOW TABLES", 10)
128+
expect(mockQueryCalls[0].query).not.toContain("LIMIT")
129+
130+
mockQueryCalls = []
131+
await connector.execute("DESCRIBE TABLE t", 10)
132+
expect(mockQueryCalls[0].query).not.toContain("LIMIT")
133+
134+
mockQueryCalls = []
135+
await connector.execute("EXISTS TABLE t", 10)
136+
expect(mockQueryCalls[0].query).not.toContain("LIMIT")
137+
})
138+
139+
test("does NOT append LIMIT when limit=0 (unlimited)", async () => {
140+
mockQueryResult = [{ id: 1 }, { id: 2 }]
141+
await connector.execute("SELECT * FROM t", 0)
142+
expect(mockQueryCalls[0].query).not.toContain("LIMIT")
143+
})
144+
145+
test("uses default limit=1000 when limit is undefined", async () => {
146+
mockQueryResult = [{ id: 1 }]
147+
await connector.execute("SELECT * FROM t")
148+
expect(mockQueryCalls[0].query).toContain("LIMIT 1001")
149+
})
150+
151+
test("LIMIT in SQL comment does NOT prevent LIMIT injection", async () => {
152+
mockQueryResult = [{ id: 1 }]
153+
await connector.execute("SELECT * FROM t -- LIMIT 100", 10)
154+
// Should still append LIMIT because the comment-stripped SQL has no LIMIT
155+
expect(mockQueryCalls[0].query).toContain("LIMIT 11")
156+
})
157+
158+
test("LIMIT in block comment does NOT prevent LIMIT injection", async () => {
159+
mockQueryResult = [{ id: 1 }]
160+
await connector.execute("SELECT * FROM t /* LIMIT 50 */", 10)
161+
expect(mockQueryCalls[0].query).toContain("LIMIT 11")
162+
})
163+
164+
test("real LIMIT in SQL still prevents double LIMIT", async () => {
165+
mockQueryResult = [{ id: 1 }]
166+
await connector.execute("SELECT * FROM t LIMIT 5 -- max rows", 10)
167+
expect(mockQueryCalls[0].query).not.toContain("LIMIT 11")
168+
})
169+
})
170+
171+
// --- Truncation detection ---
172+
173+
describe("truncation detection", () => {
174+
test("detects truncation when rows exceed limit", async () => {
175+
mockQueryResult = Array.from({ length: 6 }, (_, i) => ({ id: i }))
176+
const result = await connector.execute("SELECT * FROM t", 5)
177+
expect(result.truncated).toBe(true)
178+
expect(result.row_count).toBe(5)
179+
expect(result.rows.length).toBe(5)
180+
})
181+
182+
test("no truncation when rows equal limit", async () => {
183+
mockQueryResult = Array.from({ length: 5 }, (_, i) => ({ id: i }))
184+
const result = await connector.execute("SELECT * FROM t", 5)
185+
expect(result.truncated).toBe(false)
186+
expect(result.row_count).toBe(5)
187+
})
188+
189+
test("no truncation when rows below limit", async () => {
190+
mockQueryResult = [{ id: 1 }]
191+
const result = await connector.execute("SELECT * FROM t", 10)
192+
expect(result.truncated).toBe(false)
193+
expect(result.row_count).toBe(1)
194+
})
195+
196+
test("limit=0 returns all rows without truncation", async () => {
197+
mockQueryResult = Array.from({ length: 100 }, (_, i) => ({ id: i }))
198+
const result = await connector.execute("SELECT * FROM t", 0)
199+
expect(result.truncated).toBe(false)
200+
expect(result.row_count).toBe(100)
201+
})
202+
203+
test("empty result returns correctly", async () => {
204+
mockQueryResult = []
205+
const result = await connector.execute("SELECT * FROM t", 10)
206+
expect(result.row_count).toBe(0)
207+
expect(result.columns).toEqual([])
208+
expect(result.truncated).toBe(false)
209+
})
210+
})
211+
212+
// --- Nullable detection ---
213+
214+
describe("describeTable nullable detection", () => {
215+
test("detects Nullable(String) as nullable", async () => {
216+
mockQueryResult = [{ name: "col1", type: "Nullable(String)" }]
217+
const cols = await connector.describeTable("default", "t")
218+
expect(cols[0].nullable).toBe(true)
219+
})
220+
221+
test("detects String as non-nullable", async () => {
222+
mockQueryResult = [{ name: "col1", type: "String" }]
223+
const cols = await connector.describeTable("default", "t")
224+
expect(cols[0].nullable).toBe(false)
225+
})
226+
227+
test("detects Nullable(UInt32) as nullable", async () => {
228+
mockQueryResult = [{ name: "col1", type: "Nullable(UInt32)" }]
229+
const cols = await connector.describeTable("default", "t")
230+
expect(cols[0].nullable).toBe(true)
231+
})
232+
233+
test("Array(Nullable(String)) is NOT nullable at column level", async () => {
234+
// The column itself isn't Nullable — the array elements are
235+
mockQueryResult = [{ name: "col1", type: "Array(Nullable(String))" }]
236+
const cols = await connector.describeTable("default", "t")
237+
expect(cols[0].nullable).toBe(false)
238+
})
239+
240+
test("LowCardinality(Nullable(String)) is NOT nullable at column level", async () => {
241+
// Nullable is nested inside LowCardinality — column-level is LowCardinality
242+
mockQueryResult = [{ name: "col1", type: "LowCardinality(Nullable(String))" }]
243+
const cols = await connector.describeTable("default", "t")
244+
expect(cols[0].nullable).toBe(false)
245+
})
246+
})
247+
248+
// --- Connection guard ---
249+
250+
describe("connection lifecycle", () => {
251+
test("execute before connect throws clear error", async () => {
252+
const freshConnector = await connect({ host: "localhost" })
253+
// Don't call connect()
254+
expect(freshConnector.execute("SELECT 1")).rejects.toThrow("not connected")
255+
})
256+
257+
test("close is idempotent", async () => {
258+
await connector.close()
259+
await connector.close() // should not throw
260+
expect(mockCloseCalls).toBe(1) // only called once
261+
})
262+
})
263+
264+
// --- Binds parameter ---
265+
266+
describe("binds parameter", () => {
267+
test("binds parameter is silently ignored", async () => {
268+
mockQueryResult = [{ id: 1 }]
269+
// Should not throw — binds are ignored
270+
const result = await connector.execute("SELECT 1", 10, ["unused", "binds"])
271+
expect(result.row_count).toBe(1)
272+
})
273+
274+
test("empty binds array works fine", async () => {
275+
mockQueryResult = [{ id: 1 }]
276+
const result = await connector.execute("SELECT 1", 10, [])
277+
expect(result.row_count).toBe(1)
278+
})
279+
})
280+
281+
// --- Column mapping ---
282+
283+
describe("result format", () => {
284+
test("maps rows to column-ordered arrays", async () => {
285+
mockQueryResult = [
286+
{ id: 1, name: "alice", age: 30 },
287+
{ id: 2, name: "bob", age: 25 },
288+
]
289+
const result = await connector.execute("SELECT * FROM t", 10)
290+
expect(result.columns).toEqual(["id", "name", "age"])
291+
expect(result.rows).toEqual([
292+
[1, "alice", 30],
293+
[2, "bob", 25],
294+
])
295+
})
296+
})
297+
298+
// --- listTables type detection ---
299+
300+
describe("listTables engine-to-type mapping", () => {
301+
test("MergeTree engines map to table", async () => {
302+
mockQueryResult = [{ name: "t1", engine: "MergeTree" }]
303+
const tables = await connector.listTables("default")
304+
expect(tables[0].type).toBe("table")
305+
})
306+
307+
test("MaterializedView maps to view", async () => {
308+
mockQueryResult = [{ name: "v1", engine: "MaterializedView" }]
309+
const tables = await connector.listTables("default")
310+
expect(tables[0].type).toBe("view")
311+
})
312+
313+
test("View maps to view", async () => {
314+
mockQueryResult = [{ name: "v2", engine: "View" }]
315+
const tables = await connector.listTables("default")
316+
expect(tables[0].type).toBe("view")
317+
})
318+
})
319+
})

packages/drivers/test/driver-security.test.ts

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -623,16 +623,7 @@ describe("Connection registry", () => {
623623
reset()
624624
})
625625

626-
test("ClickHouse gives helpful hint", async () => {
627-
setConfigs({ ch: { type: "clickhouse" } as any })
628-
try {
629-
await get("ch")
630-
expect.unreachable("Should have thrown")
631-
} catch (e: any) {
632-
expect(e.message).toContain("ClickHouse is not yet supported")
633-
expect(e.message).toContain("clickhouse-client")
634-
}
635-
})
626+
// ClickHouse test removed — ClickHouse is now a supported driver (v0.5.16)
636627

637628
test("Cassandra gives helpful hint", async () => {
638629
setConfigs({ cass: { type: "cassandra" } as any })

0 commit comments

Comments
 (0)