Box-free POCO read fast path (#509) - #449
Conversation
QueryAsync<T> now materializes rows with compiled per-column delegates that read each value straight from the response stream into the target property, bypassing the reader's shared object[] row buffer and the boxing/unboxing MapTo<T> setter. This removes the box (on decode) and the unbox (on assign) per value-type property, per row: ~38% fewer managed allocations and roughly half the Gen0 collections on a 500k-row 3-column read. Mirrors the box-free write path (#434): a new ITypedReader<T> interface, implemented across every scalar/value type; PocoReadExpressionFactory dispatches on it (transparently unwrapping Nullable(T)/LowCardinality(T)); PocoTypeRegistry compiles and caches a per-wire-column delegate array. Columns are mixed per-column: typed box-free read, a boxed fallback (byte-identical to MapTo, with fail-fast validation) for composites, or a discard for unmapped columns. Also adds multiple read representations, selected by the property type (QueryAsync<T> only; MapTo<T> keeps its strict exact-type rules): - DateTime/DateTime64/Date -> DateTime, DateTimeOffset, or DateOnly - String/FixedString -> string or byte[] - Decimal -> decimal or ClickHouseDecimal - Int128/UInt128 -> BigInteger or native System.Int128/UInt128 (net8+) Each type's boxed Read() delegates to its typed ReadValue, so both paths are byte-identical by construction. A read value converter or unregistered type falls back entirely to the existing path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
TriageCategory: Summary What this impacts
Concerns
Required reviewer action
|
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
Pull request overview
This PR introduces a new box-free materialization fast path for ClickHouseClient.QueryAsync<T>(), reducing per-cell allocations by reading values directly from the response stream into POCO properties. It extends the type system with typed read support (ITypedReader<T>) and adds selective per-column fallback so composite columns don’t disable fast reads for scalar siblings.
Changes:
- Add
ITypedReader<T>and implement typed, non-boxing reads across scalar/value ClickHouse types (and selected multi-representation reads likeDateTimeOffset,DateOnly,byte[], nativeInt128/UInt128on .NET 8+). - Implement a POCO read fast-path pipeline: expression factory dispatch (
PocoReadExpressionFactory), assignment rule sharing (PocoColumnAssignment), and cached per-shape row materializers (PocoTypeRegistry). - Update
QueryAsync<T>()to use the new fast path when available, and add a dedicated test suite validating parity and binding behavior.
Reviewed changes
Copilot reviewed 39 out of 39 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| RELEASENOTES.md | Document allocation/perf impact and new QueryAsync<T> binding capabilities. |
| CHANGELOG.md | Changelog entry for the new POCO read fast path and multi-representation bindings. |
| ClickHouse.Driver/Types/ITypedReader.cs | Introduce ITypedReader<T> to enable non-boxing deserialization. |
| ClickHouse.Driver/Types/AbstractDateTimeType.cs | Add typed reads for DateTime/DateTimeOffset/DateOnly and centralize canonical boxed read. |
| ClickHouse.Driver/Types/DateType.cs | Adapt Date decoding to the new AbstractDateTimeType read model. |
| ClickHouse.Driver/Types/Date32Type.cs | Adapt Date32 decoding to the new AbstractDateTimeType read model. |
| ClickHouse.Driver/Types/DateTimeType.cs | Split instant decoding and add typed DateTimeOffset support via base hooks. |
| ClickHouse.Driver/Types/DateTime64Type.cs | Share wire read between DateTime and DateTimeOffset typed reads. |
| ClickHouse.Driver/Types/AbstractBigIntegerType.cs | Add typed BigInteger read path to support fast materialization. |
| ClickHouse.Driver/Types/Int128Type.cs | Add native Int128 typed read on .NET 8+. |
| ClickHouse.Driver/Types/UInt128Type.cs | Add native UInt128 typed read on .NET 8+. |
| ClickHouse.Driver/Types/DecimalType.cs | Add typed reads for decimal and ClickHouseDecimal, keeping boxed behavior canonical. |
| ClickHouse.Driver/Types/StringType.cs | Provide both string and byte[] typed representations while preserving boxed mode selection. |
| ClickHouse.Driver/Types/FixedStringType.cs | Provide both string and byte[] typed representations while preserving boxed mode selection. |
| ClickHouse.Driver/Types/UuidType.cs | Add typed Guid read for fast POCO assignment. |
| ClickHouse.Driver/Types/IPv4Type.cs | Add typed IPAddress read for fast POCO assignment. |
| ClickHouse.Driver/Types/IPv6Type.cs | Add typed IPAddress read for fast POCO assignment. |
| ClickHouse.Driver/Types/BooleanType.cs | Add typed bool read for fast POCO assignment. |
| ClickHouse.Driver/Types/BFloat16Type.cs | Add typed float read for fast POCO assignment. |
| ClickHouse.Driver/Types/Int8Type.cs | Add typed sbyte read for fast POCO assignment. |
| ClickHouse.Driver/Types/Int16Type.cs | Add typed short read for fast POCO assignment. |
| ClickHouse.Driver/Types/Int32Type.cs | Add typed int read for fast POCO assignment. |
| ClickHouse.Driver/Types/Int64Type.cs | Add typed long read for fast POCO assignment. |
| ClickHouse.Driver/Types/UInt8Type.cs | Add typed byte read for fast POCO assignment. |
| ClickHouse.Driver/Types/UInt16Type.cs | Add typed ushort read for fast POCO assignment. |
| ClickHouse.Driver/Types/UInt32Type.cs | Add typed uint read for fast POCO assignment. |
| ClickHouse.Driver/Types/UInt64Type.cs | Add typed ulong read for fast POCO assignment. |
| ClickHouse.Driver/Types/Float32Type.cs | Add typed float read for fast POCO assignment. |
| ClickHouse.Driver/Types/Float64Type.cs | Add typed double read for fast POCO assignment. |
| ClickHouse.Driver/Types/Enum8Type.cs | Add typed string read to avoid boxing for enum materialization. |
| ClickHouse.Driver/Types/Enum16Type.cs | Add typed string read to avoid boxing for enum materialization. |
| ClickHouse.Driver/Poco/PocoReadExpressionFactory.cs | Build per-column, typed read expressions driven by ITypedReader<T>, with Nullable/LowCardinality handling. |
| ClickHouse.Driver/Poco/PocoColumnAssignment.cs | Centralize assignment validation/error text used by both MapTo<T> and fast-path fallback. |
| ClickHouse.Driver/Poco/PocoTypeRegistry.cs | Cache and build per-shape per-column row materializers with mixed fast/fallback/discard behavior. |
| ClickHouse.Driver/ADO/Readers/ClickHouseDataReader.cs | Expose internal fast-path hooks (TryGetRowMaterializer, TryReadRow) and reuse shared assignment logic. |
| ClickHouse.Driver/ClickHouseClient.cs | Switch QueryAsync<T> to use fast path when available, otherwise retain the existing boxed loop. |
| ClickHouse.Driver.Tests/Copy/PocoReadFastPathTests.cs | Add coverage for parity, multi-type bindings, Nullable/LowCardinality, mixed composite fallback, and failure modes. |
AllScalarsPoco tested Float32/Float64 but not BFloat16 (which also materializes into a float via its own ITypedReader<float>). Add a BFloat16 column using an exactly-representable value. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Clearer than TryReadRow (which collided conceptually with the public Read()): the method advances to and materializes the next row into T, pairing with TryGetRowMaterializer. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Read: QueryAsync<T> can materialize a Map(K,V) column into a List<KeyValuePair<K,V>> or KeyValuePair<K,V>[] property (in addition to Dictionary<K,V>), via a MapType special case in PocoReadExpressionFactory that dispatches to a generic MapMaterializer closed over K,V. The list/array form preserves on-wire entry order and duplicate keys, which a Dictionary collapses. Dictionary properties are unchanged (boxed path). Write: MapType.Write now also accepts an ordered collection of KeyValuePair<,> (e.g. List / array), not just IDictionary — so such a POCO property round-trips into a Map column on binary insert. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Enum8Type and Enum16Type now implement ITypedReader<int> alongside ITypedReader<string>, so a QueryAsync<T> property can bind to either the enum label or the stored numeric value. Both overloads differ only by return type, so they become explicit interface impls sharing a private ReadLabel helper — the pattern StringType and AbstractDateTimeType already use for their multiple representations. The numeric read consumes the same bytes as the label read (1 for Enum8, 2 for Enum16), keeping it byte-identical to the boxed path, and skips the label lookup: the raw value cannot fail on a member absent from the column's enum definition. An int property on an enum column previously threw at plan time, since the boxed fallback yields a label string that is not assignable to int. A Nullable(Enum) column still needs an int? property. Co-Authored-By: Claude <noreply@anthropic.com>
What
Makes
ClickHouseClient.QueryAsync<T>materialize rows without per-value boxing, and adds multiple read representations per column. Mirrors the box-free write path (#434).Today the POCO read path boxes twice per value-type cell: once in
Read()(into the sharedobject[]row buffer) and once unboxing in the compiledAction<T,object>MapTo<T>setter.QueryAsync<T>now compiles a per-column delegate that reads each value straight from the stream into the strongly-typed property, eliminating both.How
ITypedReader<T>(analog ofITypedWriter<T>) implemented across every scalar/value type. Each type's boxedRead()delegates to its typedReadValue, so the two paths are byte-identical by construction.PocoReadExpressionFactorydispatches viaITypedReader<targetClrType>, transparently unwrappingNullable(T)andLowCardinality(T).PocoTypeRegistrycompiles + caches a per-wire-column delegate array, keyed by(T, wire-column signature). Per-column mixing: box-free typed read, a per-column boxed fallback for composites (byte-identical toMapTo, with fail-fast validation), or a discard for unmapped columns — so one composite column doesn't disable the fast path for its scalar siblings.PocoColumnAssignmentkeepsMapToand the fallback's validation/errors identical.Multiple read representations (selected by property type;
QueryAsync<T>only —MapTo<T>keeps strict exact-type rules)DateTime/DateTime64/DateDateTime,DateTimeOffset,DateOnlyString/FixedStringstring,byte[]Decimaldecimal,ClickHouseDecimalEnum8/Enum16string(label),int(stored numeric value)Int128/UInt128BigInteger, or nativeSystem.Int128/UInt128(net8+)Results (
PocoReadBenchmark,long/string/double, local WSL)GetValue/GetFieldValue<T>(ADO contract) unchanged.Testing
PocoReadFastPathTests(23 tests): all-scalar parity vs the boxed path, every multi-type binding, Nullable variants, LowCardinality, per-column mixing with composite fallback, unmapped-column alignment, fail-fast validation, unregistered-type error.Enum8/Enum16read as both label andintover the same column (negative and beyond-sbytevalues pin each wire width),Nullable(Enum8)intoint?, anint-property insert round-tripping through a realEnum8column, and the fail-fast error for a non-nullableinton aNullable(Enum)column (which needsint?).