Skip to content

Commit aed3d3d

Browse files
Per Kopsperkops
authored andcommitted
feat(extensions): add SqlDecimal to decimal conversion helper
Kusto SDK maps decimals to SqlDecimal (128-bit) which must be safely converted to .NET decimal (96-bit) respecting precision/scale constraints. Added public SqlDecimalExtensions.ToDecimal() with comprehensive test coverage
1 parent c5683e9 commit aed3d3d

File tree

4 files changed

+273
-6
lines changed

4 files changed

+273
-6
lines changed
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
// ReSharper disable CheckNamespace
2+
namespace Atc.Kusto;
3+
4+
/// <summary>
5+
/// Provides extension methods for converting <see cref="SqlDecimal"/> values to .NET <see cref="decimal"/> type.
6+
/// These methods handle the precision and scale constraints when converting from Kusto's 128-bit decimals
7+
/// to .NET's 96-bit decimal type.
8+
/// </summary>
9+
public static class SqlDecimalExtensions
10+
{
11+
private const int DotNetDecimalMaxPrecision = 28;
12+
private const int DotNetDecimalMaxScale = 27;
13+
14+
/// <summary>
15+
/// Converts a <see cref="SqlDecimal"/> to a <see cref="decimal"/> by safely adjusting precision and scale
16+
/// to fit within .NET decimal's constraints (28 max precision, 27 max scale).
17+
/// <para>
18+
/// Azure Data Explorer (Kusto) uses 128-bit decimals that can exceed .NET's 96-bit decimal capacity.
19+
/// This method ensures safe conversion by:
20+
/// <list type="bullet">
21+
/// <item><description>Validating the integer part fits within .NET decimal's precision</description></item>
22+
/// <item><description>Adjusting scale if necessary to fit the total precision constraint</description></item>
23+
/// <item><description>Using <see cref="SqlDecimal.ConvertToPrecScale"/> for safe rounding when needed</description></item>
24+
/// </list>
25+
/// </para>
26+
/// </summary>
27+
/// <param name="sqlDecimal">The SQL decimal value to convert.</param>
28+
/// <returns>A <see cref="decimal"/> value representing the SQL decimal, adjusted if necessary to fit .NET constraints.</returns>
29+
/// <exception cref="OverflowException">
30+
/// Thrown when the integer part (digits before the decimal point) exceeds .NET decimal's maximum precision capacity of 28 digits.
31+
/// This indicates the value is too large to represent as a .NET decimal.
32+
/// </exception>
33+
/// <example>
34+
/// <code>
35+
/// // Simple conversion
36+
/// var sqlDec = new SqlDecimal(123.45m);
37+
/// decimal result = sqlDec.ToDecimal(); // 123.45
38+
///
39+
/// // High precision conversion (automatically adjusted)
40+
/// var highPrecision = new SqlDecimal(38, 20, true, ...); // 38 digits, 20 scale
41+
/// decimal adjusted = highPrecision.ToDecimal(); // Adjusted to fit 28 precision
42+
/// </code>
43+
/// </example>
44+
public static decimal ToDecimal(this SqlDecimal sqlDecimal)
45+
{
46+
var integerDigits = sqlDecimal.Precision - sqlDecimal.Scale;
47+
if (integerDigits > DotNetDecimalMaxPrecision)
48+
{
49+
throw new OverflowException(
50+
$"Integer part ({integerDigits} digits) exceeds .NET decimal capacity of {DotNetDecimalMaxPrecision} digits. " +
51+
$"SqlDecimal value has precision={sqlDecimal.Precision}, scale={sqlDecimal.Scale}.");
52+
}
53+
54+
var maxAvailableScale = DotNetDecimalMaxPrecision - integerDigits;
55+
var targetScale = System.Math.Min(sqlDecimal.Scale, System.Math.Min(DotNetDecimalMaxScale, maxAvailableScale));
56+
var targetPrecision = System.Math.Min(sqlDecimal.Precision, integerDigits + targetScale);
57+
58+
if (targetPrecision != sqlDecimal.Precision || targetScale != sqlDecimal.Scale)
59+
{
60+
// Precision or scale needs adjustment - use safe conversion
61+
var safeDecimal = SqlDecimal.ConvertToPrecScale(sqlDecimal, targetPrecision, targetScale);
62+
return (decimal)safeDecimal;
63+
}
64+
65+
// Direct conversion is safe
66+
return (decimal)sqlDecimal;
67+
}
68+
}

