Fix Dynamic: infer Decimal scale from the value to prevent silent truncation - #467
Fix Dynamic: infer Decimal scale from the value to prevent silent truncation#467polyglotAI-bot wants to merge 9 commits into
Conversation
Writing a `decimal` or `ClickHouseDecimal` into a `Dynamic` column inferred the ClickHouse type from the .NET type via a per-Type cache that hardcoded `Decimal128(38, 9)`, so any value with scale > 9 was silently truncated toward zero on write (e.g. `0.0000000001` was stored as `0`). `DynamicType.Write` now infers decimals from the value via the new `TypeConverter.InferDecimalType`, which derives the scale from the value's own scale and selects the narrowest Decimal width (Decimal32/64/128/256) whose precision covers it, throwing when the value needs more than 76 digits. Fixes: #466
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.
Pull request overview
Fixes silent precision loss when writing decimal/ClickHouseDecimal values into ClickHouse Dynamic columns by making decimal type inference value-aware (scale derived from the value, and width chosen to fit the required precision), preventing truncation to a fixed Decimal128(38, 9).
Changes:
- Added
TypeConverter.InferDecimalType(ClickHouseDecimal)to compute scale/precision from the value and selectDecimal32/64/128/256accordingly (throwing when exceeding ClickHouse’s 76-digit limit). - Updated
DynamicType.Writeto bypass the per-Typeinference cache for scalar decimals and use the new value-aware inference. - Added regression coverage in
DynamicTeststo ensure round-tripping works for scales > 9 across all decimal widths; documented the fix in changelog/release notes.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| RELEASENOTES.md | Documents the Dynamic decimal truncation fix and the new inference behavior. |
| ClickHouse.Driver/Types/TypeConverter.cs | Introduces value-aware decimal type inference selecting the narrowest compatible ClickHouse decimal width. |
| ClickHouse.Driver/Types/DynamicType.cs | Uses per-value inference for scalar decimals to avoid cached fixed-scale truncation. |
| ClickHouse.Driver.Tests/Types/DynamicTests.cs | Adds regression test cases covering high-scale and width-selection scenarios for Dynamic decimals. |
| CHANGELOG.md | Records the bug fix for silent decimal precision loss in Dynamic writes. |
…it overflow Adds direct TypeConverter.InferDecimalType unit tests covering every Decimal width arm on both edges of each threshold plus the >76-digit overflow throw arm. The overflow path is unreachable by any Dynamic round-trip integration test (a 77-digit decimal cannot be written), and the width arms were only exercised by the feature-gated [RequiredFeature(Feature.Dynamic)] integration test, leaving the codecov/patch gate below its 75% target. These unit tests run unconditionally (no server, no feature gate), closing the coverage gap. Fixes: #466
|
Would it make sense to just have a single default that can encompass the entire possible range of C# decimals, instead of inspecting the value? |
|
Good question — I looked into whether a single fixed default could replace the per-value inference. It works for 1. A single default that losslessly covers every So a single default makes every decimal in a 2. The real blocker: 3. Deriving the scale from the value is already the established pattern here. Given #2, I'd keep the value-aware inference: the value-derived scale is required for the |
Resolve the CHANGELOG.md / RELEASENOTES.md conflicts by keeping both the #438 (GetSchemaTable NumericScale) and #466 (Dynamic decimal scale) Bug Fixes entries. Adapt the new Dynamic-decimal regression test to the CreateTableName() helper that #470 introduced repo-wide for test-table isolation, replacing the manual `"test." + SanitizeTableName(...Guid...)` name so the tables are registered for [OneTimeTearDown] cleanup and follow the repo convention.
…mal-scale # Conflicts: # CHANGELOG.md # RELEASENOTES.md
…mal-scale # Conflicts: # CHANGELOG.md # RELEASENOTES.md
…mal-scale # Conflicts: # CHANGELOG.md # RELEASENOTES.md
…mal-scale # Conflicts: # CHANGELOG.md # RELEASENOTES.md
…mal-scale # Conflicts: # CHANGELOG.md # RELEASENOTES.md
…mal-scale # Conflicts: # CHANGELOG.md # RELEASENOTES.md
Description
Fixes #466.
When a
decimalorClickHouseDecimalwas written into aDynamiccolumn, the ClickHouse type was inferred from the .NET type, never the value.DynamicType.Writeused a per-System.Typecache (GetCachedInferredType), andTypeConvertermaps bothdecimalandClickHouseDecimalto a hardcodedDecimal128(38, 9). Any value with scale > 9 was therefore silently reduced to 9 fractional digits on write —DecimalType.WriteScaledcallsClickHouseDecimal.ScaleMantissa(value, 9), which is integer division (truncation toward zero, no rounding, no error).Decimal128has room for 38 digits, so this was pure data loss:0.0000000001mwas stored as0.The type header and the payload in a
Dynamicvalue are both produced from the same inferred type object, so there is a single source of truth to fix — no scale/width mismatch between header and data.Changes
ClickHouse.Driver/Types/TypeConverter.cs— newinternal static InferDecimalType(ClickHouseDecimal)that derives the scale from the value's own scale and picks the narrowest Decimal width whose precision (max(significant-digits, scale)) covers the value:Decimal32(≤9),Decimal64(≤18),Decimal128(≤38),Decimal256(≤76). It throwsArgumentOutOfRangeException(matching the sibling throw inDecimalType.Write) when the value needs more than the 76 digits ClickHouse supports. Because the chosen scale always equals the value's scale,ScaleMantissanever divides, so no digits are dropped.ClickHouse.Driver/Types/DynamicType.cs—Writenow routes a scalardecimal/ClickHouseDecimalvalue throughInferDecimalType(value-aware) instead of the per-Typecache; all other types keep using the cache unchanged.No public API surface changes (both new/changed members are
internal).Test
Write_DecimalWithScaleAbove9_ShouldRoundTripWithoutTruncation(parametrizedTestCaseSourceinDynamicTests.cs) writes each value into aDynamiccolumn viaClickHouseBulkCopyand asserts the read-backClickHouseDecimalequals the original. Cases span the value-axes and all four widths:Decimal64), tiny scale-10 magnitude (0.0000000001, previously stored as0), an integer-part + scale-10 value, a negative scale-16 value, a max-System.Decimal-scale value (scale 28,Decimal128), and aClickHouseDecimalof scale 40 that only fitsDecimal256;Decimal128), not the scale alone;Decimal32).Verified: all high-scale cases FAIL on
main(truncated to scale 9) and PASS with the fix; the contrast case passes on both. The fullDynamicTests+ all Decimal tests (3283) pass, and the Release build is clean.Pre-PR validation gate
main, all pass with the fix)TestCaseSource, test naming); CHANGELOG.md + RELEASENOTES.md updatedNotes
Dynamiccolumn (e.g.List<decimal>) still routes through the type cache and would need the broader value-aware collection-inference change; that is tracked separately and intentionally not bundled here.