Skip to content

Commit a7d2018

Browse files
bugfix/decimal-set-scale
Fixed a bug where SetScale was producing invalid results.
1 parent 7e8bb94 commit a7d2018

File tree

3 files changed

+62
-21
lines changed

3 files changed

+62
-21
lines changed

Directory.Build.props

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
<Project>
22
<PropertyGroup>
3-
<Version>12.1.0</Version>
4-
<PackageVersion>12.1.0</PackageVersion>
5-
<AssemblyVersion>12.1.0</AssemblyVersion>
3+
<Version>12.1.1</Version>
4+
<PackageVersion>12.1.1</PackageVersion>
5+
<AssemblyVersion>12.1.1</AssemblyVersion>
66
<TargetFrameworks>net8.0;net9.0</TargetFrameworks>
77
<LangVersion>13</LangVersion>
88
<Nullable>enable</Nullable>

OnixLabs.Numerics.UnitTests/NumericsExtensionsTests.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -169,16 +169,16 @@ public void DecimalSetScaleWithRoundingShouldTruncateWhenNoPrecisionLoss(string
169169
public void DecimalSetScaleShouldThrowWhenScaleIsNegative()
170170
{
171171
// Given / When / Then
172-
Exception exception = Assert.Throws<ArgumentException>(() => 123.45m.SetScale(-1));
173-
Assert.Contains("Scale must be greater than, or equal to zero.", exception.Message);
172+
Exception exception = Assert.Throws<ArgumentOutOfRangeException>(() => 123.45m.SetScale(-1));
173+
Assert.Contains("Scale must be within the inclusive range of 0 to 28.", exception.Message);
174174
}
175175

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

184184
[Theory(DisplayName = "INumber<T>.IsBetween should produce the expected result")]

OnixLabs.Numerics/NumericsExtensions.cs

Lines changed: 55 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@ namespace OnixLabs.Numerics;
2424
[EditorBrowsable(EditorBrowsableState.Never)]
2525
public static class NumericsExtensions
2626
{
27+
/// <summary>
28+
/// The maximum scale of a <see cref="decimal"/> value.
29+
/// </summary>
30+
private const int MaxScale = 28;
31+
2732
/// <summary>
2833
/// Gets the minimum value of a <see cref="decimal"/> value as a <see cref="BigInteger"/>.
2934
/// </summary>
@@ -66,24 +71,39 @@ public static BigInteger GetUnscaledValue(this decimal value)
6671
/// <exception cref="ArgumentException"> if <paramref name="scale"/> is negative.</exception>
6772
public static decimal SetScale(this decimal value, int scale)
6873
{
69-
Require(scale >= 0, "Scale must be greater than, or equal to zero.", nameof(scale));
74+
RequireWithinRangeInclusive(scale, 0, MaxScale, "Scale must be within the inclusive range of 0 to 28.", nameof(scale));
75+
76+
// Determine maximum representable scale given the integer part length
77+
BigInteger unscaledValue = value.GetUnscaledValue();
78+
BigInteger absUnscaled = BigInteger.Abs(unscaledValue);
79+
BigInteger factorCurrent = BigInteger.Pow(10, value.Scale);
80+
BigInteger integerPart = BigInteger.DivRem(absUnscaled, factorCurrent, out _);
81+
int integerDigits = integerPart.IsZero ? 1 : integerPart.ToString().Length;
82+
int maxPossibleScale = MaxScale - integerDigits;
7083

84+
Require(scale <= maxPossibleScale, $"Maximum possible scale for the specified value is {maxPossibleScale}.", nameof(scale));
85+
86+
// No change needed
7187
if (value.Scale == scale)
7288
return value;
7389

90+
// Increase scale: pad with zeros
7491
if (value.Scale < scale)
7592
{
76-
decimal factor = GenericMath.Pow10<decimal>(scale - value.Scale);
77-
return value * factor / factor;
93+
int diff = scale - value.Scale;
94+
BigInteger padded = unscaledValue * BigInteger.Pow(10, diff);
95+
return padded.ToDecimal(scale);
7896
}
7997

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

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

86-
throw new InvalidOperationException($"Cannot reduce scale without losing precision: {value}");
106+
return quotient.ToDecimal(scale);
87107
}
88108

89109
/// <summary>
@@ -101,21 +121,42 @@ public static decimal SetScale(this decimal value, int scale)
101121
/// <exception cref="ArgumentException"> if <paramref name="scale"/> is negative.</exception>
102122
public static decimal SetScale(this decimal value, int scale, MidpointRounding mode)
103123
{
104-
Require(scale >= 0, "Scale must be greater than, or equal to zero.", nameof(scale));
124+
RequireWithinRangeInclusive(scale, 0, MaxScale, "Scale must be within the inclusive range of 0 to 28.", nameof(scale));
125+
126+
// Determine maximum representable scale
127+
BigInteger unscaledValue = value.GetUnscaledValue();
128+
BigInteger absUnscaled = BigInteger.Abs(unscaledValue);
129+
BigInteger factorCurrent = BigInteger.Pow(10, value.Scale);
130+
BigInteger integerPart = BigInteger.DivRem(absUnscaled, factorCurrent, out _);
131+
int integerDigits = integerPart.IsZero ? 1 : integerPart.ToString().Length;
132+
int maxPossibleScale = MaxScale - integerDigits;
105133

134+
Require(scale <= maxPossibleScale, $"Maximum possible scale for the specified value is {maxPossibleScale}.", nameof(scale));
135+
136+
// No change needed
106137
if (value.Scale == scale)
107138
return value;
108139

140+
// Increase scale: pad with zeros
109141
if (value.Scale < scale)
110142
{
111-
decimal factor = GenericMath.Pow10<decimal>(scale - value.Scale);
112-
return value * factor / factor;
143+
int diff = scale - value.Scale;
144+
BigInteger padded = unscaledValue * BigInteger.Pow(10, diff);
145+
return padded.ToDecimal(scale);
113146
}
114147

115-
decimal pow10 = GenericMath.Pow10<decimal>(scale);
116-
decimal truncated = Math.Truncate(value * pow10) / pow10;
148+
// Decrease scale: drop or round extra digits
149+
int drop = value.Scale - scale;
150+
BigInteger divisor = BigInteger.Pow(10, drop);
151+
BigInteger.DivRem(unscaledValue, divisor, out BigInteger remainder);
152+
153+
// If fractional remainder, then rounding required
154+
if (remainder != 0)
155+
return decimal.Round(value, scale, mode);
117156

118-
return value == truncated ? truncated : Math.Round(value, scale, mode);
157+
// No rounding required
158+
BigInteger quotient = unscaledValue / divisor;
159+
return quotient.ToDecimal(scale);
119160
}
120161

121162
/// <summary>

0 commit comments

Comments
 (0)