Skip to content

Box-free ADO read path: typed column slots (stacked on #449) - #499

Open
alex-clickhouse wants to merge 13 commits into
poco-read-boxfreefrom
poco-read-boxfree-slots
Open

Box-free ADO read path: typed column slots (stacked on #449)#499
alex-clickhouse wants to merge 13 commits into
poco-read-boxfreefrom
poco-read-boxfree-slots

Conversation

@alex-clickhouse

@alex-clickhouse alex-clickhouse commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

Summary

Stacked on #449 (poco-read-boxfree). Review that one first — this PR's diff is against it, not main, and merging it before #449 would drag #449's commits along.

#449 made QueryAsync<T> materialize POCOs without boxing. It left the ADO read path untouched: Read() still decoded every column through ClickHouseType.Read, which returns object, into a shared object[] 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. IsDBNull answers 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):

Access pattern Before After Δ
Read() only (scan) 58.0 MB 19.9 MB −66%
Typed accessors 58.0 MB 19.9 MB −66%
GetFieldValue<T> 58.0 MB 19.9 MB −66%
GetValue (untyped) 58.0 MB 58.0 MB

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 IReadValueConverter configured the typed accessors keep their old profile (24 B/row for an Int64 column, unchanged); only GetFieldValue<T> improves — see Behaviour changes.

How

  • ColumnSlotValueSlot<T> / NullableSlot<T> for anything a type can decode straight into its own FrameworkType, BoxedSlot for everything else. Both generics are unconstrained; T = U? is deliberately left to the boxed fallback, because a generic IValueGetter<T> interface that could serve both goes through the shared-generics dictionary and measured 3.5–5.5× the sealed-class isinst it would replace.
  • ColumnSlotFactory binds a slot only when the type's typed reader is for its own FrameworkType — the CLR type its boxed Read returns. A type offering extra representations (a DateTime column also readable as DateTimeOffset) must not get a slot bound to one GetValue would not have produced. Slots come from the resolved type instance, so ReadStringsAsByteArrays and UseCustomDecimals are handled without cache-key hazards.
  • TransparentWrapper centralizes the wrappers that are pass-through on the RowBinary wire (LowCardinality, SimpleAggregateFunction, Object). Nullable is deliberately excluded — it prefixes a marker byte, so it selects the slot kind instead of being unwrapped away.
  • No runtime code generation. A non-generic ITypedReader marker answers "can this column read box-free at all?" with a type test, and a static table of 24 ValueSlot<T>/NullableSlot<T> constructors dispatches on the CLR type. Every instantiation the reader needs is emitted at compile time, so this adds no MakeGenericMethod to the scalar read path — which NativeAOT cannot satisfy for value types.
  • Slots are built on the first 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 first Read() and after Read() returns false; covers GetValue, the indexers, GetValues, GetFieldValue<T>, IsDBNull and 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[] made GetValue return null, IsDBNull return true, and a typed accessor throw NullReferenceException. With typed storage a non-nullable value column would instead read back as a perfectly ordinary 0/false/Guid.Empty — indistinguishable from real data. Correct code that checks Read()'s return value is unaffected.

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.

protected object[] CurrentRow is removed. It is absent from the tracked public-API surface and the reader's only constructor is private, so no external assembly could derive from it and observe the member. No PublicAPI/*.txt change is needed.

Everything else is preserved, including exact-type strictness (GetFieldValue<long> over an Int32 column still throws rather than widening), the DBNull.Value representation of NULL, and GetString returning "" for a NULL cell.

IReadValueConverter: which overload runs is unchanged. The typed accessors go through ConvertValue(object, …) — they were (T)GetValue(ordinal) before and still are — and GetFieldValue<T> goes through ConvertValue<T>, as it already did. De-boxing the typed accessors would have moved them onto ConvertValue<T>, which is observable to a converter whose two overloads disagree (the generic one matching on typeof(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 handed ConvertValue<T> a value the object[] 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 an Int64 column.

alex-clickhouse and others added 11 commits August 2, 2026 12:14
…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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 + ColumnSlotFactory and rewires ClickHouseDataReader to decode rows into typed slots (box-free for typed accessors / GetFieldValue<T>).
  • Adds TransparentWrapper unwrapping and extends typed-reader marker infrastructure (ITypedReader base) 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
alex-clickhouse marked this pull request as ready for review August 4, 2026 14:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants