From bd9afc28f5037aa8a443bcd6b1907a7edb113b8b Mon Sep 17 00:00:00 2001 From: Paul Medynski <31868385+paulmedynski@users.noreply.github.com> Date: Wed, 5 Nov 2025 10:00:35 -0400 Subject: [PATCH 1/4] User Story 40111: HasRows + Mutliple INFO tokens - Added tests for SqlDataReader.HasRows for responses with and without INFO tokens. --- .../SimulatedServerTests/HasRowsTests.cs | 241 ++++++++++++++++++ .../tools/TDS/TDS.Servers/QueryEngine.cs | 6 +- .../tests/tools/TDS/TDS.Servers/TdsServer.cs | 9 + .../tests/tools/TDS/TDS/TDSUtilities.cs | 154 ++++++----- 4 files changed, 330 insertions(+), 80 deletions(-) create mode 100644 src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/HasRowsTests.cs diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/HasRowsTests.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/HasRowsTests.cs new file mode 100644 index 0000000000..99ecc7d2cc --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/HasRowsTests.cs @@ -0,0 +1,241 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; + +using Microsoft.SqlServer.TDS; +using Microsoft.SqlServer.TDS.ColMetadata; +using Microsoft.SqlServer.TDS.Done; +using Microsoft.SqlServer.TDS.EndPoint; +using Microsoft.SqlServer.TDS.Info; +using Microsoft.SqlServer.TDS.Row; +using Microsoft.SqlServer.TDS.Servers; +using Microsoft.SqlServer.TDS.SQLBatch; + +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.Data.SqlClient.Tests.UnitTests.SimulatedServerTests; +public sealed class HasRowsTests : IDisposable +{ + private readonly InfoQueryEngine _engine; + private readonly TdsServer _server; + private readonly SqlConnection _connection; + private readonly List _infoMessagesReceived = new(); + + public HasRowsTests(ITestOutputHelper output) + { + _engine = new(new(){ Log = new LogWriter(output) }); + + _server = new(_engine); + _server.Start(); + + var connStr = new SqlConnectionStringBuilder() + { + DataSource = $"localhost,{_server.EndPoint.Port}", + Encrypt = SqlConnectionEncryptOption.Optional, + }.ConnectionString; + + _connection = new SqlConnection(connStr); + + _connection.InfoMessage += new( + (object sender, SqlInfoMessageEventArgs imevent) => + { + // The informational messages are exposed as Errors for some + // reason. Capture them in order. + for (int i = 0; i < imevent.Errors.Count; i++) + { + _infoMessagesReceived.Add(imevent.Errors[i].Message); + } + }); + + _connection.Open(); + } + + public void Dispose() + { + _connection.Dispose(); + _server.Dispose(); + } + + // Verify that HasRows is not set when we only receive INFO tokens. + [Fact] + public void OnlyInfo() + { + using SqlCommand command = new( + // Use command text that isn't recognized by the query engine. This + // should elicit a response that includes 2 INFO tokens and no row + // results. + "select 'Hello, World!'", + _connection); + using SqlDataReader reader = command.ExecuteReader(); + + // We should not have detected any rows. + Assert.False(reader.HasRows); + + // Verify that we received the expected 2 INFO messages with the + // expected text. + Assert.Equal(2, _infoMessagesReceived.Count); + Assert.Equal("select 'Hello, World!'", _infoMessagesReceived[0]); + Assert.Equal( + "Received query is not recognized by the query engine. Please " + + "ask a very specific question.", + _infoMessagesReceived[1]); + + // Confirm that we really didn't get any rows. + Assert.False(reader.Read()); + } + + // Verify that HasRows is true when more than one INFO token is included + // in the response to a SQL batch that returns rows. + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + public void InfoAndRows(ushort infoCount) + { + // Configure the engine to include the desired number of INFO tokens + // with its response. + _engine.InfoCount = infoCount; + + using SqlCommand command = new( + // Use command text that is intercepted by the InfoQueryEngine. + InfoQueryEngine.InfoCommandText, + _connection); + using SqlDataReader reader = command.ExecuteReader(); + + // We should have read past the INFO tokens and determined that there + // are row results. + Assert.True(reader.HasRows); + + // Verify that we received the expected number of INFO messages with + // the expected text. + Assert.Equal(infoCount, (ushort)_infoMessagesReceived.Count); + for (ushort i = 0; i < infoCount; i++) + { + Assert.Equal($"Info message {i}", _infoMessagesReceived[i]); + } + + // Verify that we can read the single row. + Assert.True(reader.Read()); + Assert.Equal("Foo Value", reader.GetString(0)); + Assert.False(reader.Read()); + } +} + +// A writer compatible with TdsUtilities.Log() that pumps accumulated log +// messages to an xUnit output helper. +internal sealed class LogWriter : StringWriter +{ + private readonly ITestOutputHelper _output; + + public LogWriter(ITestOutputHelper output) + { + _output = output; + } + + // The TDSUtilities.Log() method calls Flush() after each operation, so we + // can use that to emit the accumulated messages here. + public override void Flush() + { + // Get the accumulated buffer. + var builder = GetStringBuilder(); + + // Trim trailing whitespace, since _output always appends a newline. + var text = builder.ToString().TrimEnd(); + + // Emit if there's anything worthwhile. + if (text.Length > 0) + { + _output.WriteLine(text); + base.Flush(); + } + + // Clear the buffer for the next accumulation. + builder.Clear(); + } +} + +// A query engine that can include INFO tokens in its response. +internal sealed class InfoQueryEngine : QueryEngine +{ + // The query text that this engine recognizes and will trigger the + // inclusion of InfoCount INFO tokens in the response. + internal const string InfoCommandText = "select Foo from Bar"; + + // The number of INFO tokens to include in the response. + internal ushort InfoCount { get; set; } = 0; + + // Construct with server arguments. + internal InfoQueryEngine(TdsServerArguments arguments) + : base(arguments) + { + } + + // Override to provide our INFO token response. + // + // Calls the base implementation for unrecognized commands. + // + protected override TDSMessageCollection CreateQueryResponse( + ITDSServerSession session, + TDSSQLBatchToken batchRequest) + { + // Defer to the base implementation for unrecognized commands. + if (batchRequest.Text != InfoCommandText) + { + return base.CreateQueryResponse(session, batchRequest); + } + + // Build a response with the desired number of INFO tokens and then + // one row result. + TDSMessage response = new(TDSMessageType.Response); + + // Add the INFO tokens first. + for (ushort i = 0; i < InfoCount; i++) + { + // Choose an error code outside the reserved range. + TDSInfoToken token = new(30000u + i, 0, 0, $"Info message {i}"); + response.Add(token); + TDSUtilities.Log(Log, "INFO Response", token); + } + + // Add the column metadata. + TDSColumnData column = new() + { + DataType = TDSDataType.NVarChar, + // Magic foo copied from QueryEngine. + DataTypeSpecific = new TDSShilohVarCharColumnSpecific( + 256, new TDSColumnDataCollation(13632521, 52)) + }; + column.Flags.Updatable = TDSColumnDataUpdatableFlag.ReadOnly; + + TDSColMetadataToken metadataToken = new(); + metadataToken.Columns.Add(column); + response.Add(metadataToken); + + TDSUtilities.Log(Log, "INFO Response", metadataToken); + + // Add the row result data. + TDSRowToken rowToken = new(metadataToken); + rowToken.Data.Add("Foo Value"); + response.Add(rowToken); + TDSUtilities.Log(Log, "INFO Response", rowToken); + + // Add the done token. + TDSDoneToken doneToken = new( + TDSDoneTokenStatusType.Final | + TDSDoneTokenStatusType.Count, + TDSDoneTokenCommandType.Select, + 1); + response.Add(doneToken); + TDSUtilities.Log(Log, "INFO Response", doneToken); + + return new(response); + } +} diff --git a/src/Microsoft.Data.SqlClient/tests/tools/TDS/TDS.Servers/QueryEngine.cs b/src/Microsoft.Data.SqlClient/tests/tools/TDS/TDS.Servers/QueryEngine.cs index 579c47abcc..7b727d4fef 100644 --- a/src/Microsoft.Data.SqlClient/tests/tools/TDS/TDS.Servers/QueryEngine.cs +++ b/src/Microsoft.Data.SqlClient/tests/tools/TDS/TDS.Servers/QueryEngine.cs @@ -33,6 +33,7 @@ public class QueryEngine /// public QueryEngine(TdsServerArguments arguments) { + Log = arguments.Log; ServerArguments = arguments; } @@ -327,8 +328,9 @@ protected virtual TDSMessageCollection CreateQueryResponse(ITDSServerSession ses } else { - // Create an info token that contains the query received - TDSInfoToken infoToken = new TDSInfoToken(2012, 2, 0, lowerBatchText); + // Create an info token that contains the verbatim query text we + // received (not the lower-cased version). + TDSInfoToken infoToken = new TDSInfoToken(2012, 2, 0, batchRequest.Text); // Log response TDSUtilities.Log(Log, "Response", infoToken); diff --git a/src/Microsoft.Data.SqlClient/tests/tools/TDS/TDS.Servers/TdsServer.cs b/src/Microsoft.Data.SqlClient/tests/tools/TDS/TDS.Servers/TdsServer.cs index d3bb1861ef..548990d20b 100644 --- a/src/Microsoft.Data.SqlClient/tests/tools/TDS/TDS.Servers/TdsServer.cs +++ b/src/Microsoft.Data.SqlClient/tests/tools/TDS/TDS.Servers/TdsServer.cs @@ -20,6 +20,15 @@ public TdsServer(TdsServerArguments arguments) : base(arguments) { } + /// + /// Constructor with a query engine. Uses the engine's servers arguments. + /// + /// Query engine + public TdsServer(QueryEngine queryEngine) + : base(queryEngine.ServerArguments, queryEngine) + { + } + /// /// Constructor with arguments and query engine /// diff --git a/src/Microsoft.Data.SqlClient/tests/tools/TDS/TDS/TDSUtilities.cs b/src/Microsoft.Data.SqlClient/tests/tools/TDS/TDS/TDSUtilities.cs index d8a0c292c5..fc245d4060 100644 --- a/src/Microsoft.Data.SqlClient/tests/tools/TDS/TDS/TDSUtilities.cs +++ b/src/Microsoft.Data.SqlClient/tests/tools/TDS/TDS/TDSUtilities.cs @@ -345,109 +345,107 @@ public static void Log(TextWriter log, string prefix, object instance) return; } - // Check if null - if (instance == null) - { - SerializedWriteLineToLog(log, string.Format("{0}: ", prefix)); - - return; - } - - // Get object type - Type objectType = instance.GetType(); - - // Check if simple type - if (objectType.IsEnum - || instance is bool - || instance is string - || instance is int - || instance is uint - || instance is byte - || instance is sbyte - || instance is short - || instance is ushort - || instance is long - || instance is ulong - || instance is double - || instance is float - || instance is Version) - { - SerializedWriteLineToLog(log, string.Format("{0}: {1}", prefix, instance)); - - return; - } - - - // Check declaring type - if (objectType.IsGenericType || (objectType.BaseType != null && objectType.BaseType.IsGenericType)) // IList + lock (s_logWriterLock) { - int index = 0; - - // Log values - foreach (object o in (instance as System.Collections.IEnumerable)) + // Check if null + if (instance == null) { - Log(log, string.Format("{0}[{1}]", prefix, index++), o); + SerializedWriteLineToLog(log, string.Format("{0}: ", prefix)); + log.Flush(); + return; } - // Check if we logged anything - if (index == 0) + // Get object type + Type objectType = instance.GetType(); + + // Check if simple type + if (objectType.IsEnum + || instance is bool + || instance is string + || instance is int + || instance is uint + || instance is byte + || instance is sbyte + || instance is short + || instance is ushort + || instance is long + || instance is ulong + || instance is double + || instance is float + || instance is Version) { - SerializedWriteLineToLog(log, string.Format("{0}: ", prefix)); + SerializedWriteLineToLog(log, string.Format("{0}: {1}", prefix, instance)); + log.Flush(); + return; } - } - else if (objectType.IsArray) - { - // Prepare prefix - string preparedLine = string.Format("{0}: [", prefix); - // Log values - foreach (object o in (instance as Array)) + + // Check declaring type + if (objectType.IsGenericType || (objectType.BaseType != null && objectType.BaseType.IsGenericType)) // IList { - preparedLine += string.Format("{0:X} ", o); + int index = 0; + + // Log values + foreach (object o in (instance as System.Collections.IEnumerable)) + { + Log(log, string.Format("{0}[{1}]", prefix, index++), o); + } + + // Check if we logged anything + if (index == 0) + { + SerializedWriteLineToLog(log, string.Format("{0}: ", prefix)); + } } + else if (objectType.IsArray) + { + // Prepare prefix + string preparedLine = string.Format("{0}: [", prefix); - // Finish the line - preparedLine += "]"; + // Log values + foreach (object o in (instance as Array)) + { + preparedLine += string.Format("{0:X} ", o); + } - // Move to the next line - SerializedWriteLineToLog(log, preparedLine); - } + // Finish the line + preparedLine += "]"; - // Iterate all public properties - foreach (PropertyInfo info in objectType.GetProperties()) - { - // Check if this is an indexer - if (info.GetIndexParameters().Length > 0 || !info.DeclaringType.Assembly.Equals(Assembly.GetExecutingAssembly())) - { - // We ignore indexers - continue; + // Move to the next line + SerializedWriteLineToLog(log, preparedLine); } - // Get property value - object value = info.GetValue(instance, null); - - // Log each property - Log(log, string.Format("{0}.{1}.{2}", prefix, objectType.Name, info.Name), value); - } + // Iterate all public properties + foreach (PropertyInfo info in objectType.GetProperties()) + { + // Check if this is an indexer + if (info.GetIndexParameters().Length > 0 || !info.DeclaringType.Assembly.Equals(Assembly.GetExecutingAssembly())) + { + // We ignore indexers + continue; + } + + // Get property value + object value = info.GetValue(instance, null); + + // Log each property + Log(log, string.Format("{0}.{1}.{2}", prefix, objectType.Name, info.Name), value); + } - // Flush to destination - lock (s_logWriterLock) - { + // Flush to destination log.Flush(); } } /// - /// Serialized write line to destination + /// Serialized write line to destination. Must be called under lock. /// /// Destination /// Text to log public static void SerializedWriteLineToLog(TextWriter log, string text) { - lock (s_logWriterLock) - { - log.WriteLine(string.Format("[{0}] {1}", DateTime.Now, text)); - } + var now = DateTime.UtcNow; + log.WriteLine($"[{now:yyyy-MM-dd hh:mm:ss.fff}Z] {text}"); } } } From bb2bc3441c5a9feb211cdb49cb36f805bf468bf4 Mon Sep 17 00:00:00 2001 From: Paul Medynski <31868385+paulmedynski@users.noreply.github.com> Date: Wed, 5 Nov 2025 10:19:51 -0400 Subject: [PATCH 2/4] User Story 40111: HasRows + Mutliple INFO tokens - Added comments. --- .../SimulatedServerTests/HasRowsTests.cs | 73 ++++++++++++------- 1 file changed, 48 insertions(+), 25 deletions(-) diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/HasRowsTests.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/HasRowsTests.cs index 99ecc7d2cc..9409e5900a 100644 --- a/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/HasRowsTests.cs +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/HasRowsTests.cs @@ -5,7 +5,6 @@ using System; using System.Collections.Generic; using System.IO; -using System.Text; using Microsoft.SqlServer.TDS; using Microsoft.SqlServer.TDS.ColMetadata; @@ -20,28 +19,42 @@ using Xunit.Abstractions; namespace Microsoft.Data.SqlClient.Tests.UnitTests.SimulatedServerTests; + public sealed class HasRowsTests : IDisposable { + // The query engine used by the server. private readonly InfoQueryEngine _engine; + + // The TDS server we will connect to. private readonly TdsServer _server; + + // The connection to the server; always open post-construction. private readonly SqlConnection _connection; - private readonly List _infoMessagesReceived = new(); + + // The list of INFO message text received. + private readonly List _infoText = new(); + // Construct to setup the server and connection. public HasRowsTests(ITestOutputHelper output) { - _engine = new(new(){ Log = new LogWriter(output) }); + // Use our log writer to capture TDS Server logs to the xUnit output. + _engine = new(new() { Log = new LogWriter(output) }); + // Start the TDS server. _server = new(_engine); _server.Start(); + // Use the server's endpoint to build a connection string. var connStr = new SqlConnectionStringBuilder() { DataSource = $"localhost,{_server.EndPoint.Port}", Encrypt = SqlConnectionEncryptOption.Optional, }.ConnectionString; + // Create the connection. _connection = new SqlConnection(connStr); + // Add a handler for INFO messages to capture them. _connection.InfoMessage += new( (object sender, SqlInfoMessageEventArgs imevent) => { @@ -49,13 +62,15 @@ public HasRowsTests(ITestOutputHelper output) // reason. Capture them in order. for (int i = 0; i < imevent.Errors.Count; i++) { - _infoMessagesReceived.Add(imevent.Errors[i].Message); + _infoText.Add(imevent.Errors[i].Message); } }); - + + // Open the connection. _connection.Open(); } + // Dispose of resources. public void Dispose() { _connection.Dispose(); @@ -70,6 +85,9 @@ public void OnlyInfo() // Use command text that isn't recognized by the query engine. This // should elicit a response that includes 2 INFO tokens and no row // results. + // + // See QueryEngine.CreateQueryResponse()'s else block. + // "select 'Hello, World!'", _connection); using SqlDataReader reader = command.ExecuteReader(); @@ -77,27 +95,26 @@ public void OnlyInfo() // We should not have detected any rows. Assert.False(reader.HasRows); - // Verify that we received the expected 2 INFO messages with the - // expected text. - Assert.Equal(2, _infoMessagesReceived.Count); - Assert.Equal("select 'Hello, World!'", _infoMessagesReceived[0]); + // Verify that we received the expected 2 INFO messages. + Assert.Equal(2, _infoText.Count); + Assert.Equal("select 'Hello, World!'", _infoText[0]); Assert.Equal( "Received query is not recognized by the query engine. Please " + "ask a very specific question.", - _infoMessagesReceived[1]); + _infoText[1]); // Confirm that we really didn't get any rows. Assert.False(reader.Read()); } - // Verify that HasRows is true when more than one INFO token is included - // in the response to a SQL batch that returns rows. + // Verify that HasRows is true when a variable number of INFO tokens is + // included in the response to a SQL batch that returns rows. [Theory] [InlineData(0)] [InlineData(1)] [InlineData(2)] [InlineData(3)] - [InlineData(4)] + [InlineData(10)] public void InfoAndRows(ushort infoCount) { // Configure the engine to include the desired number of INFO tokens @@ -105,26 +122,25 @@ public void InfoAndRows(ushort infoCount) _engine.InfoCount = infoCount; using SqlCommand command = new( - // Use command text that is intercepted by the InfoQueryEngine. - InfoQueryEngine.InfoCommandText, + // Use command text that is intercepted by our engine. + InfoQueryEngine.CommandText, _connection); using SqlDataReader reader = command.ExecuteReader(); // We should have read past the INFO tokens and determined that there // are row results. Assert.True(reader.HasRows); - - // Verify that we received the expected number of INFO messages with - // the expected text. - Assert.Equal(infoCount, (ushort)_infoMessagesReceived.Count); + + // Verify that we received the expected INFO messages. + Assert.Equal(infoCount, (ushort)_infoText.Count); for (ushort i = 0; i < infoCount; i++) { - Assert.Equal($"Info message {i}", _infoMessagesReceived[i]); + Assert.Equal($"{InfoQueryEngine.InfoPreamble}{i}", _infoText[i]); } // Verify that we can read the single row. Assert.True(reader.Read()); - Assert.Equal("Foo Value", reader.GetString(0)); + Assert.Equal(InfoQueryEngine.RowData, reader.GetString(0)); Assert.False(reader.Read()); } } @@ -167,7 +183,14 @@ internal sealed class InfoQueryEngine : QueryEngine { // The query text that this engine recognizes and will trigger the // inclusion of InfoCount INFO tokens in the response. - internal const string InfoCommandText = "select Foo from Bar"; + internal const string CommandText = "select Foo from Bar"; + + // The row data that this engine will return for the recognized query. + internal const string RowData = "Foo Value"; + + // The preamble for all INFO message text. The 0-based index of the INFO + // token will be appended to this to form the complete message text. + internal const string InfoPreamble = "Info message "; // The number of INFO tokens to include in the response. internal ushort InfoCount { get; set; } = 0; @@ -187,7 +210,7 @@ protected override TDSMessageCollection CreateQueryResponse( TDSSQLBatchToken batchRequest) { // Defer to the base implementation for unrecognized commands. - if (batchRequest.Text != InfoCommandText) + if (batchRequest.Text != CommandText) { return base.CreateQueryResponse(session, batchRequest); } @@ -200,7 +223,7 @@ protected override TDSMessageCollection CreateQueryResponse( for (ushort i = 0; i < InfoCount; i++) { // Choose an error code outside the reserved range. - TDSInfoToken token = new(30000u + i, 0, 0, $"Info message {i}"); + TDSInfoToken token = new(30000u + i, 0, 0, $"{InfoPreamble}{i}"); response.Add(token); TDSUtilities.Log(Log, "INFO Response", token); } @@ -223,7 +246,7 @@ protected override TDSMessageCollection CreateQueryResponse( // Add the row result data. TDSRowToken rowToken = new(metadataToken); - rowToken.Data.Add("Foo Value"); + rowToken.Data.Add(RowData); response.Add(rowToken); TDSUtilities.Log(Log, "INFO Response", rowToken); From d3508b41fedf5808fc48aeb1a964aab1ba800b5a Mon Sep 17 00:00:00 2001 From: Paul Medynski <31868385+paulmedynski@users.noreply.github.com> Date: Wed, 5 Nov 2025 10:55:36 -0400 Subject: [PATCH 3/4] User Story 40111: HasRows + Mutliple INFO tokens - Added placement of INFO tokens as start, middle, and end of the response stream to elicit failures. --- .../SimulatedServerTests/HasRowsTests.cs | 150 ++++++++++++++++-- 1 file changed, 139 insertions(+), 11 deletions(-) diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/HasRowsTests.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/HasRowsTests.cs index 9409e5900a..6e394dfda7 100644 --- a/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/HasRowsTests.cs +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/HasRowsTests.cs @@ -108,18 +108,20 @@ public void OnlyInfo() } // Verify that HasRows is true when a variable number of INFO tokens is - // included in the response to a SQL batch that returns rows. + // included at the start of the response stream to a SQL batch that returns + // rows. [Theory] [InlineData(0)] [InlineData(1)] [InlineData(2)] [InlineData(3)] [InlineData(10)] - public void InfoAndRows(ushort infoCount) + public void InfoAndRows_Start(ushort infoCount) { - // Configure the engine to include the desired number of INFO tokens - // with its response. + // Configure the engine to specify the desired number and placement of + // INFO tokens with its response. _engine.InfoCount = infoCount; + _engine.InfoPlacement = InfoPlacement.Start; using SqlCommand command = new( // Use command text that is intercepted by our engine. @@ -143,6 +145,87 @@ public void InfoAndRows(ushort infoCount) Assert.Equal(InfoQueryEngine.RowData, reader.GetString(0)); Assert.False(reader.Read()); } + + // Verify that HasRows is true when a variable number of INFO tokens is + // included in the middle of the response stream to a SQL batch that returns + // rows. + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(10)] + public void InfoAndRows_Middle(ushort infoCount) + { + // Configure the engine to specify the desired number and placement of + // INFO tokens with its response. + _engine.InfoCount = infoCount; + _engine.InfoPlacement = InfoPlacement.Middle; + + using SqlCommand command = new( + // Use command text that is intercepted by our engine. + InfoQueryEngine.CommandText, + _connection); + using SqlDataReader reader = command.ExecuteReader(); + + // We should have read past the column metadata token(s) and INFO + // tokens, and determined that there are row results. + Assert.True(reader.HasRows); + + // Verify that we received the expected INFO messages. + Assert.Equal(infoCount, (ushort)_infoText.Count); + for (ushort i = 0; i < infoCount; i++) + { + Assert.Equal($"{InfoQueryEngine.InfoPreamble}{i}", _infoText[i]); + } + + // Verify that we can read the single row. + Assert.True(reader.Read()); + Assert.Equal(InfoQueryEngine.RowData, reader.GetString(0)); + Assert.False(reader.Read()); + } + + // Verify that HasRows is true when a variable number of INFO tokens is + // included at the end of the response stream to a SQL batch that returns + // rows. + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(10)] + public void InfoAndRows_End(ushort infoCount) + { + // Configure the engine to specify the desired number and placement of + // INFO tokens with its response. + _engine.InfoCount = infoCount; + _engine.InfoPlacement = InfoPlacement.End; + + using SqlCommand command = new( + // Use command text that is intercepted by our engine. + InfoQueryEngine.CommandText, + _connection); + using SqlDataReader reader = command.ExecuteReader(); + + // We should have read the column metadata and determined that there + // are row results. + Assert.True(reader.HasRows); + + // We haven't read the INFO tokens yet. + Assert.Equal(0, (ushort)_infoText.Count); + + // Verify that we can read the single row. + Assert.True(reader.Read()); + Assert.Equal(InfoQueryEngine.RowData, reader.GetString(0)); + Assert.False(reader.Read()); + + // Verify that we received the expected INFO messages. + Assert.Equal(infoCount, (ushort)_infoText.Count); + for (ushort i = 0; i < infoCount; i++) + { + Assert.Equal($"{InfoQueryEngine.InfoPreamble}{i}", _infoText[i]); + } + } } // A writer compatible with TdsUtilities.Log() that pumps accumulated log @@ -151,6 +234,10 @@ internal sealed class LogWriter : StringWriter { private readonly ITestOutputHelper _output; + // Disable emission if TEST_SUPPRESS_LOGGING is defined. + private readonly bool _suppressLogging = + Environment.GetEnvironmentVariable("TEST_SUPPRESS_LOGGING") is not null; + public LogWriter(ITestOutputHelper output) { _output = output; @@ -169,7 +256,11 @@ public override void Flush() // Emit if there's anything worthwhile. if (text.Length > 0) { - _output.WriteLine(text); + // Suppress emission if requested, but still do everything else. + if (!_suppressLogging) + { + _output.WriteLine(text); + } base.Flush(); } @@ -178,6 +269,19 @@ public override void Flush() } } +// Indicates where in the response stream to place the INFO tokens. +public enum InfoPlacement +{ + // Place the INFO tokens before the column metadata and row data. + Start, + + // Place the INFO tokens between the column metadata and row data. + Middle, + + // Place the INFO tokens after the row data. + End +} + // A query engine that can include INFO tokens in its response. internal sealed class InfoQueryEngine : QueryEngine { @@ -195,6 +299,9 @@ internal sealed class InfoQueryEngine : QueryEngine // The number of INFO tokens to include in the response. internal ushort InfoCount { get; set; } = 0; + // Determines where to place the INFO tokens in the response stream. + internal InfoPlacement InfoPlacement { get; set; } = InfoPlacement.Start; + // Construct with server arguments. internal InfoQueryEngine(TdsServerArguments arguments) : base(arguments) @@ -219,13 +326,22 @@ protected override TDSMessageCollection CreateQueryResponse( // one row result. TDSMessage response = new(TDSMessageType.Response); - // Add the INFO tokens first. - for (ushort i = 0; i < InfoCount; i++) + // Helper to add INFO tokens at the desired placement. + var addInfoTokens = new Action(() => { - // Choose an error code outside the reserved range. - TDSInfoToken token = new(30000u + i, 0, 0, $"{InfoPreamble}{i}"); - response.Add(token); - TDSUtilities.Log(Log, "INFO Response", token); + for (ushort i = 0; i < InfoCount; i++) + { + // Choose an error code outside the reserved range. + TDSInfoToken token = new(30000u + i, 0, 0, $"{InfoPreamble}{i}"); + response.Add(token); + TDSUtilities.Log(Log, "INFO Response", token); + } + }); + + // Add INFO tokens at the start if desired. + if (InfoPlacement == InfoPlacement.Start) + { + addInfoTokens(); } // Add the column metadata. @@ -244,12 +360,24 @@ protected override TDSMessageCollection CreateQueryResponse( TDSUtilities.Log(Log, "INFO Response", metadataToken); + // Add INFO tokens in the middle if desired. + if (InfoPlacement == InfoPlacement.Middle) + { + addInfoTokens(); + } + // Add the row result data. TDSRowToken rowToken = new(metadataToken); rowToken.Data.Add(RowData); response.Add(rowToken); TDSUtilities.Log(Log, "INFO Response", rowToken); + // Add INFO tokens at the end if desired. + if (InfoPlacement == InfoPlacement.End) + { + addInfoTokens(); + } + // Add the done token. TDSDoneToken doneToken = new( TDSDoneTokenStatusType.Final | From 51401570d1c3a59297b623e10de3447632ee100c Mon Sep 17 00:00:00 2001 From: Paul Medynski <31868385+paulmedynski@users.noreply.github.com> Date: Wed, 5 Nov 2025 14:28:27 -0400 Subject: [PATCH 4/4] User Story 40111: HasRows + Mutliple INFO tokens - Pinpointed the failing cases and have allowed them to pass for now, with TODOs to address if/when we make a fix to SqlDataReader. --- .../SimulatedServerTests/HasRowsTests.cs | 66 ++++++++++++++++--- 1 file changed, 56 insertions(+), 10 deletions(-) diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/HasRowsTests.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/HasRowsTests.cs index 6e394dfda7..b824f491d8 100644 --- a/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/HasRowsTests.cs +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/SimulatedServerTests/HasRowsTests.cs @@ -139,11 +139,25 @@ public void InfoAndRows_Start(ushort infoCount) { Assert.Equal($"{InfoQueryEngine.InfoPreamble}{i}", _infoText[i]); } + _infoText.Clear(); // Verify that we can read the single row. Assert.True(reader.Read()); + + // HasRows never gets reset. + Assert.True(reader.HasRows); + + // Verify the row data. Assert.Equal(InfoQueryEngine.RowData, reader.GetString(0)); + + // No further rows. Assert.False(reader.Read()); + + // HasRows never gets reset. + Assert.True(reader.HasRows); + + // No further INFO tokens were found. + Assert.Empty(_infoText); } // Verify that HasRows is true when a variable number of INFO tokens is @@ -168,21 +182,41 @@ public void InfoAndRows_Middle(ushort infoCount) _connection); using SqlDataReader reader = command.ExecuteReader(); - // We should have read past the column metadata token(s) and INFO - // tokens, and determined that there are row results. - Assert.True(reader.HasRows); + // TODO: HasRows should be true here, regardless of the number of INFO + // tokens. + bool hasRowsExpected = infoCount <= 1; + Assert.Equal(hasRowsExpected, reader.HasRows); - // Verify that we received the expected INFO messages. + // Starting a reader consumes the column metadata and then stops, so + // we haven't encountered the INFO tokens yet. + Assert.Empty(_infoText); + + // Verify that we can read the single row. + Assert.True(reader.Read()); + + // TODO: HasRows should still be true - it never gets cleared. + Assert.Equal(hasRowsExpected, reader.HasRows); + + // Reading into the first row reads past the INFO tokens, so we should + // have them all accumulated now. Assert.Equal(infoCount, (ushort)_infoText.Count); for (ushort i = 0; i < infoCount; i++) { Assert.Equal($"{InfoQueryEngine.InfoPreamble}{i}", _infoText[i]); } + _infoText.Clear(); - // Verify that we can read the single row. - Assert.True(reader.Read()); + // Verify the row data. Assert.Equal(InfoQueryEngine.RowData, reader.GetString(0)); + + // No further rows. Assert.False(reader.Read()); + + // TODO: HasRows should still be true - it never gets cleared. + Assert.Equal(hasRowsExpected, reader.HasRows); + + // No further INFO tokens were found. + Assert.Empty(_infoText); } // Verify that HasRows is true when a variable number of INFO tokens is @@ -210,15 +244,27 @@ public void InfoAndRows_End(ushort infoCount) // We should have read the column metadata and determined that there // are row results. Assert.True(reader.HasRows); - - // We haven't read the INFO tokens yet. - Assert.Equal(0, (ushort)_infoText.Count); + + // Starting a reader consumes the column metadata and then stops, so + // we haven't encountered the INFO tokens yet. + Assert.Empty(_infoText); // Verify that we can read the single row. Assert.True(reader.Read()); + + // HasRows never gets reset + Assert.True(reader.HasRows); + + // Still no INFO tokens. + Assert.Empty(_infoText); + + // Verify the row data. Assert.Equal(InfoQueryEngine.RowData, reader.GetString(0)); - Assert.False(reader.Read()); + // No further rows. + Assert.False(reader.Read()); + Assert.True(reader.HasRows); + // Verify that we received the expected INFO messages. Assert.Equal(infoCount, (ushort)_infoText.Count); for (ushort i = 0; i < infoCount; i++)