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( "numeric", static (options, mapping, _) => mapping.CreateInfo( options, new RoundingDecimalNumericConverter()), isDefault: true); return mappings; } } } public sealed class RoundingDecimalNumericConverter : PgBufferedConverter { 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(), 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(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.