Skip to content

bugfix/decimal-set-scale #98

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Apr 23, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions Directory.Build.props
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
<Project>
<PropertyGroup>
<Version>12.1.0</Version>
<PackageVersion>12.1.0</PackageVersion>
<AssemblyVersion>12.1.0</AssemblyVersion>
<Version>12.1.1</Version>
<PackageVersion>12.1.1</PackageVersion>
<AssemblyVersion>12.1.1</AssemblyVersion>
<TargetFrameworks>net8.0;net9.0</TargetFrameworks>
<LangVersion>13</LangVersion>
<Nullable>enable</Nullable>
Expand Down
8 changes: 4 additions & 4 deletions OnixLabs.Numerics.UnitTests/NumericsExtensionsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -169,16 +169,16 @@ public void DecimalSetScaleWithRoundingShouldTruncateWhenNoPrecisionLoss(string
public void DecimalSetScaleShouldThrowWhenScaleIsNegative()
{
// Given / When / Then
Exception exception = Assert.Throws<ArgumentException>(() => 123.45m.SetScale(-1));
Assert.Contains("Scale must be greater than, or equal to zero.", exception.Message);
Exception exception = Assert.Throws<ArgumentOutOfRangeException>(() => 123.45m.SetScale(-1));
Assert.Contains("Scale must be within the inclusive range of 0 to 28.", exception.Message);
}

[Fact(DisplayName = "Decimal.SetScale(rounding) should throw when scale is negative")]
public void DecimalSetScaleWithRoundingShouldThrowWhenScaleIsNegative()
{
// Given / When / Then
Exception exception = Assert.Throws<ArgumentException>(() => 123.45m.SetScale(-1, MidpointRounding.AwayFromZero));
Assert.Contains("Scale must be greater than, or equal to zero.", exception.Message);
Exception exception = Assert.Throws<ArgumentOutOfRangeException>(() => 123.45m.SetScale(-1, MidpointRounding.AwayFromZero));
Assert.Contains("Scale must be within the inclusive range of 0 to 28.", exception.Message);
}

[Theory(DisplayName = "INumber<T>.IsBetween should produce the expected result")]
Expand Down
69 changes: 55 additions & 14 deletions OnixLabs.Numerics/NumericsExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ namespace OnixLabs.Numerics;
[EditorBrowsable(EditorBrowsableState.Never)]
public static class NumericsExtensions
{
/// <summary>
/// The maximum scale of a <see cref="decimal"/> value.
/// </summary>
private const int MaxScale = 28;

/// <summary>
/// Gets the minimum value of a <see cref="decimal"/> value as a <see cref="BigInteger"/>.
/// </summary>
Expand Down Expand Up @@ -66,24 +71,39 @@ public static BigInteger GetUnscaledValue(this decimal value)
/// <exception cref="ArgumentException"> if <paramref name="scale"/> is negative.</exception>
public static decimal SetScale(this decimal value, int scale)
{
Require(scale >= 0, "Scale must be greater than, or equal to zero.", nameof(scale));
RequireWithinRangeInclusive(scale, 0, MaxScale, "Scale must be within the inclusive range of 0 to 28.", nameof(scale));

// Determine maximum representable scale given the integer part length
BigInteger unscaledValue = value.GetUnscaledValue();
BigInteger absUnscaled = BigInteger.Abs(unscaledValue);
BigInteger factorCurrent = BigInteger.Pow(10, value.Scale);
BigInteger integerPart = BigInteger.DivRem(absUnscaled, factorCurrent, out _);
int integerDigits = integerPart.IsZero ? 1 : integerPart.ToString().Length;
int maxPossibleScale = MaxScale - integerDigits;

Require(scale <= maxPossibleScale, $"Maximum possible scale for the specified value is {maxPossibleScale}.", nameof(scale));

// No change needed
if (value.Scale == scale)
return value;

// Increase scale: pad with zeros
if (value.Scale < scale)
{
decimal factor = GenericMath.Pow10<decimal>(scale - value.Scale);
return value * factor / factor;
int diff = scale - value.Scale;
BigInteger padded = unscaledValue * BigInteger.Pow(10, diff);
return padded.ToDecimal(scale);
}

decimal pow10 = GenericMath.Pow10<decimal>(scale);
decimal truncated = Math.Truncate(value * pow10) / pow10;
// Decrease scale: drop extra digits
int drop = value.Scale - scale;
BigInteger divisor = BigInteger.Pow(10, drop);
BigInteger quotient = BigInteger.DivRem(unscaledValue, divisor, out BigInteger remainder);

if (value == truncated)
return truncated;
// If there is any remainder, dropping would lose precision
Check(remainder == 0, $"Cannot set scale to {scale} due to a loss of precision.");

throw new InvalidOperationException($"Cannot reduce scale without losing precision: {value}");
return quotient.ToDecimal(scale);
}

/// <summary>
Expand All @@ -101,21 +121,42 @@ public static decimal SetScale(this decimal value, int scale)
/// <exception cref="ArgumentException"> if <paramref name="scale"/> is negative.</exception>
public static decimal SetScale(this decimal value, int scale, MidpointRounding mode)
{
Require(scale >= 0, "Scale must be greater than, or equal to zero.", nameof(scale));
RequireWithinRangeInclusive(scale, 0, MaxScale, "Scale must be within the inclusive range of 0 to 28.", nameof(scale));

// Determine maximum representable scale
BigInteger unscaledValue = value.GetUnscaledValue();
BigInteger absUnscaled = BigInteger.Abs(unscaledValue);
BigInteger factorCurrent = BigInteger.Pow(10, value.Scale);
BigInteger integerPart = BigInteger.DivRem(absUnscaled, factorCurrent, out _);
int integerDigits = integerPart.IsZero ? 1 : integerPart.ToString().Length;
int maxPossibleScale = MaxScale - integerDigits;

Require(scale <= maxPossibleScale, $"Maximum possible scale for the specified value is {maxPossibleScale}.", nameof(scale));

// No change needed
if (value.Scale == scale)
return value;

// Increase scale: pad with zeros
if (value.Scale < scale)
{
decimal factor = GenericMath.Pow10<decimal>(scale - value.Scale);
return value * factor / factor;
int diff = scale - value.Scale;
BigInteger padded = unscaledValue * BigInteger.Pow(10, diff);
return padded.ToDecimal(scale);
}

decimal pow10 = GenericMath.Pow10<decimal>(scale);
decimal truncated = Math.Truncate(value * pow10) / pow10;
// Decrease scale: drop or round extra digits
int drop = value.Scale - scale;
BigInteger divisor = BigInteger.Pow(10, drop);
BigInteger.DivRem(unscaledValue, divisor, out BigInteger remainder);

// If fractional remainder, then rounding required
if (remainder != 0)
return decimal.Round(value, scale, mode);

return value == truncated ? truncated : Math.Round(value, scale, mode);
// No rounding required
BigInteger quotient = unscaledValue / divisor;
return quotient.ToDecimal(scale);
}

/// <summary>
Expand Down