NejCommon.NET/Utils/RoundingNumericTypeInfoResolverFactory.cs

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.