413 lines
11 KiB
C#
413 lines
11 KiB
C#
|
|
using System.Globalization;
|
|
using System.Numerics;
|
|
using System.Text;
|
|
using Npgsql.Internal;
|
|
using Npgsql.Internal.Converters;
|
|
using Npgsql.Internal.Postgres;
|
|
|
|
namespace NejCommon.Utils;
|
|
|
|
#pragma warning disable NPG9001 // decimalype is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
|
|
public sealed class RoundingNumericTypeInfoResolverFactory
|
|
: PgTypeInfoResolverFactory
|
|
{
|
|
public override IPgTypeInfoResolver CreateResolver()
|
|
=> new Resolver();
|
|
|
|
public override IPgTypeInfoResolver? CreateArrayResolver()
|
|
=> null;
|
|
|
|
private sealed class Resolver : IPgTypeInfoResolver
|
|
{
|
|
private readonly TypeInfoMappingCollection _mappings =
|
|
CreateMappings();
|
|
|
|
public PgTypeInfo? GetTypeInfo(
|
|
Type? type,
|
|
DataTypeName? dataTypeName,
|
|
PgSerializerOptions options)
|
|
=> _mappings.Find(type, dataTypeName, options);
|
|
|
|
private static TypeInfoMappingCollection CreateMappings()
|
|
{
|
|
var mappings = new TypeInfoMappingCollection();
|
|
|
|
mappings.AddStructType<decimal>(
|
|
"numeric",
|
|
static (options, mapping, _) =>
|
|
mapping.CreateInfo(
|
|
options,
|
|
new RoundingDecimalNumericConverter()),
|
|
isDefault: true);
|
|
|
|
return mappings;
|
|
}
|
|
}
|
|
}
|
|
|
|
public sealed class RoundingDecimalNumericConverter
|
|
: PgBufferedConverter<decimal>
|
|
{
|
|
private const ushort SignPositive = 0x0000;
|
|
private const ushort SignNegative = 0x4000;
|
|
private const ushort SignNaN = 0xC000;
|
|
private const ushort SignPositiveInfinity = 0xD000;
|
|
private const ushort SignNegativeInfinity = 0xF000;
|
|
|
|
private static readonly BigInteger MaxDecimalCoefficient =
|
|
(BigInteger.One << 96) - 1;
|
|
|
|
public override bool CanConvert(
|
|
DataFormat format,
|
|
out BufferRequirements bufferRequirements)
|
|
{
|
|
// Let Npgsql buffer the entire binary numeric value before calling
|
|
// ReadCore/WriteCore.
|
|
return CanConvertBufferedDefault(format, out bufferRequirements);
|
|
}
|
|
|
|
protected override decimal ReadCore(PgReader reader)
|
|
{
|
|
// PostgreSQL numeric binary format:
|
|
//
|
|
// int16 ndigits
|
|
// int16 weight
|
|
// int16 sign
|
|
// int16 dscale
|
|
// int16 digits[ndigits]
|
|
//
|
|
// Each digit is base 10000.
|
|
|
|
var digitCount = reader.ReadInt16();
|
|
var weight = reader.ReadInt16();
|
|
var sign = reader.ReadUInt16();
|
|
|
|
// IMPORTANT:
|
|
// This is DISPLAY scale, not necessarily the number of physically
|
|
// significant fractional digits.
|
|
//
|
|
// e.g. 100.000000000000000000000000000000 may have
|
|
// dscale=30 but physically contain only the digit 100.
|
|
_ = reader.ReadInt16();
|
|
|
|
if (digitCount < 0)
|
|
throw new InvalidOperationException(
|
|
$"Invalid PostgreSQL numeric digit count: {digitCount}");
|
|
|
|
switch (sign)
|
|
{
|
|
case SignNaN:
|
|
throw new InvalidCastException(
|
|
"Numeric NaN cannot be represented as System.Decimal.");
|
|
|
|
case SignPositiveInfinity:
|
|
throw new InvalidCastException(
|
|
"Numeric Infinity cannot be represented as System.Decimal.");
|
|
|
|
case SignNegativeInfinity:
|
|
throw new InvalidCastException(
|
|
"Numeric -Infinity cannot be represented as System.Decimal.");
|
|
|
|
case not (SignPositive or SignNegative):
|
|
throw new InvalidCastException(
|
|
$"Unknown PostgreSQL numeric sign 0x{sign:X4}.");
|
|
}
|
|
|
|
if (digitCount == 0)
|
|
return decimal.Zero;
|
|
|
|
BigInteger coefficient = BigInteger.Zero;
|
|
|
|
for (var i = 0; i < digitCount; i++)
|
|
{
|
|
var digit = reader.ReadInt16();
|
|
|
|
if (digit is < 0 or >= 10000)
|
|
throw new InvalidOperationException(
|
|
$"Invalid PostgreSQL numeric digit: {digit}");
|
|
|
|
coefficient *= 10000;
|
|
coefficient += digit;
|
|
}
|
|
|
|
return ToDecimal(
|
|
coefficient,
|
|
digitCount,
|
|
weight,
|
|
sign == SignNegative);
|
|
}
|
|
|
|
private static decimal ToDecimal(
|
|
BigInteger coefficient,
|
|
int digitCount,
|
|
int weight,
|
|
bool negative)
|
|
{
|
|
if (coefficient.IsZero)
|
|
return decimal.Zero;
|
|
|
|
/*
|
|
* PostgreSQL numeric:
|
|
*
|
|
* digits are base 10000
|
|
* weight tells us the weight of the first group
|
|
*
|
|
* Therefore:
|
|
*
|
|
* coefficient * 10^(-physicalScale)
|
|
*
|
|
* gives us the actual numerical value.
|
|
*/
|
|
var physicalScale =
|
|
checked(4 * (digitCount - 1 - weight));
|
|
|
|
// Value has zeroes to the right, e.g. a positive exponent.
|
|
if (physicalScale < 0)
|
|
{
|
|
coefficient *= BigInteger.Pow(10, -physicalScale);
|
|
physicalScale = 0;
|
|
}
|
|
|
|
/*
|
|
* Strip REAL trailing zeroes first.
|
|
*
|
|
* This is the important part for values such as:
|
|
*
|
|
* 100.000000000000000000000000000000
|
|
*
|
|
* We care about the value, not PostgreSQL's nominal dscale.
|
|
*/
|
|
while (physicalScale > 0 &&
|
|
coefficient % 10 == 0)
|
|
{
|
|
coefficient /= 10;
|
|
physicalScale--;
|
|
}
|
|
|
|
/*
|
|
* System.Decimal is:
|
|
*
|
|
* 96-bit unsigned coefficient
|
|
* scale 0..28
|
|
*
|
|
* Try the highest scale we can preserve.
|
|
*
|
|
* If necessary, progressively round away fractional precision.
|
|
*/
|
|
var targetScale = Math.Min(physicalScale, 28);
|
|
|
|
for (; targetScale >= 0; targetScale--)
|
|
{
|
|
var digitsToDrop = physicalScale - targetScale;
|
|
|
|
var rounded = digitsToDrop == 0
|
|
? coefficient
|
|
: RoundToEven(coefficient, digitsToDrop);
|
|
|
|
if (rounded <= MaxDecimalCoefficient)
|
|
{
|
|
return CreateDecimal(
|
|
rounded,
|
|
targetScale,
|
|
negative);
|
|
}
|
|
}
|
|
|
|
// Even integer-scale representation doesn't fit.
|
|
// This is a REAL overflow rather than useless fractional precision.
|
|
throw new OverflowException(
|
|
"PostgreSQL numeric magnitude does not fit in System.Decimal.");
|
|
}
|
|
|
|
private static BigInteger RoundToEven(
|
|
BigInteger value,
|
|
int decimalDigitsToDrop)
|
|
{
|
|
if (decimalDigitsToDrop <= 0)
|
|
return value;
|
|
|
|
var divisor =
|
|
BigInteger.Pow(10, decimalDigitsToDrop);
|
|
|
|
var quotient =
|
|
BigInteger.DivRem(value, divisor, out var remainder);
|
|
|
|
var doubledRemainder = remainder * 2;
|
|
var comparison = doubledRemainder.CompareTo(divisor);
|
|
|
|
// MidpointRounding.ToEven
|
|
if (comparison > 0 ||
|
|
(comparison == 0 && !quotient.IsEven))
|
|
{
|
|
quotient++;
|
|
}
|
|
|
|
return quotient;
|
|
}
|
|
|
|
private static decimal CreateDecimal(
|
|
BigInteger coefficient,
|
|
int scale,
|
|
bool negative)
|
|
{
|
|
var lo = (uint)(coefficient & uint.MaxValue);
|
|
var mid = (uint)((coefficient >> 32) & uint.MaxValue);
|
|
var hi = (uint)((coefficient >> 64) & uint.MaxValue);
|
|
|
|
return new decimal(
|
|
unchecked((int)lo),
|
|
unchecked((int)mid),
|
|
unchecked((int)hi),
|
|
negative,
|
|
checked((byte)scale));
|
|
}
|
|
|
|
// ----------------------------
|
|
// Writing CLR decimal -> numeric
|
|
// ----------------------------
|
|
|
|
public override Size GetSize(
|
|
SizeContext context,
|
|
decimal value,
|
|
ref object? writeState)
|
|
{
|
|
var encoded = Encode(value);
|
|
|
|
// ndigits + weight + sign + dscale = 8 bytes
|
|
// plus 2 bytes per base-10000 digit.
|
|
return 8 + encoded.Digits.Length * 2;
|
|
}
|
|
|
|
protected override void WriteCore(
|
|
PgWriter writer,
|
|
decimal value)
|
|
{
|
|
var encoded = Encode(value);
|
|
|
|
writer.WriteInt16(
|
|
checked((short)encoded.Digits.Length));
|
|
|
|
writer.WriteInt16(encoded.Weight);
|
|
writer.WriteUInt16(encoded.Sign);
|
|
writer.WriteInt16(encoded.Scale);
|
|
|
|
foreach (var digit in encoded.Digits)
|
|
writer.WriteInt16(digit);
|
|
}
|
|
|
|
private static EncodedNumeric Encode(decimal value)
|
|
{
|
|
var bits = decimal.GetBits(value);
|
|
|
|
var negative =
|
|
(bits[3] & int.MinValue) != 0;
|
|
|
|
var scale =
|
|
(bits[3] >> 16) & 0xFF;
|
|
|
|
var coefficient =
|
|
((BigInteger)(uint)bits[2] << 64) |
|
|
((BigInteger)(uint)bits[1] << 32) |
|
|
(uint)bits[0];
|
|
|
|
if (coefficient.IsZero)
|
|
{
|
|
return new EncodedNumeric(
|
|
Array.Empty<short>(),
|
|
0,
|
|
SignPositive,
|
|
checked((short)scale));
|
|
}
|
|
|
|
var text =
|
|
coefficient.ToString(
|
|
CultureInfo.InvariantCulture);
|
|
|
|
string integerPart;
|
|
string fractionalPart;
|
|
|
|
if (scale == 0)
|
|
{
|
|
integerPart = text;
|
|
fractionalPart = "";
|
|
}
|
|
else if (text.Length <= scale)
|
|
{
|
|
integerPart = "0";
|
|
fractionalPart =
|
|
new string('0', scale - text.Length) + text;
|
|
}
|
|
else
|
|
{
|
|
integerPart = text[..^scale];
|
|
fractionalPart = text[^scale..];
|
|
}
|
|
|
|
// PostgreSQL numeric groups contain 4 decimal digits each.
|
|
var integerPadding =
|
|
(4 - integerPart.Length % 4) % 4;
|
|
|
|
integerPart =
|
|
new string('0', integerPadding) +
|
|
integerPart;
|
|
|
|
var fractionalPadding =
|
|
(4 - fractionalPart.Length % 4) % 4;
|
|
|
|
fractionalPart +=
|
|
new string('0', fractionalPadding);
|
|
|
|
var integerGroups =
|
|
integerPart.Length / 4;
|
|
|
|
var all = integerPart + fractionalPart;
|
|
|
|
var groups =
|
|
new List<short>(all.Length / 4);
|
|
|
|
for (var i = 0; i < all.Length; i += 4)
|
|
{
|
|
groups.Add(
|
|
short.Parse(
|
|
all.AsSpan(i, 4),
|
|
CultureInfo.InvariantCulture));
|
|
}
|
|
|
|
var weight = integerGroups - 1;
|
|
|
|
// PostgreSQL itself doesn't transmit unnecessary leading groups.
|
|
while (groups.Count > 0 && groups[0] == 0)
|
|
{
|
|
groups.RemoveAt(0);
|
|
weight--;
|
|
}
|
|
|
|
// Nor unnecessary trailing groups.
|
|
while (groups.Count > 0 &&
|
|
groups[^1] == 0)
|
|
{
|
|
groups.RemoveAt(groups.Count - 1);
|
|
}
|
|
|
|
if (groups.Count == 0)
|
|
weight = 0;
|
|
|
|
return new EncodedNumeric(
|
|
groups.ToArray(),
|
|
checked((short)weight),
|
|
negative
|
|
? SignNegative
|
|
: SignPositive,
|
|
checked((short)scale));
|
|
}
|
|
|
|
private sealed record EncodedNumeric(
|
|
short[] Digits,
|
|
short Weight,
|
|
ushort Sign,
|
|
short Scale);
|
|
}
|
|
|
|
#pragma warning restore NPG9001 // decimalype is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
|