Description
When a decimal or ClickHouseDecimal value is written into a Dynamic column, the ClickHouse type is inferred from the .NET type, never from the value:
DynamicType.Write (ClickHouse.Driver/Types/DynamicType.cs:29-45) calls GetCachedInferredType(value.GetType()), which caches TypeConverter.ToClickHouseType results per System.Type — so the inferred type cannot depend on the value at all.
TypeConverter.cs:194-195 maps both ClickHouseDecimal and decimal to a hardcoded new Decimal128Type { Scale = 9 }, i.e. Decimal128(38, 9).
Any value whose scale exceeds 9 is therefore silently reduced to 9 fractional digits on write: DecimalType.WriteScaled (Types/DecimalType.cs:124-128) calls ClickHouseDecimal.ScaleMantissa(value, 9), which is plain integer division (Numerics/ClickHouseDecimal.cs:414-421) — truncation toward zero, no rounding, no error. Decimal128 has room for 38 digits, so this is pure data loss with no protocol justification. A decimal can carry scale up to 28, and small-magnitude values lose everything: 0.0000000001m is written as 0.
Nothing warns the caller — no exception, no OverflowException, no log. The existing Write_Decimal_ShouldPreservePrecision test in ClickHouse.Driver.Tests/Types/DynamicTests.cs uses 123.456789m (scale 6), which is inside the fixed scale and so never exercises this.
Same root cause as clickhouse-java's Dynamic decimal inference (scale fixed to the width's capacity rather than derived from the value's own scale).
ClickHouse server version
26.7.1.1315 (local server at http://localhost:8123), verified against a running server.
Reproduction
[Test]
[RequiredFeature(Feature.Dynamic)]
public async Task Write_DecimalWithScaleAbove9_ShouldRoundTrip()
{
var targetTable = "test.scratch_dynamic_decimal";
await connection.ExecuteStatementAsync(
$"CREATE OR REPLACE TABLE {targetTable} (id UInt32, value Dynamic) ENGINE = Memory");
var decimalValue = 0.0123456789012345m; // scale 16, easily fits Decimal128(38, 16)
using var bulkCopy = new ClickHouseBulkCopy(connection) { DestinationTableName = targetTable };
await bulkCopy.WriteToServerAsync([new object[] { 1u, decimalValue }]);
using var reader = await connection.ExecuteReaderAsync($"SELECT value FROM {targetTable}");
ClassicAssert.IsTrue(reader.Read());
var result = (ClickHouseDecimal)reader.GetValue(0);
Assert.That(result, Is.EqualTo(new ClickHouseDecimal(decimalValue)));
}
Result (dotnet test --filter FullyQualifiedName~ScratchDynamicDecimalTests, both net9.0 and net10.0):
Expected: 0.0123456789012345
But was: 0.012345678
Server-side check confirms the loss happened on the wire, not on read:
server type = Dynamic, server value = 0.012345678
Expected: the value round-trips as 0.0123456789012345. Actual: truncated to 9 fractional digits (note truncation, not rounding — the trailing 9012345 is dropped rather than rounding ...678 up to ...679).
Suggested fix
Make the Dynamic write path value-aware for decimals rather than type-aware:
ClickHouse.Driver/Types/DynamicType.cs:44-45 — the per-Type InferredTypeCache is the structural blocker; decimals need a value-dependent branch that derives Scale from ClickHouseDecimal.Scale (and picks the narrowest width whose precision covers scale + integer digits) instead of the cached Decimal128(38, 9) from TypeConverter.cs:194-195.
- Whatever scale is chosen, it must be >= the value's own scale so
ClickHouseDecimal.ScaleMantissa never divides. If a value genuinely cannot be represented (scale > 76), throwing beats silently dropping digits.
- Worth extending
Write_Decimal_ShouldPreservePrecision with high-scale samples (scale 16/20/28) once fixed.
Link
Related upstream issue: ClickHouse/clickhouse-java#2964
Description
When a
decimalorClickHouseDecimalvalue is written into aDynamiccolumn, the ClickHouse type is inferred from the .NET type, never from the value:DynamicType.Write(ClickHouse.Driver/Types/DynamicType.cs:29-45) callsGetCachedInferredType(value.GetType()), which cachesTypeConverter.ToClickHouseTyperesults perSystem.Type— so the inferred type cannot depend on the value at all.TypeConverter.cs:194-195maps bothClickHouseDecimalanddecimalto a hardcodednew Decimal128Type { Scale = 9 }, i.e.Decimal128(38, 9).Any value whose scale exceeds 9 is therefore silently reduced to 9 fractional digits on write:
DecimalType.WriteScaled(Types/DecimalType.cs:124-128) callsClickHouseDecimal.ScaleMantissa(value, 9), which is plain integer division (Numerics/ClickHouseDecimal.cs:414-421) — truncation toward zero, no rounding, no error.Decimal128has room for 38 digits, so this is pure data loss with no protocol justification. Adecimalcan carry scale up to 28, and small-magnitude values lose everything:0.0000000001mis written as0.Nothing warns the caller — no exception, no
OverflowException, no log. The existingWrite_Decimal_ShouldPreservePrecisiontest inClickHouse.Driver.Tests/Types/DynamicTests.csuses123.456789m(scale 6), which is inside the fixed scale and so never exercises this.Same root cause as clickhouse-java's Dynamic decimal inference (scale fixed to the width's capacity rather than derived from the value's own scale).
ClickHouse server version
26.7.1.1315(local server athttp://localhost:8123), verified against a running server.Reproduction
Result (
dotnet test --filter FullyQualifiedName~ScratchDynamicDecimalTests, both net9.0 and net10.0):Server-side check confirms the loss happened on the wire, not on read:
Expected: the value round-trips as
0.0123456789012345. Actual: truncated to 9 fractional digits (note truncation, not rounding — the trailing9012345is dropped rather than rounding...678up to...679).Suggested fix
Make the Dynamic write path value-aware for decimals rather than type-aware:
ClickHouse.Driver/Types/DynamicType.cs:44-45— the per-TypeInferredTypeCacheis the structural blocker; decimals need a value-dependent branch that derivesScalefromClickHouseDecimal.Scale(and picks the narrowest width whose precision coversscale + integer digits) instead of the cachedDecimal128(38, 9)fromTypeConverter.cs:194-195.ClickHouseDecimal.ScaleMantissanever divides. If a value genuinely cannot be represented (scale > 76), throwing beats silently dropping digits.Write_Decimal_ShouldPreservePrecisionwith high-scale samples (scale 16/20/28) once fixed.Link
Related upstream issue: ClickHouse/clickhouse-java#2964