Skip to content

Commit ef9d846

Browse files
Per Kopsperkops
authored andcommitted
fix(streaming): convert progressive frame values to match column types
Progressive frames send decimals as strings but DataTable expects SqlDecimal, causing ArgumentException during row assignment. Add FrameValueTypeConverter to handle type conversions before DataRow assignment in both streaming handlers. Add InternalsVisibleTo for tests.
1 parent aed3d3d commit ef9d846

File tree

5 files changed

+261
-2
lines changed

5 files changed

+261
-2
lines changed

src/Atc.Kusto/Atc.Kusto.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
</ItemGroup>
3030

3131
<ItemGroup>
32+
<InternalsVisibleTo Include="Atc.Kusto.Tests" />
3233
<InternalsVisibleTo Include="DynamicProxyGenAssembly2" />
3334
</ItemGroup>
3435

src/Atc.Kusto/Handlers/Internal/BufferedStreamingQueryHandler.cs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -275,7 +275,15 @@ private void HandleTableFragment(
275275
var record = new object[tableFragmentFrame.FieldCount];
276276
while (tableFragmentFrame.GetNextRecord(record))
277277
{
278-
dt.Rows.Add(record);
278+
var newRow = dt.NewRow();
279+
280+
// Convert frame values to match column types (Kusto sends decimals as strings in progressive frames)
281+
for (var i = 0; i < record.Length; i++)
282+
{
283+
newRow[i] = FrameValueTypeConverter.ConvertToColumnType(record[i], dt.Columns[i].DataType);
284+
}
285+
286+
dt.Rows.Add(newRow);
279287
}
280288
}
281289

