clickhouse-driver

一个ClickHouse的驱动库

clickhouse
driver
database
sql
moon add liuhuo23/clickhouse-driver@0.2.0
Download zip
Author
Version
0.2.0
License
Apache-2.0
Last updated
15 days ago
Downloads
15

Dependencies

README

#liuhuo23/clickhouse-driver

A ClickHouse HTTP driver for MoonBit — simple, portable, and stateless.

#Overview

  • Uses the native ClickHouse HTTP interface (default port 8123).
  • Responses parsed as TabSeparatedWithNamesAndTypes so column names and types come back automatically — no manual type mapping on the client side.
  • Single unified API: execute_query(sql, params?) covers SELECT, DDL, and inline-VALUES INSERT.
  • Named-parameter substitution via ClickHouse's {name: Type} placeholder syntax — values are passed as param_<key>=<value> URL parameters and substituted server-side (quoted and escaped automatically).
  • Proper error handling via MoonBit try-catch with a DbError suberror (ServerError / ConnectionError).
  • Simple per-request connections by default — no persistent state to leak; opt into keep-alive reuse with conn.pool(size) for high-QPS workloads.
  • Compatible with ClickHouse 22.x+ (the HTTP interface has been stable since 21.x).

#Installation

Add the dependency to your moon.mod:

import { "moonbitlang/async@0.20.1", }

Then in your package's moon.pkg:

import { "moonbitlang/async/http", "moonbitlang/core/buffer", "moonbitlang/core/encoding/base64", "moonbitlang/core/encoding/utf8", "liuhuo23/clickhouse-driver" @lib, }

#Quick start

///|
async fn main {
let conn = @lib.connect(
host="127.0.0.1",
port=8123,
user="default",
password="",
database="default",
client_name="my-app",
)
defer conn.close()

// 1. Health check
conn.ping()

// 2. SELECT — columns come back automatically
let result = conn.execute_query(
"SELECT id, name FROM users WHERE created_at > {lo: DateTime} LIMIT {n: UInt32}",
params=Map::from_array([("lo", "2024-01-01 00:00:00"), ("n", "10")]),
)

// 3. Inspect schema
for col in result.columns {
println(col.name + " : " + col.type_)
}

// 4. Iterate rows (each cell is a string)
for row in result.rows {
println(row.values)
}

// 5. Or convert to row maps keyed by column name
for m in result.to_map() {
println(m["name"])
}

// 6. INSERT via inline VALUES — all values come from params
ignore(
conn.execute_query(
"INSERT INTO users (id, name) VALUES " +
"({id: UInt32}, {name: String}), ({id2: UInt32}, {name2: String})",
params=Map::from_array([
("id", "1"),
("name", "alice"),
("id2", "2"),
("name2", "bob"),
]),
),
)
}

#API reference

#connect

pub fn connect(
host~ : String,
port~ : Int,
user~ : String,
password~ : String,
database~ : String,
client_name~ : String,
timeout_ms? : Int = 0,
https? : Bool = false,
skip_verify? : Bool = false,
compress? : Bool = false,
) -> Connection

Builds a Connection config from explicit parameters. Does not open a TCP connection — by default calls are stateless and open fresh HTTP connections per request. All new options are optional — existing call sites keep working. Wrap it with conn.pool(size) when you want to reuse connections (see Connection pooling).

