Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 1 addition & 1 deletion docs/docs/configure/warehouses.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Warehouses

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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

The headline count now exceeds what this page documents.

This page still only contains 10 concrete warehouse sections, so the new opener leaves two supported types undocumented here. Either add the missing sections or rephrase the intro so it matches what this page actually covers.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/docs/configure/warehouses.md` at line 3, The document's opening line
("Altimate Code connects to 12 warehouse types.") is inconsistent with the
actual content (only 10 warehouse sections); either update that opener to
reflect the documented count (change "12" to "10") or add the two missing
warehouse sections to match "12", and ensure any mention of connection
configuration (the `.altimate-code/connections.json` /
`~/.altimate-code/connections.json` sentence) remains accurate; locate and
update the header text or add the two new warehouse sections within this
markdown file so the intro and sections stay in sync.


## Configuration

Expand Down
19 changes: 10 additions & 9 deletions packages/drivers/src/clickhouse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,17 +60,16 @@ export async function connect(config: ConnectionConfig): Promise<Connector> {
client = createClient(clientConfig)
},

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

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

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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Trailing -- comments still neutralize the injected LIMIT.

sqlNoComments is only used for detection. The executed query is still built from the original SQL, so SELECT * FROM t -- LIMIT 100 becomes SELECT * FROM t -- LIMIT 100 LIMIT 11, and the appended limit stays inside the line comment. Strip or terminate trailing comments before appending the guard limit.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/drivers/src/clickhouse.ts` around lines 81 - 85, The appended LIMIT
can be neutralized by trailing line/block comments because query is built from
the original sql; change the construction to strip trailing comments before
appending the guard limit: use the already-derived sqlNoComments (or create
sqlSanitized by removing trailing `--...` and final `/*...*/`) when building
`query` (instead of `sql`), preserve the semicolon trimming behavior (e.g.
remove a trailing `;`), and then append `LIMIT ${effectiveLimit + 1}` only to
that sanitized string so the injected limit cannot be commented out; reference
variables `sql`, `sqlNoComments`, `query`, `supportsLimit`, and
`effectiveLimit`.

}

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

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

Expand Down
319 changes: 319 additions & 0 deletions packages/drivers/test/clickhouse-unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,319 @@
/**
* Unit tests for ClickHouse driver logic:
* - DDL vs SELECT routing (command vs query)
* - LIMIT injection and bypass prevention
* - Truncation detection
* - Nullable detection from type string
* - Connection guard (execute before connect)
* - Binds parameter is silently ignored
*/
import { describe, test, expect, mock, beforeEach } from "bun:test"

// --- Mock @clickhouse/client ---

let mockCommandCalls: any[] = []
let mockQueryCalls: any[] = []
let mockQueryResult: any[] = []
let mockCloseCalls = 0

function resetMocks() {
mockCommandCalls = []
mockQueryCalls = []
mockQueryResult = []
mockCloseCalls = 0
}

mock.module("@clickhouse/client", () => ({
createClient: (_config: any) => ({
command: async (opts: any) => {
mockCommandCalls.push(opts)
},
query: async (opts: any) => {
mockQueryCalls.push(opts)
return { json: async () => mockQueryResult }
},
close: async () => {
mockCloseCalls++
},
}),
}))

// Import after mocking
const { connect } = await import("../src/clickhouse")