src/Atc.Kusto/Handlers/Internal/StreamingQueryHandler.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,13 @@ private async IAsyncEnumerable<T> HandleTableFragment(
164164

165165
// Create a new row from the known schema.
166166
var newRow = dt.NewRow();
167-
newRow.ItemArray = (object[])record.Clone(); // ???
167+
168+
// Convert frame values to match column types (Kusto sends decimals as strings in progressive frames)
169+
for (var i = 0; i < record.Length; i++)
170+
{
171+
newRow[i] = FrameValueTypeConverter.ConvertToColumnType(record[i], dt.Columns[i].DataType);
172+
}
173+
168174
dt.Rows.Add(newRow);
169175

170176
if (tableKind == WellKnownDataSet.PrimaryResult)
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
namespace Atc.Kusto.Utilities.Internal;
2+
3+
/// <summary>
4+
/// Provides utility methods for converting Kusto progressive frame values to match DataTable column types.
5+
/// </summary>
6+
/// <remarks>
7+
/// Kusto progressive frames may send values in wire format (e.g., decimals as strings)
8+
/// that need conversion to match the DataTable column types defined by the schema.
9+
/// </remarks>
10+
internal static class FrameValueTypeConverter
11+
{
12+
/// <summary>
13+
/// Converts a value from a Kusto progressive frame to match the target column type.
14+
/// </summary>
15+
/// <param name="value">The value from the progressive frame.</param>
16+
/// <param name="targetType">The expected column type from the DataTable schema.</param>
17+
/// <returns>
18+
/// The value converted to the target type, or <see cref="DBNull.Value"/> if the value is null.
19+
/// If conversion fails, returns the original value.
20+
/// </returns>
21+
/// <remarks>
22+
/// This method handles several known type mismatches:
23+
/// <list type="bullet">
24+
/// <item><description>String → SqlDecimal: Kusto sends decimal values as strings in progressive frames.</description></item>
25+
/// <item><description>Other mismatches: Uses <see cref="Convert.ChangeType(object, Type, IFormatProvider)"/> with invariant culture.</description></item>
26+
/// </list>
27+
/// </remarks>
28+
public static object? ConvertToColumnType(
29+
object? value,
30+
Type targetType)
31+
{
32+
if (value is null or DBNull)
33+
{
34+
return DBNull.Value;
35+
}
36+
37+
// If types already match, return as-is
38+
if (value.GetType() == targetType)
39+
{
40+
return value;
41+
}
42+
43+
// Handle string → SqlDecimal conversion (Kusto sends decimals as strings in progressive frames)
44+
if (value is string stringValue &&
45+
targetType == typeof(SqlDecimal) &&
46+
decimal.TryParse(stringValue, NumberStyles.Any, CultureInfo.InvariantCulture, out var decimalValue))
47+
{
48+
return new SqlDecimal(decimalValue);
49+
}
50+
51+
// For other type mismatches, try Convert.ChangeType
52+
try
53+
{
54+
return Convert.ChangeType(value, targetType, CultureInfo.InvariantCulture);
55+
}
56+
catch
57+
{
58+
// If conversion fails, return original value and let DataTable handle it
59+
return value;
60+
}
61+
}
62+
}
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
namespace Atc.Kusto.Tests.Utilities.Internal;
2+
3+
public sealed class FrameValueTypeConverterTests
4+
{
5+
[Fact]
6+
public void ConvertToColumnType_Should_Return_DBNull_When_Value_Is_Null()
7+
{
8+
// Arrange
9+
var targetType = typeof(string);
10+
11+
// Act
12+
var result = FrameValueTypeConverter.ConvertToColumnType(null, targetType);
13+
14+
// Assert
15+
Assert.Equal(DBNull.Value, result);
16+
}
17+
18+
[Fact]
19+
public void ConvertToColumnType_Should_Return_DBNull_When_Value_Is_DBNull()
20+
{
21+
// Arrange
22+
object? value = DBNull.Value;
23+
var targetType = typeof(int);
24+
25+
// Act
26+
var result = FrameValueTypeConverter.ConvertToColumnType(value, targetType);
27+
28+
// Assert
29+
Assert.Equal(DBNull.Value, result);
30+
}
31+
32+
[Fact]
33+
public void ConvertToColumnType_Should_Return_Same_Value_When_Types_Match()
34+
{
35+
// Arrange
36+
const int value = 42;
37+
var targetType = typeof(int);
38+
39+
// Act
40+
var result = FrameValueTypeConverter.ConvertToColumnType(value, targetType);
41+
42+
// Assert
43+
Assert.Equal(42, result);
44+
Assert.IsType<int>(result);
45+
}
46+
47+
[Theory]
48+
[InlineData("123.45", 123.45)]
49+
[InlineData("0.001", 0.001)]
50+
[InlineData("999999.99", 999999.99)]
51+
[InlineData("-456.78", -456.78)]
52+
public void ConvertToColumnType_Should_Convert_String_To_SqlDecimal(
53+
string stringValue,
54+
decimal expectedDecimalValue)
55+
{
56+
// Arrange
57+
var targetType = typeof(SqlDecimal);
58+
59+
// Act
60+
var result = FrameValueTypeConverter.ConvertToColumnType(stringValue, targetType);
61+
62+
// Assert
63+
Assert.NotNull(result);
64+
Assert.IsType<SqlDecimal>(result);
65+
var sqlDecimal = (SqlDecimal)result;
66+
Assert.Equal(expectedDecimalValue, (decimal)sqlDecimal);
67+
}
68+
69+
[Fact]
70+
public void ConvertToColumnType_Should_Handle_Invalid_String_For_SqlDecimal()
71+
{
72+
// Arrange
73+
const string value = "not-a-number";
74+
var targetType = typeof(SqlDecimal);
75+
76+
// Act
77+
var result = FrameValueTypeConverter.ConvertToColumnType(value, targetType);
78+
79+
// Assert - should return original value when conversion fails
80+
Assert.Equal("not-a-number", result);
81+
}
82+
83+
[Theory]
84+
[InlineData("42", typeof(int), 42)]
85+
[InlineData("3.14", typeof(double), 3.14)]
86+
[InlineData("true", typeof(bool), true)]
87+
public void ConvertToColumnType_Should_Use_ChangeType_For_Other_Conversions(
88+
string stringValue,
89+
Type targetType,
90+
object expectedValue)
91+
{
92+
// Act
93+
var result = FrameValueTypeConverter.ConvertToColumnType(stringValue, targetType);
94+
95+
// Assert
96+
Assert.NotNull(result);
97+
Assert.IsType(targetType, result);
98+
Assert.Equal(expectedValue, result);
99+
}
100+
101+
[Fact]
102+
public void ConvertToColumnType_Should_Return_Original_Value_When_Conversion_Fails()
103+
{
104+
// Arrange - try to convert a complex object to an int
105+
var value = new { Property = "test" };
106+
var targetType = typeof(int);
107+
108+
// Act
109+
var result = FrameValueTypeConverter.ConvertToColumnType(value, targetType);
110+
111+
// Assert - should return original value
112+
Assert.Equal(value, result);
113+
}
114+
115+
[Fact]
116+
public void ConvertToColumnType_Should_Handle_Numeric_Type_Conversions()
117+
{
118+
// Arrange
119+
const int value = 42;
120+
var targetType = typeof(long);
121+
122+
// Act
123+
var result = FrameValueTypeConverter.ConvertToColumnType(value, targetType);
124+
125+
// Assert
126+
Assert.NotNull(result);
127+
Assert.IsType<long>(result);
128+
Assert.Equal(42L, result);
129+
}
130+
131+
[Fact]
132+
public void ConvertToColumnType_Should_Use_InvariantCulture()
133+
{
134+
// Arrange - use a decimal string with period (not comma) as decimal separator
135+
const string value = "1234.56";
136+
var targetType = typeof(decimal);
137+
138+
// Act
139+
var result = FrameValueTypeConverter.ConvertToColumnType(value, targetType);
140+
141+
// Assert
142+
Assert.NotNull(result);
143+
Assert.IsType<decimal>(result);
144+
Assert.Equal(1234.56m, result);
145+
}
146+
147+
[Fact]
148+
public void ConvertToColumnType_Should_Handle_String_To_DateTime()
149+
{
150+
// Arrange
151+
const string value = "2024-01-15T10:30:00Z";
152+
var targetType = typeof(DateTime);
153+
154+
// Act
155+
var result = FrameValueTypeConverter.ConvertToColumnType(value, targetType);
156+
157+
// Assert
158+
Assert.NotNull(result);
159+
Assert.IsType<DateTime>(result);
160+
}
161+
162+
[Theory]
163+
[InlineData("20443.07")]
164+
[InlineData("12345.6789")]
165+
[InlineData("0.00")]
166+
public void ConvertToColumnType_Should_Handle_Realistic_Kusto_Decimal_Strings(string decimalString)
167+
{
168+
// Arrange - this simulates the actual scenario from the Kusto progressive frames
169+
var targetType = typeof(SqlDecimal);
170+
171+
// Act
172+
var result = FrameValueTypeConverter.ConvertToColumnType(decimalString, targetType);
173+
174+
// Assert
175+
Assert.NotNull(result);
176+
Assert.IsType<SqlDecimal>(result);
177+
178+
var sqlDecimal = (SqlDecimal)result;
179+
var expectedDecimal = decimal.Parse(decimalString, CultureInfo.InvariantCulture);
180+
Assert.Equal(expectedDecimal, (decimal)sqlDecimal);
181+
}
182+
}

0 commit comments

Comments
 (0)