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.
Two flavors of parameter binding, both backed by ClickHouse’s native
{name: Type} placeholder protocol:
execute_query(sql, params?) — named binding with {name} /
{name: Type}. Untyped {name} defaults to String, so table names,
identifiers, and string columns read naturally.
execute(sql, values) — positional binding with ?. 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.
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).
Every call opens a short-lived HTTP connection — no persistent state to
leak, no need to reconnect after long idle periods.
Compatible with ClickHouse 22.x+ (the HTTP interface has been stable since
21.x).
///|
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. Untyped placeholders
// default to String, so table names / string values need no type tag.
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 — same `?` placeholder convention as other DB drivers.
ignore(
conn.execute(
"INSERT INTO users (id, name) VALUES (?, ?), (?, ?)",
["1", "alice", "2", "bob"],
),
)
}
Executes any SQL statement (SELECT, DDL, inline-VALUES INSERT, …) with
named parameter binding 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.
For the common case where a parameter is a string (table name, identifier,
or string column value), the type can be omitted — {name} is treated as
{name: String} automatically. Use the explicit {name: Type} form only
when binding into a non-String column (numbers, dates, etc.).
Examples:
// No params
let r = conn.execute_query("SELECT version()")
// Single typed param (non-String column)
let r = conn.execute_query(
"SELECT * FROM events WHERE id = {id: UInt64}",
params=Map::from_array([("id", "42")]),
)
// Table-name param — untyped {tn} defaults to String
let r = conn.execute_query(
"SELECT count() FROM {tn}",
params=Map::from_array([("tn", "events")]),
)
Executes SQL with positional? placeholders. Each ? in the SQL is
bound to the next value in values, in order. 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.
All values are bound as String; ClickHouse coerces them to the target
column type on the server side (works for numbers, dates, and most common
scalar types). For non-String columns where coercion is not enough, fall
back to execute_query with explicit {name: Type} named binding.
Examples:
// Multi-row INSERT — same parameter shape repeated per row
ignore(conn.execute(
"INSERT INTO events (id, ts, msg) VALUES (?, ?, ?), (?, ?, ?)",
["1", "2024-01-01 00:00:00", "hello",
"2", "2024-01-02 00:00:00", "world"],
))
// Single-row with positional binding
ignore(conn.execute(
"INSERT INTO events (id, name) VALUES (?, ?)",
["42", "alice"],
))
Raises:
DbError::ServerError(code, name, message) — the server returned a
non-2xx HTTP response (syntax error, unknown table, permission denied, …).
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.
columns is populated when the response uses
TabSeparatedWithNamesAndTypes (which is what execute_query requests by
default). For DDL / INSERT statements the array is empty.
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").
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>
[¶m_<key>=<url-encoded value>...]
HTTP/1.1
Host: <host>:<port>
Authorization: Basic <base64(user:password)>
X-ClickHouse-Client-Name: <client_name>
Connection: close
Content-Length: 0
ClickHouse replies with TabSeparatedWithNamesAndTypes:
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
Streaming / progress callbacks — HTTP returns the full result
before the connection closes. There is no way to stream partial results
or receive row-by-row progress callbacks.
No mid-query cancel — once a request is sent, the driver has no
handle to cancel it. Drop the connection if you must abort.
Inline-VALUES only — large bulk inserts (≫ a few thousand rows)
should switch to ClickHouse’s native TCP protocol for the actual data
transfer, or use the client_name HTTP insert API which supports a
body-streaming variant via POST with content-type application/x-ndjson.
This driver sticks to the simplest form: SQL with literal VALUES, with
the literals coming from params.
Response body size limit — read_response_body enforces a 256 MB
cap to avoid runaway memory. Queries returning more should use filters or
aggregation.
Run the example CLI
moon run cmd/main
The CLI demo (cmd/main/main.mbt) walks through ping, a parameterised
SELECT, CREATE TABLE, an inline-VALUES INSERT with parameters, a
parameterised SELECT … WHERE …, exception handling, and cleanup.
liuhuo23/clickhouse-driver
English | 简体中文
A ClickHouse HTTP driver for MoonBit — simple, portable, and stateless.
Overview
{name: Type}placeholder protocol:execute_query(sql, params?)— named binding with{name}/{name: Type}. Untyped{name}defaults toString, so table names, identifiers, and string columns read naturally.execute(sql, values)— positional binding with?. 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. Values are passed asparam_<key>=<value>URL parameters and substituted server-side (quoted and escaped automatically).try-catchwith aDbErrorsuberror (ServerError/ConnectionError).Installation
Add the dependency to your
moon.mod:Then in your package’s
moon.pkg:Quick start
API reference
connectBuilds a
Connectionconfig from explicit parameters. Does not open a TCP connection — calls are stateless and open fresh HTTP connections per request.hostStringportInt8123)userStringpasswordStringdatabaseStringclient_nameStringX-ClickHouse-Client-NameheaderConnectionA lightweight config struct — no persistent socket. Every call opens a short-lived HTTP connection and closes it on return.
Connection::pingSends
SELECT 1and expects 200 OK. Lightweight health check.Connection::execute_queryExecutes any SQL statement (SELECT, DDL, inline-VALUES INSERT, …) with named parameter binding and returns the parsed result.
paramsis an optional map of named parameters. Each entry is sent as aparam_<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.For the common case where a parameter is a string (table name, identifier, or string column value), the type can be omitted —
{name}is treated as{name: String}automatically. Use the explicit{name: Type}form only when binding into a non-String column (numbers, dates, etc.).Examples:
Connection::executeExecutes SQL with positional
?placeholders. Each?in the SQL is bound to the next value invalues, in order. 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.All values are bound as
String; ClickHouse coerces them to the target column type on the server side (works for numbers, dates, and most common scalar types). For non-String columns where coercion is not enough, fall back toexecute_querywith explicit{name: Type}named binding.Examples:
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::cancelNo-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::closeNo-op over HTTP. Use with
deferfor symmetry:ResultSetcolumnsis populated when the response usesTabSeparatedWithNamesAndTypes(which is whatexecute_queryrequests by default). For DDL / INSERT statements the array is empty.ResultSet::to_mapConverts 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 ifcolumnsis empty.RowA single row. Each cell is a string representation of the underlying ClickHouse value (e.g.
"42","2025-01-01 00:00:00","NULL").ColumnColumn metadata parsed from
TabSeparatedWithNamesAndTypes.type_is the raw ClickHouse type string, e.g."UInt32","String","Nullable(Int64)".Error handling
DbErrorsuberrorServerErrorcode : Int,name : String,message : StringConnectionErrorStringcodeis the HTTP status code (typically400for client errors like syntax / unknown table,500for server errors).nameis"HTTPError".messageis 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:
ClickHouse replies with
TabSeparatedWithNamesAndTypes:The driver parses this into
ResultSet { columns, rows }.Why POST? ClickHouse’s HTTP interface treats
GETrequests asreadonly(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 withparam_<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/ROLLBACKand noSerializableisolation.INSERT … SELECTis atomic at the part level. For data with versioning semantics, use one of the special engines:ReplacingMergeTree(version_column)— keeps the row with the largestversion_columnafter merge.CollapsingMergeTree(sign_column)— uses asigncolumn (+1insert,-1cancel) to collapse pairs of rows on merge.VersionedCollapsingMergeTree(version, sign)— likeCollapsingMergeTreebut order-independent.SummingMergeTree/AggregatingMergeTree— for state-aggregation patterns.Limitations
client_nameHTTP insert API which supports a body-streaming variant via POST with content-typeapplication/x-ndjson. This driver sticks to the simplest form: SQL with literal VALUES, with the literals coming fromparams.read_response_bodyenforces a 256 MB cap to avoid runaway memory. Queries returning more should use filters or aggregation.Run the example CLI
The CLI demo (
cmd/main/main.mbt) walks through ping, a parameterised SELECT,CREATE TABLE, an inline-VALUES INSERT with parameters, a parameterisedSELECT … WHERE …, exception handling, and cleanup.