From 3cc826ed05f81d4e245afb5e66d6e889319b6fb6 Mon Sep 17 00:00:00 2001 From: Jim Blythe Date: Fri, 20 Feb 2026 11:56:03 -0800 Subject: [PATCH 1/3] Modify SqlStreamingXml XmlWriter to internally use a MemoryStream instead of a StringBuilder. (#1877) Note: UTF8Encoding(false) addition in s_writerSettings is consistent with prior default used within StringWriter/StringBuilder --- .../src/Microsoft/Data/SqlClient/SqlStream.cs | 94 ++++++++++--------- ...icrosoft.Data.SqlClient.ManualTests.csproj | 1 + .../SqlStreamingXmlTest.cs | 91 ++++++++++++++++++ 3 files changed, 140 insertions(+), 46 deletions(-) create mode 100644 src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/SqlStreamingXmlTest/SqlStreamingXmlTest.cs diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlStream.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlStream.cs index 653faf7213..b317d0e238 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlStream.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlStream.cs @@ -224,9 +224,9 @@ private int ReadBytes(byte[] buffer, int offset, int count) // we are guaranteed that cb is < Int32.Max since we always pass in count which is of type Int32 to // our getbytes interface - count -= (int)cb; - offset += (int)cb; - intCount += (int)cb; + count -= cb; + offset += cb; + intCount += cb; } else { @@ -387,9 +387,9 @@ public override int Read(byte[] buffer, int offset, int count) Buffer.BlockCopy(_cachedBytes[_currentArrayIndex], _currentPosition, buffer, offset, cb); _currentPosition += cb; - count -= (int)cb; - offset += (int)cb; - intCount += (int)cb; + count -= cb; + offset += cb; + intCount += cb; } return intCount; @@ -477,13 +477,14 @@ private long TotalLength sealed internal class SqlStreamingXml { - private static readonly XmlWriterSettings s_writerSettings = new() { CloseOutput = true, ConformanceLevel = ConformanceLevel.Fragment }; + private static readonly XmlWriterSettings s_writerSettings = new() { CloseOutput = true, ConformanceLevel = ConformanceLevel.Fragment, Encoding = new UTF8Encoding(false) }; private readonly int _columnOrdinal; private SqlDataReader _reader; private XmlReader _xmlReader; + private bool _canReadChunk; private XmlWriter _xmlWriter; - private StringWriter _strWriter; + private MemoryStream _memoryStream; private long _charsRemoved; public SqlStreamingXml(int i, SqlDataReader reader) @@ -495,11 +496,12 @@ public SqlStreamingXml(int i, SqlDataReader reader) public void Close() { ((IDisposable)_xmlWriter).Dispose(); + ((IDisposable)_memoryStream).Dispose(); ((IDisposable)_xmlReader).Dispose(); _reader = null; _xmlReader = null; _xmlWriter = null; - _strWriter = null; + _memoryStream = null; } public int ColumnOrdinal => _columnOrdinal; @@ -508,14 +510,15 @@ public long GetChars(long dataIndex, char[] buffer, int bufferIndex, int length) { if (_xmlReader == null) { - SqlStream sqlStream = new(_columnOrdinal, _reader, addByteOrderMark: true, processAllRows:false, advanceReader:false); + SqlStream sqlStream = new(_columnOrdinal, _reader, addByteOrderMark: true, processAllRows: false, advanceReader: false); _xmlReader = sqlStream.ToXmlReader(); - _strWriter = new StringWriter((System.IFormatProvider)null); - _xmlWriter = XmlWriter.Create(_strWriter, s_writerSettings); + _canReadChunk = _xmlReader.CanReadValueChunk; + _memoryStream = new MemoryStream(); + _xmlWriter = XmlWriter.Create(_memoryStream, s_writerSettings); } - int charsToSkip = 0; - int cnt = 0; + long charsToSkip = 0; + long cnt = 0; if (dataIndex < _charsRemoved) { throw ADP.NonSeqByteAccess(dataIndex, _charsRemoved, nameof(GetChars)); @@ -529,72 +532,73 @@ public long GetChars(long dataIndex, char[] buffer, int bufferIndex, int length) // total size up front without reading and converting the XML. if (buffer == null) { - return (long)(-1); + return -1; } - StringBuilder strBldr = _strWriter.GetStringBuilder(); - while (!_xmlReader.EOF) + long memoryStreamRemaining = _memoryStream.Length - _memoryStream.Position; + while (memoryStreamRemaining < (length + charsToSkip) && !_xmlReader.EOF) { - if (strBldr.Length >= (length + charsToSkip)) + // Check whether the MemoryStream has been fully read. + // If so, reset the MemoryStream for reuse and to avoid growing size too much. + if (_memoryStream.Length > 0 && memoryStreamRemaining == 0) { - break; + // This also sets the Position back to 0. + _memoryStream.SetLength(0); } // Can't call _xmlWriter.WriteNode here, since it reads all of the data in before returning the first char. // Do own implementation of WriteNode instead that reads just enough data to return the required number of chars //_xmlWriter.WriteNode(_xmlReader, true); // _xmlWriter.Flush(); WriteXmlElement(); + // Update memoryStreamRemaining based on the number of chars just written to the MemoryStream + memoryStreamRemaining = _memoryStream.Length - _memoryStream.Position; if (charsToSkip > 0) { - // Aggressively remove the characters we want to skip to avoid growing StringBuilder size too much - cnt = strBldr.Length < charsToSkip ? strBldr.Length : charsToSkip; - strBldr.Remove(0, cnt); + cnt = memoryStreamRemaining < charsToSkip ? memoryStreamRemaining : charsToSkip; + // Move the Position forward + _memoryStream.Seek(cnt, SeekOrigin.Current); + memoryStreamRemaining -= cnt; charsToSkip -= cnt; - _charsRemoved += (long)cnt; + _charsRemoved += cnt; } } if (charsToSkip > 0) { - cnt = strBldr.Length < charsToSkip ? strBldr.Length : charsToSkip; - strBldr.Remove(0, cnt); + cnt = memoryStreamRemaining < charsToSkip ? memoryStreamRemaining : charsToSkip; + // Move the Position forward + _memoryStream.Seek(cnt, SeekOrigin.Current); + memoryStreamRemaining -= cnt; charsToSkip -= cnt; - _charsRemoved += (long)cnt; + _charsRemoved += cnt; } - if (strBldr.Length == 0) + if (memoryStreamRemaining == 0) { return 0; } // At this point charsToSkip must be 0 Debug.Assert(charsToSkip == 0); - cnt = strBldr.Length < length ? strBldr.Length : length; + cnt = memoryStreamRemaining < length ? memoryStreamRemaining : length; for (int i = 0; i < cnt; i++) { - buffer[bufferIndex + i] = strBldr[i]; + buffer[bufferIndex + i] = (char)_memoryStream.ReadByte(); } - // Remove the characters we have already returned - strBldr.Remove(0, cnt); - _charsRemoved += (long)cnt; - return (long)cnt; + _charsRemoved += cnt; + return cnt; } // This method duplicates the work of XmlWriter.WriteNode except that it reads one element at a time // instead of reading the entire node like XmlWriter. + // Caller already ensures !_xmlReader.EOF private void WriteXmlElement() { - if (_xmlReader.EOF) - { - return; - } - - bool canReadChunk = _xmlReader.CanReadValueChunk; - char[] writeNodeBuffer = null; - // Constants const int WriteNodeBufferSize = 1024; + long memoryStreamPosition = _memoryStream.Position; + _xmlReader.Read(); switch (_xmlReader.NodeType) { @@ -608,12 +612,9 @@ private void WriteXmlElement() } break; case XmlNodeType.Text: - if (canReadChunk) + if (_canReadChunk) { - if (writeNodeBuffer == null) - { - writeNodeBuffer = new char[WriteNodeBufferSize]; - } + char[] writeNodeBuffer = new char[WriteNodeBufferSize]; int read; while ((read = _xmlReader.ReadValueChunk(writeNodeBuffer, 0, WriteNodeBufferSize)) > 0) { @@ -650,6 +651,7 @@ private void WriteXmlElement() break; } _xmlWriter.Flush(); + _memoryStream.Position = memoryStreamPosition; } } } diff --git a/src/Microsoft.Data.SqlClient/tests/ManualTests/Microsoft.Data.SqlClient.ManualTests.csproj b/src/Microsoft.Data.SqlClient/tests/ManualTests/Microsoft.Data.SqlClient.ManualTests.csproj index 44bb79cbc9..b68f2847a0 100644 --- a/src/Microsoft.Data.SqlClient/tests/ManualTests/Microsoft.Data.SqlClient.ManualTests.csproj +++ b/src/Microsoft.Data.SqlClient/tests/ManualTests/Microsoft.Data.SqlClient.ManualTests.csproj @@ -218,6 +218,7 @@ + diff --git a/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/SqlStreamingXmlTest/SqlStreamingXmlTest.cs b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/SqlStreamingXmlTest/SqlStreamingXmlTest.cs new file mode 100644 index 0000000000..610023f18d --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/SqlStreamingXmlTest/SqlStreamingXmlTest.cs @@ -0,0 +1,91 @@ +// 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.Data; +using System.Diagnostics; +using System.Globalization; +using Xunit; + +namespace Microsoft.Data.SqlClient.ManualTesting.Tests +{ + public static class SqlStreamingXmlTest + { + [ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup))] + public static void LinearSingleNode() + { + SqlConnection connection = new(DataTestUtility.TCPConnectionString); + // Use a literal XML column of the specified size. The XML is constructed by replicating a string of 'B' characters to reach the desired size, and wrapping it in XML tags. + const string commandTextBase = "SELECT Convert(xml, N'' + REPLICATE(CAST('' AS nvarchar(max)) +N'B', ({0} * 1024 * 1024) - 11) + N'')"; + + TimeSpan time1 = TimedExecution(commandTextBase, 1); + TimeSpan time5 = TimedExecution(commandTextBase, 5); + + // Compare linear time for 1MB vs 5MB. We expect the time to be at most 6 times higher for 5MB, which permits additional 20% for any noise in the measurements. + Assert.True(time5.TotalMilliseconds <= (time1.TotalMilliseconds * 6), $"Execution time did not follow linear scale: 1MB={time1.TotalMilliseconds}ms vs. 5MB={time5.TotalMilliseconds}ms"); + } + + [ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup))] + public static void LinearMultipleNodes() + { + SqlConnection connection = new(DataTestUtility.TCPConnectionString); + // Use a literal XML column with the specified number of 1MB elements. The XML is constructed by replicating a string of 'B' characters to reach 1MB, then replicating to the desired number of elements. + const string commandTextBase = "SELECT Convert(xml, REPLICATE(N'' + REPLICATE(CAST('' AS nvarchar(max)) + N'B', (1024 * 1024) - 11) + N'', {0}))"; + + TimeSpan time1 = TimedExecution(commandTextBase, 1); + TimeSpan time5 = TimedExecution(commandTextBase, 5); + + // Compare linear time for 1MB vs 5MB. We expect the time to be at most 6 times higher for 5MB, which permits additional 20% for any noise in the measurements. + Assert.True(time5.TotalMilliseconds <= (time1.TotalMilliseconds * 6), $"Execution time did not follow linear scale: 1x={time1.TotalMilliseconds}ms vs. 5x={time5.TotalMilliseconds}ms"); + } + + private static TimeSpan TimedExecution(string commandTextBase, int scale) + { + SqlConnection connection = new(DataTestUtility.TCPConnectionString); + var stopwatch = new Stopwatch(); + + using (SqlCommand command = connection.CreateCommand()) + { + connection.Open(); + command.CommandText = string.Format(CultureInfo.InvariantCulture, commandTextBase, scale); + + SqlDataReader sqlDataReader = command.ExecuteReader(CommandBehavior.SequentialAccess); + if (sqlDataReader.Read()) + { + stopwatch.Start(); + ReadAllChars(sqlDataReader, scale); + stopwatch.Stop(); + } + connection.Close(); + } + + return stopwatch.Elapsed; + } + + /// + /// Replicate the reading approach used with issue #1877 + /// + private static void ReadAllChars(SqlDataReader sqlDataReader, int expectedMB) + { + var expectedSize = expectedMB * 1024 * 1024; + var text = new char[expectedSize]; + var buffer = new char[1]; + + long position = 0; + long numCharsRead; + do + { + numCharsRead = sqlDataReader.GetChars(0, position, buffer, 0, 1); + if (numCharsRead > 0) + { + text[position] = buffer[0]; + position += numCharsRead; + } + } + while (numCharsRead > 0); + + Assert.Equal(expectedSize, position); + } + } +} From e2dec42ec756d9cca0c242f0d566da3e9d69b471 Mon Sep 17 00:00:00 2001 From: Jim Blythe Date: Thu, 26 Feb 2026 16:14:11 -0800 Subject: [PATCH 2/3] Fix MemoryStream to allow appending when GetChars request spans multiple elements Enhance comments within SqlStreamingXml Extend Manual tests to fully cover GetChars WriteXmlElement includes uncovered paths not accessible for SQL XML column types which normalize Whitespace, CDATA, EntityReference, XmlDeclaration, ProcessingInstruction, DocumentType, and Comment node types --- .../src/Microsoft/Data/SqlClient/SqlStream.cs | 19 +- .../SqlStreamingXmlTest.cs | 259 +++++++++++++++++- 2 files changed, 262 insertions(+), 16 deletions(-) diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlStream.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlStream.cs index b317d0e238..33f88b4ebf 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlStream.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlStream.cs @@ -477,7 +477,12 @@ private long TotalLength sealed internal class SqlStreamingXml { - private static readonly XmlWriterSettings s_writerSettings = new() { CloseOutput = true, ConformanceLevel = ConformanceLevel.Fragment, Encoding = new UTF8Encoding(false) }; + private static readonly XmlWriterSettings s_writerSettings = new() { + CloseOutput = true, + ConformanceLevel = ConformanceLevel.Fragment, + // Potentially limits XML to not supporting UTF-16 characters, but this is required to avoid writing + // a byte order mark and is consistent with prior default used within StringWriter/StringBuilder. + Encoding = new UTF8Encoding(false) }; private readonly int _columnOrdinal; private SqlDataReader _reader; @@ -525,7 +530,8 @@ public long GetChars(long dataIndex, char[] buffer, int bufferIndex, int length) } else if (dataIndex > _charsRemoved) { - charsToSkip = (int)(dataIndex - _charsRemoved); + //dataIndex is zero-based, but _charsRemoved is one-based, so the difference is the number of chars to skip in the MemoryStream before we start copying data to the buffer + charsToSkip = dataIndex - _charsRemoved; } // If buffer parameter is null, we have to return -1 since there is no way for us to know the @@ -550,7 +556,7 @@ public long GetChars(long dataIndex, char[] buffer, int bufferIndex, int length) //_xmlWriter.WriteNode(_xmlReader, true); // _xmlWriter.Flush(); WriteXmlElement(); - // Update memoryStreamRemaining based on the number of chars just written to the MemoryStream + // Update memoryStreamRemaining based on the number of bytes/chars just written to the MemoryStream memoryStreamRemaining = _memoryStream.Length - _memoryStream.Position; if (charsToSkip > 0) { @@ -583,6 +589,7 @@ public long GetChars(long dataIndex, char[] buffer, int bufferIndex, int length) cnt = memoryStreamRemaining < length ? memoryStreamRemaining : length; for (int i = 0; i < cnt; i++) { + // ReadByte moves the Position forward buffer[bufferIndex + i] = (char)_memoryStream.ReadByte(); } _charsRemoved += cnt; @@ -598,10 +605,15 @@ private void WriteXmlElement() const int WriteNodeBufferSize = 1024; long memoryStreamPosition = _memoryStream.Position; + // Move the Position to the end of the MemoryStream since we are always appending. + _memoryStream.Seek(0, SeekOrigin.End); _xmlReader.Read(); switch (_xmlReader.NodeType) { + // Note: Whitespace, CDATA, EntityReference, XmlDeclaration, ProcessingInstruction, DocumentType, and Comment node types + // are not expected in the XML returned from SQL Server as it normalizes them out, but handle them just in case. + // SignificantWhitespace will occur when used with xml:space="preserve" case XmlNodeType.Element: _xmlWriter.WriteStartElement(_xmlReader.Prefix, _xmlReader.LocalName, _xmlReader.NamespaceURI); _xmlWriter.WriteAttributes(_xmlReader, true); @@ -651,6 +663,7 @@ private void WriteXmlElement() break; } _xmlWriter.Flush(); + // Reset the Position back to where it was before writing this element so that the caller can continue reading from the expected position. _memoryStream.Position = memoryStreamPosition; } } diff --git a/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/SqlStreamingXmlTest/SqlStreamingXmlTest.cs b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/SqlStreamingXmlTest/SqlStreamingXmlTest.cs index 610023f18d..f3831761d9 100644 --- a/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/SqlStreamingXmlTest/SqlStreamingXmlTest.cs +++ b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/SqlStreamingXmlTest/SqlStreamingXmlTest.cs @@ -6,6 +6,7 @@ using System.Data; using System.Diagnostics; using System.Globalization; +using System.Xml.Linq; using Xunit; namespace Microsoft.Data.SqlClient.ManualTesting.Tests @@ -13,10 +14,9 @@ namespace Microsoft.Data.SqlClient.ManualTesting.Tests public static class SqlStreamingXmlTest { [ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup))] - public static void LinearSingleNode() + public static void Linear_SingleNode() { - SqlConnection connection = new(DataTestUtility.TCPConnectionString); - // Use a literal XML column of the specified size. The XML is constructed by replicating a string of 'B' characters to reach the desired size, and wrapping it in XML tags. + // Use literal XML column constructed by replicating a string of 'B' characters to reach the desired size, and wrapping it in XML tags. const string commandTextBase = "SELECT Convert(xml, N'' + REPLICATE(CAST('' AS nvarchar(max)) +N'B', ({0} * 1024 * 1024) - 11) + N'')"; TimeSpan time1 = TimedExecution(commandTextBase, 1); @@ -27,10 +27,9 @@ public static void LinearSingleNode() } [ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup))] - public static void LinearMultipleNodes() + public static void Linear_MultipleNodes() { - SqlConnection connection = new(DataTestUtility.TCPConnectionString); - // Use a literal XML column with the specified number of 1MB elements. The XML is constructed by replicating a string of 'B' characters to reach 1MB, then replicating to the desired number of elements. + // Use literal XML column constructed by replicating a string of 'B' characters to reach 1MB, then replicating to the desired number of elements. const string commandTextBase = "SELECT Convert(xml, REPLICATE(N'' + REPLICATE(CAST('' AS nvarchar(max)) + N'B', (1024 * 1024) - 11) + N'', {0}))"; TimeSpan time1 = TimedExecution(commandTextBase, 1); @@ -40,10 +39,244 @@ public static void LinearMultipleNodes() Assert.True(time5.TotalMilliseconds <= (time1.TotalMilliseconds * 6), $"Execution time did not follow linear scale: 1x={time1.TotalMilliseconds}ms vs. 5x={time5.TotalMilliseconds}ms"); } + [ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup))] + public static void GetChars_RequiresBuffer() + { + SqlConnection connection = new(DataTestUtility.TCPConnectionString); + const string commandText = "SELECT Convert(xml, N'bar')"; + long charCount = 0; + + using (SqlCommand command = connection.CreateCommand()) + { + connection.Open(); + command.CommandText = commandText; + + SqlDataReader sqlDataReader = command.ExecuteReader(CommandBehavior.SequentialAccess); + if (sqlDataReader.Read()) + { + charCount = sqlDataReader.GetChars(0, 0, null, 0, 1); + } + connection.Close(); + } + + //verify -1 is returned since buffer was not provided + Assert.Equal(-1, charCount); + } + + [ConditionalTheory(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup))] + [InlineData(true)] + [InlineData(false)] + public static void GetChars_SequentialDataIndex(bool backwards) + { + SqlConnection connection = new(DataTestUtility.TCPConnectionString); + const string commandText = "SELECT Convert(xml, N'bar')"; + char[] buffer = new char[2]; + + using (SqlCommand command = connection.CreateCommand()) + { + connection.Open(); + command.CommandText = commandText; + + SqlDataReader sqlDataReader = command.ExecuteReader(CommandBehavior.SequentialAccess); + if (sqlDataReader.Read()) + { + sqlDataReader.GetChars(0, 0, buffer, 0, 2); + // Verify that providing the same or lower index than the previous call results in an exception. + // When backwards is true we test providing an index that is one less than the previous call, + // otherwise we test providing the same index as the previous call - both should not be allowed. + Assert.Throws(() => sqlDataReader.GetChars(0, backwards ? 0 : 1, buffer, 0, 2)); + } + connection.Close(); + } + } + + [ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup))] + public static void GetChars_PartialSingleElement() + { + SqlConnection connection = new(DataTestUtility.TCPConnectionString); + const string commandText = "SELECT Convert(xml, N'_bar_baz')"; + long charCount = 0; + char[] buffer = new char[3]; + + using (SqlCommand command = connection.CreateCommand()) + { + connection.Open(); + command.CommandText = commandText; + + SqlDataReader sqlDataReader = command.ExecuteReader(CommandBehavior.SequentialAccess); + if (sqlDataReader.Read()) + { + // Read just the 'bar' characters from the XML by specifying the offset, and the length of 3. + // The offset is 6 to skip the entire first element '' and the initial '_' part of text. + charCount = sqlDataReader.GetChars(0, 6, buffer, 0, 3); + } + connection.Close(); + } + + Assert.Equal(3, charCount); + Assert.Equal("bar", new string(buffer)); + } + + [ConditionalTheory(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup))] + [InlineData(true)] + [InlineData(false)] + public static void GetChars_PartialAcrossElements(bool initialRead) + { + SqlConnection connection = new(DataTestUtility.TCPConnectionString); + const string commandText = "SELECT Convert(xml, N'baz')"; + long charCount = 0; + char[] buffer = new char[8]; + + using (SqlCommand command = connection.CreateCommand()) + { + connection.Open(); + command.CommandText = commandText; + + SqlDataReader sqlDataReader = command.ExecuteReader(CommandBehavior.SequentialAccess); + if (sqlDataReader.Read()) + { + if (initialRead) + { + // When initialRead is true, we verify continuation after a previous read, + // otherwise we just verify that we can read across XML elements in a single call. + char[] initialBuffer = new char[2]; + sqlDataReader.GetChars(0, 0, initialBuffer, 0, 2); + Assert.Equal("bazbaz_bar_baz"""; + int expectedSize = xml.Length; + string commandText = $"SELECT Convert(xml, N'{xml}')"; + + using (SqlCommand command = connection.CreateCommand()) + { + connection.Open(); + command.CommandText = commandText; + + SqlDataReader sqlDataReader = command.ExecuteReader(CommandBehavior.SequentialAccess); + if (sqlDataReader.Read()) + { + if (initialRead) + { + // When initialRead is true, we verify continuation after a previous read, + // otherwise we just verify that we can read everything in a single call. + char[] initialBuffer = new char[2]; + long initialLength = sqlDataReader.GetChars(0, 0, initialBuffer, 0, 2); + char[] remainingBuffer = new char[98]; + long remainingLength = sqlDataReader.GetChars(0, 2, remainingBuffer, 0, 98); + string combined = new string(initialBuffer) + new string(remainingBuffer); + + Assert.Equal(expectedSize, initialLength + remainingLength); + Assert.Equal(xml, combined.Substring(0, expectedSize)); + } + else + { + // Try to read more characters than the actual XML to verify that the method returns only the actual number of characters. + (long length, string text) = ReadAllChars(sqlDataReader, 100); + + Assert.Equal(expectedSize, length); + Assert.Equal(xml, text.Substring(0, expectedSize)); + } + } + connection.Close(); + } + } + + [ConditionalTheory(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup))] + [InlineData(true)] + [InlineData(false)] + public static void GetChars_ExcessiveDataIndex(bool initialRead) + { + SqlConnection connection = new(DataTestUtility.TCPConnectionString); + string xml = """_bar_baz"""; + string commandText = $"SELECT Convert(xml, N'{xml}')"; + + using (SqlCommand command = connection.CreateCommand()) + { + connection.Open(); + command.CommandText = commandText; + + SqlDataReader sqlDataReader = command.ExecuteReader(CommandBehavior.SequentialAccess); + if (sqlDataReader.Read()) + { + if (initialRead) + { + // When initialRead is true, we verify continuation after a previous read, + // otherwise we just verify the large DataIndex in a single call. + char[] initialBuffer = new char[2]; + long initialLength = sqlDataReader.GetChars(0, 0, initialBuffer, 0, 2); + Assert.Equal(2, initialLength); + } + + // buffer will not be touched since the DataIndex is beyond the end of the XML, but a suitable buffer must still be provided. + char[] buffer = new char[100]; + long length = sqlDataReader.GetChars(0, 100, buffer, 0, 2); + Assert.Equal(0, length); + } + connection.Close(); + } + } + + [ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup))] + public static void GetChars_AsXDocument() + { + SqlConnection connection = new(DataTestUtility.TCPConnectionString); + // Use a more complex XML column verify through XDocument. + string xml = """John """; + XDocument expect = XDocument.Parse(xml); + int expectedSize = xml.Length; + string commandText = $"SELECT Convert(xml, N'{xml}')"; + + using (SqlCommand command = connection.CreateCommand()) + { + connection.Open(); + command.CommandText = commandText; + + SqlDataReader sqlDataReader = command.ExecuteReader(CommandBehavior.SequentialAccess); + if (sqlDataReader.Read()) + { + (long length, string xmlString) = ReadAllChars(sqlDataReader, expectedSize); + + Assert.Equal(expectedSize, length); + XDocument actual = XDocument.Parse(xmlString); + Assert.Equal((int)expect.Root.Attribute("Id"), (int)actual.Root.Attribute("Id")); + Assert.Equal((string)expect.Root.Attribute("Role"), (string)actual.Root.Attribute("Role")); + Assert.NotNull(expect.Root.Element("Name")?.Value); + Assert.Equal(expect.Root.Element("Name")!.Value, actual.Root.Element("Name")!.Value); + Assert.NotNull(expect.Root.Element("Children")?.HasElements); + Assert.Equal(expect.Root.Element("Children")!.HasElements, actual.Root.Element("Children")?.HasElements); + Assert.NotNull(expect.Root.Element("PreservedWhitespace")?.Value); + Assert.Equal(expect.Root.Element("PreservedWhitespace")!.Value, actual.Root.Element("PreservedWhitespace")!.Value); + } + connection.Close(); + } + } + private static TimeSpan TimedExecution(string commandTextBase, int scale) { SqlConnection connection = new(DataTestUtility.TCPConnectionString); - var stopwatch = new Stopwatch(); + Stopwatch stopwatch = new Stopwatch(); + int expectedSize = scale * 1024 * 1024; + using (SqlCommand command = connection.CreateCommand()) { @@ -54,8 +287,9 @@ private static TimeSpan TimedExecution(string commandTextBase, int scale) if (sqlDataReader.Read()) { stopwatch.Start(); - ReadAllChars(sqlDataReader, scale); + (long length, string _) = ReadAllChars(sqlDataReader, expectedSize); stopwatch.Stop(); + Assert.Equal(expectedSize, length); } connection.Close(); } @@ -66,11 +300,10 @@ private static TimeSpan TimedExecution(string commandTextBase, int scale) /// /// Replicate the reading approach used with issue #1877 /// - private static void ReadAllChars(SqlDataReader sqlDataReader, int expectedMB) + private static (long, string) ReadAllChars(SqlDataReader sqlDataReader, int expectedSize) { - var expectedSize = expectedMB * 1024 * 1024; - var text = new char[expectedSize]; - var buffer = new char[1]; + char[] text = new char[expectedSize]; + char[] buffer = new char[1]; long position = 0; long numCharsRead; @@ -85,7 +318,7 @@ private static void ReadAllChars(SqlDataReader sqlDataReader, int expectedMB) } while (numCharsRead > 0); - Assert.Equal(expectedSize, position); + return (position, new string(text)); } } } From 9f44ae87735d12ec7957a88265ea8d9470c85a86 Mon Sep 17 00:00:00 2001 From: Jim Blythe Date: Fri, 27 Feb 2026 11:51:41 -0800 Subject: [PATCH 3/3] On Close, reset _canReadChunk & _charsRemoved --- .../src/Microsoft/Data/SqlClient/SqlStream.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlStream.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlStream.cs index 33f88b4ebf..c98cccede4 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlStream.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlStream.cs @@ -505,8 +505,10 @@ public void Close() ((IDisposable)_xmlReader).Dispose(); _reader = null; _xmlReader = null; + _canReadChunk = false; _xmlWriter = null; _memoryStream = null; + _charsRemoved = 0; } public int ColumnOrdinal => _columnOrdinal;