ParameterTypeDescription
hostStringServer hostname or IP
portIntHTTP port (default 8123)
userStringUsername
passwordStringPassword
databaseStringDefault database
client_nameStringSent via X-ClickHouse-Client-Name header
timeout_msIntPer-request timeout in ms; 0 = no timeout
httpsBoolUse TLS (https://); default false
skip_verifyBoolSkip TLS cert verification; default false
compressBoolRequest ClickHouse LZ4 compression; default false

#Connection

///|
pub struct Connection {
host : String
port : Int
user : String
password : String
database : String
client_name : String
timeout_ms : Int
https : Bool
skip_verify : Bool
compress : Bool
}

A lightweight config struct — no persistent socket. Every call opens a short-lived HTTP connection and closes it on return. For connection reuse across requests, see Connection pooling.

#Connection::ping

pub async fn ping(self : Connection) -> Unit raise

Sends SELECT 1 and expects 200 OK. Lightweight health check.

#Connection::execute_query

pub async fn execute_query(
self : Connection,
sql : String,
params? : Map[String, String] = {},
) -> ResultSet raise

Executes any SQL statement (SELECT, DDL, inline-VALUES INSERT, ...) and returns the parsed result.

params is an optional map of named parameters. Each entry is sent as a param_<key>=<value> URL parameter, and ClickHouse substitutes the value into matching {key: Type} placeholders server-side. Values are automatically quoted and escaped — pass them as raw strings.

Examples:

// No params
let r = conn.execute_query("SELECT version()")

// Single param
let r = conn.execute_query(
"SELECT * FROM events WHERE id = {id: UInt64}",
params=Map::from_array([("id", "42")]),
)

// Multiple params, used in INSERT VALUES
ignore(conn.execute_query(
"INSERT INTO events (id, ts, msg) VALUES " +
"({id: UInt64}, {ts: DateTime}, {msg: String})",
params=Map::from_array([
("id", "1"),
("ts", "2024-01-01 00:00:00"),
("msg", "hello"),
]),
))

Raises:
  • DbError::ServerError(code, name, message) — the server returned a non-2xx HTTP response (syntax error, unknown table, permission denied, …).
  • DbError::ConnectionError(String) — network / I/O error.

#Connection::insert

pub async fn insert(
self : Connection,
table : String,
columns : Array[String],
rows : Array[Array[String]],
format? : InsertFormat = Tsv,
) -> Unit raise

Streams a batch insert as the HTTP request body (instead of inlining VALUES literals). The body is streamed in chunks, so large inserts don't blow up the SQL text size. format selects Tsv (default) or Ndjson (FORMAT JSONEachRow); values are sent as text and coerced by ClickHouse.

conn.insert("users", ["id", "name"], [["1", "alice"], ["2", "bob"]])

#Connection::execute_stream

pub async fn execute_stream(
self : Connection,
sql : String,
params? : Map[String, String] = {},
) -> ResultSetCursor raise

Executes a query and returns a streaming cursor over the result rows — the response body is consumed lazily, one row at a time. columns() gives the schema immediately; next() returns rows until None; always close() to release the connection. Not supported together with compress=true.

let cur = conn.execute_stream("SELECT * FROM events")
defer cur.close()
while true {
match cur.next() {
None => break
Some(row) => println(row.values)
}
}

#Connection pooling

Connection opens a fresh TCP/TLS connection for every request. For high-QPS workloads or remote/TLS servers, repeated handshakes add up — Connection::pool reuses keep-alive connections:

let pool = conn.pool(size=4) // up to 4 concurrent connections
defer pool.close()

pool.ping() // same API as Connection
let r = pool.execute_query("SELECT count() FROM events")
pool.insert("events", ["id", "ts"], [["1", "2024-01-01 00:00:00"]])
let cur = pool.execute_stream("SELECT * FROM events") // cursor returns the
defer cur.close() // connection on close

  • size bounds concurrent connections; extra requests wait for a free one.
  • Connections that fail are discarded and replaced, never reused.
  • pool.execute_stream returns its connection when the stream is fully drained; an abandoned stream drops the connection instead.
  • pool.close() closes idle connections; in-flight ones are closed on return.

ConnectionPool mirrors the Connection API:

pub async fn ConnectionPool::ping(Self) -> Unit
pub async fn ConnectionPool::execute_query(Self, String, params? : Map[String, String]) -> ResultSet
pub async fn ConnectionPool::execute(Self, String, Array[String]) -> ResultSet
pub async fn ConnectionPool::insert(Self, String, Array[String], Array[Array[String]], format? : InsertFormat) -> Unit
pub async fn ConnectionPool::execute_stream(Self, String, params? : Map[String, String]) -> ResultSetCursor
pub fn ConnectionPool::max_size(Self) -> Int
pub fn ConnectionPool::close(Self) -> Unit

#Connection::cancel

pub async fn cancel(self : Connection) -> Unit

No-op over HTTP. Each query is a single short-lived request, so there is no persistent connection in which to send a cancel signal. Kept in the API for symmetry with the previous native-TCP design.

#Connection::close

pub fn close(self : Connection) -> Unit

No-op over HTTP. Use with defer for symmetry:

let conn = @lib.connect(...)
defer conn.close()

#ResultSet

///|
pub struct ResultSet {
columns : Array[Column]
rows : Array[Row]
}

columns is populated when the response uses TabSeparatedWithNamesAndTypes (which is what execute_query requests by default). For DDL / INSERT statements the array is empty.

#ResultSet::to_map

pub fn to_map(self : ResultSet) -> Array[Map[String, String]]

Converts the result to an array of per-row maps. Each element is a Map[String, String] where keys are column names and values are the string representation of the cell. Empty if columns is empty.

for m in result.to_map() {
let name = m.get_or_default("name", "")
println(name)
}

#Row

///|
pub struct Row {
values : Array[String]
}

A single row. Each cell is a string representation of the underlying ClickHouse value (e.g. "42", "2025-01-01 00:00:00", "NULL").

#Column

///|
pub struct Column {
name : String
type_ : String
}

Column metadata parsed from TabSeparatedWithNamesAndTypes. type_ is the raw ClickHouse type string, e.g. "UInt32", "String", "Nullable(Int64)".

#Error handling

try {
conn.execute_query("SELECT * FROM no_such_table")
} catch {
@lib.DbError::ServerError(code~, name=_, message~) =>
println("server error: code=" + code.to_string() + " " + message)
@lib.DbError::ConnectionError(msg) =>
println("connection error: " + msg)
_ => println("other error")
}

#DbError suberror

///|
pub suberror DbError {
ServerError(code~ : Int, name~ : String, message~ : String)
ConnectionError(String)
} derive(Show)

VariantFieldsWhen
ServerErrorcode : Int, name : String, message : StringServer returned a non-2xx HTTP response with an error body.
ConnectionErrorStringNetwork / I/O error (connection refused, malformed response, …).

code is the HTTP status code (typically 400 for client errors like syntax / unknown table, 500 for server errors). name is "HTTPError". message is the first 500 chars of the response body (which contains the ClickHouse exception text).

#How it works

The driver issues one HTTP request per call:

POST /?database=<db>&default_format=TabSeparatedWithNamesAndTypes &query=<url-encoded SQL> [&param_<key>=<url-encoded value>...] HTTP/1.1 Host: <host>:<port> Authorization: Basic <base64(user:password)> X-ClickHouse-Client-Name: <client_name> Content-Length: 0

No Connection: close is sent, so connections can be kept alive: the plain Connection closes the socket after each call, while ConnectionPool reuses the same socket for subsequent requests.

ClickHouse replies with TabSeparatedWithNamesAndTypes:

<col1>\t<col2>\t<col3> <Type1>\t<Type2>\t<Type3> <val1>\t<val2>\t<val3> <val4>\t<val5>\t<val6> ...

The driver parses this into ResultSet { columns, rows }.

Why POST? ClickHouse's HTTP interface treats GET requests as readonly (For queries over HTTP, method GET implies readonly). POST works for every query type — SELECT, DDL, INSERT — so we use a single method.

Why URL params for named parameters? ClickHouse substitutes {key: Type} placeholders in SQL with param_<key>=<value> URL parameters. The server takes care of quoting and type coercion, so the driver can pass values as raw strings without worrying about escaping.

#ClickHouse transactions

ClickHouse has no traditional ACID transactions. There is no BEGIN / COMMIT / ROLLBACK and no Serializable isolation. INSERT … SELECT is atomic at the part level. For data with versioning semantics, use one of the special engines:

  • ReplacingMergeTree(version_column) — keeps the row with the largest version_column after merge.
  • CollapsingMergeTree(sign_column) — uses a sign column (+1 insert, -1 cancel) to collapse pairs of rows on merge.
  • VersionedCollapsingMergeTree(version, sign) — like CollapsingMergeTree but order-independent.
  • SummingMergeTree / AggregatingMergeTree — for state-aggregation patterns.

#Limitations

  1. No mid-query cancel — once a request is sent, the driver has no handle to cancel it. Drop the connection if you must abort.
  2. Streaming reads are cursor-onlyexecute_stream() streams rows lazily, but execute_query() still buffers the full result (capped at 256 MB) because the driver needs the complete body for LZ4 decompression and text parsing.
  3. Compression + streaming don't mix yetexecute_stream() raises ConnectionError when compress=true; use execute_query() for compressed responses.
  4. Response body size limit — buffered responses are capped at 256 MB to avoid runaway memory. Queries returning more should use filters, aggregation, or execute_stream().
  5. No insert progress / retriesinsert() streams the body in one shot; on failure the whole request must be retried.

#Run the example CLI

# Default connection (127.0.0.1:8123, user `default`) moon run cmd/main # Point it at any ClickHouse via environment variables CLICKHOUSE_HOST=127.0.0.1 CLICKHOUSE_PORT=38123 \ CLICKHOUSE_USER=default CLICKHOUSE_PASSWORD=barn \ moon run cmd/main

The demo (cmd/main/main.mbt) walks through every major API:

  • ping() health check and close() / cancel() semantics
  • execute_query() — SELECT / DDL with named {name: Type} params
  • execute()positional ? binding (inline VALUES)
  • insert()streamed bulk insert as the HTTP body, TSV and JSONEachRow formats
  • execute_stream()row-by-row cursor with immediate schema access
  • to_map() — result rows as column-name maps
  • Error handling — ServerError (missing table, syntax error) and ConnectionError caught with try-catch
  • compress=true — LZ4-compressed responses decoded automatically
  • timeout_ms — a slow query (SELECT sleep(3)) is cancelled after the configured timeout
  • https=true — TLS connection (fails gracefully against a plain-HTTP server, succeeds against a TLS-enabled port)
  • conn.pool(size) — pooled keep-alive connections with concurrent queries

#
DbError

pub suberror DbError {
ServerError(code~ : Int, name~ : String, message~ : String)
ConnectionError(String)
} derive(
Debug
)

Errors raised by the driver.

#
Column

pub struct Column {
name : String
type_ : String
}

Column metadata returned from a query.

#
Connection

pub struct Connection {
host : String
port : Int
user : String
password : String
database : String
client_name : String
timeout_ms : Int
https : Bool
skip_verify : Bool
compress : Bool
}

Connection config for ClickHouse over HTTP. Each query opens its own short-lived HTTP connection.

#
Connection::cancel

fn Connection::cancel(self : Connection) -> Unit

No-op for HTTP: queries can't be cancelled mid-flight without persistent TCP.

#
Connection::close

fn Connection::close(self : Connection) -> Unit

No-op for HTTP: each call opens a fresh short-lived connection.

#
Connection::execute

async fn Connection::execute(self : Connection, sql : String, values : Array[String]) -> ResultSet

Execute SQL with positional ? placeholders. Each ? in the SQL is bound to the next value in values, in order. This is the concise form for inline-VALUES INSERTs and other queries that repeat the same parameter shape across rows — no need to invent unique names per row and no Map::from_array(...) ceremony.

All values are bound as String; ClickHouse coerces them to the target column type on the server side. This works for numbers, dates, and most common scalar types. For non-String columns where coercion is not enough (e.g. exotic parameterized types), fall back to execute_query with explicit {name: Type} named binding.

Like apply_default_types, this is a byte scan — it does not parse SQL. If a literal ? appears inside a string, restructure the query to avoid the literal (ClickHouse's HTTP syntax has no escape form for ?).

Example: execute("INSERT INTO t (a, b) VALUES (?, ?), (?, ?)", ["1", "alice", "2", "bob"])

#
Connection::execute_query

async fn Connection::execute_query(self : Connection, sql : String, params? : Map[String, String]) -> ResultSet

Execute any SQL statement (SELECT, DDL, INSERT, ...) and return the parsed TabSeparatedWithNamesAndTypes result. For non-SELECT statements the returned rows is typically empty.

params is an optional map of named parameters, substituted into the SQL via ClickHouse's {name: Type} placeholder syntax. Each key/value is passed as a param_<key>=<value> URL parameter; the server inserts the value (quoted and escaped) into matching placeholders.

For the common case where a parameter is a string (table name, identifier, or string column value), the type can be omitted and is defaulted to String automatically — so {tn} and {tn: String} are equivalent. Use the explicit {name: Type} form when binding into a non-String column.

Example: execute_query("SELECT * FROM events WHERE ts > {lo}", params=Map::from_array([("lo", "2024-01-01 00:00:00")]))

#
Connection::execute_stream

async fn Connection::execute_stream(self : Connection, sql : String, params? : Map[String, String]) -> ResultSetCursor

Execute a query and return a streaming cursor over the result rows.

Unlike execute_query (which buffers the whole result in memory), the response body is read lazily row-by-row via next(). This is the right tool for large SELECTs:

let cur = conn.execute_stream("SELECT * FROM events") defer cur.close() while let Some(row) = cur.next() { println(row.values) }

The cursor must be closed with close() (also via defer) to release the underlying HTTP connection. Named params work exactly like execute_query. Compressed responses (compress=true) are not supported for streaming yet and raise ConnectionError.

#
Connection::insert

async fn Connection::insert(self : Connection, table : String, columns : Array[String], rows : Array[Array[String]], format? : InsertFormat) -> Unit

Stream a batch insert to the server over the HTTP body (POST), instead of inlining rows as VALUES literals in the SQL text.

table and columns are interpolated verbatim into the SQL (identifiers — quote them yourself if needed). Each element of rows is one row whose fields correspond positionally to columns. All values are sent as text; ClickHouse coerces them to the target column types.

format selects the wire format (Tsv by default, Ndjson for JSONEachRow). The body is streamed to the server in chunks, so large inserts do not blow up the SQL text size. Raises ServerError on a non-2xx response and ConnectionError on transport failures / timeouts.

Example: conn.insert("users", ["id", "name"], [["1", "alice"], ["2", "bob"]])

#
Connection::ping

async fn Connection::ping(self : Connection) -> Unit

Round-trip health check: sends SELECT 1 and expects 200 OK.

#
Connection::pool

fn Connection::pool(self : Connection, size? : Int) -> ConnectionPool

Build a ConnectionPool from this connection config. size is the maximum number of concurrent connections (default 4; values <= 0 are clamped to 4).

#
ConnectionPool

pub struct ConnectionPool {
// private fields
}

A pool of reusable HTTP connections.

Connection opens a fresh TCP/TLS connection for every request; a pool keeps up to max_size keep-alive connections and reuses them across requests, avoiding repeated handshakes (especially valuable for TLS and remote servers). All query methods mirror Connection.

The async runtime is single-threaded and cooperative, so the idle list is only touched inside short synchronous sections — no locking is needed. Requests beyond max_size wait for a free connection; connections that fail are discarded and replaced, never handed back to the pool.

#
ConnectionPool::cancel

fn ConnectionPool::cancel(self : ConnectionPool) -> Unit

No-op: pool requests are independent; kept for API symmetry with Connection.

#
ConnectionPool::close

fn ConnectionPool::close(self : ConnectionPool) -> Unit

Close the pool: mark it closed and close all idle connections. Connections still checked out are closed when they are returned.

#
ConnectionPool::execute

async fn ConnectionPool::execute(self : ConnectionPool, sql : String, values : Array[String]) -> ResultSet

Same as Connection::execute (positional ? binding), pooled.

#
ConnectionPool::execute_query

async fn ConnectionPool::execute_query(self : ConnectionPool, sql : String, params? : Map[String, String]) -> ResultSet

Same as Connection::execute_query (named {name: Type} params), but reuses pooled connections.

#
ConnectionPool::execute_stream

async fn ConnectionPool::execute_stream(self : ConnectionPool, sql : String, params? : Map[String, String]) -> ResultSetCursor

Same as Connection::execute_stream (row-by-row cursor), pooled. The cursor returns its connection to the pool on close() when the stream was fully read, and drops it otherwise. compress=true is not supported for streaming.

#
ConnectionPool::insert

async fn ConnectionPool::insert(self : ConnectionPool, table : String, columns : Array[String], rows : Array[Array[String]], format? : InsertFormat) -> Unit

Same as Connection::insert (streamed TSV/JSONEachRow body), pooled.

#
ConnectionPool::max_size

fn ConnectionPool::max_size(self : ConnectionPool) -> Int

The maximum number of concurrent connections this pool will create.

#
ConnectionPool::ping

async fn ConnectionPool::ping(self : ConnectionPool) -> Unit

Round-trip health check through the pool.

#
InsertFormat

pub(all) enum InsertFormat {
Tsv
Ndjson
}

Wire format used by Connection::insert for the request body.

#
ResultSet

pub struct ResultSet {
columns : Array[Column]
rows : Array[Row]
}

Complete result set containing column metadata and rows.

#
ResultSet::to_map

fn ResultSet::to_map(self : ResultSet) -> Array[Map[String, String]]

Convert result to an array of row maps. Each element is a Map[String, String] mapping column name → value for that row.

#
ResultSetCursor

pub struct ResultSetCursor {
// private fields
}

Streaming cursor returned by Connection::execute_stream.

The HTTP response body is consumed lazily, one row at a time, so queries with large result sets do not need to be buffered in memory. Always call close() when done to release the underlying connection.

columns() is available immediately after the cursor is created; rows are read with repeated next() calls (returns None at end of stream).

#
ResultSetCursor::close

fn ResultSetCursor::close(self : ResultSetCursor) -> Unit

Close the streaming cursor and release the underlying connection.

For cursors created through a ConnectionPool, a fully-drained stream returns the connection to the pool for reuse; a partially-read stream drops the connection (the server may still be streaming data). Idempotent — safe to call multiple times.

#
ResultSetCursor::columns

The columns (name + type) of the streamed query result.

#
ResultSetCursor::next

async fn ResultSetCursor::next(self : ResultSetCursor) -> Row?

Read the next row from the stream, or None at end of stream.

If the connection was configured with a positive timeout_ms, each next() call is bounded by that timeout; a timeout raises ConnectionError.

#
Row

pub struct Row {
values : Array[String]
}

A single row of query results. Each value is a string representation.

#
connect

fn connect(host~ : String, port~ : Int, user~ : String, password~ : String, database~ : String, client_name~ : String, timeout_ms? : Int, https? : Bool, skip_verify? : Bool, compress? : Bool) -> Connection

Build a Connection config from explicit connection params. Does NOT open a TCP connection — calls are stateless.

Optional params (all backward compatible): timeout_ms — request timeout in ms; 0 = no timeout (default) https — use TLS (https://); default false skip_verify— skip TLS certificate verification; default false compress — request ClickHouse LZ4 response compression; default false