Fix reading Enum8/Enum16 columns as integers via DbDataReader - #445
Fix reading Enum8/Enum16 columns as integers via DbDataReader#445polyglotAI-bot wants to merge 5 commits into
Conversation
Enum columns materialize as their string label (EnumType.FrameworkType is string), so the numeric ClickHouseDataReader accessors unbox-cast the boxed string and threw InvalidCastException. GetByte, GetSByte, GetInt16, GetInt32, GetInt64, GetUInt16/32/64, and GetFieldValue<T> for an integer T now resolve the label back to the underlying ordinal via the enum's value map, while GetString/GetValue/GetFieldValue<string> keep returning the label. Ordinals that don't fit the requested integer type throw OverflowException instead of truncating silently. Fixes: #439
TriageCategory: Summary What this impacts
Concerns
Required reviewer action
|
There was a problem hiding this comment.
Pull request overview
This PR fixes a usability gap in the ADO.NET ClickHouseDataReader: Enum8/Enum16 (and Nullable(Enum...)) columns are materialized as their string labels, which previously caused numeric accessors (e.g. GetInt32, GetByte, GetFieldValue<int>) to throw InvalidCastException. The change teaches numeric accessors to recognize enum-typed columns and map the stored label back to its underlying ordinal.
Changes:
- Added enum-aware ordinal resolution in
ClickHouseDataReadernumeric accessors andGetFieldValue<T>for integerT. - Added regression tests covering enum ordinals, overflow behavior, nullable enums, and a contrast case for plain strings.
- Documented the fix in
CHANGELOG.mdandRELEASENOTES.md.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| ClickHouse.Driver/ADO/Readers/ClickHouseDataReader.cs | Adds enum-aware handling to numeric accessors and GetFieldValue<T> to return underlying enum ordinals instead of failing on string labels. |
| ClickHouse.Driver.Tests/ADO/DataReaderTests.cs | Adds DB round-trip regression tests validating enum ordinal reads, overflow cases, nullable behavior, and non-enum contrast behavior. |
| CHANGELOG.md | Notes the enum numeric accessor fix under Unreleased bug fixes. |
| RELEASENOTES.md | Notes the enum numeric accessor fix under Unreleased bug fixes. |
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 384b4ed. Configure here.
Addresses review feedback on the Enum8/Enum16 integer-accessor fix (#445): - The integer accessors and GetFieldValue<T> now resolve the enum ordinal from the raw materialized label (CurrentRow) via a new EnumType.TryLookup, through a single shared TryGetEnumOrdinal helper. Both paths behave identically: the ordinal reflects the wire value and is independent of any IReadValueConverter (which transforms the string representation, not the ordinal). Unknown/non-label strings fall back to the existing cast path (InvalidCastException) rather than surfacing KeyNotFoundException. - The string projections (GetString/GetValue/GetFieldValue<string>) still apply the converter, unchanged.
|
I'm not super excited about adding this whole rigmarole to every numeric read: What are the performance implications, can you do a quick benchmark? Is there some alternative approach? Maybe we just expose TryGenEnumOrdinal(int ordinal, out value) to the user and lave all the GetInt() etc as they are? |
Per @alex-clickhouse's review on #445: instead of adding enum-ordinal resolution to every numeric accessor (a hot path), expose an explicit, opt-in ClickHouseDataReader.TryGetEnumOrdinal(int ordinal, out int value) and revert GetByte/GetSByte/GetInt16/32/64, GetUInt16/32/64 and GetFieldValue<T> to their original one-liners (zero overhead on non-enum reads). Reworked the tests to target the new API, and updated the CHANGELOG/RELEASENOTES and PublicAPI surface. Fixes: #439
…d-as-int # Conflicts: # CHANGELOG.md # RELEASENOTES.md
|
Good call — I benchmarked the per-accessor overhead and then reworked the PR to the shape you suggested. Benchmark (BenchmarkDotNet, .NET 10, in-memory to isolate just the accessor cost). The enum branch added to each numeric accessor was
So ~2 ns/call added on every non-enum numeric read. That's negligible against a real read's deserialization + network cost, but you're right that it's a genuine ~4× on the bare accessor and avoidable overhead on a hot path for a niche feature. So I took your suggestion. public bool TryGetEnumOrdinal(int ordinal, out int value)It returns the underlying wire ordinal for an Reworked the tests to target the new API (positive/negative/ Pushed as 60bc85f. |
…d-as-int # Conflicts: # CHANGELOG.md # RELEASENOTES.md

Description
Fixes #439.
Enum8/Enum16columns are materialized by the binary reader as their string label —EnumType.FrameworkTypeistypeof(string)andEnum8Type.Read/Enum16Type.ReadreturnLookup(sbyte/short), which is the label. As a result the numericDbDataReaderaccessors did a direct unbox cast on the storedstring(e.g.GetInt32→(int)(object)"Active") and threwInvalidCastException. There was no way to obtain the underlying ordinal without changing the SQL (toInt8(enum_col)).The fix teaches the integer accessors to recognise an enum column and resolve the stored label back to its underlying ordinal via the enum's existing reverse-lookup map (
EnumType.Lookup(string)), leaving the string-returning accessors untouched.Changes
ClickHouse.Driver/ADO/Readers/ClickHouseDataReader.cs:TryGetEnumOrdinal(...)helper: for astringvalue whose (Nullable-unwrapped) column type is anEnumType, it returns the ordinal fromEnumType.Lookup(label); it returnsfalsefor every non-enum column, so their existing cast behaviour is preserved verbatim.GetByte,GetSByte,GetInt16,GetInt32,GetInt64,GetUInt16,GetUInt32,GetUInt64now return the ordinal for enum columns (via checkedConvert.ToXxx), and are unchanged for all other columns.GetFieldValue<T>returns the ordinal for enum columns whenTis an integer type (sbyte/byte/short/ushort/int/uint/long/ulong); theIReadValueConvertertail is preserved.GetString,GetValue, andGetFieldValue<string>are unchanged — they still return the enum label (backward compatible).CHANGELOG.md/RELEASENOTES.md: added a Bug Fixes entry under Unreleased.ClickHouse.Driver.Tests/ADO/DataReaderTests.cs.Notes:
Enum8,Enum16, andNullable(Enum...)(a null still surfaces viaIsDBNull).LowCardinality(Enum)is intentionally not handled because ClickHouse rejects that type server-side.OverflowExceptionrather than truncating silently (e.g.GetByteon anEnum16ordinal of1000, or any unsigned accessor on a negative ordinal).Test
New DB round-trip tests in
DataReaderTests, all asserting values derived from the server's owntoInt8/16(...)output:ShouldReadEnum8ColumnViaNumericAccessors— every integer accessor andGetFieldValue<int/byte/sbyte/long>return the ordinal;GetString/GetValue/GetFieldValue<string>still return the label.ShouldReadNegativeEnum8OrdinalAcrossIntegerAccessors— a-1ordinal returns-1through the signed accessors;GetByteand the unsigned accessors throwOverflowException(no silent wraparound).ShouldReadEnum16OrdinalBeyondByteRangeAcrossIntegerAccessors— a1000ordinal returns through the wider signed/unsigned accessors;GetByte/GetSBytethrowOverflowException.ShouldReadNullableEnum8AsInt— non-null returns the ordinal; null reportsIsDBNull.ShouldThrowWhenReadingStringColumnAsInt32— contrast case: a plainStringcolumn still throwsInvalidCastException, proving the change is scoped to enum columns.Verified the enum tests fail on
mainwithInvalidCastExceptionand pass with the fix; the fullDataReaderTests+EnumTypeTests+ReadValueConverterTests+ POCO read tests (132 tests over the touched paths) stay green, with no existing tests modified.Pre-PR validation gate
mainwithInvalidCastException, passes with fix)