From 817c0d9d3268ecc2fd7c7282c0fe7af8065bd883 Mon Sep 17 00:00:00 2001 From: honzapatCZ Date: Sun, 23 Aug 2026 22:13:06 +0200 Subject: [PATCH] Some AMP, some postgres(liek working DECIMAL) improvements. --- Controllers/TypedResultsPolyfill.cs | 2 +- Emails/wwwroot/output.css | 460 +++++++++++++++++- Models/CommonDbContext.cs | 34 +- Utils/AutoMapPropertyUtils.cs | 21 + .../RoundingNumericTypeInfoResolverFactory.cs | 412 ++++++++++++++++ 5 files changed, 920 insertions(+), 9 deletions(-) create mode 100644 Utils/AutoMapPropertyUtils.cs create mode 100644 Utils/RoundingNumericTypeInfoResolverFactory.cs diff --git a/Controllers/TypedResultsPolyfill.cs b/Controllers/TypedResultsPolyfill.cs index dcce209..7096d93 100644 --- a/Controllers/TypedResultsPolyfill.cs +++ b/Controllers/TypedResultsPolyfill.cs @@ -60,7 +60,7 @@ namespace NejCommon.Controllers var endpointBuilder = new MetadataEndpointBuilder(); populateMetadataMethod.Invoke(null, new object[] { context.MethodInfo, endpointBuilder }); - var responseTypes = endpointBuilder.Metadata.Cast().ToList(); + var responseTypes = endpointBuilder.Metadata.OfType().ToList(); if (!responseTypes.Any()) return; operation.Responses.Clear(); foreach (var responseType in responseTypes) diff --git a/Emails/wwwroot/output.css b/Emails/wwwroot/output.css index ce6e911..0bffb69 100644 --- a/Emails/wwwroot/output.css +++ b/Emails/wwwroot/output.css @@ -1,5 +1,5 @@ /* -! tailwindcss v3.4.15 | MIT License | https://tailwindcss.com +! tailwindcss v3.2.4 | MIT License | https://tailwindcss.com */ /* @@ -16,7 +16,7 @@ /* 2 */ border-style: solid; /* 2 */ - border-color: #e5e7eb; + border-color: currentColor; /* 2 */ } @@ -415,7 +415,7 @@ video { /* Make elements with the HTML hidden attribute stay hidden by default */ -[hidden]:where(:not([hidden="until-found"])) { +[hidden] { display: none; } @@ -513,18 +513,38 @@ video { --tw-backdrop-sepia: ; } +.visible { + visibility: visible; +} + .static { position: static; } +.fixed { + position: fixed; +} + .absolute { position: absolute; } +.m-0 { + margin: 0px; +} + .ml-2 { margin-left: 0.5rem; } +.block { + display: block; +} + +.inline-block { + display: inline-block; +} + .inline { display: inline; } @@ -553,6 +573,22 @@ video { height: 12rem; } +.h-10 { + height: 2.5rem; +} + +.h-3 { + height: 0.75rem; +} + +.h-2 { + height: 0.5rem; +} + +.h-5 { + height: 1.25rem; +} + .w-full { width: 100%; } @@ -565,10 +601,34 @@ video { width: 12rem; } +.w-10 { + width: 2.5rem; +} + +.w-2 { + width: 0.5rem; +} + +.w-5 { + width: 1.25rem; +} + +.max-w-\[680px\] { + max-width: 680px; +} + .flex-grow { flex-grow: 1; } +.table-fixed { + table-layout: fixed; +} + +.transform { + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} + .flex-col { flex-direction: column; } @@ -605,6 +665,14 @@ video { align-self: flex-end; } +.overflow-hidden { + overflow: hidden; +} + +.break-all { + word-break: break-all; +} + .rounded-md { border-radius: 0.375rem; } @@ -613,6 +681,26 @@ video { border-radius: 0.25rem; } +.rounded-2xl { + border-radius: 1rem; +} + +.rounded-xl { + border-radius: 0.75rem; +} + +.rounded-lg { + border-radius: 0.5rem; +} + +.rounded-full { + border-radius: 9999px; +} + +.rounded-sm { + border-radius: 0.125rem; +} + .border-4 { border-width: 4px; } @@ -621,11 +709,133 @@ video { border-width: 1px; } +.border-t { + border-top-width: 1px; +} + +.border-r { + border-right-width: 1px; +} + +.border-b { + border-bottom-width: 1px; +} + +.border-email-line { + --tw-border-opacity: 1; + border-color: rgb(41 55 71 / var(--tw-border-opacity)); +} + +.border-email-neon { + --tw-border-opacity: 1; + border-color: rgb(76 227 90 / var(--tw-border-opacity)); +} + +.border-email-line-soft { + --tw-border-opacity: 1; + border-color: rgb(27 41 54 / var(--tw-border-opacity)); +} + +.border-email-neon-soft { + --tw-border-opacity: 1; + border-color: rgb(21 56 32 / var(--tw-border-opacity)); +} + +.border-email-red-soft { + --tw-border-opacity: 1; + border-color: rgb(58 23 26 / var(--tw-border-opacity)); +} + +.border-email-yellow-soft { + --tw-border-opacity: 1; + border-color: rgb(59 48 18 / var(--tw-border-opacity)); +} + .bg-primary { --tw-bg-opacity: 1; background-color: rgb(var(--primary) / var(--tw-bg-opacity)); } +.bg-email-canvas { + --tw-bg-opacity: 1; + background-color: rgb(7 11 16 / var(--tw-bg-opacity)); +} + +.bg-email-header { + --tw-bg-opacity: 1; + background-color: rgb(8 17 28 / var(--tw-bg-opacity)); +} + +.bg-email-neon-soft { + --tw-bg-opacity: 1; + background-color: rgb(21 56 32 / var(--tw-bg-opacity)); +} + +.bg-email-surface { + --tw-bg-opacity: 1; + background-color: rgb(13 21 30 / var(--tw-bg-opacity)); +} + +.bg-email-surface-2 { + --tw-bg-opacity: 1; + background-color: rgb(17 28 39 / var(--tw-bg-opacity)); +} + +.bg-email-line-soft { + --tw-bg-opacity: 1; + background-color: rgb(27 41 54 / var(--tw-bg-opacity)); +} + +.bg-email-red { + --tw-bg-opacity: 1; + background-color: rgb(240 82 82 / var(--tw-bg-opacity)); +} + +.bg-email-neon { + --tw-bg-opacity: 1; + background-color: rgb(76 227 90 / var(--tw-bg-opacity)); +} + +.bg-email-blue { + --tw-bg-opacity: 1; + background-color: rgb(59 130 246 / var(--tw-bg-opacity)); +} + +.bg-email-blue-2 { + --tw-bg-opacity: 1; + background-color: rgb(37 99 235 / var(--tw-bg-opacity)); +} + +.bg-email-slate-bar { + --tw-bg-opacity: 1; + background-color: rgb(122 135 149 / var(--tw-bg-opacity)); +} + +.bg-email-purple { + --tw-bg-opacity: 1; + background-color: rgb(139 92 246 / var(--tw-bg-opacity)); +} + +.bg-email-yellow { + --tw-bg-opacity: 1; + background-color: rgb(251 191 36 / var(--tw-bg-opacity)); +} + +.bg-email-orange { + --tw-bg-opacity: 1; + background-color: rgb(249 115 22 / var(--tw-bg-opacity)); +} + +.bg-email-red-soft { + --tw-bg-opacity: 1; + background-color: rgb(58 23 26 / var(--tw-bg-opacity)); +} + +.bg-email-yellow-soft { + --tw-bg-opacity: 1; + background-color: rgb(59 48 18 / var(--tw-bg-opacity)); +} + .py-2 { padding-top: 0.5rem; padding-bottom: 0.5rem; @@ -641,6 +851,66 @@ video { padding-right: 2.5rem; } +.px-3 { + padding-left: 0.75rem; + padding-right: 0.75rem; +} + +.py-6 { + padding-top: 1.5rem; + padding-bottom: 1.5rem; +} + +.px-7 { + padding-left: 1.75rem; + padding-right: 1.75rem; +} + +.px-5 { + padding-left: 1.25rem; + padding-right: 1.25rem; +} + +.px-2 { + padding-left: 0.5rem; + padding-right: 0.5rem; +} + +.py-3 { + padding-top: 0.75rem; + padding-bottom: 0.75rem; +} + +.px-4 { + padding-left: 1rem; + padding-right: 1rem; +} + +.py-2\.5 { + padding-top: 0.625rem; + padding-bottom: 0.625rem; +} + +.py-1 { + padding-top: 0.25rem; + padding-bottom: 0.25rem; +} + +.py-0\.5 { + padding-top: 0.125rem; + padding-bottom: 0.125rem; +} + +.py-0 { + padding-top: 0px; + padding-bottom: 0px; +} + +.py-1\.5 { + padding-top: 0.375rem; + padding-bottom: 0.375rem; +} + .pt-2 { padding-top: 0.5rem; } @@ -665,10 +935,74 @@ video { padding-top: 0.25rem; } +.pl-3 { + padding-left: 0.75rem; +} + +.pt-5 { + padding-top: 1.25rem; +} + +.pt-4 { + padding-top: 1rem; +} + +.pr-1\.5 { + padding-right: 0.375rem; +} + +.pr-1 { + padding-right: 0.25rem; +} + +.pl-2 { + padding-left: 0.5rem; +} + +.pl-1\.5 { + padding-left: 0.375rem; +} + +.pl-1 { + padding-left: 0.25rem; +} + +.pr-3 { + padding-right: 0.75rem; +} + +.pl-4 { + padding-left: 1rem; +} + +.pb-4 { + padding-bottom: 1rem; +} + +.pt-3 { + padding-top: 0.75rem; +} + +.pb-3 { + padding-bottom: 0.75rem; +} + .text-left { text-align: left; } +.text-center { + text-align: center; +} + +.align-middle { + vertical-align: middle; +} + +.font-email { + font-family: Trebuchet MS, Segoe UI, Arial, sans-serif; +} + .text-sm { font-size: 0.875rem; line-height: 1.25rem; @@ -699,6 +1033,46 @@ video { line-height: 2rem; } +.text-\[11px\] { + font-size: 11px; +} + +.text-\[28px\] { + font-size: 28px; +} + +.text-\[14px\] { + font-size: 14px; +} + +.text-\[13px\] { + font-size: 13px; +} + +.text-\[22px\] { + font-size: 22px; +} + +.text-\[10px\] { + font-size: 10px; +} + +.text-\[9px\] { + font-size: 9px; +} + +.text-\[24px\] { + font-size: 24px; +} + +.text-\[8px\] { + font-size: 8px; +} + +.text-\[12px\] { + font-size: 12px; +} + .font-semibold { font-weight: 600; } @@ -707,6 +1081,46 @@ video { font-weight: 700; } +.font-extrabold { + font-weight: 800; +} + +.font-medium { + font-weight: 500; +} + +.uppercase { + text-transform: uppercase; +} + +.normal-case { + text-transform: none; +} + +.leading-\[34px\] { + line-height: 34px; +} + +.leading-5 { + line-height: 1.25rem; +} + +.leading-4 { + line-height: 1rem; +} + +.tracking-\[0\.18em\] { + letter-spacing: 0.18em; +} + +.tracking-wide { + letter-spacing: 0.025em; +} + +.tracking-normal { + letter-spacing: 0em; +} + .text-secondary { --tw-text-opacity: 1; color: rgb(var(--secondary-text) / var(--tw-text-opacity)); @@ -717,6 +1131,41 @@ video { color: rgb(var(--primary-text) / var(--tw-text-opacity)); } +.text-email-text { + --tw-text-opacity: 1; + color: rgb(244 247 250 / var(--tw-text-opacity)); +} + +.text-email-neon { + --tw-text-opacity: 1; + color: rgb(76 227 90 / var(--tw-text-opacity)); +} + +.text-email-muted { + --tw-text-opacity: 1; + color: rgb(160 172 184 / var(--tw-text-opacity)); +} + +.text-email-dim { + --tw-text-opacity: 1; + color: rgb(112 128 145 / var(--tw-text-opacity)); +} + +.text-email-red { + --tw-text-opacity: 1; + color: rgb(240 82 82 / var(--tw-text-opacity)); +} + +.text-email-yellow { + --tw-text-opacity: 1; + color: rgb(251 191 36 / var(--tw-text-opacity)); +} + +.text-email-purple { + --tw-text-opacity: 1; + color: rgb(139 92 246 / var(--tw-text-opacity)); +} + .line-through { text-decoration-line: line-through; } @@ -725,6 +1174,11 @@ video { filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow); } +.backdrop-filter { + -webkit-backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia); + backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia); +} + :root { /* --primary: #3d3d3d; diff --git a/Models/CommonDbContext.cs b/Models/CommonDbContext.cs index cc728c2..3bde51f 100644 --- a/Models/CommonDbContext.cs +++ b/Models/CommonDbContext.cs @@ -54,7 +54,7 @@ public abstract class CommonDbContext : DbContext } public async Task, CreatedAtRoute>> ApiSaveChangesAsyncCreate(IServiceProvider providers, T1 value, bool apply = true, T2? response = null) where T2 : class, IAutomappedAttribute, new() { - if(response == null) + if (response == null) response = new T2(); if (!apply) return TypedResults.CreatedAtRoute(response.ApplyFrom(providers, value)); @@ -68,7 +68,7 @@ public abstract class CommonDbContext : DbContext } public async Task, Ok>> ApiSaveChangesAsyncOk(IServiceProvider providers, T1 value, bool apply = true, T2? response = null) where T2 : class, IAutomappedAttribute, new() { - if(response == null) + if (response == null) response = new T2(); if (!apply) return TypedResults.Ok(response.ApplyFrom(providers, value)); @@ -82,15 +82,15 @@ public abstract class CommonDbContext : DbContext } public async Task, Ok>>> ApiSaveChangesAsyncOk(IServiceProvider providers, IEnumerable value, bool apply = true, T2? response = null) where T2 : class, IAutomappedAttribute, new() { - if(response == null) + if (response == null) response = new T2(); if (!apply) - return TypedResults.Ok(value.Select(x=> new T2().ApplyFrom(providers, x)).ToList()); + return TypedResults.Ok(value.Select(x => new T2().ApplyFrom(providers, x)).ToList()); var res = await ApiSaveChangesAsync(); if (res) - return TypedResults.Ok(value.Select(x=> new T2().ApplyFrom(providers, x)).ToList()); + return TypedResults.Ok(value.Select(x => new T2().ApplyFrom(providers, x)).ToList()); else return SaveError; } @@ -178,6 +178,30 @@ public abstract class CommonDbContext : DbContext return entity; } + public T Create(TParent parentI, string navigationName, Action config = null, params object[] constructorArguments) where T : class, new() where TParent : class + { + var candidates = Model + .GetEntityTypes() + .Where(x => x.ClrType == typeof(TParent)) + .FirstOrDefault(); + var entityT = candidates?.FindNavigation(navigationName)?.TargetEntityType; + + if (entityT == null || entityT.IsOwned()) + { + var childEntity = new T(); + + config?.Invoke(childEntity); + + return childEntity; + } + + var entity = this.Set(entityT.Name).CreateProxy(constructorArguments); + + config?.Invoke(entity); + this.Add(entity); + this.ChangeTracker.DetectChanges(); + return entity; + } public T Create(Action config = null, params object[] constructorArguments) { var entity = this.CreateProxy(constructorArguments); diff --git a/Utils/AutoMapPropertyUtils.cs b/Utils/AutoMapPropertyUtils.cs new file mode 100644 index 0000000..1a7109a --- /dev/null +++ b/Utils/AutoMapPropertyUtils.cs @@ -0,0 +1,21 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Internal; +using NejAccountingAPI.Models; + +namespace AutoMapProperty; + +public class AutoMapPropertyUtils +{ + public static T Create(IServiceProvider provider) where T : class + { + var appDb = provider.GetRequiredService(); + + return appDb.Create(); + } + public static T Create(IServiceProvider provider, TParent parent, string name) where T : class, new() where TParent : class + { + var appDb = provider.GetRequiredService(); + + return appDb.Create(parent, name); + } +} \ No newline at end of file diff --git a/Utils/RoundingNumericTypeInfoResolverFactory.cs b/Utils/RoundingNumericTypeInfoResolverFactory.cs new file mode 100644 index 0000000..3a5579f --- /dev/null +++ b/Utils/RoundingNumericTypeInfoResolverFactory.cs @@ -0,0 +1,412 @@ + +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.