TCP J3: Add Variant(...) support - #459
Conversation
TriageCategory: Summary What this impacts
Concerns
Required reviewer action
|
There was a problem hiding this comment.
Pull request overview
Adds TCP/native support for ClickHouse Variant(T1, ..., Tn) columns and updates the codec prefix-writing API so state prefixes can be written for the same row-slice as the subsequent column body (required for future data-dependent prefixes).
Changes:
- Widened
IColumnCodec.WriteStatePrefixto accept(column, start, length)and updated the block writer + composite codecs to forward the slice. - Implemented
VariantColumn(dense discriminator + per-alternative child runs) andVariantColumnCodec(BASIC discriminators mode). - Added unit tests and INSERT→SELECT round-trip coverage for Variant, and repointed “unsupported type” tests from
Variant(...)toPoint.
Reviewed changes
Copilot reviewed 17 out of 17 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| ClickHouse.Driver.Tcp/Types/VariantColumn.cs | New dense column type for Variant with discriminator stream + child columns. |
| ClickHouse.Driver.Tcp/Types/IColumnCodec.cs | API change: WriteStatePrefix now receives (column, start, length). |
| ClickHouse.Driver.Tcp/Types/ColumnCodecRegistry.cs | Registers Variant(...) factory in the default codec registry. |
| ClickHouse.Driver.Tcp/Types/ColumnCodecExtensions.cs | Adds full-column WriteStatePrefix(codec, writer, column) convenience overload. |
| ClickHouse.Driver.Tcp/Types/Codecs/VariantColumnCodec.cs | New Variant codec (BASIC mode) with dense + ergonomic write paths. |
| ClickHouse.Driver.Tcp/Types/Codecs/TupleColumnCodec.cs | Forwards slice parameters through WriteStatePrefix. |
| ClickHouse.Driver.Tcp/Types/Codecs/NullableColumnCodec.cs | Forwards slice parameters through WriteStatePrefix. |
| ClickHouse.Driver.Tcp/Types/Codecs/NestedColumnCodec.cs | Forwards slice parameters through WriteStatePrefix. |
| ClickHouse.Driver.Tcp/Types/Codecs/MapColumnCodec.cs | Forwards slice parameters through WriteStatePrefix. |
| ClickHouse.Driver.Tcp/Types/Codecs/LowCardinalityColumnCodec.cs | Updates prefix writer signature (prefix remains data-independent). |
| ClickHouse.Driver.Tcp/Types/Codecs/ArrayColumnCodec.cs | Forwards slice parameters through WriteStatePrefix. |
| ClickHouse.Driver.Tcp/Format/BlockWriter.cs | Writes state prefix with the same (start, rowCount) slice as the body. |
| ClickHouse.Driver.Tcp.Tests/Utilities/InsertRoundTripCase.cs | Adds Variant INSERT→SELECT round-trip cases and server settings. |
| ClickHouse.Driver.Tcp.Tests/Types/VariantColumnCodecTests.cs | New byte-anchored unit tests for Variant codec. |
| ClickHouse.Driver.Tcp.Tests/Types/NullableColumnCodecTests.cs | Updates “unsupported inner” test to use Point instead of Variant. |
| ClickHouse.Driver.Tcp.Tests/Types/LowCardinalityColumnCodecTests.cs | Updates tests to use new WriteStatePrefix overload. |
| ClickHouse.Driver.Tcp.Tests/Types/ColumnCodecRegistryTests.cs | Updates “unsupported but well-formed” test to use Point. |
8ce4435 to
436da05
Compare
436da05 to
32cbdaa
Compare
bd760dc to
d9ad7e0
Compare
d9ad7e0 to
b34f599
Compare
b34f599 to
63e0070
Compare
63e0070 to
1c14219
Compare
1c14219 to
1feb65c
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 18 out of 18 changed files in this pull request and generated 2 comments.
Comments suppressed due to low confidence (1)
ClickHouse.Driver.Tcp/Types/VariantColumn.cs:69
- VariantColumn precomputes LocalIndices by indexing counters[d] for every non-NULL discriminator, but it never validates that d is within the typeColumns range. If a malformed discriminator (or a VariantColumn constructed incorrectly) contains a value >= typeColumns.Length (and not 255), this will throw IndexOutOfRangeException during construction rather than a clear argument/format error.
for (int row = 0; row < rowCount; row++)
{
byte d = discriminators[row];
localIndex[row] = d == NullDiscriminator ? -1 : counters[d]++;
}
| // Map each alternative's writable CLR types to its discriminator, so the ergonomic write path can pick a | ||
| // row's alternative from the runtime type of its value. The canonical element type is registered first; | ||
| // if two alternatives claim the same CLR type the lower discriminator wins (the server does not allow | ||
| // duplicate alternative types, so this is only a defensive tie-break). | ||
| discriminatorByClrType = new Dictionary<Type, int>(); | ||
| bool writable = true; | ||
| for (int i = 0; i < typeCount; i++) | ||
| { | ||
| childFlatBuilders[i] = (Func<string, string, object[], int, IColumn>)builderTemplate | ||
| .MakeGenericMethod(children[i].ElementType) | ||
| .CreateDelegate(typeof(Func<string, string, object[], int, IColumn>)); | ||
|
|
||
| discriminatorByClrType.TryAdd(children[i].ElementType, i); | ||
| foreach (Type writeType in children[i].WritableElementTypes) | ||
| { | ||
| discriminatorByClrType.TryAdd(writeType, i); | ||
| } | ||
|
|
||
| // Probe writability with an empty child column so a Variant over a non-writable alternative (e.g. | ||
| // Nothing) is rejected up front rather than mid-write. | ||
| IColumn probe = childFlatBuilders[i](string.Empty, children[i].TypeName, Array.Empty<object>(), 0); | ||
| writable &= children[i].CanWrite(probe); | ||
| } |
| // The rebuilt wrapper borrows its children (freshly densified ones are unpooled, unchanged ones are | ||
| // still owned by the original), so it does not dispose them. | ||
| return new VariantColumn(column.Name, column.TypeName, denseSource.Discriminators.ToArray(), densified, column.RowCount, pooledDiscriminators: false, ownsColumns: false); |
1feb65c to
38ee038
Compare
38ee038 to
f57f502
Compare
f57f502 to
8d9bbc3
Compare
eff12ec to
566a0be
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
d131fbd to
93937df
Compare
93937df to
d09236a
Compare
d09236a to
be8298c
Compare
f149f19 to
a05d662
Compare
a05d662 to
c576a45
Compare
c576a45 to
6074afb
Compare
Reads and writes the ClickHouse Variant composite: a fixed mode word, then the per-row discriminator byte stream, then each alternative's values in alternative order. The ergonomic insert source is a flat IColumn<object> whose values' runtime CLR types select their alternative, with null marking a NULL row. Also widens IColumnCodec.WriteStatePrefix from (writer) to (writer, column, start, length). A type whose prefix bytes derive from the data must see the same rows the following body writes, and each block's prefix reflects only that block's rows. Delegating composites forward the outer column and slice unchanged, which is byte-identical for every type supported today. Squashed from four commits. Three of them -- the prefix widening, the initial Variant support, and a densify step this range later dropped -- carried unresolved conflict markers across eight files, committed by a botched rebase resolution and then papered over by the fourth commit rewriting the same regions. Tip builds stayed green, so it went unnoticed. The tree here is that fourth commit's, so the markers and the non-compiling intermediate states are gone by construction. Co-Authored-By: Claude <noreply@anthropic.com>
…only PR 459 feedback: the discriminator map registered each alternative's convenience write types (e.g. DateTime for a DateTimeOffset alternative) as well as its canonical element type, but each per-alternative bucket is materialized as the canonical ElementType (BuildFlatColumn<ElementType>), so a convenience-typed value would fail the bucket cast with a deep InvalidCastException. Register only the canonical element type, so such a value is rejected up front with a clear "no alternative" error. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The indexer read discriminators[row] straight out of the buffer, which is normally a pooled array longer than the column. That made an out-of-range row's behavior depend on whatever byte was left in the tail: a stale 255 reported the row as an existing NULL, while any other value fell through to the exactly-sized local-index array and threw. Indexing through the RowCount-sliced Discriminators span makes both spellings a bounds failure. The row count stays a parameter: each child column holds only the rows that selected it and a NULL row takes a slot in none of them, so the children say nothing about the height. The constructor validates the discriminator buffer instead, which also stops the local-index walk faulting on a short buffer with nothing naming the cause. Co-Authored-By: Claude <noreply@anthropic.com>
IVariantColumn already described the wire layout — discriminators, per-type child columns, and the precomputed per-row index into the selected child — so this promotes it rather than adding anything. Variant needs the columnar surface more than the other composites: it has no useful materialized element type, so its IColumn<T> surface is IColumn<object> and every row read through it is boxed. Dispatching on the discriminator and reading the selected child column is the only typed way in. NullDiscriminator moves from VariantColumn onto the interface. The interface doc refers to it, and VariantColumn is internal, so leaving it there would have left a public interface documented in terms of a constant no consumer can name.
Making IVariantColumn public in the previous commit turned the dense write path's trust boundary into a public extension point. The writer trusts invariants only VariantColumn's constructor establishes — every discriminator is a valid alternative index or the NULL marker, and LocalIndices is exactly as long as the column with a correct per-type running index — and it puts the discriminators on the wire before touching them, so a bad value from a caller-supplied implementation would desync the block mid-stream rather than fail cleanly. Gate on the concrete VariantColumn instead, which is what every other composite already does (ArrayValueColumn, MapColumn, NestedColumn, NullableValueColumn, LowCardinalityColumn) and what the LowCardinality split established one branch earlier. The interface stays public as a read view; anything else writable still arrives as IColumn<object> and takes the scattered path, which validates as it goes.
Say that a child column is the block's to dispose rather than the caller's, and that GetTypeColumn throws IndexOutOfRangeException outside [0, TypeCount) — which includes NullDiscriminator, the value the documented dispatch idiom reads straight off Discriminators, so it is the easy mistake to make.
Variant builds no per-slice write state, so it forwards its own column to every alternative's prefix phase and relies on each one ignoring it. For a Tuple alternative that lands in the tuple codec's forwarding walk over its children -- a live path with no test behind it: nothing in the suite paired Variant or Map with a Tuple, leaving that branch at zero coverage. Adds Variant(String, Tuple(UInt8, String)), and the same shape with a LowCardinality element inside the tuple so a child that carries its own state prefix has to reach the wire through the forwarded column. That second case is the one with teeth: dropping the forwarding walk desyncs the block mid-stream rather than throwing. Co-Authored-By: Claude <noreply@anthropic.com>
Variant was the only multi-child composite that built no write state. It wrote its prefix by handing its own column to every alternative and relying on each one ignoring it -- which held only because every alternative's prefix is data-independent, an invariant enforced from a different file (Dynamic is rejected inside Variant precisely because it would break it). It was also the reason Array, Tuple, Map and Nested each carry a branch for "an outer composite forwarded me a column I cannot project": Variant was the only forwarder that could hand a composite child a foreign column. BeginWrite now projects the slice into one column per alternative -- borrowed from the dense column, or scattered out of the boxed values -- and opens each alternative's own child state over it, which both phases then share. The per-alternative slicing is the same single pass WriteDense already did, so this moves work rather than adding it, and the body no longer re-derives it. Every alternative gets a column and a state even when no row selects it: the alternative set comes from the type, not the data, so each prefix belongs on the wire regardless. Two round-trip cases pin that -- a LowCardinality alternative and a Tuple-wrapping-LowCardinality alternative, each selected by no row, whose dictionary prefix must still be emitted. Two incidental improvements: the scatter now runs before any bytes are written, so a value with no matching alternative fails before the block is partly on the wire rather than after the prefix; and the wire output is unchanged, as the byte-exact codec tests confirm. Co-Authored-By: Claude <noreply@anthropic.com>
Map reaches its key and value codecs through a shape object rather than directly, so it is the composite whose own write state is most easily bypassed, and nothing paired it with a variant. The value is a LowCardinality so the case also pins that a dictionary prefix two composites down still reaches the wire. Also drops two comments on the Tuple-alternative cases that described the forwarding design the previous commit removed. Co-Authored-By: Claude <noreply@anthropic.com>
Same split as the other composites. Co-Authored-By: Claude <noreply@anthropic.com>
6074afb to
d4536a2
Compare
Stacked on #456 (TCP J1: LowCardinality). Adds
Variant(...)support and the write-path prefix-signature change it sits on top of.J0a —
WriteStatePrefixreceives the sliced columnWidened
IColumnCodec.WriteStatePrefix(writer)→WriteStatePrefix(writer, column, start, length). A type whose prefix bytes derive from the data (a runtime-discovered type list) must see the same rows the following body writes, and each block's prefix reflects only that block's rows.WriteStatePrefix(codec, writer, column)convenience overload mirroring the existingWriteColumnhelper.J3 —
Variant(T1, ..., Tn)BASIC discriminatorsA discriminated union: each row holds a value of one alternative or NULL. Only the BASIC discriminators mode is supported (the server default over the native protocol); a COMPACT prefix is rejected.
Wire layout (per non-empty block):
UInt64mode word (0= BASIC) → each alternative's own state prefix (empty for every supported alternative) → oneUInt8discriminator per row (255= NULL) → a dense run per alternative, in declared order, holding the rows that selected it.Nullable— NULL is the discriminator's job — so, like Array/Tuple/Map/Nested, there is noNullable(Variant(...))round-trip; NULL composes inside instead.VariantColumn(discriminators + per-type child columns) — the zero-copy write source — with a precomputed per-row local index so measurement/addressing is O(1).IColumn<object>, scattered by each value's runtime CLR type into per-type buffers via the tuple flat-write machinery.Array(Variant(...))) and takes a composite alternative (Variant(Array(T), ...)).Testing
Variant(Array(UInt64), String), andArray(Variant(...))).VariantColumn100%,VariantColumnCodec~92% (remainder is defensive catch/guard paths).Variant(...)as an "unsupported type" stand-in were repointed atPoint(now that Variant resolves).CanWriteto reject a wrong-arity dense column.Known limitation
On the ergonomic (
IColumn<object>) write path, a variant whose alternatives share a convenience CLR write type (e.g.Variant(DateTime, DateTime64)) resolves ambiguously (lower discriminator wins). The denseVariantColumnpath is unaffected. Noted for follow-up.🤖 Generated with Claude Code