ChDriver

A native-protocol DBConnection driver for ClickHouse -- it speaks ClickHouse's binary TCP protocol directly, rather than going over HTTP. It's usable on its own, independent of Ecto: ChDriver.start_link/1, ChDriver.query/2..4, ChDriver.query!/2..4, and ChDriver.stream/2..4 are the whole public surface. Everything else (ChDriver.DBConnection, ChDriver.Connection, ChDriver.Protocol) is wiring underneath it.

adapter (Ecto.Adapters.ClickHouse) builds an Ecto adapter on top of this driver -- Ecto.Adapters.ClickHouse.Connection implements Ecto.Adapters.SQL.Connection by driving a %ChDriver.Query{} through the normal DBConnection parse/encode/execute flow.

Usage

{:ok, pool} = ChDriver.start_link(hostname: "localhost", port: 9000, database: "default")
{:ok, %ChDriver.Result{columns: columns, rows: rows}} =
ChDriver.query(pool, "SELECT number FROM system.numbers LIMIT 5")
# query!/2..4 raises instead of returning {:error, reason}
result = ChDriver.query!(pool, "SELECT number FROM system.numbers WHERE number > {min:UInt64} LIMIT 5",
[{"min", "10", 1}])
# stream/2..4 yields one wire-protocol Data block at a time instead of
# buffering the whole result; `conn` must already be checked out via
# DBConnection.run/3 or DBConnection.transaction/3
{:ok, rows} =
DBConnection.run(pool, fn conn ->
conn
|> ChDriver.stream("SELECT number FROM system.numbers LIMIT 200000")
|> Enum.take(5)
end)

start_link/1 accepts ChDriver.Connection.connect/1's connection options (:hostname, :port, :database, :username, :password, :connect_timeout, :recv_timeout, :max_buffer_size, :compression) plus the usual DBConnection.start_link/2 pool options (:pool_size, :name, etc.) -- it's a DBConnection pool of native-protocol connections, so anything that accepts a DBConnection reference works as conn.

query/2..4's params are {name, raw_text} or {name, raw_text, escape_rounds} tuples binding ClickHouse native {name:Type} placeholders written directly in statement -- see ChDriver.Protocol's and ChDriver.Query's moduledocs for the full parameter-binding story (Ecto.Adapters.ClickHouse.Connection's ?-based binding is built on top of this).

Compression: the compressed block envelope and its LZ4/CityHash NIF

ChDriver.Protocol.Block.Compressed (the pure-Elixir compressed-block wire envelope) and ChDriver.Codec (the Rust NIF backing it) together implement ClickHouse's compressed native-block wire envelope, and this driver wires it up via the :compression option (:none, the default, or :lz4) accepted by start_link/1 and overridable per call via query/4's/stream/4's opts. Enabling it negotiates LZ4 compression for both directions of a query's block traffic -- see ChDriver.Connection.connect/1 and ChDriver.Protocol.Messages.encode_query/2 for exactly how that negotiation works and what it means for callers.

Why a custom LZ4/CityHash codec

ClickHouse wraps every block it sends or receives in a fixed envelope:

[16 bytes] CityHash128 checksum (covers everything below)
[1 byte] compression method marker (0x02 = NONE, 0x82 = LZ4, 0x90 = ZSTD)
[4 bytes] compressed size, little-endian
[4 bytes] uncompressed size, little-endian
[...] payload

ChDriver.Protocol.Block.Compressed.encode/2 builds one of these envelopes (:lz4 or :none); decode/1 parses one off the front of a binary, returning the decompressed payload plus any unconsumed trailing bytes so callers can loop over back-to-back blocks. Multiple blocks are simply concatenated back-to-back with no additional framing.

Two things about this envelope make it awkward to build from off-the-shelf packages, which is why ChDriver.Codec exists as a small Rust NIF rather than reaching for a generic Hex dependency:

First, the LZ4 here isn't the LZ4 Frame format you get from the lz4 command line tool. It's the raw LZ4 block format, with no header of its own. ClickHouse supplies its own header instead (the four fields above).

Second, the checksum isn't today's CityHash. ClickHouse froze on CityHash v1.0.2 years ago -- a different, older algorithm than the v1.0.3 that most modern "cityhash" packages implement, not just a different byte-packing of the same hash. The two only agree for short inputs and diverge once the input exceeds roughly 64 bytes, so a generic "cityhash" library on your package manager of choice will silently give you the wrong answer for any real payload. You need the exact old version.

ChDriver.Codec handles both, and packs the checksum into the exact byte order ClickHouse expects on the wire:

compressed = ChDriver.Codec.lz4_compress(data)
# uncompressed_size has to come from somewhere -- ClickHouse's block
# header carries it, since the raw LZ4 format has no length prefix
{:ok, data} = ChDriver.Codec.lz4_decompress(compressed, uncompressed_size)
checksum = ChDriver.Codec.cityhash128(data)
# <<...16 bytes...>>

cityhash128/1 is checked against Google's own published CityHash v1.0.2 test vectors, so you're not just trusting our word for it.

Precompiled builds

ChDriver.Codec is a Rust NIF, and most people don't want to install a Rust toolchain just to use a ClickHouse client. Releases are built for the common targets (macOS and Linux, both gnu and musl, arm64 and x86_64) via rustler_precompiled, so a normal mix deps.get should just download a prebuilt binary rather than compiling anything.

If you're building from source, or on a target we don't precompile for, it'll fall back to compiling the crate locally. That needs cargo and a stable Rust toolchain on your machine.

Installation

Not on Hex. Pull it in from this repo with a path dependency:

def deps do
[
{:ch_driver, path: "path/to/clickhouse_adapter_elixir/ch_driver"}
]
end

Developing on the Rust NIF

mix deps.get
mix test
cd native/ch_driver_native && cargo test