Box-free ADO read path: typed column slots (stacked on #449) - #499
Open
alex-clickhouse wants to merge 13 commits into
Open
Box-free ADO read path: typed column slots (stacked on #449)#499alex-clickhouse wants to merge 13 commits into
alex-clickhouse wants to merge 13 commits into
Conversation
…path SimpleAggregateFunction and Object are pass-through on the RowBinary wire in exactly the way LowCardinality is: Read/Write delegate to the wrapped type and FrameworkType reports the wrapped type's. But PocoReadExpressionFactory only special-cased LowCardinality, so a SimpleAggregateFunction(sum, UInt64) column bound to a ulong property silently fell off the typed read path onto the boxed one, even though it decodes byte-for-byte identically to a bare UInt64. Extract the unwrap into TransparentWrapper so the rule lives in one place and loops, which also handles nesting (LowCardinality over SimpleAggregateFunction and vice versa). Nullable is deliberately excluded: it prefixes a marker byte and so is not wire-transparent. Also correct AGENTS.md's claim that a Roslyn analyzer enforces PublicAPI/*.txt. Microsoft.CodeAnalysis.PublicApiAnalyzers is not referenced anywhere in the solution; those files are hand-maintained. Co-Authored-By: Claude <noreply@anthropic.com>
…ect[] ClickHouseDataReader.Read() decoded every column via ClickHouseType.Read, which returns object, and stored the result in a shared object[] row buffer. That boxed every value-type cell once per value per row, eagerly, whether or not the caller ever asked for that column: a 500k x 10 query allocated 145 MB / 304 B per row, and the figure was identical whether the caller read all ten columns, two, or none. Replace the buffer with one ColumnSlot per wire column, allocated once per reader and overwritten in place. A column gets a typed slot iff its type implements ITypedReader<FrameworkType> — iff it can decode straight into the CLR type its boxed Read would have returned. Everything else keeps a BoxedSlot that calls the same Read as before. This commit is deliberately behaviour-only-neutral: every accessor still boxes via GetBoxed(), so the win so far is that the box is lazy and per-call rather than eager and per-row. Untyped callers that read every column (Dapper) pay the same allocation as before; ones that project a subset stop paying for what they skip. GetFieldValue<T> and the typed accessors are de-boxed in the next commits. IsDBNull now asks the slot, so a null check neither materializes nor boxes a value. It still never invokes a configured IReadValueConverter. The one observable difference: GetValue boxes per call, so two GetValue(i) calls on the same value-type cell return two distinct boxes. They still compare equal by Equals — only ReferenceEquals changes. Slots are built from the resolved type instance, never a per-query-shape cache, so ReadStringsAsByteArrays and UseBigDecimal are handled without any cache-key hazard. The factory pre-filters on the concrete ClickHouseType class so a composite column's FrameworkType (long[], Dictionary<K,V>, ...) cannot grow the constructor cache without bound. protected object[] CurrentRow is removed. It is absent from every PublicAPI txt file, and ClickHouseDataReader's only constructor is private, so no external assembly can derive from it and observe the member. Verified behaviour-neutral: the net9.0 suite reports 9714 passed / 142 skipped / 0 failed both before and after. ColumnSlotTests adds 140 more, the core of which is a differential parity check — for every ITypedReader shape, bare and under Nullable in both states, the slot's GetBoxed() must equal the boxed Read's result in value and CLR type, and consume the same bytes (checked with a trailing sentinel). Co-Authored-By: Claude <noreply@anthropic.com>
Adds the tiered dispatch on top of the column slots: GetFieldValue<T> now takes the value from ValueSlot<T> or NullableSlot<T> directly, and only falls back to (T)GetBoxed() when neither matches. On the common path — a caller asking for exactly the type the column decodes to — the box and the matching unbox both disappear. Dispatch is two sealed-class isinst checks rather than one generic-interface check. A generic IValueGetter<T> implemented twice would let a single slot serve both long and long?, but it resolves through the shared-generics dictionary and benchmarks at 3.5-5.5x the cost of the unbox it replaces, against 1.5-2.4x for the sealed-class checks. So T = U? is deliberately left to the boxed fallback: it is rare in practice (linq2db pairs IsDBNull with GetInt64, Dapper uses GetValue) and it still works, just without the win. No semantic change. A configured IReadValueConverter still sees ConvertValue<T> with the identical T — that overload exists for exactly this case — and the fallback is the pre-slot expression verbatim, so exact-type strictness is preserved by construction: no widening, and reading a NULL as a non-nullable T still throws the runtime's own "cannot cast DBNull" InvalidCastException. BoxFreeReaderAccessorTests pins the declined cases against a live server, since those are the ones a future "helpful" coercion would silently break. Co-Authored-By: Claude <noreply@anthropic.com>
GetInt64/GetDouble/GetDateTime/GetGuid and friends were every one of them
`(T)GetValue(ordinal)`, so each paid for a box it immediately unboxed. They now
go through the same tiered slot dispatch GetFieldValue<T> uses. This is the path
compiled ORM mappers drive — linq2db registers GetBoolean/GetInt16/GetInt32/
GetInt64/GetFloat/GetDouble/GetString/GetDecimal/GetDateTime/GetGuid and inlines
them per column per row — so it is where the win is worth the most.
Three accessors are handled specially rather than mechanically:
- GetBoolean coerces (Convert.ToBoolean) rather than casts, so only an exact
Bool column short-circuits; everything else keeps the widening and its
exception messages.
- GetString coerces via ToString(), which includes the quirk that a NULL yields
"" rather than null, because DBNull.Value.ToString() is empty. Only a
non-nullable String column takes the fast path, leaving that behaviour alone.
- GetDecimal reaches the value box-free under both decimal representations,
since UseCustomDecimals decides whether the slot holds decimal or
ClickHouseDecimal.
GetChar and GetTuple have no slot to hit and stay on the boxed cast.
With an IReadValueConverter configured the typed accessors keep routing through
GetValue, so they still call ConvertValue(object, ...) exactly as before.
De-boxing them would mean calling ConvertValue<T> instead, which is observable
to a converter whose two overloads disagree — not worth a silent semantic change
for the rare converter case when everyone else gets the fast path anyway.
GetFieldValue<T> is unaffected either way: it already called ConvertValue<T>.
Measured on a 4-value-column payload, bytes allocated per row:
Read() only 0 (was 96)
typed accessors 0 (was 96)
GetFieldValue<T> 0 (was 96)
GetValue 96 (unchanged, by design)
BoxFreeReaderAllocationTests pins those numbers. It is the only test in the
suite that can fail if boxing creeps back — every value assertion would still
pass — so it is deliberately server-free and synchronous, driving a pre-built
RowBinary payload on the test thread so GetAllocatedBytesForCurrentThread is
exact rather than a sample of whatever else the process is doing.
Co-Authored-By: Claude <noreply@anthropic.com>
AdoReadPathBenchmark covers the four ways a caller drives the reader over one
wide row — scan, typed accessors (linq2db), GetFieldValue<T>, GetValue (Dapper)
— plus a projected read. ReadValueBenchmark measures single-column reads, which
isolates per-accessor cost but hides what column slots actually changed: what a
row costs to decode before anyone looks at it, and how that scales with the
fraction of columns read.
Measured, 200k rows x 10 columns (4 Int64, 2 Float64, 2 String, DateTime, UUID):
GetValue (Dapper) 58.01 MB 1.00
Scan 19.86 MB 0.34
TypedAccessors 19.86 MB 0.34
GetFieldValue<T> 19.86 MB 0.34
TypedAccessors, 2/10 19.86 MB 0.34
Wall-clock is not reported: over WSL2 loopback the run was noise-dominated
(std dev up to 100 ms against a 180 ms mean), so it can neither show nor rule
out a CPU change. The allocation figures are exact and are what the changelog
claims. The residual 19.9 MB is decode allocation — the two strings and UuidType's
scratch buffer — not boxing.
Also adds the coverage-driven cases the earlier phases missed: the ObjectType
unwrap branch (unreachable through the parser, since ObjectType.Parse returns a
SimpleAggregateFunctionType), the factory declining a type whose ITypedReader<T>
is not for its own FrameworkType, and GetDecimal's boxed fallback under a
converter.
Co-Authored-By: Claude <noreply@anthropic.com>
…o early AggregateFunctionType throws AggregateFunctionException from FrameworkType, Read and ToString, deliberately: you are meant to learn you need xMerge() when you read the value, not before. Slots are now built for every column when the reader is constructed, so ColumnSlotFactory.TryCreateTyped has to reach its no-typed-reader bail-out without ever touching FrameworkType — hoisting that read above the bail-out for readability would turn merely *selecting* an AggregateFunction column into a failure to open the reader at all. The current ordering is correct; this only documents why it is load-bearing and pins it, at both the factory level and end to end. Co-Authored-By: Claude <noreply@anthropic.com>
With the old object[] row buffer, reading before the first Read() returned null from GetValue, true from IsDBNull, and threw NullReferenceException from a typed accessor — accidental, but loud. Typed slots hold typed storage, so the same call would instead have returned a perfectly ordinary 0 / false / Guid.Empty for a non-nullable value column: a wrong answer indistinguishable from real data, where before there was a crash. Every value accessor now goes through one gate on the existing hasCurrentRow flag and throws InvalidOperationException: GetValue, both indexers, GetValues, GetFieldValue<T>, IsDBNull and the typed accessors. This matches SqlClient and the rest of ADO.NET. Column metadata (FieldCount, GetName, GetOrdinal, GetFieldType, GetDataTypeName, GetSchemaTable) is deliberately not gated — a caller inspecting the shape of an empty result set needs it, and DataTable.Load asks for it before its first Read(). The gate also covers reads after Read() has returned false, which previously kept serving the last row. That is the one place this is stricter than both the old and the intermediate behaviour, and it caught a real defect in the test harness: GetEnsureSingleRow built its "unexpected extra row" message inline as an argument to IsFalse, so it read the row after Read() had already returned false — on every single call, allocating a joined string of every column value purely to discard it. Now built only when there is an extra row. That was the only call site in the solution. Verified across net6.0/8.0/9.0/10.0 (9906-9923 passed, 0 failed) plus the linq2db integration test. Allocation is unchanged: still 0 B/row for Read(), the typed accessors and GetFieldValue<T>. Co-Authored-By: Claude <noreply@anthropic.com>
ColumnSlotFactory reached its slot types through MakeGenericMethod + CreateDelegate, cached per CLR type. That works, but NativeAOT cannot construct generic instantiations over value types that were not rooted at compile time, and this sits on the read path of every scalar column — so the change widened the driver's existing AOT exposure from composite columns to all of them. Replaced with a static table of ValueSlot<T>/NullableSlot<T> constructors, one entry per CLR type any ITypedReader<T> is implemented for. Every instantiation is now emitted by the compiler, so trimming and AOT can see the whole set. The interface-list walk that decided "does this type read box-free at all?" is gone too: ITypedReader<T> now derives from a non-generic ITypedReader marker, so the question is a plain type test. That leaves the factory with no reflection at all, and drops both ConcurrentDictionary caches — the marker test replaces the per-class cache, and the static table replaces the per-CLR-type one. It also makes the ordering constraint self-evident rather than incidental: the marker test is what keeps FrameworkType (which AggregateFunctionType throws from) out of reach until we know the column could match. The cost is that the table no longer maintains itself. Adding an ITypedReader<T> for a new T and forgetting the entry would silently demote that column to the boxed path — correct values, allocation quietly back, and nothing else in the suite would notice. Binders_CoverEveryTypedReadTarget reflects over every ClickHouseType in the assembly and fails with the missing type named. Verified by deleting the Guid entry: it reports "< <System.Guid> >". A few table entries are unreachable today — DateTimeOffset, DateOnly and the native Int128/UInt128 are alternative read representations offered alongside a type's FrameworkType, never as it. Listed anyway so the table is exactly "every ITypedReader<T> target" and the completeness check stays mechanical. Over-inclusion is inert; a missing entry is the failure mode that matters. net6.0/8.0/9.0/10.0 green (9907-9924 passed, 0 failed), integration tests green, allocation unchanged at 0 B/row. Co-Authored-By: Claude <noreply@anthropic.com>
GetBoolean and GetDecimal coerce rather than cast, so neither can use the shared GetSlotValue<T> body and both match their slot kinds by hand. Both recognised only the non-nullable ValueSlot<T>, so a populated Nullable(Bool) or Nullable(Decimal) cell fell through to GetValue and boxed — on column types the feature already claims to cover. Add the populated-NullableSlot branches for bool, decimal and ClickHouseDecimal. A NULL cell still falls through to the boxed path, so Convert.ToBoolean(DBNull.Value) and the (decimal)DBNull cast keep throwing exactly as before, and a configured IReadValueConverter still bypasses the whole block. The new allocation test measures differentially against the identical non-nullable shape rather than against zero: under useBigDecimal the ClickHouseDecimal-to-decimal conversion allocates two byte arrays per call (~72 B/row) whatever the reader does, and that cost is common to both sides. Without the fix the nullable column measures identical to the boxed control (136 B/row for ClickHouseDecimal, 56 for decimal); with it, identical to the non-nullable twin. Also adds the value coverage the branches needed — GetDecimal had no nullable assertion anywhere, and Nullable(Bool) was only ever asserted at true. Co-Authored-By: Claude <noreply@anthropic.com>
…ctor The reader constructed one ColumnSlot per selected column eagerly, but QueryAsync<T>'s box-free POCO path materializes straight from the stream through TryMaterializeNextRow and never reads a slot. Every one of them was dead allocation on the driver's primary read API, and an empty result set or a reader opened only for its column metadata paid the same. Move construction into the first Read(). The safety argument is unchanged in substance: slots are built once and never nulled, so hasCurrentRow implies non-null, and that is the condition every value accessor already establishes through Slot(). GetValues is the one accessor that indexes the array directly, and it carries its own copy of the guard. Also corrects the comments and changelog wording the slot work left stale: several still described the removed object[] row buffer, one credited the slot factory with runtime reflection that a later commit deleted, and the changelog understated the untyped path by omitting the per-column slot and the repeated- GetValue box. One consequence worth recording: ColumnSlotFactory's bail-out ordering can no longer be guarded end to end. With slot construction inside Read(), an ordering regression raises the same AggregateFunctionException, with the same message, from the same call. The unit test in ColumnSlotTests remains a real guard; Read_AggregateFunctionColumn_OpensTheReaderAndFailsOnlyOnTheValue no longer is, and its comment now says so rather than implying otherwise. Co-Authored-By: Claude <noreply@anthropic.com>
…t plan it QueryAsync_SimpleAggregateFunctionColumn_ReadsUnderlyingBoxFree opened a reader over the raw wrapped column only to assert a materializer plan could be built, then ran QueryAsync against SELECT Name, sum(Total) — whose Total projects to an ordinary UInt64. The wrapped column's bytes never reached the fast path, so the test would have passed with no wrapper read support at all. Read the raw column instead, through FINAL so the two 'a' parts are merged whether or not the background merge has run, while the column stays declared — and so encoded on the wire — as SimpleAggregateFunction(sum, UInt64). Assert the wire type as well, so a future rewrite cannot quietly stop covering the wrapper. The aggregate-result case is kept alongside it. Co-Authored-By: Claude <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Pull request overview
This PR removes the eager object[] row buffer boxing on the ADO read path by introducing per-column typed slots that are allocated lazily on the first Read() and then overwritten in place for each row. This targets high-frequency ORM access patterns (typed accessors / GetFieldValue<T>) while keeping GetValue and other untyped APIs boxing by design (now lazily per call), and it also generalizes “wire-transparent wrapper” unwrapping so LowCardinality/SimpleAggregateFunction/Object don’t silently disable fast paths.
Changes:
- Introduces
ColumnSlot+ColumnSlotFactoryand rewiresClickHouseDataReaderto decode rows into typed slots (box-free for typed accessors /GetFieldValue<T>). - Adds
TransparentWrapperunwrapping and extends typed-reader marker infrastructure (ITypedReaderbase) so wrapper types don’t suppress fast paths. - Adds comprehensive server-free + live-server test coverage and a benchmark for the new ADO read path; updates changelog/release notes/docs accordingly.
Reviewed changes
Copilot reviewed 17 out of 17 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| RELEASENOTES.md | Documents the ADO read-path boxing elimination and its behavioral notes. |
| CHANGELOG.md | Adds the same user-facing change summary for release tracking. |
| ClickHouse.Driver/Types/TransparentWrapper.cs | Centralizes unwrapping of wire-transparent wrappers used by fast paths. |
| ClickHouse.Driver/Types/ITypedReader.cs | Adds non-generic ITypedReader marker to support cheap capability checks. |
| ClickHouse.Driver/Poco/PocoReadExpressionFactory.cs | Uses TransparentWrapper.Unwrap so wrapper columns retain POCO fast-path eligibility. |
| ClickHouse.Driver/ClickHouseClient.cs | Updates POCO fast-path comment to reflect slot-based ADO reader internals. |
| ClickHouse.Driver/ADO/Readers/ColumnSlotFactory.cs | Creates typed/nullable/boxed slots per column based on FrameworkType and typed reader support. |
| ClickHouse.Driver/ADO/Readers/ColumnSlot.cs | Implements ValueSlot<T>, NullableSlot<T>, and BoxedSlot storage/boxing semantics. |
| ClickHouse.Driver/ADO/Readers/ClickHouseDataReader.cs | Replaces object[] row buffer with per-column slots; gates value access on “current row” state. |
| ClickHouse.Driver.Tests/Utilities/TestUtilities.cs | Fixes GetEnsureSingleRow to avoid reading values after Read() returns false (and avoid wasted allocation). |
| ClickHouse.Driver.Tests/Copy/PocoReadFastPathTests.cs | Adds end-to-end coverage for SimpleAggregateFunction wrapper POCO fast path. |
| ClickHouse.Driver.Tests/Copy/PocoReadFastPathParityTests.cs | Updates/parity-tests wrapper unwrapping behavior for POCO read expressions. |
| ClickHouse.Driver.Tests/ADO/ColumnSlotTests.cs | Adds server-free differential parity and slot-selection tests for typed slots. |
| ClickHouse.Driver.Tests/ADO/BoxFreeReaderAllocationTests.cs | Adds allocation regression guard to ensure typed paths remain box-free. |
| ClickHouse.Driver.Tests/ADO/BoxFreeReaderAccessorTests.cs | Adds live-server tests for accessor semantics (including “no current row” behavior). |
| ClickHouse.Driver.Benchmark/AdoReadPathBenchmark.cs | Adds BenchmarkDotNet benchmark for scan/typed/generic/untyped/projection ADO access patterns. |
| AGENTS.md | Corrects PublicAPI tracking guidance to “hand-maintained” (no analyzer enforcement). |
The remark and test comment named both IReadValueConverter overloads without saying which path calls which, so "rather than switching to ConvertValue<T>" read as if nothing calls the generic one. GetFieldValue<T> does. Co-Authored-By: Claude <noreply@anthropic.com>
…ible facts The XML docs and inline rationale had grown to the point of restating the diff. Keep the load-bearing invariants — slot/boxed observational parity, why the ITypedReader marker test has to precede FrameworkType, why the binder table is static, why the typed accessors stay on ConvertValue(object, …) — and drop the narration and the microbenchmark ratios around them. The changelog and release notes entries went from nine bullets of implementation detail to one bullet plus the two behaviour changes a caller can actually observe: the InvalidOperationException with no current row, and GetValue boxing per call. Dropped the type-coverage lists, the slot mechanics, the removal of the private-by-construction CurrentRow member, and the MB-level allocation figures in favour of a high-level claim. Comment- and markdown-only; no code changed. Co-Authored-By: Claude <noreply@anthropic.com>
alex-clickhouse
marked this pull request as ready for review
August 4, 2026 14:51
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
#449 made
QueryAsync<T>materialize POCOs without boxing. It left the ADO read path untouched:Read()still decoded every column throughClickHouseType.Read, which returnsobject, into a sharedobject[]row buffer — boxing every value-type cell once per value per row, eagerly, whether or not the caller ever looked at that column.This PR gives the reader one strongly-typed slot per column, built on its first
Read()and overwritten in place.GetFieldValue<T>and the typed accessors (GetInt64,GetDouble,GetDateTime,GetGuid,GetBoolean,GetDecimal,GetString,GetUInt64,GetSByte,GetBigInteger,GetIPAddress, …) read straight out of the slot, so both the box and the matching unbox disappear.IsDBNullanswers from the slot's presence flag, so a null check neither materializes nor boxes a value.This is the path compiled ORM mappers drive — linq2db registers
GetInt64/GetDouble/GetDateTime/… and inlines them per column per row — so it is where the elimination is worth the most.Results
200k-row read of a 10-column shape (4 ×
Int64, 2 ×Float64, 2 ×String,DateTime,UUID):Read()only (scan)GetFieldValue<T>GetValue(untyped)Gen0 collections drop by two thirds. The residual 19.9 MB is decode allocation — the two strings and the
UUID's scratch buffer — not boxing. On a purely numeric shape per-row allocation goes to zero (96 B/row → 0 B/row for four value columns).Wall-clock is deliberately not claimed: the benchmark includes server, HTTP and decode cost, and the difference is inside the noise there.
These numbers are for the no-converter case. With an
IReadValueConverterconfigured the typed accessors keep their old profile (24 B/row for anInt64column, unchanged); onlyGetFieldValue<T>improves — see Behaviour changes.How
ColumnSlot—ValueSlot<T>/NullableSlot<T>for anything a type can decode straight into its ownFrameworkType,BoxedSlotfor everything else. Both generics are unconstrained;T = U?is deliberately left to the boxed fallback, because a genericIValueGetter<T>interface that could serve both goes through the shared-generics dictionary and measured 3.5–5.5× the sealed-classisinstit would replace.ColumnSlotFactorybinds a slot only when the type's typed reader is for its ownFrameworkType— the CLR type its boxedReadreturns. A type offering extra representations (aDateTimecolumn also readable asDateTimeOffset) must not get a slot bound to oneGetValuewould not have produced. Slots come from the resolved type instance, soReadStringsAsByteArraysandUseCustomDecimalsare handled without cache-key hazards.TransparentWrappercentralizes the wrappers that are pass-through on the RowBinary wire (LowCardinality,SimpleAggregateFunction,Object).Nullableis deliberately excluded — it prefixes a marker byte, so it selects the slot kind instead of being unwrapped away.ITypedReadermarker answers "can this column read box-free at all?" with a type test, and a static table of 24ValueSlot<T>/NullableSlot<T>constructors dispatches on the CLR type. Every instantiation the reader needs is emitted at compile time, so this adds noMakeGenericMethodto the scalar read path — which NativeAOT cannot satisfy for value types.Read(), not in the constructor: Box-free POCO read fast path (#509) #449's POCO path materializes straight from the stream and never touches one, so eager construction would allocate a permanently dead object per column on the primary read API.Behaviour changes
Reading a column value with no current row now throws
InvalidOperationException. Applies before the firstRead()and afterRead()returnsfalse; coversGetValue, the indexers,GetValues,GetFieldValue<T>,IsDBNulland the typed accessors. Column metadata (FieldCount,GetName,GetOrdinal,GetFieldType,GetDataTypeName,GetSchemaTable) is unaffected and still available without a row.This is the one place the change is not behaviour-preserving, and it is deliberate. Previously the all-null
object[]madeGetValuereturnnull,IsDBNullreturntrue, and a typed accessor throwNullReferenceException. With typed storage a non-nullable value column would instead read back as a perfectly ordinary0/false/Guid.Empty— indistinguishable from real data. Correct code that checksRead()'s return value is unaffected.GetValueboxes per call, so twoGetValue(i)calls on the same value-type cell return two distinct boxes. They still compare equal byEquals; onlyReferenceEqualschanges.protected object[] CurrentRowis removed. It is absent from the tracked public-API surface and the reader's only constructor isprivate, so no external assembly could derive from it and observe the member. NoPublicAPI/*.txtchange is needed.Everything else is preserved, including exact-type strictness (
GetFieldValue<long>over anInt32column still throws rather than widening), theDBNull.Valuerepresentation of NULL, andGetStringreturning""for a NULL cell.IReadValueConverter: which overload runs is unchanged. The typed accessors go throughConvertValue(object, …)— they were(T)GetValue(ordinal)before and still are — andGetFieldValue<T>goes throughConvertValue<T>, as it already did. De-boxing the typed accessors would have moved them ontoConvertValue<T>, which is observable to a converter whose two overloads disagree (the generic one matching ontypeof(T), the object one on the value's runtime type), so they keep the boxed route whenever a converter is present.GetFieldValue<T>is the one converter path that gets faster. It previously handedConvertValue<T>a value theobject[]row buffer had already boxed, so the interface's zero-boxing guidance had nothing to save; it now reads the slot directly, taking that path from 24 B/row to 0 for anInt64column.