PetToys.BigDecimal.ClickHouse
1.0.1
Prefix Reserved
dotnet add package PetToys.BigDecimal.ClickHouse --version 1.0.1
NuGet\Install-Package PetToys.BigDecimal.ClickHouse -Version 1.0.1
<PackageReference Include="PetToys.BigDecimal.ClickHouse" Version="1.0.1" />
<PackageVersion Include="PetToys.BigDecimal.ClickHouse" Version="1.0.1" />
<PackageReference Include="PetToys.BigDecimal.ClickHouse" />
paket add PetToys.BigDecimal.ClickHouse --version 1.0.1
#r "nuget: PetToys.BigDecimal.ClickHouse, 1.0.1"
#:package PetToys.BigDecimal.ClickHouse@1.0.1
#addin nuget:?package=PetToys.BigDecimal.ClickHouse&version=1.0.1
#tool nuget:?package=PetToys.BigDecimal.ClickHouse&version=1.0.1
PetToys.BigDecimal.ClickHouse
ClickHouse helpers for PetToys.BigDecimal.Core: they map the
Decimal32, Decimal64, Decimal128, and Decimal256 column types - whose
range and scale go well past decimal - onto BigDecimal when reading and
writing through ClickHouse.Driver.
Bring your own configured connection; this package only deals with the value mapping.
Installation
dotnet add package PetToys.BigDecimal.ClickHouse
The core type's own package, PetToys.BigDecimal.Core, comes along
as a dependency.
Releases go to nuget.org, so the command above is all that is needed. Prereleases are published to GitHub Packages instead: that feed has to be added to your
nuget.config, and it requires a personal access token withread:packageseven for a public package.
Usage
Ask for the mapping on the query that needs it:
using ClickHouse.Driver;
using PetToys.BigDecimal.Numerics;
using var client = new ClickHouseClient(connectionString);
await using var reader = await client.ExecuteReaderAsync(
"SELECT total FROM invoices WHERE id = 1",
null,
ClickHouseBigDecimal.CreateQueryOptions(),
cancellationToken);
await reader.ReadAsync(cancellationToken);
BigDecimal total = reader.GetBigDecimal("total");
Nullable(Decimal...) and Array(Decimal...) come with it, as BigDecimal?
and BigDecimal[]. Every other column of the same row reads exactly as it would
without this package.
To write in bulk:
await client.InsertBigDecimalAsync(
"invoices",
["id", "total"],
rows,
options: null,
cancellationToken);
InsertBigDecimalAsync reads the destination's column types from the server
once per call, because a width that disagrees with the column does not fail, it
stores a different number. A loader that inserts many batches should declare them
instead, through InsertOptions.ColumnTypes, which skips the lookup.
To write one value as a query parameter, annotate its type in the statement, as ClickHouse requires, and build the connection from settings carrying the mapping:
using ClickHouse.Driver;
using ClickHouse.Driver.ADO;
var settings = new ClickHouseClientSettings(connectionString).UseBigDecimal();
await using var connection = new ClickHouseConnection(settings);
await connection.OpenAsync(cancellationToken);
await using var command = connection.CreateCommand(
"INSERT INTO invoices (id, total) VALUES (1, {total:Decimal256(6)})");
command.AddParameter("total", value);
await command.ExecuteNonQueryAsync(cancellationToken);
The two scopes, and what the wide one changes
CreateQueryOptions maps one query. UseBigDecimal on
ClickHouseClientSettings maps every query on the connection, and the two are
not equivalent:
- Every decimal column read on that connection becomes a
BigDecimal, including in code written before this package was referenced. GetFieldTypekeeps reporting the driver's own decimal type whileGetValuereturns aBigDecimal. The reader disagrees with itself, and nothing outside the driver can change it, so anything that builds a schema from the reader and then fills it - aDataTableabove all - sees a column typed for one type receiving another.- The read hook is consulted once per value, not once per column, so a wide result set pays for every column of every row.
It exists because ClickHouseCommand has no per-query hook: an ADO caller, or
anything layered on one, has no narrower option. Where ClickHouseClient is in
reach, prefer the per-query form.
GetFieldValue<BigDecimal> does not work in either scope and cannot be made to:
the driver casts its own value to the requested type before consulting the hook
that would have changed it. Use the accessors above.
Everything this package adds lives in the ClickHouse.Driver namespace, the one
a caller already imports, including the extensions on types that live below it:
ClickHouse.Driver.ADO is needed only where the snippet names
ClickHouseClientSettings or ClickHouseConnection itself.
Requirements
UseCustomDecimals=trueon the connection. The mapping needs the driver's own arbitrary-precision decimals. With the option off, a value wider thandecimalraises inside the driver before this package is reached, which nothing here can rescue.UseBigDecimalon the settings switches it on; the per-query form cannot, and says so by name if it meets a column the driver decoded throughdecimal.ClickHouse.Driver1.4.0, up to but not including 2. That is the version this package's use ofIReadValueConverterandIParameterFormatterwas measured against, and the range is closed at the major because the driver passes the formatter its arguments in an order the interface does not declare. A driver major that reshapes that therefore fails at restore rather than at the first write; the ceiling moves once the new major has been tested against.
Trimming yes, Native AOT no, and the reason is the driver
This assembly is marked IsAotCompatible, and its own code is gated by the trim,
single-file and AOT analyzers. That marking is per assembly and does not reach
ClickHouse.Driver, which is what decides the answer here.
Trimmed publishing works. It is verified by publishing a probe over this
package and running it against a real server, on every supported framework. The
publish does warn: ClickHouse.Driver 1.4.0 carries no IsTrimmable in either
ClickHouse.Driver.dll or ClickHouse.Driver.Common.dll, and neither does its
Microsoft.IO.RecyclableMemoryStream dependency, so you get IL2104 for both -
"assembly produced trim warnings". That is expected, and it is the reason to
test your own application rather than to trust this paragraph.
Native AOT does not work, and it fails at runtime rather than at publish.
The driver compiles: the failure arrives on the first query, as a
TypeInitializationException out of ClickHouse.Driver.Types.TypeConverter,
whose static constructor calls TupleType.BuildTupleFactory to look up a
constructor of System.Tuple<double, double> by reflection, which ILC has
removed. Nothing in this package is on that path, and nothing in it can rescue
the call. If you need Native AOT against ClickHouse today, the way through is
the driver's own issue tracker, not a workaround here.
What round-trips, and what does not
| Column | Coverage |
|---|---|
Decimal32(s), Decimal64(s), Decimal128(s), Decimal256(s) |
Read losslessly, at every precision and scale ClickHouse allows. |
| The same, when writing | Lossless while the value fits the column's precision and width. Fractional digits beyond the column's scale are rounded half to even. |
| A value whose integer part exceeds the column | OverflowException, naming the column, its declared type, and which of the two bounds it crossed. |
NaN, Infinity, -Infinity |
Refused with NotSupportedException before anything is sent, naming the value and the width. No ClickHouse decimal type represents them. |
The type's own bounds are 77 significant digits, a largest magnitude of 2^256-1, and a range of 1e-255 to approximately 1.157e77. Every ClickHouse decimal fits inside them, which is why reading never overflows and never rounds, and writing is the direction with a boundary. That is the opposite of the PostgreSQL adapter.
Two things ClickHouse does that this package does not hide
A round trip returns the column's scale, not the value's. Writing 1.5 into a
Decimal64(4) reads back 1.5000. ClickHouse keeps the scale in the column type
and nowhere in the value, so there is nothing to restore it from; the values
remain numerically equal.
Rounding is this package's, not the server's. ClickHouse truncates toward zero when it parses a decimal literal into a narrower column, and so does the driver when it lowers a scale: both turn 0.135 into 0.13 at scale 2. This type rounds half to even, giving 0.14, and every write path here rescales the value itself so that neither of the other two is ever asked to. One rule, whichever path a value takes.
A parameter has to name its type, and forgetting it is refused by name
ClickHouse takes a parameter's type from the statement, {total:Decimal256(6)},
and that annotation is the only place a column's type reaches this package. Write
@total instead and there is no scale to rescale against, so the write is
refused before anything is sent:
The parameter 'total' carries a BigDecimal and the statement does not name its
ClickHouse type, so the column's scale is unknown. Annotate it in the statement,
as in {total:Decimal256(6)}. Through Dapper, pass it with DynamicParameters.Add
as well - an anonymous object is stripped before the parameter reaches this
package.
The refusal is this package's, and it arrives instead of the driver's own
Unknown type, which names the CLR type and nothing else. Nothing is guessed:
a type derived from the value rather than from the column would let the server
truncate at the column's scale, which is the one thing this package exists to
prevent.
UseBigDecimal installs that guard, and it is the only hook here that composes
rather than replaces. A parameter type resolver already on the settings is kept
and asked about every type this package does not map; the read hook and the
parameter formatter are replaced, because neither of those interfaces lets an
implementation say a value is not its own.
Links
License
Provided under the Apache License, Version 2.0.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 is compatible. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- ClickHouse.Driver (>= 1.4.0 && < 2.0.0)
- PetToys.BigDecimal.Core (>= 1.0.1)
-
net8.0
- ClickHouse.Driver (>= 1.4.0 && < 2.0.0)
- PetToys.BigDecimal.Core (>= 1.0.1)
-
net9.0
- ClickHouse.Driver (>= 1.4.0 && < 2.0.0)
- PetToys.BigDecimal.Core (>= 1.0.1)
NuGet packages (1)
Showing the top 1 NuGet packages that depend on PetToys.BigDecimal.ClickHouse:
| Package | Downloads |
|---|---|
|
PetToys.BigDecimal.ClickHouse.Dapper
Dapper mapping that reads and writes ClickHouse Decimal32, Decimal64, Decimal128, and Decimal256 columns as PetToys.BigDecimal.Core values, over the formatter PetToys.BigDecimal.ClickHouse installs on the connection. |
GitHub repositories
This package is not used by any popular GitHub repositories.
A patch release over 1.0.0. One tag versions every package in the repository, so
all six are published together; what each of them carries is below.
PetToys.BigDecimal.Core
Fixed:
- Parsing a literal whose exponent is far below the scale floor no longer costs
one division per digit it drops. A scale above 255 is reduced to it, so
"1e-99999" asked the pack to drop 99744 decimal positions and it divided for
every one of them, thousands of times over after the magnitude had already
become zero. The parser caps an exponent at 100000, which is what bounds the
worst case rather than what creates it. That input took 48 microseconds on
.NET 10 before the change; after it, the benchmark reads the same literal at
1.2x a parse that drops nothing, such as "1e-200". Every parsed value, scale
and sign is unchanged - those literals are zero at scale 255 before the
change and after it - and rounding, rescaling and the database wire paths
reach the same helper and stop the same way.
Notes:
- What GetHashCode costs is documented now, in the README and in the method's
own remarks. A hash has to agree with numeric equality, so it goes through
the value's shortest form: one carrying no trailing zeros costs 12.7x to 16.1x
decimal's hash, which strips them for the same reason, and one widened to a
database column's scale about 2x as much, because that one pays a division
pass over the magnitude. Nothing in the algorithm changed - the table of costs
covered arithmetic, parsing and formatting and simply did not reach hashing.
- What Pow guarantees for a power it cannot represent exactly is stated now, in
the method's own remarks and in the README, and the suite carries the deep
chains that back it. The power is raised in a working width of 154 digits
against the 77 a result keeps, so the rounding reads the digit the exact power
reads unless the exact power sits nearer the midpoint than 1e-66 of a unit in
the last place, which takes a 5 and then sixty-five zeros past the 77th
digit, or a 4 and then sixty-five nines. Nothing in the algorithm changed and
no value moved; what changed is that the documents no longer read as though
the exact power were carried.