describe("ClickHouse driver unit tests", () => {
let connector: Awaited<ReturnType<typeof connect>>

beforeEach(async () => {
resetMocks()
connector = await connect({ host: "localhost", port: 8123 })
await connector.connect()
})

// --- DDL vs SELECT routing ---

describe("DDL routing via client.command()", () => {
const ddlStatements = [
"INSERT INTO t VALUES (1, 'a')",
"CREATE TABLE t (id UInt32) ENGINE = MergeTree()",
"DROP TABLE t",
"ALTER TABLE t ADD COLUMN x String",
"TRUNCATE TABLE t",
"OPTIMIZE TABLE t FINAL",
"SYSTEM RELOAD DICTIONARY",
"SET max_memory_usage = 1000000",
]

for (const sql of ddlStatements) {
test(`routes "${sql.slice(0, 40)}..." to client.command()`, async () => {
const result = await connector.execute(sql)
expect(mockCommandCalls.length).toBe(1)
expect(mockQueryCalls.length).toBe(0)
expect(result.row_count).toBe(0)
})
}

test("strips trailing semicolons from DDL", async () => {
await connector.execute("DROP TABLE t; ")
expect(mockCommandCalls[0].query).toBe("DROP TABLE t")
})
})

describe("SELECT routing via client.query()", () => {
test("routes SELECT to client.query()", async () => {
mockQueryResult = [{ id: 1, name: "test" }]
await connector.execute("SELECT id, name FROM t")
expect(mockQueryCalls.length).toBe(1)
expect(mockCommandCalls.length).toBe(0)
})

test("routes SHOW to client.query()", async () => {
mockQueryResult = [{ name: "db1" }]
await connector.execute("SHOW DATABASES")
expect(mockQueryCalls.length).toBe(1)
})

test("routes DESCRIBE to client.query()", async () => {
mockQueryResult = [{ name: "col1", type: "String" }]
await connector.execute("DESCRIBE TABLE t")
expect(mockQueryCalls.length).toBe(1)
})

test("routes EXPLAIN to client.query()", async () => {
mockQueryResult = [{ explain: "ReadFromMergeTree" }]
await connector.execute("EXPLAIN SELECT 1")
expect(mockQueryCalls.length).toBe(1)
})
})

// --- LIMIT injection ---

describe("LIMIT injection", () => {
test("appends LIMIT to SELECT without one", async () => {
mockQueryResult = [{ id: 1 }]
await connector.execute("SELECT * FROM t", 10)
expect(mockQueryCalls[0].query).toContain("LIMIT 11")
})

test("does NOT append LIMIT to SELECT that already has one", async () => {
mockQueryResult = [{ id: 1 }]
await connector.execute("SELECT * FROM t LIMIT 5", 10)
expect(mockQueryCalls[0].query).not.toContain("LIMIT 11")
})

test("does NOT append LIMIT to SHOW/DESCRIBE/EXPLAIN/EXISTS", async () => {
mockQueryResult = [{ name: "t" }]

await connector.execute("SHOW TABLES", 10)
expect(mockQueryCalls[0].query).not.toContain("LIMIT")

mockQueryCalls = []
await connector.execute("DESCRIBE TABLE t", 10)
expect(mockQueryCalls[0].query).not.toContain("LIMIT")

mockQueryCalls = []
await connector.execute("EXISTS TABLE t", 10)
expect(mockQueryCalls[0].query).not.toContain("LIMIT")
})

test("does NOT append LIMIT when limit=0 (unlimited)", async () => {
mockQueryResult = [{ id: 1 }, { id: 2 }]
await connector.execute("SELECT * FROM t", 0)
expect(mockQueryCalls[0].query).not.toContain("LIMIT")
})

test("uses default limit=1000 when limit is undefined", async () => {
mockQueryResult = [{ id: 1 }]
await connector.execute("SELECT * FROM t")
expect(mockQueryCalls[0].query).toContain("LIMIT 1001")
})

test("LIMIT in SQL comment does NOT prevent LIMIT injection", async () => {
mockQueryResult = [{ id: 1 }]
await connector.execute("SELECT * FROM t -- LIMIT 100", 10)
// Should still append LIMIT because the comment-stripped SQL has no LIMIT
expect(mockQueryCalls[0].query).toContain("LIMIT 11")
})

test("LIMIT in block comment does NOT prevent LIMIT injection", async () => {
mockQueryResult = [{ id: 1 }]
await connector.execute("SELECT * FROM t /* LIMIT 50 */", 10)
expect(mockQueryCalls[0].query).toContain("LIMIT 11")
})

test("real LIMIT in SQL still prevents double LIMIT", async () => {
mockQueryResult = [{ id: 1 }]
await connector.execute("SELECT * FROM t LIMIT 5 -- max rows", 10)
expect(mockQueryCalls[0].query).not.toContain("LIMIT 11")
})
})

// --- Truncation detection ---

describe("truncation detection", () => {
test("detects truncation when rows exceed limit", async () => {
mockQueryResult = Array.from({ length: 6 }, (_, i) => ({ id: i }))
const result = await connector.execute("SELECT * FROM t", 5)
expect(result.truncated).toBe(true)
expect(result.row_count).toBe(5)
expect(result.rows.length).toBe(5)
})

test("no truncation when rows equal limit", async () => {
mockQueryResult = Array.from({ length: 5 }, (_, i) => ({ id: i }))
const result = await connector.execute("SELECT * FROM t", 5)
expect(result.truncated).toBe(false)
expect(result.row_count).toBe(5)
})

test("no truncation when rows below limit", async () => {
mockQueryResult = [{ id: 1 }]
const result = await connector.execute("SELECT * FROM t", 10)
expect(result.truncated).toBe(false)
expect(result.row_count).toBe(1)
})

test("limit=0 returns all rows without truncation", async () => {
mockQueryResult = Array.from({ length: 100 }, (_, i) => ({ id: i }))
const result = await connector.execute("SELECT * FROM t", 0)
expect(result.truncated).toBe(false)
expect(result.row_count).toBe(100)
})

test("empty result returns correctly", async () => {
mockQueryResult = []
const result = await connector.execute("SELECT * FROM t", 10)
expect(result.row_count).toBe(0)
expect(result.columns).toEqual([])
expect(result.truncated).toBe(false)
})
})

// --- Nullable detection ---

describe("describeTable nullable detection", () => {
test("detects Nullable(String) as nullable", async () => {
mockQueryResult = [{ name: "col1", type: "Nullable(String)" }]
const cols = await connector.describeTable("default", "t")
expect(cols[0].nullable).toBe(true)
})

test("detects String as non-nullable", async () => {
mockQueryResult = [{ name: "col1", type: "String" }]
const cols = await connector.describeTable("default", "t")
expect(cols[0].nullable).toBe(false)
})

test("detects Nullable(UInt32) as nullable", async () => {
mockQueryResult = [{ name: "col1", type: "Nullable(UInt32)" }]
const cols = await connector.describeTable("default", "t")
expect(cols[0].nullable).toBe(true)
})

test("Array(Nullable(String)) is NOT nullable at column level", async () => {
// The column itself isn't Nullable — the array elements are
mockQueryResult = [{ name: "col1", type: "Array(Nullable(String))" }]
const cols = await connector.describeTable("default", "t")
expect(cols[0].nullable).toBe(false)
})

test("LowCardinality(Nullable(String)) is NOT nullable at column level", async () => {
// Nullable is nested inside LowCardinality — column-level is LowCardinality
mockQueryResult = [{ name: "col1", type: "LowCardinality(Nullable(String))" }]
const cols = await connector.describeTable("default", "t")
expect(cols[0].nullable).toBe(false)
})
})

// --- Connection guard ---

describe("connection lifecycle", () => {
test("execute before connect throws clear error", async () => {
const freshConnector = await connect({ host: "localhost" })
// Don't call connect()
expect(freshConnector.execute("SELECT 1")).rejects.toThrow("not connected")
})

test("close is idempotent", async () => {
await connector.close()
await connector.close() // should not throw
expect(mockCloseCalls).toBe(1) // only called once
})
})

// --- Binds parameter ---

describe("binds parameter", () => {
test("binds parameter is silently ignored", async () => {
mockQueryResult = [{ id: 1 }]
// Should not throw — binds are ignored
const result = await connector.execute("SELECT 1", 10, ["unused", "binds"])
expect(result.row_count).toBe(1)
})

test("empty binds array works fine", async () => {
mockQueryResult = [{ id: 1 }]
const result = await connector.execute("SELECT 1", 10, [])
expect(result.row_count).toBe(1)
})
})

// --- Column mapping ---

describe("result format", () => {
test("maps rows to column-ordered arrays", async () => {
mockQueryResult = [
{ id: 1, name: "alice", age: 30 },
{ id: 2, name: "bob", age: 25 },
]
const result = await connector.execute("SELECT * FROM t", 10)
expect(result.columns).toEqual(["id", "name", "age"])
expect(result.rows).toEqual([
[1, "alice", 30],
[2, "bob", 25],
])
})
})

// --- listTables type detection ---

describe("listTables engine-to-type mapping", () => {
test("MergeTree engines map to table", async () => {
mockQueryResult = [{ name: "t1", engine: "MergeTree" }]
const tables = await connector.listTables("default")
expect(tables[0].type).toBe("table")
})

test("MaterializedView maps to view", async () => {
mockQueryResult = [{ name: "v1", engine: "MaterializedView" }]
const tables = await connector.listTables("default")
expect(tables[0].type).toBe("view")
})

test("View maps to view", async () => {
mockQueryResult = [{ name: "v2", engine: "View" }]
const tables = await connector.listTables("default")
expect(tables[0].type).toBe("view")
})
})
})
11 changes: 1 addition & 10 deletions packages/drivers/test/driver-security.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -623,16 +623,7 @@ describe("Connection registry", () => {
reset()
})

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

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