src/Atc.Kusto/GlobalUsings.cs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1+
global using System.Buffers;
12
global using System.Collections.Concurrent;
23
global using System.Data;
34
global using System.Data.SqlTypes;
45
global using System.Diagnostics.CodeAnalysis;
6+
global using System.Globalization;
57
global using System.Reflection;
68
global using System.Runtime.CompilerServices;
7-
global using System.Text;
9+
global using System.Text.Json;
10+
global using System.Text.Json.Serialization;
811
global using System.Text.RegularExpressions;
912
global using System.Threading.Channels;
1013
global using Atc.Kusto.Extensions;
@@ -15,10 +18,8 @@
1518
global using Atc.Kusto.Handlers.Internal;
1619
global using Atc.Kusto.Options;
1720
global using Atc.Kusto.Providers.Internal;
18-
global using Atc.Kusto.Serialization.Internal;
1921
global using Atc.Kusto.Utilities.Internal;
2022
global using Azure.Core;
21-
global using Kusto.Cloud.Platform.Data;
2223
global using Kusto.Data;
2324
global using Kusto.Data.Common;
2425
global using Kusto.Data.Exceptions;
@@ -29,5 +30,6 @@
2930
global using Microsoft.Extensions.Logging;
3031
global using Microsoft.Extensions.Logging.Abstractions;
3132
global using Microsoft.Extensions.Options;
33+
global using Newtonsoft.Json.Linq;
3234
global using Polly;
3335
global using Polly.Retry;
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
namespace Atc.Kusto.Tests.Extensions;
2+
3+
public sealed class SqlDecimalExtensionsTests
4+
{
5+
[Theory]
6+
[InlineData(123.45)]
7+
[InlineData(0)]
8+
[InlineData(-456.78)]
9+
[InlineData(999999999.99)]
10+
[InlineData(0.01)]
11+
[InlineData(0.001)]
12+
[InlineData(0.0001)]
13+
public void ToDecimal_Should_Convert_Values_Correctly(decimal value)
14+
{
15+
// Arrange
16+
var sqlDecimal = new SqlDecimal(value);
17+
18+
// Act
19+
var result = sqlDecimal.ToDecimal();
20+
21+
// Assert
22+
result.Should().Be(value);
23+
}
24+
25+
[Fact]
26+
public void ToDecimal_Should_Handle_Maximum_Precision_Within_Limits()
27+
{
28+
// Arrange - Create a SqlDecimal with 28 digits precision, 10 scale (within .NET limits)
29+
var sqlDecimal = new SqlDecimal(28, 10, true, 1234567890, 123456789, 12345, 0);
30+
var expectedValue = (decimal)sqlDecimal;
31+
32+
// Act
33+
var result = sqlDecimal.ToDecimal();
34+
35+
// Assert
36+
result.Should().Be(expectedValue);
37+
}
38+
39+
[Fact]
40+
public void ToDecimal_Should_Handle_Zero_Scale()
41+
{
42+
// Arrange - Integer value (no decimal places)
43+
var sqlDecimal = new SqlDecimal(10, 0, true, 1234567890, 0, 0, 0);
44+
var expectedValue = (decimal)sqlDecimal;
45+
46+
// Act
47+
var result = sqlDecimal.ToDecimal();
48+
49+
// Assert
50+
result.Should().Be(expectedValue);
51+
}
52+
53+
[Fact]
54+
public void ToDecimal_Should_Handle_Maximum_Scale()
55+
{
56+
// Arrange - Maximum scale of 27
57+
const decimal value = 1.123456789012345678901234567m; // 27 decimal places
58+
var sqlDecimal = new SqlDecimal(value);
59+
60+
// Act
61+
var result = sqlDecimal.ToDecimal();
62+
63+
// Assert
64+
result.Should().Be(value);
65+
}
66+
67+
[Fact]
68+
public void ToDecimal_Should_Handle_Negative_Values()
69+
{
70+
// Arrange
71+
const decimal value = -123456.789m;
72+
var sqlDecimal = new SqlDecimal(value);
73+
74+
// Act
75+
var result = sqlDecimal.ToDecimal();
76+
77+
// Assert
78+
result.Should().Be(value);
79+
}
80+
81+
[Fact]
82+
public void ToDecimal_Should_Adjust_Scale_When_Total_Precision_Requires_It()
83+
{
84+
// Arrange - Create SqlDecimal with valid but high precision
85+
// 18 integer digits + 10 scale = 28 total (at limit, should convert directly)
86+
const decimal value = 123456789012345678.9876543210m;
87+
var sqlDecimal = new SqlDecimal(value);
88+
89+
// Act
90+
var result = sqlDecimal.ToDecimal();
91+
92+
// Assert
93+
result.Should().Be(value);
94+
}
95+
96+
[Fact]
97+
public void ToDecimal_Should_Throw_When_Value_Has_Too_Many_Integer_Digits()
98+
{
99+
// Arrange - SqlDecimal constructor validates, so we create a scenario
100+
// where conversion logic would detect the issue
101+
// We'll use Parse to create a SqlDecimal from a string that represents
102+
// a value with more integer digits than .NET decimal can handle
103+
const string bigValue = "123456789012345678901234567890.5"; // 30 integer digits
104+
var sqlDecimal = SqlDecimal.Parse(bigValue);
105+
106+
// Act
107+
Action act = () => sqlDecimal.ToDecimal();
108+
109+
// Assert
110+
act.Should().Throw<OverflowException>().WithMessage("*Integer part*exceeds*decimal capacity*");
111+
}
112+
113+
[Fact]
114+
public void ToDecimal_Should_Handle_SqlDecimal_With_Low_Precision()
115+
{
116+
// Arrange - Very small precision
117+
var sqlDecimal = new SqlDecimal(5, 2, true, 12345, 0, 0, 0); // 123.45
118+
var expectedValue = (decimal)sqlDecimal;
119+
120+
// Act
121+
var result = sqlDecimal.ToDecimal();
122+
123+
// Assert
124+
result.Should().Be(expectedValue);
125+
}
126+
127+
[Fact]
128+
public void ToDecimal_Should_Handle_SqlDecimal_With_High_Scale()
129+
{
130+
// Arrange - High scale relative to precision
131+
var sqlDecimal = new SqlDecimal(20, 18, true, 12345678, 0, 0, 0); // Very small number with many decimals
132+
133+
// Act
134+
var result = sqlDecimal.ToDecimal();
135+
136+
// Assert
137+
result.Should().NotBe(0);
138+
}
139+
140+
[Fact]
141+
public void ToDecimal_Should_Handle_Large_Positive_Values()
142+
{
143+
// Arrange - Large value that fits within .NET decimal constraints
144+
const decimal value = 999999999999999999999999.9999m; // 24 integer + 4 decimal = 28 total
145+
var sqlDecimal = new SqlDecimal(value);
146+
147+
// Act
148+
var result = sqlDecimal.ToDecimal();
149+
150+
// Assert
151+
result.Should().Be(value);
152+
}
153+
154+
[Fact]
155+
public void ToDecimal_Should_Handle_Large_Negative_Values()
156+
{
157+
// Arrange - Large negative value that fits within .NET decimal constraints
158+
const decimal value = -999999999999999999999999.9999m; // 24 integer + 4 decimal = 28 total
159+
var sqlDecimal = new SqlDecimal(value);
160+
161+
// Act
162+
var result = sqlDecimal.ToDecimal();
163+
164+
// Assert
165+
result.Should().Be(value);
166+
}
167+
168+
[Fact]
169+
public void ToDecimal_Should_Preserve_Precision_When_Within_Limits()
170+
{
171+
// Arrange - Value with 28 total digits (max for .NET decimal)
172+
const decimal value = 1234567890123456.7890123456m; // 16 integer + 10 decimal = 26 total
173+
var sqlDecimal = new SqlDecimal(value);
174+
175+
// Act
176+
var result = sqlDecimal.ToDecimal();
177+
178+
// Assert
179+
result.Should().Be(value);
180+
}
181+
182+
[Fact]
183+
public void ToDecimal_Should_Be_Idempotent()
184+
{
185+
// Arrange
186+
const decimal value = 12345.6789m;
187+
var sqlDecimal = new SqlDecimal(value);
188+
189+
// Act
190+
var result1 = sqlDecimal.ToDecimal();
191+
var result2 = sqlDecimal.ToDecimal();
192+
193+
// Assert
194+
result1.Should().Be(result2);
195+
result1.Should().Be(value);
196+
}
197+
}

test/Atc.Kusto.Tests/GlobalUsings.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
global using System.Collections;
22
global using System.Data;
33
global using System.Data.Common;
4+
global using System.Data.SqlTypes;
45
global using System.Diagnostics.CodeAnalysis;
5-
global using System.Text.Json;
6+
global using System.Globalization;
67
global using Atc.Kusto.Extensions;
78
global using Atc.Kusto.Extensions.Internal;
89
global using Atc.Kusto.Factories;
@@ -12,9 +13,8 @@
1213
global using Atc.Kusto.HealthChecks;
1314
global using Atc.Kusto.Options;
1415
global using Atc.Kusto.Providers.Internal;
15-
global using Atc.Kusto.Serialization.Internal;
1616
global using Atc.Kusto.Tests.AutoNSubstituteDataAttributes;
17-
global using Atc.Serialization;
17+
global using Atc.Kusto.Utilities.Internal;
1818
global using AutoFixture.AutoNSubstitute;
1919
global using Azure.Identity;
2020
global using Kusto.Cloud.Platform.Utils;

0 commit comments

Comments
 (0)