Fix reading Enum8/Enum16 columns as integers via DbDataReader - #445
Fix reading Enum8/Enum16 columns as integers via DbDataReader#445polyglotAI-bot wants to merge 6 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
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 9 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (2)
ClickHouse.Driver/Types/EnumType.cs:62
EnumType.ToString()currently returns a malformed type string (missing the closing ")"). This leaks into ADO surfaces likeClickHouseDataReader.GetDataTypeName()and can produce confusing output such asEnum(Active=1,Inactive=2.
public bool TryLookup(string key, out int value) => Values.TryGetValue(key, out value);
public string Lookup(int value) => reverseValues.TryGetValue(value, out var key) ? key : throw new KeyNotFoundException($"Enum value {value} not found");
public override string ToString() => $"{Name}({string.Join(",", Values.Select(kvp => kvp.Key + "=" + kvp.Value))}";
ClickHouse.Driver/ADO/Readers/ClickHouseDataReader.cs:143
- The PR title/description says the numeric
DbDataReaderaccessors (GetInt32,GetByte,GetFieldValue<int>, etc.) were taught to return the enum ordinal, but the implementation and new regression tests keep those accessors throwingInvalidCastExceptionand instead add an opt-inTryGetEnumOrdinalmethod. Please align the behavior with the PR description/issue (#439) (i.e., make numeric accessors work for enum columns) or update the PR title/description to reflect the opt-in API approach.
/// <summary>
/// Attempts to read the underlying integer ordinal of an <c>Enum8</c>/<c>Enum16</c> (or
/// <c>Nullable(Enum...)</c>) column for the current row. Enum columns materialize as their
/// string label, so the standard numeric accessors (<c>GetInt32</c>, <c>GetByte</c>, …) throw
/// <see cref="InvalidCastException"/> on them, matching ADO.NET's behavior for a string-backed
/// column; use this method to obtain the underlying wire ordinal instead.
/// </summary>
/// <param name="ordinal">The zero-based column ordinal.</param>
/// <param name="value">
/// When this method returns <see langword="true"/>, the underlying integer ordinal that arrived
/// on the wire; otherwise <c>0</c>. The ordinal is the raw wire value and is independent of any
/// configured read value converter (which transforms the string label, not the ordinal).
/// </param>
/// <returns>
/// <see langword="true"/> if the column is an <c>Enum8</c>/<c>Enum16</c> type and the current
/// value is a known label; otherwise <see langword="false"/> (a non-enum column, a
/// <see cref="DBNull"/> value, or a value that is not a known enum label).
/// </returns>
public bool TryGetEnumOrdinal(int ordinal, out int value)
{
if (CurrentRow[ordinal] is string label &&
GetEffectiveClickHouseType(ordinal) is EnumType enumType &&
enumType.TryLookup(label, out value))
{
return true;
}
value = default;
return false;
}
…d-as-int # Conflicts: # CHANGELOG.md # RELEASENOTES.md
|
Note: revisit this after poco box-free reads are merged. |

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)