ChDriver.Protocol.NativeBlock (ch_driver v0.3.0)

Copy Markdown

Decodes ClickHouse's "Native" block format — the columns and rows of a query result, once any compression envelope has been stripped away.

This is internal to the driver's decoding pipeline. A block carries its column names, types, and row data; decode_block/1 turns that into %{columns: [{name, type}], rows: [[term]]}.

Only a pragmatic subset of ClickHouse's type system is supported — see ChDriver.Types.Registry for the scalar types and ChDriver.Protocol.Block.Wrappers for compound types like Array, Map, and Nullable. If you're adding support for a new ClickHouse type, ARCHITECTURE.md has the map of which module owns what.

Serialization prefixes are hoisted to the front of the column

Some types carry a fixed-size serialization prefix ahead of their row data: LowCardinality(T) an 8-byte dictionary key version, and Variant(...) an 8-byte discriminator mode. The non-obvious part is that ClickHouse writes every prefix in a column's type tree before any of that column's data, not immediately before the sub-column the prefix belongs to.

So Array(LowCardinality(String)) is [LC key version][array offsets][LC dictionary + indexes], not [array offsets][LC key version][LC dictionary + indexes]. Reading the prefix inline (where the nesting would suggest) consumes the first 8 bytes of the offsets instead, which silently mis-splits the rows rather than failing loudly.

That's why decoding is two-phase: decode_prefixes/2 walks the type tree depth-first and consumes every prefix up front, then decode_column_data/4 decodes the data with those prefix values passed back in, popping them in the same depth-first order they were read. A zero-row block carries no prefixes at all, so phase one is skipped entirely in that case.

Summary

Functions

Decodes a Native block (everything after the external table name): BlockInfo, column count, row count, and each column's name/type/data.

Decodes num_rows rows of type from binary, consuming any hoisted serialization prefixes from prefixes (in the depth-first order decode_prefixes/2 produced them).

Decodes a Data or ProfileEvents packet body: the external table name, followed by a Native block.

Consumes the hoisted serialization prefixes for type from the front of binary (see the moduledoc for why they're all up front rather than inline), returning {:ok, prefixes, rest} where prefixes is the depth-first-ordered list of prefix values.

Functions

decode_block(binary)

@spec decode_block(binary()) ::
  {:ok, map(), binary()} | {:incomplete, binary()} | {:error, term()}

Decodes a Native block (everything after the external table name): BlockInfo, column count, row count, and each column's name/type/data.

Returns {:ok, %{columns: [{name, type}], rows: [[term]]}, rest}, {:incomplete, binary}, or {:error, reason}.

decode_column_data(type, num_rows, binary, prefixes)

Decodes num_rows rows of type from binary, consuming any hoisted serialization prefixes from prefixes (in the depth-first order decode_prefixes/2 produced them).

Returns {:ok, values, rest, remaining_prefixes}.

decode_data_packet(binary, compression \\ :none)

@spec decode_data_packet(binary(), ChDriver.Protocol.Block.Compressed.method()) ::
  {:ok, map(), binary()} | {:incomplete, binary()} | {:error, term()}

Decodes a Data or ProfileEvents packet body: the external table name, followed by a Native block.

compression (:none (default) or :lz4) must match whatever was negotiated for this query — when :lz4, the block is decompressed before decoding.

Returns {:ok, %{table_name:, columns:, rows:}, rest}, {:incomplete, binary}, or {:error, reason}.

decode_prefixes(type, binary)

Consumes the hoisted serialization prefixes for type from the front of binary (see the moduledoc for why they're all up front rather than inline), returning {:ok, prefixes, rest} where prefixes is the depth-first-ordered list of prefix values.

Types with no prefix of their own still recurse into their inner types, since a nested LowCardinality/Variant anywhere in the tree contributes one.