Skip to content

Commit a0357b2

Browse files
Fix dotnet#3736 | Propagate Errors from ExecuteScalar (dotnet#3912)
* Fix github issue 3736 * Add async path fix and tests * Consistent exception handling in tests * Update MultipleResultTests * Fix async path
1 parent 54701d8 commit a0357b2

4 files changed

Lines changed: 305 additions & 6 deletions

File tree

src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlCommand.Scalar.cs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,11 @@ private static object CompleteExecuteScalar(SqlDataReader reader, bool returnLas
184184
result = reader.GetValue(0);
185185
}
186186
} while (returnLastResult && reader.NextResult());
187+
188+
// Drain remaining results to ensure all error tokens are processed
189+
// before returning the result (fix for GH issue #3736).
190+
while (reader.NextResult())
191+
{ }
187192
}
188193
finally
189194
{
@@ -256,7 +261,7 @@ private Task<object> ExecuteScalarAsyncInternal(CancellationToken cancellationTo
256261
SqlDataReader reader = executeTask.Result;
257262

258263
// @TODO: Use continue with state?
259-
reader.ReadAsync(cancellationToken).ContinueWith(readTask =>
264+
reader.ReadAsync(cancellationToken).ContinueWith(async readTask =>
260265
{
261266
// @TODO: This seems a bit confusing with unnecessary extra dispose calls and try/finally blocks
262267
try
@@ -298,6 +303,11 @@ private Task<object> ExecuteScalarAsyncInternal(CancellationToken cancellationTo
298303
exception = e;
299304
}
300305
}
306+
307+
// Drain remaining results to ensure all error tokens are processed
308+
// before returning the result (fix for GH issue #3736).
309+
while (await reader.NextResultAsync(cancellationToken).ConfigureAwait(false))
310+
{ }
301311
}
302312
finally
303313
{

src/Microsoft.Data.SqlClient/tests/ManualTests/Microsoft.Data.SqlClient.ManualTesting.Tests.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@
188188
<Compile Include="SQL\SplitPacketTest\SplitPacketTest.cs" />
189189
<Compile Include="SQL\SqlCommand\SqlCommandCompletedTest.cs" />
190190
<Compile Include="SQL\SqlCommand\SqlCommandCancelTest.cs" />
191+
<Compile Include="SQL\SqlCommand\SqlCommandExecuteScalarTest.cs" />
191192
<Compile Include="SQL\SqlCommand\SqlCommandSetTest.cs" />
192193
<Compile Include="SQL\SqlCredentialTest\SqlCredentialTest.cs" />
193194
<Compile Include="SQL\SqlDependencyTest\SqlDependencyTest.cs" />

src/Microsoft.Data.SqlClient/tests/ManualTests/ProviderAgnostic/MultipleResultsTest/MultipleResultsTest.cs

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -87,11 +87,10 @@ public void ExecuteScalar()
8787

8888
command.CommandText = s_sqlStatement;
8989

90-
// ExecuteScalar will select the first result set and the info message preceding it, then stop.
91-
command.ExecuteScalar();
92-
Assert.True(messages.TryDequeue(out string lastMessage));
93-
Assert.Empty(messages);
94-
Assert.Equal(ResultSet1_Message, lastMessage);
90+
// ExecuteScalar now drains all result sets to ensure errors are not silently ignored (GH #3736 fix).
91+
// Since the SQL statement contains RAISERRORs after the first result set, an exception is thrown.
92+
SqlException ex = Assert.Throws<SqlException>(() => command.ExecuteScalar());
93+
Assert.Contains("Error 1", ex.Message);
9594
}
9695

9796
[ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup))]
Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for more information.
4+
5+
using System.Threading.Tasks;
6+
using Xunit;
7+
8+
namespace Microsoft.Data.SqlClient.ManualTesting.Tests
9+
{
10+
/// <summary>
11+
/// Tests for ExecuteScalar to verify proper exception handling.
12+
/// Regression tests for GitHub issue #3736: https://github.com/dotnet/SqlClient/issues/3736
13+
/// </summary>
14+
public static class SqlCommandExecuteScalarTest
15+
{
16+
/// <summary>
17+
/// ExecuteScalar should propagate conversion errors that occur after the first result.
18+
/// </summary>
19+
[ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup))]
20+
public static void ExecuteScalar_ShouldThrowOnConversionError()
21+
{
22+
string tableName = DataTestUtility.GetLongName("GH3736");
23+
24+
using SqlConnection connection = new(DataTestUtility.TCPConnectionString);
25+
connection.Open();
26+
27+
try
28+
{
29+
// Arrange
30+
// Insert valid VARCHAR values - '42-43' is a valid string, not an invalid number
31+
DataTestUtility.CreateTable(connection, tableName, "(Id INT IDENTITY(1,1) NOT NULL, Val VARCHAR(10) NOT NULL)");
32+
using (SqlCommand insertCmd = connection.CreateCommand())
33+
{
34+
insertCmd.CommandText =
35+
$"INSERT INTO {tableName} (Val) VALUES ('12345'); " +
36+
$"INSERT INTO {tableName} (Val) VALUES ('42-43');";
37+
insertCmd.ExecuteNonQuery();
38+
}
39+
40+
// Act
41+
// The WHERE clause compares VARCHAR to INT (no quotes around 12345), causing SQL Server
42+
// to convert each Val to INT. '12345' converts fine, but '42-43' fails with error 245.
43+
using SqlCommand cmd = new($"SELECT Id FROM {tableName} WHERE Val = 12345", connection);
44+
SqlException ex = Assert.Throws<SqlException>(() => cmd.ExecuteScalar());
45+
46+
// Assert
47+
Assert.Equal(245, ex.Number);
48+
Assert.Contains("Conversion failed", ex.Message);
49+
}
50+
finally
51+
{
52+
DataTestUtility.DropTable(connection, tableName);
53+
}
54+
}
55+
56+
/// <summary>
57+
/// ExecuteScalar should throw on conversion error so transaction can be properly rolled back.
58+
/// </summary>
59+
[ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup))]
60+
public static void ExecuteScalar_TransactionShouldRollbackOnError()
61+
{
62+
string sourceTable = DataTestUtility.GetLongName("GH3736_Src");
63+
string targetTable = DataTestUtility.GetLongName("GH3736_Tgt");
64+
65+
using SqlConnection connection = new(DataTestUtility.TCPConnectionString);
66+
connection.Open();
67+
68+
try
69+
{
70+
// Arrange
71+
// sourceTable.Val is VARCHAR - both '12345' and '42-43' are valid strings
72+
DataTestUtility.CreateTable(connection, sourceTable, "(Id INT IDENTITY(1,1) NOT NULL, Val VARCHAR(10) NOT NULL)");
73+
DataTestUtility.CreateTable(connection, targetTable, "(Id INT IDENTITY(1,1) NOT NULL, Val1 INT NOT NULL, Val2 INT NOT NULL)");
74+
using (SqlCommand insertCmd = connection.CreateCommand())
75+
{
76+
insertCmd.CommandText =
77+
$"INSERT INTO {sourceTable} (Val) VALUES ('12345'); " +
78+
$"INSERT INTO {sourceTable} (Val) VALUES ('42-43');";
79+
insertCmd.ExecuteNonQuery();
80+
}
81+
82+
// Act
83+
// The conversion error occurs in cmd2's WHERE clause (Val = 12345 without quotes),
84+
// not during the INSERT statements above.
85+
SqlException ex = Assert.Throws<SqlException>(() =>
86+
{
87+
using SqlTransaction transaction = connection.BeginTransaction();
88+
using (SqlCommand cmd1 = new($"INSERT INTO {targetTable} (Val1, Val2) VALUES (42, 43)", connection, transaction))
89+
{
90+
cmd1.ExecuteNonQuery();
91+
}
92+
using (SqlCommand cmd2 = new($"SELECT Id FROM {sourceTable} WHERE Val = 12345", connection, transaction))
93+
{
94+
cmd2.ExecuteScalar();
95+
}
96+
using (SqlCommand cmd3 = new($"INSERT INTO {targetTable} (Val1, Val2) VALUES (100, 200)", connection, transaction))
97+
{
98+
cmd3.ExecuteNonQuery();
99+
}
100+
transaction.Commit();
101+
});
102+
103+
// Assert
104+
Assert.Equal(245, ex.Number);
105+
using (SqlCommand verifyCmd = new($"SELECT COUNT(*) FROM {targetTable}", connection))
106+
{
107+
int count = (int)verifyCmd.ExecuteScalar();
108+
Assert.Equal(0, count);
109+
}
110+
}
111+
finally
112+
{
113+
DataTestUtility.DropTable(connection, sourceTable);
114+
DataTestUtility.DropTable(connection, targetTable);
115+
}
116+
}
117+
118+
/// <summary>
119+
/// ExecuteScalar should work correctly when there is no error.
120+
/// </summary>
121+
[ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup))]
122+
public static void ExecuteScalar_ShouldWorkCorrectlyWithoutError()
123+
{
124+
// Arrange
125+
using SqlConnection connection = new(DataTestUtility.TCPConnectionString);
126+
connection.Open();
127+
using SqlCommand cmd = new("SELECT 42", connection);
128+
129+
// Act
130+
object result = cmd.ExecuteScalar();
131+
132+
// Assert
133+
Assert.Equal(42, result);
134+
}
135+
136+
/// <summary>
137+
/// ExecuteScalar should return null when there are no rows.
138+
/// </summary>
139+
[ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup))]
140+
public static void ExecuteScalar_ShouldReturnNullWhenNoRows()
141+
{
142+
// Arrange
143+
using SqlConnection connection = new(DataTestUtility.TCPConnectionString);
144+
connection.Open();
145+
using SqlCommand cmd = new("SELECT 1 WHERE 1 = 0", connection);
146+
147+
// Act
148+
object result = cmd.ExecuteScalar();
149+
150+
// Assert
151+
Assert.Null(result);
152+
}
153+
154+
/// <summary>
155+
/// ExecuteScalarAsync should propagate conversion errors that occur after the first result.
156+
/// </summary>
157+
[ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup))]
158+
public static async Task ExecuteScalarAsync_ShouldThrowOnConversionError()
159+
{
160+
string tableName = DataTestUtility.GetLongName("GH3736_Async");
161+
162+
using SqlConnection connection = new(DataTestUtility.TCPConnectionString);
163+
await connection.OpenAsync();
164+
165+
try
166+
{
167+
// Arrange
168+
DataTestUtility.CreateTable(connection, tableName, "(Id INT IDENTITY(1,1) NOT NULL, Val VARCHAR(10) NOT NULL)");
169+
using (SqlCommand insertCmd = connection.CreateCommand())
170+
{
171+
insertCmd.CommandText =
172+
$"INSERT INTO {tableName} (Val) VALUES ('12345'); " +
173+
$"INSERT INTO {tableName} (Val) VALUES ('42-43');";
174+
await insertCmd.ExecuteNonQueryAsync();
175+
}
176+
177+
// Act
178+
using SqlCommand cmd = new($"SELECT Id FROM {tableName} WHERE Val = 12345", connection);
179+
SqlException ex = await Assert.ThrowsAsync<SqlException>(() => cmd.ExecuteScalarAsync());
180+
181+
// Assert
182+
Assert.Equal(245, ex.Number);
183+
Assert.Contains("Conversion failed", ex.Message);
184+
}
185+
finally
186+
{
187+
DataTestUtility.DropTable(connection, tableName);
188+
}
189+
}
190+
191+
/// <summary>
192+
/// ExecuteScalarAsync should throw on conversion error so transaction can be properly rolled back.
193+
/// </summary>
194+
[ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup))]
195+
public static async Task ExecuteScalarAsync_TransactionShouldRollbackOnError()
196+
{
197+
string sourceTable = DataTestUtility.GetLongName("GH3736_AsyncSrc");
198+
string targetTable = DataTestUtility.GetLongName("GH3736_AsyncTgt");
199+
200+
using SqlConnection connection = new(DataTestUtility.TCPConnectionString);
201+
await connection.OpenAsync();
202+
203+
try
204+
{
205+
// Arrange
206+
// sourceTable.Val is VARCHAR - both '12345' and '42-43' are valid strings
207+
DataTestUtility.CreateTable(connection, sourceTable, "(Id INT IDENTITY(1,1) NOT NULL, Val VARCHAR(10) NOT NULL)");
208+
DataTestUtility.CreateTable(connection, targetTable, "(Id INT IDENTITY(1,1) NOT NULL, Val1 INT NOT NULL, Val2 INT NOT NULL)");
209+
using (SqlCommand insertCmd = connection.CreateCommand())
210+
{
211+
insertCmd.CommandText =
212+
$"INSERT INTO {sourceTable} (Val) VALUES ('12345'); " +
213+
$"INSERT INTO {sourceTable} (Val) VALUES ('42-43');";
214+
await insertCmd.ExecuteNonQueryAsync();
215+
}
216+
217+
// Act
218+
// The conversion error occurs in cmd2's WHERE clause (Val = 12345 without quotes),
219+
// not during the INSERT statements above.
220+
SqlException ex = await Assert.ThrowsAsync<SqlException>(async () =>
221+
{
222+
using SqlTransaction transaction = connection.BeginTransaction();
223+
using (SqlCommand cmd1 = new($"INSERT INTO {targetTable} (Val1, Val2) VALUES (42, 43)", connection, transaction))
224+
{
225+
await cmd1.ExecuteNonQueryAsync();
226+
}
227+
using (SqlCommand cmd2 = new($"SELECT Id FROM {sourceTable} WHERE Val = 12345", connection, transaction))
228+
{
229+
await cmd2.ExecuteScalarAsync();
230+
}
231+
using (SqlCommand cmd3 = new($"INSERT INTO {targetTable} (Val1, Val2) VALUES (100, 200)", connection, transaction))
232+
{
233+
await cmd3.ExecuteNonQueryAsync();
234+
}
235+
transaction.Commit();
236+
});
237+
238+
// Assert
239+
Assert.Equal(245, ex.Number);
240+
using (SqlCommand verifyCmd = new($"SELECT COUNT(*) FROM {targetTable}", connection))
241+
{
242+
int count = (int)await verifyCmd.ExecuteScalarAsync();
243+
Assert.Equal(0, count);
244+
}
245+
}
246+
finally
247+
{
248+
DataTestUtility.DropTable(connection, sourceTable);
249+
DataTestUtility.DropTable(connection, targetTable);
250+
}
251+
}
252+
253+
/// <summary>
254+
/// ExecuteScalarAsync should work correctly when there is no error.
255+
/// </summary>
256+
[ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup))]
257+
public static async Task ExecuteScalarAsync_ShouldWorkCorrectlyWithoutError()
258+
{
259+
// Arrange
260+
using SqlConnection connection = new(DataTestUtility.TCPConnectionString);
261+
await connection.OpenAsync();
262+
using SqlCommand cmd = new("SELECT 42", connection);
263+
264+
// Act
265+
object result = await cmd.ExecuteScalarAsync();
266+
267+
// Assert
268+
Assert.Equal(42, result);
269+
}
270+
271+
/// <summary>
272+
/// ExecuteScalarAsync should return null when there are no rows.
273+
/// </summary>
274+
[ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup))]
275+
public static async Task ExecuteScalarAsync_ShouldReturnNullWhenNoRows()
276+
{
277+
// Arrange
278+
using SqlConnection connection = new(DataTestUtility.TCPConnectionString);
279+
await connection.OpenAsync();
280+
using SqlCommand cmd = new("SELECT 1 WHERE 1 = 0", connection);
281+
282+
// Act
283+
object result = await cmd.ExecuteScalarAsync();
284+
285+
// Assert
286+
Assert.Null(result);
287+
}
288+
}
289+
}

0 commit comments

Comments
 (0)