TCP I4: Add Map(K, V) support - #443
Conversation
TriageCategory: Summary What this impacts
Concerns
Required reviewer action
|
There was a problem hiding this comment.
Pull request overview
Adds native/TCP (ClickHouse binary protocol) support for the ClickHouse Map(K, V) type by introducing a dedicated column shape and codec that mirror the existing Array(T)/Tuple(...) composite patterns.
Changes:
- Added
MapColumnCodecimplementing theMap(K, V)wire layout (offsets + flat key stream + flat value stream), including typed shape-bridging and pooled-buffer write paths. - Added
MapColumn<TKey, TValue>as the dense decoded column representation (flat key/value inner columns + offsets) surfaced asKeyValuePair<TKey, TValue>[]per row. - Registered the new
Mapcodec and expanded unit + round-trip test coverage; updated prior “unsupported composite” placeholders fromMap(...)toNested(...).
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| ClickHouse.Driver.Tcp/Types/MapColumn.cs | New dense decoded column type for Map(K,V) exposing rows as KeyValuePair[] while retaining zero-copy inner streams for re-serialization. |
| ClickHouse.Driver.Tcp/Types/ColumnCodecRegistry.cs | Registers the Map factory in the default codec registry. |
| ClickHouse.Driver.Tcp/Types/Codecs/MapColumnCodec.cs | New Map(K,V) codec: state prefix delegation, offsets parsing, dense/jagged write paths, and row sizing logic. |
| ClickHouse.Driver.Tcp.Tests/Utilities/InsertRoundTripCase.cs | Adds integration-style round-trip cases for multiple Map shapes and nesting. |
| ClickHouse.Driver.Tcp.Tests/Types/NullableColumnCodecTests.cs | Updates “unsupported inner” test placeholder from Map(...) to Nested(...). |
| ClickHouse.Driver.Tcp.Tests/Types/MapColumnCodecTests.cs | New focused unit tests covering read/write, slicing, null-row rejection, offset corruption guards, and sizing paths. |
| ClickHouse.Driver.Tcp.Tests/Types/ColumnCodecRegistryTests.cs | Updates “well-formed but unsupported” test placeholder from Map(...) to Nested(...). |
24c7c26 to
7010709
Compare
7010709 to
3cb0944
Compare
3cb0944 to
88b5947
Compare
88b5947 to
86201dd
Compare
86201dd to
59b6878
Compare
59b6878 to
88b953f
Compare
88b953f to
da7fde3
Compare
da7fde3 to
190ab72
Compare
190ab72 to
824ac04
Compare
824ac04 to
5a76546
Compare
78a9d34 to
8fb5213
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
8fb5213 to
206dc7f
Compare
206dc7f to
5554777
Compare
5554777 to
5a74d9e
Compare
5a74d9e to
df92c99
Compare
df92c99 to
0e32beb
Compare
0e32beb to
75e4db4
Compare
Map is byte-identical to Array(Tuple(K, V)) on the wire: the key then the value codec's state prefix, a per-row UInt64 offsets stream, then a flat keys stream and a flat values stream, positionally aligned. MapColumnCodec reuses Array's offset framing and Tuple's per-element streams, bridging the two child codecs to a typed MapColumn<TKey, TValue> through a cached per-type-pair shape. Each row surfaces as KeyValuePair<K, V>[] rather than Dictionary<K, V> so that duplicate keys and pair order — both meaningful on the wire — round-trip intact; a dictionary would silently collapse duplicates. Map rows are non-nullable and the server rejects Nullable(Map(...)), so nullability composes inside the value as Map(K, Nullable(V)); Map keys are themselves non-nullable. Two existing tests used Map as their "well-formed but unimplemented type" placeholder; repointed them to Nested(...), the next unimplemented composite. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Map gains IColumnCodec.Densify: it projects the ergonomic KeyValuePair[]-per-row column into the dense map column (offsets + a flat key column + a flat value column) once, recursing the key and value codecs' own Densify so a composite key/value (Map(K, Array(V)), Map(K, Tuple(...)), Map(K, Nullable(V))) becomes dense all the way down; an already-dense map is returned by reference when neither child changed. This is what lets the pipeline hand Map's children the dense form their now-single dense write path expects. When only one of the key/value columns needs densifying, the rebuilt MapColumn keeps the other by reference (still owned by the source column) and builds the changed one fresh. MapColumn previously always disposed both, so disposing such a rebuilt wrapper would double-dispose the borrowed column (returning pooled buffers twice). It now takes per-child ownership (RestrictOwnership) and the rebuild owns only the column it created. The dense-form end-to-end coverage — InsertAsync_DenseReadbackReinserted_RoundTripsThroughSelect, introduced earlier in the stack — extends to Map automatically: it re-inserts the dense read-back MapColumn for every Map InsertRoundTripCase alongside the ergonomic one. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Map writes its offsets then the flattened key/value runs straight from the ergonomic source, recursing into the key and value codecs, with no densify pre-pass or byte measurement. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…he gaps Six codec round-trips asserted values the Map cases in InsertRoundTripCase already prove through a real INSERT/SELECT: ..._FixedWidthKeyAndValueRoundTripsWithEmptyRows -> Maps<string,uint> / Maps<byte,string> ..._StringKeyRoundTrips -> Maps<string,uint> ..._NullableValueRoundTrips -> Maps<string,uint?> (same rows) ..._ArrayValueRoundTrips -> Maps<string,int[]> (same rows) ReadColumn_EveryRowEmpty_RoundTripsAsAllEmpty -> the "every row empty" case WriteColumn_DenseMapColumn_... -> InsertAsync_DenseReadbackReinserted The dense one is worth noting: it looks like an internal shape unreachable from integration, but InsertAsync_DenseReadbackReinserted re-inserts the read-back MapColumn for every Map case, so that path is covered end to end. ReadColumn_WriteThenRead_DuplicateKeysWithinRowArePreserved stays -- the server rejects duplicate keys on insert, so no round-trip can express it -- and it now also owns the Values coverage, widened with an empty-map row and a RowCount assertion. MapColumn.Values materializes the whole jagged cache in one pass while GetValue takes the uncached indexer, and the length == 0 branch of Values was otherwise unreached. Two integration cases added: a Map with both key and value fixed-width (the existing cases always pair one fixed with one variable), and a Map whose value is itself a Map -- the only path recursing the map shape through itself, which neither layer covered. Verified against ClickHouse 26.6 -- 823 tests pass, integration included. Co-Authored-By: Claude <noreply@anthropic.com>
Same defect the Array column had: Materialize read offsets[row] and offsets[row + 1] out of the offsets buffer, which is normally a pooled array longer than the column. A row past RowCount read the tail, and since a previous, larger read leaves monotonic offsets behind there, the pair is usually still increasing — so the column handed out real-looking pairs from the flat key and value columns rather than throwing. The row count stays a parameter for the same reason it does on Array: the key and value columns are flat (one entry per pair across every row) and the offsets are pooled, so the constructor validates the offsets length instead of deriving anything. Co-Authored-By: Claude <noreply@anthropic.com>
Add IMapColumn<TKey, TValue>, the public read surface for the wire layout under a Map(K, V): the two flat, positionally aligned key and value columns plus the per-row offsets that delimit each row's entries. Same shape IArrayColumn exposes — Map is byte-identical to Array(Tuple(K, V)) — with the run split across two columns. Beyond avoiding the per-row KeyValuePair[] the materialized surface allocates, this lets a caller read only the keys or only the values, and preserves duplicate keys and entry order, which the pair array carries but no dictionary-shaped view could. Offsets stays sliced to RowCount + 1, so the public span cannot expose the tail of the pooled buffer behind it.
Same two corrections as the nullable and array branches: "zero-copy" overclaimed (reading a child column's values still materializes for string-like and composite element types), and the two child columns are IDisposable, so say plainly that they are the block's to dispose rather than the caller's.
Tuple had cases for an Array and a nested Tuple element but none for a Map, so nothing proved the map's offsets and key/value streams get written from the per-element column the tuple projects rather than from the tuple's own column. Co-Authored-By: Claude <noreply@anthropic.com>
Last of the three. The shape's BeginWrite is now called unconditionally, so a column this codec cannot write fails there rather than silently emitting the key and value prefixes against a foreign column. Co-Authored-By: Claude <noreply@anthropic.com>
Map cannot type-test in the codec the way Array and Tuple do: its state is a private nested type of the generic shape, unnameable from the non-generic codec, which is why the codec only null-tests and hands anything non-null through. The hard cast on the other side then failed as a bare InvalidCastException from inside the shape. That cast now reports which state arrived and which was wanted. The cast stays first so the succeeding path builds no type name -- the shape is shared by every map codec with the same key and value CLR types, so it has nowhere to cache one. Co-Authored-By: Claude <noreply@anthropic.com>
75e4db4 to
22f1c0a
Compare
81ef8ff to
22f1c0a
Compare
Summary
Adds
Map(K, V)to the TCP/Native client (epic I4), stacked on the Tuple branch (I3).On the wire,
Map(K, V)is byte-identical toArray(Tuple(K, V)): the key then value codec's state prefix, a per-rowUInt64offsets stream, then a flat keys stream and a flat values stream, positionally aligned (pair i = (keys[i], values[i])). The codec reuses Array's offset framing and Tuple's per-element streams.Design decision:
KeyValuePair<K, V>[], notDictionary<K, V>Each row surfaces as
KeyValuePair<K, V>[]so that duplicate keys and pair order — both meaningful on the wire — round-trip intact. ADictionarywould silently collapse duplicates. This intentionally diverges from the HTTP driver'sDictionary<K, V>(the two clients are permitted to surface different CLR types), and is also more span/pool-friendly.Also accepting dictionary rows on the write path was tried as an experiment and dropped as not worth it. Advertising
Dictionary<K, V>/IReadOnlyDictionary<K, V>as extra writable element types meant a third specialized flatten path, plus a per-row size re-check with a throw on mismatch — unlike a pair array, a dictionary can change size between the flatten's measure and copy passes, and a stale trailing slot would be stored as real data. That is a lot of machinery to save a caller one enumeration into pairs, so a write column suppliesKeyValuePair<K, V>[].Nullable(Map(...)), so nullability composes inside the value asMap(K, Nullable(V)). Map keys are themselves non-nullable in ClickHouse.Map(String, Array(T)),Map(String, Tuple(...)),Array(Map(K, V)).Changes
Types/Codecs/MapColumnCodec.cs— codec +IMapShape/MapShape<K,V>/MapShapesbridge (mirrorsArrayColumnCodec, carrying two child codecs over one offsets vector).Types/MapColumn.cs—IColumn<KeyValuePair<K,V>[]>(mirrorsArrayValueColumn).Types/ColumnCodecRegistry.cs— registerAddFactory("Map", …).Tests/Types/MapColumnCodecTests.csand Map cases inTests/Utilities/InsertRoundTripCase.cs.ColumnCodecRegistryTests,NullableColumnCodecTests) that usedMap(...)as their "well-formed but unimplemented type" placeholder toNested(...), the next unimplemented composite.Testing
MapColumnCodecTestsand 7 integration round-trip cases proven against a real ClickHouse server (Map(String, UInt32),Map(UInt8, String), empty/all-empty,Map(String, Nullable(UInt32)),Map(String, Array(Int32)),Map(String, Tuple(Int32, String)),Array(Map(String, UInt32))).MapColumn.cs100%,MapColumnCodec.cs92.9% (remainder = multi-GB overflow guards + cleanup catch-blocks, same as Array).MeasureRowso the commonMap(String, …)path doesn't allocate per row).🤖 Generated with Claude Code