Skip to content

Commit 1b25cfb

Browse files
committed
Support the modern JSON type (#142)
Object('json') was removed from the server in 24.8, so JSON was not merely legacy on current ClickHouse - it was unusable: reading a JSON column raised UnknownTypeError, and the one format asynch implemented no longer existed server-side. Read path composes the format out of column readers rather than hand-rolling it. dynamiccolumn.pyx implements SerializationDynamic/SerializationVariant: the variant list arrives in the state prefix, global discriminators are recovered by sorting the type names (SharedVariant sorts among them, so its index has to be derived, not assumed), and values that match no declared variant are decoded from their encodeDataType + serializeBinary blob. That decoder builds the value's column reader from the decoded type string and drives it over an in-memory reader, so every type asynch already supports is reachable inside a shared JSON value for free. jsoncolumn.pyx folds the per-path dynamic columns and the Array(Tuple(String, String)) shared sub-column back into nested dicts, including dotted-path denormalisation. Writing infers a spec per value, groups rows per path and variant, and emits V2 framing; the block's items now reach write_state_prefix, since the prefix lists the paths about to be written. Dynamic is registered as a column type too - it shows up as a variant spec for heterogeneous arrays. Object('json') keeps its own reader: its framing (UInt8 version then a type spec) is incompatible, and only old servers speak it. Verified against ClickHouse 26.7: nested objects, heterogeneous and nested arrays, arrays of objects, nulls, big integers, unicode, empty documents, a 2000-row multi-path batch, dict and text input, and server-side path access (doc.a.b) on what the driver wrote. Round-trip equality holds for every document in the test set. Known gap: a JSON column nested inside Array/Tuple can be read but not written - that needs the container columns to thread items through their prefixes.
1 parent 32d5401 commit 1b25cfb

8 files changed

Lines changed: 947 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,12 @@ unchanged.
3232

3333
#### API
3434

35+
- Support for the modern `JSON` type (ClickHouse 24.8+). Reading composes a
36+
`Dynamic`/`Variant` reader per path with the shared-data sub-column and
37+
returns nested dicts; writing accepts dicts or JSON text and emits the V2
38+
object framing. `Dynamic` is readable as a column type in its own right.
39+
The pre-24.8 `Object('json')` spelling keeps its own reader, since modern
40+
servers reject the type outright (#142)
3541
- `Connection.cancel()` / `Cursor.cancel()` stop a running query from another
3642
task. Only the cancel packet is sent - the task awaiting the query owns the
3743
read side and drains the stream, so the connection stays usable (#104)

README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,29 @@ async def stream_rows(conn: Connection):
210210
process(row)
211211
```
212212

213+
### JSON columns
214+
215+
`JSON` columns (ClickHouse 24.8+) read as nested dicts and accept dicts or
216+
JSON text on insert.
217+
218+
```python
219+
async def use_json(conn: Connection):
220+
async with conn.cursor() as cursor:
221+
await cursor.execute(
222+
"CREATE TABLE test.events (id UInt32, doc JSON) ENGINE = MergeTree ORDER BY id"
223+
)
224+
await cursor.execute(
225+
"INSERT INTO test.events (id, doc) VALUES",
226+
[
227+
(1, {"user": {"name": "ada"}, "tags": ["a", "b"]}),
228+
(2, '{"user": {"name": "bob"}}'), # JSON text works too
229+
],
230+
)
231+
232+
await cursor.execute("SELECT doc FROM test.events ORDER BY id")
233+
assert await cursor.fetchone() == ({"user": {"name": "ada"}, "tags": ["a", "b"]},)
234+
```
235+
213236
### Cancelling a query
214237

215238
A long-running query can be stopped from another task; the connection is left

asynch/proto/columns/__init__.pyx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ from .intervalcolumn import (
4141
IntervalYearColumn,
4242
)
4343
from .ipcolumn import IPv4Column, IPv6Column
44+
from .dynamiccolumn import create_dynamic_column
4445
from .jsoncolumn import create_json_column
4546
from .lowcardinalitycolumn import create_low_cardinality_column
4647
from .mapcolumn import create_map_column
@@ -135,7 +136,12 @@ def get_column_by_spec(spec, column_options):
135136

136137
elif spec.startswith("Map"):
137138
return create_map_column(spec, create_column_with_options, column_options)
138-
elif spec.startswith("Object('json')"):
139+
elif spec == "Dynamic" or spec.startswith("Dynamic("):
140+
return create_dynamic_column(spec, create_column_with_options, column_options)
141+
142+
elif spec.startswith("Object('json')") or spec == "JSON" or spec.startswith("JSON("):
143+
# `Object('json')` is the pre-24.8 spelling; modern servers only know
144+
# `JSON`, optionally with parameters that do not change the layout.
139145
return create_json_column(spec, create_column_with_options, column_options)
140146
else:
141147
for alias, primitive in aliases:
@@ -183,7 +189,12 @@ async def write_column(
183189
column = get_column_by_spec(column_spec, column_options)
184190

185191
try:
186-
await column.write_state_prefix()
192+
if getattr(column, "prefix_needs_items", False):
193+
# A JSON column's prefix lists the paths it is about to write, so
194+
# it can only be produced from the block's items.
195+
await column.write_state_prefix(items)
196+
else:
197+
await column.write_state_prefix()
187198
await column.write_data(items)
188199

189200
except ColumnTypeMismatchException as e:
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# Generated by stubgen-pyx from asynch/proto/columns/dynamiccolumn.pyx
2+
3+
"""Reader for ClickHouse's Dynamic / Variant column families.
4+
5+
These are not user-facing types yet: they exist so the JSON column can be
6+
composed out of them instead of hand-rolling Variant deserialization. The
7+
byte layout mirrors `SerializationDynamic` and `SerializationVariant`.
8+
"""
9+
10+
from .base import Column
11+
12+
DYNAMIC_V1 = 1
13+
DYNAMIC_V2 = 2
14+
VARIANT_MODE_BASIC = 0
15+
VARIANT_MODE_COMPACT = 1
16+
NULL_DISCRIMINATOR = 255
17+
SHARED_VARIANT_NAME = "SharedVariant"
18+
19+
class DynamicColumn(Column):
20+
"""A ClickHouse `Dynamic` column.
21+
22+
The variant types are not known at construction: they arrive on the wire
23+
in `read_state_prefix`. The list always ends with an implicit
24+
`SharedVariant` - a byte string carrying `encodeDataType +
25+
serializeBinary` blobs - which catches values whose type matches none of
26+
the declared variants.
27+
"""
28+
29+
py_types: tuple[type, ...] | None
30+
31+
def __init__(self, column_by_spec_getter, shared_value_decoder=None, **kwargs): ...
32+
async def read_state_prefix(self): ...
33+
async def read_items(self, n_items): ...
34+
35+
class SharedValueDecoder:
36+
"""Decoder for the `encodeDataType + serializeBinary` payloads carried by
37+
a SharedVariant's underlying byte-string column.
38+
39+
One instance per block: the column cache then accumulates across every
40+
overflow value, both the JSON column's own shared paths and those of
41+
every nested Dynamic column.
42+
"""
43+
def __init__(self, column_by_spec_getter): ...
44+
async def decode(self, blob):
45+
"""Decode one shared value; returns the Python object it encodes.
46+
47+
Async because the column readers are: nothing here touches the
48+
socket, the reader is the in-memory blob.
49+
"""
50+
51+
def create_dynamic_column(spec, column_by_spec_getter, column_options): ...

0 commit comments

Comments
 (0)