TCP I2: Add Array(T) support - #435
Conversation
TriageCategory: Summary What this impacts
Concerns
Required reviewer action
|
There was a problem hiding this comment.
Pull request overview
Adds ClickHouse Array(T) support to the TCP/Native client type system, enabling native-format read/write of array columns (including nested arrays and arrays of nullable elements) and extending the TCP test suite to cover these composites.
Changes:
- Register
Array(T)in the TCPColumnCodecRegistryand implementArrayColumnCodec(offsets + flattened values stream). - Introduce
ArrayValueColumn<TElement>as the dense (wire-layout) decoded column shape for zero-copy writes. - Add dedicated unit tests for
ArrayColumnCodecand expand TCP insert round-trip coverage to includeArray(T)across supported inner types.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| ClickHouse.Driver.Tcp/Types/ColumnCodecRegistry.cs | Registers the Array codec factory for recursive type resolution. |
| ClickHouse.Driver.Tcp/Types/Codecs/ArrayColumnCodec.cs | Implements Array(T) read/write, measuring, and type bridging via cached shapes. |
| ClickHouse.Driver.Tcp/Types/ArrayValueColumn.cs | Adds a dense array column representation (inner flat column + offsets). |
| ClickHouse.Driver.Tcp.Tests/Utilities/InsertRoundTripCase.cs | Adds integration round-trip cases for Array(T) across supported element types and compositions. |
| ClickHouse.Driver.Tcp.Tests/Types/NullableColumnCodecTests.cs | Updates an “unsupported inner” test to use Tuple instead of Array. |
| ClickHouse.Driver.Tcp.Tests/Types/ColumnCodecRegistryTests.cs | Updates an “unsupported type” test to use Tuple instead of Array. |
| ClickHouse.Driver.Tcp.Tests/Types/ArrayColumnCodecTests.cs | Adds unit tests covering array read/write, slicing, measuring, element typing, and corruption rejection. |
f98211f to
3516b90
Compare
3516b90 to
b8520d1
Compare
b8520d1 to
374417b
Compare
374417b to
8f3e8d3
Compare
a20008b to
5ef30f4
Compare
c78b157 to
7038037
Compare
7038037 to
23b2349
Compare
80377df to
429eae4
Compare
429eae4 to
8a70483
Compare
8a70483 to
72d6fbe
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
72d6fbe to
462c756
Compare
462c756 to
51710d8
Compare
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Implements IColumnCodec.Densify for the Array codec: converts the ergonomic jagged (T[]-per-row) column into the dense wire shape — a per-row offsets array plus a single flat inner column holding every element end-to-end — in one pass, so a later measure or write indexes/slices it with no re-projection. The flat inner run is itself densified through the inner codec, so a nested composite inner (e.g. Array(Nullable(T))) becomes dense all the way down. An already-dense column whose inner is already dense is returned unchanged (by reference). Not yet wired into the write/measure paths; that arrives with the pipeline-level densify and the removal of the ergonomic write/measure branches. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ecedes them With the insert pipeline densifying every column first, an Array column reaching WriteColumn or MeasureRowBytes is always the dense wire shape (offsets + a flat inner column). Drops the jagged branches from both: WriteColumn writes the offsets and the flat inner directly, and MeasureRowBytes prices a row from its offset range. The jagged flatten, null-row rejection, and size guard now live only in Densify (which SumElementCount still backs). The direct-write slice tests use the harness's WriteDenseAsync, and the null-row tests assert on Densify, which is where that validation now runs. Also adds InsertAsync_DenseReadbackReinserted_RoundTripsThroughSelect: for every InsertRoundTripCase it re-inserts the dense read-back column (here the array and primitive/nullable dense columns) into a second table and reads it back, covering each supported type's dense insert form end-to-end alongside the ergonomic one. Later branches inherit it, so their added types (Tuple, Map, Nested, …) gain the same dense coverage as they are introduced. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Establishes the final ergonomic write path: the block layer builds a per-slice BeginWrite state shared by the state prefix and body phases; IColumnWriteState carries it. Array writes its offsets then drives each row's run straight from the jagged source — a bulk blit for a fixed-width inner, a flattening ConcatColumn view for a sectioned inner — with no densify pre-pass. Drops the now-redundant maxSendBufferBytes insert knob. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
PR 435 feedback: - Dispose the inner column if the element-type cast fails on the zero-row read path, matching the exception safety of the non-empty path. - The null-element error message referenced Array.Empty<T>() with no T in scope; reword to plain guidance. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… the gaps
Five codec round-trips asserted values that the Array(T) cases in
InsertRoundTripCase already prove through a real INSERT/SELECT:
ReadColumn_WriteThenRead_VariableInnerRoundTrips -> Arrays("String", ...)
ReadColumn_WriteThenRead_NestedArrayRoundTrips -> Arrays<byte[]>("Array(UInt8)", ...)
ReadColumn_WriteThenRead_NullableInnerRoundTrips -> Arrays<uint?>("Nullable(UInt32)", ...)
WriteColumn_DenseArrayValueColumn_... -> InsertAsync_DenseArrayValueColumnSplitAcrossBlocks
WriteColumn_SlicedRange_WritesOffsetsRelative... -> InsertAsync_JaggedArrayColumnSplitAcrossBlocks
The last two are worth spelling out. The dense column is not unreachable
from integration the way an internal shape usually is: this epic's own
InsertAsync_DenseArrayValueColumnSplitAcrossBlocks builds the same
ArrayValueColumn, and InsertAsync_DenseReadbackReinserted re-inserts the
dense read-back of every case. And the slice test wrote a slice and read it
back, so a paired offset bug cancels out -- the jagged split-across-blocks
integration test drives the same per-block slicing against a server and is
strictly stronger.
ReadColumn_WriteThenRead_FixedWidthInnerRoundTripsWithEmptyRows became
Values_AfterMaterializing_AgreesWithTheIndexer, narrowed to the two things
integration cannot see: TypeName, and the warm-cache branch of the indexer.
ArrayValueColumn.Values materializes a cache while the indexer reads
`cache is not null ? cache[row] : Materialize(row)`, and AssertColumnsEqual
only calls GetValue on a cold column, so the cached branch ran nowhere.
Three new integration cases, and ReadColumn_EveryRowEmpty deleted because
the first of them now covers it:
- every row empty (all-zero offsets, no values stream)
- an empty *inner* array, the only shape putting an equal consecutive pair
in the inner codec's own offsets stream
- Array(Array(Nullable(T))), composing the array recursion with the nullable
state prefix that the half-cases each cover separately
Not verified locally: the integration suite needs Docker for Testcontainers,
which is unavailable in this environment, so the new cases are compile-only.
Co-Authored-By: Claude <noreply@anthropic.com>
Materialize read offsets[row] and offsets[row + 1] straight out of the offsets buffer, which is normally a pooled array longer than the column. A row past RowCount therefore read the buffer's tail — and because a previous, larger read leaves monotonic offsets behind there, the pair read back is usually still increasing, so the column handed out a real-looking slice of the inner column rather than throwing. Indexing through the RowCount-sliced Offsets span makes that a bounds failure. The row count itself has to stay a parameter here: the inner column is flat (one entry per element across every row, so its height is the element total) and the offsets are pooled, so neither argument encodes it. The constructor validates the one input that can disagree instead — an offsets array too short for the row count. Co-Authored-By: Claude <noreply@anthropic.com>
IArrayColumn<TElement> already carried the flat elements as a span, but a span of a composite element type hands back materialized values — for Array(Array(T)) InnerValues is a ReadOnlySpan<T[]>, one freshly allocated array per inner row, which defeats the purpose of the zero-copy view. Promote the inner column itself onto the interface. When the element type is a composite it pattern-matches to that type's own columnar view, so a nested composite can be walked to the leaf run without materializing an intermediate level. Also add the first integration coverage of IArrayColumn: the interface shipped untested, with no test anywhere naming it.
The interface promised iteration "without allocating", unqualified. That holds only for a fixed-width element type. For a string-like or composite element the span's type is the materialized form, so producing it allocates per element — a string each for Array(String), an inner array each for Array(Array(T)). The previous commit added Inner precisely so those cases have a way down that does not materialize, but documented the remedy without documenting the problem.
The leading comments on WriteBody and BuildState had grown into paragraphs that re-derived the whole leaf-vs-sectioned inner story, and BeginWrite, WriteStatePrefix and ArrayWriteState each restated a slice of it again. Each header is now a line or two on what the method does, with the reasoning moved to the branch it justifies. The leaf-vs-sectioned rationale itself lives once, on ISpanWritableCodec, which the leaf branch points at. Also corrects the type doc: it claimed the write path is always the dense column and that the ergonomic form is projected into it beforehand. That was true under the densify step, which has since been removed -- both shapes are now written as they come. Co-Authored-By: Claude <noreply@anthropic.com>
617ee67 to
84d0680
Compare
Stacked on
tcp/epic-i1-nullable. ImplementsArray(T)for the TCP/Native client and — because the ergonomic write path needed it — finalizes the codec write interface.Write interface:
IColumnWriteStateArrayis the first codec whose state prefix and body must agree on work derived from the values, soIColumnCodecgrows a per-slice scratch phase:BeginWrite(column, start, length)→IColumnWriteState(defaultnull) — computed once per slice, handed to new state-awareWriteStatePrefix/WriteColumnoverloads, disposed by the caller after the body. A codec needing no scratch returns null and those overloads fall back to the self-contained ones, so every existing codec is unchanged.WriteStatePrefixnow takes the column slice. Each block's prefix reflects only that block's rows, and a type whose prefix is data-dependent (a futureDynamic's runtime type list) must see the same rows the body will write. Fixed-prefix codecs ignore the arguments;NullableColumnCodecforwards them to its inner unchanged.BlockWriterdrivesBeginWrite→ prefix → body → dispose per column.ColumnCodecExtensions.WriteFulldoes the same for callers outside the block layer.Array(T)ArrayColumnCodec<TElement>— delegates the state prefix to the inner codec, then reads/writes a per-rownum_rows × UInt64 LEoffsets stream (cumulative element ends) followed by the inner type's encoding for every element concatenated end-to-end. An empty column is zero bytes.ArrayValueColumn<TElement>— a flat inner column plus per-row offsets, each row materialized lazily asTElement[]. This is the wire's own layout, so it doubles as a zero-copy write source when a read-back column is re-inserted.String, i.e. those implementing the new internalISpanWritableCodec<T>— takes each row as a contiguous run (a bulk blit for fixed-width), straight from the source arrays;Nullable's null-map,LowCardinality's dictionary, a nestedArray's offsets) has to see every element as one column so each section is emitted once spanning the whole run — driving it row by row would interleave those sections and corrupt the stream. It getsConcatColumn<T>, a lazy flattening view (row cursor, binary-search fallback), not a copy.IColumnCodecand the inner element type arrives as a runtimeType), so a small non-genericArrayColumnCodecfactory closes the generic codec over the inner's element type — one reflective instantiation per element type, cached as a constructor delegate. The single(IColumn<TElement>)cast at the read boundary is the only cast this leaves. Composites recurse:Array(Array(T)),Array(Nullable(T)).New public API:
IArrayColumn<TElement>The columnar read surface of a decoded array column.
Values/the indexer allocate aTElement[]per row; this exposes the flat wire layout instead —InnerValues+Offsets, where rowiisInnerValues.Slice(Offsets[i], Offsets[i + 1] - Offsets[i])— so a read-only caller iterates without allocating a row array.Innerhands back the flat inner column so a nested composite (Array(Array(T)), laterArray(Tuple(...))) can be walked all the way down without materializing intermediate levels. All borrowed views, valid only while the owning block is alive;Offsetsis bounded toRowCount + 1rather than exposing the rented buffer's length.Insert memory bound
Blocks split by row count alone, so peak client memory during a large insert is held by the between-column flush backstop — which is now caller-tunable via a new
maxSendBufferBytesparameter on the internalInsertAsync(defaultBlockWriter.DefaultFlushThresholdBytes, validated positive). A single column larger than the cap still buffers in full. Inserted beforehandlers, so it is source-breaking for positional callers of that internal API.Safety / correctness
ClickHouseProtocolException. The offsets stream's own byte length is checked againstArray.MaxLengthbefore renting.Array.MaxLengthrather than truncatinglong→int. A null row throws anArgumentExceptionnaming the row and pointing atArray(Nullable(T)).try, so an element-type mismatch leaks neither the buffer nor the inner column. The write path pools nothing.Tests
ArrayColumnCodecTests) — trimmed to what a server round-trip cannot reach: corruption rejection (non-monotonic offsets, an offset pastint.MaxValue), an empty column consuming no bytes, row access pastRowCount, the short-offsets constructor guard,CanWrite/ElementType/NullPlaceholder, and the type-resolution errors.Array(T)round-trip case for every supported inner type (all integer widths, enums, floats, bool, string, dates/datetimes/DateTime64, UUID, IPs, all decimal widths, intervals, BFloat16, Time/Time64), plusArray(Array(T)),Array(Nullable(T))andArray(Array(Nullable(T))); jagged and dense columns split across blocks; a read-back dense column re-inserted; a server-generated array viarange(); and theIArrayColumnzero-copy surface including nested recursion.Nullable(Array(T))is server-rejected, soArrayis the one type without aNullable-wrapped case;Array(Nullable(T))composes nullability the other way instead.Arrayas its "unsupported type" example is repointed atTuple(still unsupported).Full TCP suite green against a live server: 741 tests (454 unit + 287 integration).
🤖 Generated with Claude Code