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..648ab45b67 100644
--- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlStream.cs
+++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlStream.cs
@@ -102,7 +102,7 @@ public override int Read(byte[] buffer, int offset, int count)
// Read and buffer the first two bytes
_bufferedData = new byte[2];
cBufferedData = ReadBytes(_bufferedData, 0, 2);
- // Check to se if we should add the byte order mark
+ // Check to see if we should add the byte order mark
if ((cBufferedData < 2) || ((_bufferedData[0] == 0xDF) && (_bufferedData[1] == 0xFF)))
{
_bom = 0;
@@ -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,179 +477,346 @@ private long TotalLength
sealed internal class SqlStreamingXml
{
- private static readonly XmlWriterSettings s_writerSettings = new() { CloseOutput = true, ConformanceLevel = ConformanceLevel.Fragment };
+ private readonly int _columnOrdinal; // changing this is only done through the ctor, so it is safe to be readonly
+ private SqlDataReader _reader; // reader we will stream off, becomes null when closed
+ private XmlReader _xmlReader; // XmlReader over the current column, becomes null when closed
- private readonly int _columnOrdinal;
- private SqlDataReader _reader;
- private XmlReader _xmlReader;
- private XmlWriter _xmlWriter;
- private StringWriter _strWriter;
- private long _charsRemoved;
+ private string _currentTextNode; // rolling buffer of text to deliver
+ private int _textNodeIndex; // index in _currentTextNode
+ private char? _pendingHighSurrogate; // pending high surrogate for split surrogate pairs
+ private long _charsReturned; // total chars returned
+ private bool _canReadChunk; // XmlReader.CanReadValueChunk
- public SqlStreamingXml(int i, SqlDataReader reader)
+ public SqlStreamingXml(int columnOrdinal, SqlDataReader reader)
{
- _columnOrdinal = i;
+ _columnOrdinal = columnOrdinal;
_reader = reader;
}
+ public int ColumnOrdinal => _columnOrdinal;
+
public void Close()
{
- ((IDisposable)_xmlWriter).Dispose();
- ((IDisposable)_xmlReader).Dispose();
- _reader = null;
+ _xmlReader?.Dispose();
_xmlReader = null;
- _xmlWriter = null;
- _strWriter = null;
- }
+ _reader = null;
- public int ColumnOrdinal => _columnOrdinal;
+ _currentTextNode = null;
+ _textNodeIndex = 0;
+ _pendingHighSurrogate = null;
+ _charsReturned = 0;
+ _canReadChunk = false;
+ }
public long GetChars(long dataIndex, char[] buffer, int bufferIndex, int length)
{
- if (_xmlReader == null)
+ if (_reader == null)
{
- 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);
+ throw new ObjectDisposedException(nameof(SqlStreamingXml));
}
- int charsToSkip = 0;
- int cnt = 0;
- if (dataIndex < _charsRemoved)
+ if (buffer == null)
{
- throw ADP.NonSeqByteAccess(dataIndex, _charsRemoved, nameof(GetChars));
+ return -1;
}
- else if (dataIndex > _charsRemoved)
+
+ if (length == 0)
{
- charsToSkip = (int)(dataIndex - _charsRemoved);
+ return 0;
}
- // If buffer parameter is null, we have to return -1 since there is no way for us to know the
- // total size up front without reading and converting the XML.
- if (buffer == null)
+ if (dataIndex < _charsReturned)
{
- return (long)(-1);
+ throw new InvalidOperationException($"Non-sequential read: requested {dataIndex}, already returned {_charsReturned}");
}
- StringBuilder strBldr = _strWriter.GetStringBuilder();
- while (!_xmlReader.EOF)
+ EnsureReaderInitialized();
+
+ // Skip to requested dataIndex
+ long skip = dataIndex - _charsReturned;
+ while (skip > 0)
{
- if (strBldr.Length >= (length + charsToSkip))
+ char discard;
+ if (!TryReadNextChar(out discard))
{
- break;
+ return 0; // EOF
}
- // 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();
- if (charsToSkip > 0)
+
+ skip--;
+ _charsReturned++;
+ }
+
+ // Read chars into buffer
+ int copied = 0;
+ while (copied < length)
+ {
+ char c;
+ if (!TryReadNextChar(out c))
{
- // 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);
- charsToSkip -= cnt;
- _charsRemoved += (long)cnt;
+ break;
}
+
+ buffer[bufferIndex + copied] = c;
+ copied++;
+ _charsReturned++;
+ }
+
+ return copied;
+ }
+
+ ///
+ /// Initializes the XML reader if it has not already been initialized, ensuring it is ready for reading
+ /// operations.
+ ///
+ ///
+ /// This method prepares the XML reader for use by creating and assigning a new instance
+ /// if necessary. It should be called before attempting to read XML data to guarantee that the reader is
+ /// available and properly configured.
+ ///
+ private void EnsureReaderInitialized()
+ {
+ if (_xmlReader != null)
+ {
+ return;
}
- if (charsToSkip > 0)
+ var sqlStream = new SqlStream(_columnOrdinal, _reader, addByteOrderMark: true, processAllRows: false, advanceReader: false);
+ _xmlReader = sqlStream.ToXmlReader();
+ _canReadChunk = _xmlReader.CanReadValueChunk;
+ }
+
+ ///
+ /// Progressively fetches the next char from the XmlReader, filling the current text node buffer as necessary.
+ /// Handles surrogate pairs that may be split across text nodes.
+ ///
+ private bool TryReadNextChar(out char c)
+ {
+ // Deliver pending high surrogate first
+ if (_pendingHighSurrogate.HasValue)
{
- cnt = strBldr.Length < charsToSkip ? strBldr.Length : charsToSkip;
- strBldr.Remove(0, cnt);
- charsToSkip -= cnt;
- _charsRemoved += (long)cnt;
+ c = _pendingHighSurrogate.Value;
+ _pendingHighSurrogate = null;
+ return true;
}
- if (strBldr.Length == 0)
+ // Deliver from current text node
+ if (_currentTextNode != null && _textNodeIndex < _currentTextNode.Length)
{
- return 0;
+ char next = _currentTextNode[_textNodeIndex++];
+ if (char.IsHighSurrogate(next))
+ {
+ // Surrogate Pairs could not be split across text nodes
+ c = next;
+ _pendingHighSurrogate = _currentTextNode[_textNodeIndex++];
+ return true;
+ }
+ else
+ {
+ c = next;
+ return true;
+ }
}
- // At this point charsToSkip must be 0
- Debug.Assert(charsToSkip == 0);
- cnt = strBldr.Length < length ? strBldr.Length : length;
- for (int i = 0; i < cnt; i++)
+ // Fill/Refill current text node, then recurse to deliver the next char from one single node at a time;
+ // will not read entire xml column if requested substring is met.
+ while (_xmlReader.Read())
{
- buffer[bufferIndex + i] = strBldr[i];
+ // Not using XmlWriter since this maintains better control of allocations and prevents an intermediate buffer copy.
+ switch (_xmlReader.NodeType)
+ {
+ case XmlNodeType.Element:
+ _currentTextNode = BuildStartOrEmptyTag();
+ _textNodeIndex = 0;
+ return TryReadNextChar(out c);
+
+ case XmlNodeType.Text:
+ case XmlNodeType.CDATA:
+ case XmlNodeType.Whitespace:
+ case XmlNodeType.SignificantWhitespace:
+ _currentTextNode = ReadAllText();
+ _textNodeIndex = 0;
+ return TryReadNextChar(out c);
+
+ case XmlNodeType.ProcessingInstruction:
+ _currentTextNode = $"{_xmlReader.Name} {_xmlReader.Value}?>";
+ _textNodeIndex = 0;
+ return TryReadNextChar(out c);
+
+ case XmlNodeType.Comment:
+ _currentTextNode = $"";
+ _textNodeIndex = 0;
+ return TryReadNextChar(out c);
+
+ case XmlNodeType.EndElement:
+ _currentTextNode = BuildEndTag();
+ _textNodeIndex = 0;
+ return TryReadNextChar(out c);
+
+ default:
+ // Skip EntityReference, DocumentType, XmlDeclaration which are normalized out by SQL Server
+ continue;
+ }
}
- // Remove the characters we have already returned
- strBldr.Remove(0, cnt);
- _charsRemoved += (long)cnt;
- return (long)cnt;
+
+ // Ensure we don't return any stale chars after EOF
+ c = '\0';
+ return false; // EOF
}
- // 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.
- private void WriteXmlElement()
+ ///
+ /// Reads all text content from the current node of the underlying XML reader and returns it as a string.
+ ///
+ ///
+ /// If the XML reader supports reading in chunks, this method reads the text in segments
+ /// to improve performance. Otherwise, it retrieves the value directly from the XML reader.
+ ///
+ /// A string containing all text read from the XML reader. Returns an empty string if no text is available.
+ private string ReadAllText()
{
- if (_xmlReader.EOF)
+ if (_canReadChunk)
{
- return;
+ char[] buffer = new char[8192];
+ int read;
+ StringBuilder stringBuilder = new StringBuilder();
+ while ((read = _xmlReader.ReadValueChunk(buffer, 0, buffer.Length)) > 0)
+ {
+ stringBuilder.Append(buffer, 0, read); // only valid chars
+ }
+ return stringBuilder.ToString();
}
+ else
+ {
+ return _xmlReader.Value ?? string.Empty; // never null -> avoids trailing \0
+ }
+ }
- bool canReadChunk = _xmlReader.CanReadValueChunk;
- char[] writeNodeBuffer = null;
+ ///
+ /// Constructs an XML start tag or an empty element tag for the current node of the underlying XML reader,
+ /// including the namespace prefix and any attributes if present.
+ ///
+ ///
+ /// If the current XML node contains attributes, they are included in the generated tag.
+ /// If the node is an empty element, a self-closing tag is returned; otherwise, a standard opening tag is
+ /// produced. The method does not advance the position of the XML reader.
+ ///
+ /// A string that represents the XML start tag or a self-closing empty element tag, including all attributes of
+ /// the current node.
+ private string BuildStartOrEmptyTag()
+ {
+ string prefix = _xmlReader.Prefix;
+ string tagName = string.IsNullOrEmpty(prefix) ? _xmlReader.LocalName : $"{prefix}:{_xmlReader.LocalName}";
+ StringBuilder stringBuilder = new StringBuilder();
+ stringBuilder.Append('<').Append(tagName);
- // Constants
- const int WriteNodeBufferSize = 1024;
+ if (_xmlReader.HasAttributes)
+ {
+ for (int i = 0; i < _xmlReader.AttributeCount; i++)
+ {
+ _xmlReader.MoveToAttribute(i);
+ string attrPrefix = _xmlReader.Prefix;
+ string attrName = string.IsNullOrEmpty(attrPrefix) ? _xmlReader.LocalName : $"{attrPrefix}:{_xmlReader.LocalName}";
+ stringBuilder.Append(' ').Append(attrName).Append("=\"").Append(EscapeAttribute(_xmlReader.Value)).Append('"');
+ }
+ _xmlReader.MoveToElement();
+ }
- _xmlReader.Read();
- switch (_xmlReader.NodeType)
+ if (_xmlReader.IsEmptyElement)
{
- case XmlNodeType.Element:
- _xmlWriter.WriteStartElement(_xmlReader.Prefix, _xmlReader.LocalName, _xmlReader.NamespaceURI);
- _xmlWriter.WriteAttributes(_xmlReader, true);
- if (_xmlReader.IsEmptyElement)
- {
- _xmlWriter.WriteEndElement();
- break;
- }
- break;
- case XmlNodeType.Text:
- if (canReadChunk)
+ stringBuilder.Append(" />");
+ }
+ else
+ {
+ stringBuilder.Append('>');
+ }
+
+ return stringBuilder.ToString();
+ }
+
+ ///
+ /// Builds the closing XML tag for the current element, including the namespace prefix if present.
+ ///
+ ///
+ /// The returned tag is constructed using the prefix and local name from the underlying
+ /// XML reader. If the element has no namespace prefix, only the local name is used in the tag.
+ ///
+ /// A string that represents the closing tag of the current XML element, formatted with the appropriate
+ /// namespace prefix if one exists.
+ private string BuildEndTag()
+ {
+ string prefix = _xmlReader.Prefix;
+ string tagName = string.IsNullOrEmpty(prefix) ? _xmlReader.LocalName : $"{prefix}:{_xmlReader.LocalName}";
+ return $"{tagName}>";
+ }
+
+ ///
+ /// Escapes special characters in the provided string to ensure it is safe for use in XML attributes.
+ ///
+ /// ', and '"'. It does not
+ /// escape single quotes as they are not required for SQL Server attributes. The method uses a StringBuilder for
+ /// efficient string manipulation.
+ /// ]]>
+ /// The string to be escaped. This string may contain special XML characters that need to be replaced with their
+ /// corresponding entity references.
+ /// A string with special XML characters replaced by their corresponding entity references. If the input string
+ /// is null or empty, an empty string is returned.
+ private string EscapeAttribute(string value)
+ {
+ if (string.IsNullOrEmpty(value))
+ {
+ return string.Empty;
+ }
+
+ // Only create a StringBuilder if we find a character that needs escaping, to avoid unnecessary allocations
+ StringBuilder sb = null;
+
+ for (int i = 0; i < value.Length; i++)
+ {
+ char c = value[i];
+ string replacement = c switch
+ {
+ '&' => "&",
+ '<' => "<",
+ '>' => ">",
+ '"' => """,
+ //'\'' => "'", SQL Server does not escape single quotes in attributes
+ _ => null
+ };
+
+ if (replacement != null)
+ {
+ sb ??= new StringBuilder(value.Length + 8);
+ sb.Append(value, 0, i);
+ sb.Append(replacement);
+
+ for (i = i + 1; i < value.Length; i++)
{
- if (writeNodeBuffer == null)
+ c = value[i];
+ replacement = c switch
+ {
+ '&' => "&",
+ '<' => "<",
+ '>' => ">",
+ '"' => """,
+ //'\'' => "'", SQL Server does not escape single quotes in attributes
+ _ => null
+ };
+
+ if (replacement != null)
{
- writeNodeBuffer = new char[WriteNodeBufferSize];
+ sb.Append(replacement);
}
- int read;
- while ((read = _xmlReader.ReadValueChunk(writeNodeBuffer, 0, WriteNodeBufferSize)) > 0)
+ else
{
- _xmlWriter.WriteChars(writeNodeBuffer, 0, read);
+ sb.Append(c);
}
}
- else
- {
- _xmlWriter.WriteString(_xmlReader.Value);
- }
- break;
- case XmlNodeType.Whitespace:
- case XmlNodeType.SignificantWhitespace:
- _xmlWriter.WriteWhitespace(_xmlReader.Value);
- break;
- case XmlNodeType.CDATA:
- _xmlWriter.WriteCData(_xmlReader.Value);
- break;
- case XmlNodeType.EntityReference:
- _xmlWriter.WriteEntityRef(_xmlReader.Name);
- break;
- case XmlNodeType.XmlDeclaration:
- case XmlNodeType.ProcessingInstruction:
- _xmlWriter.WriteProcessingInstruction(_xmlReader.Name, _xmlReader.Value);
- break;
- case XmlNodeType.DocumentType:
- _xmlWriter.WriteDocType(_xmlReader.Name, _xmlReader.GetAttribute("PUBLIC"), _xmlReader.GetAttribute("SYSTEM"), _xmlReader.Value);
- break;
- case XmlNodeType.Comment:
- _xmlWriter.WriteComment(_xmlReader.Value);
- break;
- case XmlNodeType.EndElement:
- _xmlWriter.WriteFullEndElement();
- break;
+
+ return sb.ToString();
+ }
}
- _xmlWriter.Flush();
+
+ return value;
}
}
}
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..cef075e369
--- /dev/null
+++ b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/SqlStreamingXmlTest/SqlStreamingXmlTest.cs
@@ -0,0 +1,865 @@
+// 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 System.Xml.Linq;
+using Xunit;
+
+namespace Microsoft.Data.SqlClient.ManualTesting.Tests
+{
+ public static class SqlStreamingXmlTest
+ {
+ [ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup))]
+ public static void GetChars_NonAsciiContent()
+ {
+ using SqlConnection connection = new(DataTestUtility.TCPConnectionString);
+ // XML containing non-ASCII characters:
+ // - \u00E9 (e-acute) - 2 bytes in UTF-8
+ // - \u00F1 (n-tilde) - 2 bytes in UTF-8
+ // - \u00FC (u-umlaut) - 2 bytes in UTF-8
+ string xml = "caf\u00E9 se\u00F1or \u00FCber";
+ int expectedLength = xml.Length;
+ string commandText = $"SELECT Convert(xml, N'{xml}')";
+
+ using SqlCommand command = connection.CreateCommand();
+ connection.Open();
+ command.CommandText = commandText;
+
+ using SqlDataReader sqlDataReader = command.ExecuteReader(CommandBehavior.SequentialAccess);
+ Assert.True(sqlDataReader.Read(), "Expected to read a row");
+
+ (long length, string result) = ReadAllChars(sqlDataReader, expectedLength);
+
+ Assert.Equal(expectedLength, length);
+ Assert.Equal(xml, result.Substring(0, (int)length));
+ }
+
+ [ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup))]
+ public static void GetChars_NonAsciiContent_BulkRead()
+ {
+ using SqlConnection connection = new(DataTestUtility.TCPConnectionString);
+ // Same non-ASCII XML but read in a single bulk GetChars call
+ string xml = "Jos\u00E9 Garc\u00EDa";
+ int expectedLength = xml.Length;
+ string commandText = $"SELECT Convert(xml, N'{xml}')";
+
+ using SqlCommand command = connection.CreateCommand();
+ connection.Open();
+ command.CommandText = commandText;
+
+ using SqlDataReader sqlDataReader = command.ExecuteReader(CommandBehavior.SequentialAccess);
+ Assert.True(sqlDataReader.Read(), "Expected to read a row");
+
+ char[] buffer = new char[expectedLength + 10];
+ long charsRead = sqlDataReader.GetChars(0, 0, buffer, 0, buffer.Length);
+
+ Assert.Equal(expectedLength, charsRead);
+ string result = new(buffer, 0, (int)charsRead);
+ Assert.Equal(xml, result);
+ }
+
+ [ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup))]
+ public static void GetChars_CjkContent()
+ {
+ using SqlConnection connection = new(DataTestUtility.TCPConnectionString);
+ // CJK characters: 3 bytes each in UTF-8
+ string xml = "\u65E5\u672C\u8A9E\u30C6\u30B9\u30C8";
+ int expectedLength = xml.Length;
+ string commandText = $"SELECT Convert(xml, N'{xml}')";
+
+ using SqlCommand command = connection.CreateCommand();
+ connection.Open();
+ command.CommandText = commandText;
+
+ using SqlDataReader sqlDataReader = command.ExecuteReader(CommandBehavior.SequentialAccess);
+ Assert.True(sqlDataReader.Read(), "Expected to read a row");
+
+ (long length, string result) = ReadAllChars(sqlDataReader, expectedLength);
+
+ Assert.Equal(expectedLength, length);
+ Assert.Equal(xml, result.Substring(0, (int)length));
+ }
+
+ [ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup))]
+ public static void GetChars_SurrogatePairContent()
+ {
+ using SqlConnection connection = new(DataTestUtility.TCPConnectionString);
+ // Surrogate Pair characters: 4 bytes each in UTF-8
+ string xml = "\U0001F600\U0001F525\U0001F680";
+ int expectedLength = xml.Length;
+ string commandText = $"SELECT Convert(xml, N'{xml}')";
+
+ using SqlCommand command = connection.CreateCommand();
+ connection.Open();
+ command.CommandText = commandText;
+
+ using SqlDataReader sqlDataReader = command.ExecuteReader(CommandBehavior.SequentialAccess);
+ Assert.True(sqlDataReader.Read(), "Expected to read a row");
+
+ (long length, string result) = ReadAllChars(sqlDataReader, expectedLength);
+
+ Assert.Equal(expectedLength, length);
+ Assert.Equal(xml, result.Substring(0, (int)length));
+ }
+
+ [ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup))]
+ public static void GetChars_SurrogatePair_ReadIndividually()
+ {
+ using SqlConnection connection = new(DataTestUtility.TCPConnectionString);
+ // Surrogate Pair character: 4 bytes in UTF-8
+ string xml = "\U0001F600";
+ const string commandText = "SELECT @xmlParam";
+
+ using SqlCommand command = connection.CreateCommand();
+ connection.Open();
+ command.CommandText = commandText;
+ command.Parameters.Add(new SqlParameter("@xmlParam", SqlDbType.Xml) { Value = xml });
+
+ using SqlDataReader sqlDataReader = command.ExecuteReader(CommandBehavior.SequentialAccess);
+ Assert.True(sqlDataReader.Read(), "Expected to read a row");
+
+ // Find the surrogate pair location in the original string
+ int highIndex = xml.IndexOf('\uD83D');
+ Assert.True(highIndex >= 0);
+
+ int lowIndex = highIndex + 1;
+
+ char[] buffer = new char[1];
+
+ // Read the high surrogate
+ long read = sqlDataReader.GetChars(0, highIndex, buffer, 0, 1);
+ Assert.Equal(1, read);
+ Assert.True(char.IsHighSurrogate(buffer[0]));
+
+ // Read the low surrogate
+ read = sqlDataReader.GetChars(0, lowIndex, buffer, 0, 1);
+ Assert.Equal(1, read);
+ Assert.True(char.IsLowSurrogate(buffer[0]));
+
+ // Reconstruct pair
+ string reconstructed = new string(new[] { xml[highIndex], xml[lowIndex] });
+ Assert.Equal("\U0001F600", reconstructed);
+ }
+
+ [ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup))]
+ public static void Linear_SingleNode()
+ {
+ // 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);
+ 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 Linear_MultipleNodes()
+ {
+ // 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);
+ 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");
+ }
+
+ [ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup))]
+ public static void GetChars_RequiresBuffer()
+ {
+ using 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;
+
+ using SqlDataReader sqlDataReader = command.ExecuteReader(CommandBehavior.SequentialAccess);
+ Assert.True(sqlDataReader.Read(), "Expected to read a row");
+
+ charCount = sqlDataReader.GetChars(0, 0, null, 0, 1);
+
+ //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 overlapByOne)
+ {
+ using 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;
+
+ using SqlDataReader sqlDataReader = command.ExecuteReader(CommandBehavior.SequentialAccess);
+ Assert.True(sqlDataReader.Read(), "Expected to read a row");
+
+ sqlDataReader.GetChars(0, 0, buffer, 0, 2);
+ // Verify that providing the same or lower index than the previous call results in an exception.
+ // When overlapByOne 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, overlapByOne ? 0 : 1, buffer, 0, 2));
+ }
+
+ [ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup))]
+ public static void GetChars_PartialSingleElement()
+ {
+ using 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;
+
+ using SqlDataReader sqlDataReader = command.ExecuteReader(CommandBehavior.SequentialAccess);
+ Assert.True(sqlDataReader.Read(), "Expected to read a row");
+
+ // 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);
+
+ 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)
+ {
+ using 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;
+
+ using SqlDataReader sqlDataReader = command.ExecuteReader(CommandBehavior.SequentialAccess);
+ Assert.True(sqlDataReader.Read(), "Expected to read a row");
+
+ 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;
+
+ using SqlDataReader sqlDataReader = command.ExecuteReader(CommandBehavior.SequentialAccess);
+ Assert.True(sqlDataReader.Read(), "Expected to read a row");
+
+ 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));
+ }
+ }
+
+ [ConditionalTheory(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup))]
+ [InlineData(true)]
+ [InlineData(false)]
+ public static void GetChars_ExcessiveDataIndex(bool initialRead)
+ {
+ using 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;
+
+ using SqlDataReader sqlDataReader = command.ExecuteReader(CommandBehavior.SequentialAccess);
+ Assert.True(sqlDataReader.Read(), "Expected to read a row");
+
+ 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);
+ }
+
+ [ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup))]
+ public static void GetChars_AsXDocument()
+ {
+ using 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;
+
+ using SqlDataReader sqlDataReader = command.ExecuteReader(CommandBehavior.SequentialAccess);
+ Assert.True(sqlDataReader.Read(), "Expected to read a row");
+
+ (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);
+ }
+
+ [ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup))]
+ public static void GetChars_ProcessingInstructionOnly()
+ {
+ using SqlConnection connection = new(DataTestUtility.TCPConnectionString);
+ string xml = "";
+ int expectedLength = xml.Length;
+ string commandText = $"SELECT Convert(xml, N'{xml}')";
+
+ using SqlCommand command = connection.CreateCommand();
+ connection.Open();
+ command.CommandText = commandText;
+
+ using SqlDataReader sqlDataReader = command.ExecuteReader(CommandBehavior.SequentialAccess);
+ Assert.True(sqlDataReader.Read(), "Expected to read a row");
+
+ (long length, string result) = ReadAllChars(sqlDataReader, expectedLength);
+
+ Assert.Equal(expectedLength, length);
+ Assert.Equal(xml, result.Substring(0, (int)length));
+ }
+
+ [ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup))]
+ public static void GetChars_ZeroLength()
+ {
+ using 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;
+
+ using SqlDataReader sqlDataReader = command.ExecuteReader(CommandBehavior.SequentialAccess);
+ Assert.True(sqlDataReader.Read(), "Expected to read a row");
+
+ // While not used, cannot pass an empty buffer to GetChars, so provide a buffer of size 1 but request 0 characters to read.
+ char[] buffer = new char[1];
+ charCount = sqlDataReader.GetChars(0, 0, buffer, 0, 0);
+
+ //verify 0 is returned since nothing was requested
+ Assert.Equal(0, charCount);
+ }
+
+ [ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup))]
+ public static void GetChars_CommentAndProcessingInstructionMixed()
+ {
+ using SqlConnection connection = new(DataTestUtility.TCPConnectionString);
+ string xml = "";
+ int expectedLength = xml.Length;
+ string commandText = $"SELECT Convert(xml, N'{xml}')";
+
+ using SqlCommand command = connection.CreateCommand();
+ connection.Open();
+ command.CommandText = commandText;
+
+ using SqlDataReader sqlDataReader = command.ExecuteReader(CommandBehavior.SequentialAccess);
+ Assert.True(sqlDataReader.Read(), "Expected to read a row");
+
+ (long length, string result) = ReadAllChars(sqlDataReader, expectedLength);
+
+ Assert.Equal(expectedLength, length);
+ Assert.Equal(xml, result.Substring(0, (int)length));
+ }
+
+ [ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup))]
+ public static void GetChars_EmptyElementWithAttributes()
+ {
+ using SqlConnection connection = new(DataTestUtility.TCPConnectionString);
+ // Use an empty element with various attributes, including empty attribute value, normal attribute value, and attributes with escaped characters to verify that all are preserved correctly.
+ string xml = "";
+ int expectedLength = xml.Length;
+ const string commandText = "SELECT @xmlParam";
+
+ using SqlCommand command = connection.CreateCommand();
+ connection.Open();
+ command.CommandText = commandText;
+ command.Parameters.Add(new SqlParameter("@xmlParam", SqlDbType.Xml) { Value = xml });
+
+ using SqlDataReader sqlDataReader = command.ExecuteReader(CommandBehavior.SequentialAccess);
+ Assert.True(sqlDataReader.Read(), "Expected to read a row");
+
+ (long length, string result) = ReadAllChars(sqlDataReader, expectedLength);
+
+ Assert.Equal(expectedLength, length);
+ Assert.Equal(xml, result.Substring(0, (int)length));
+ }
+
+ [ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup))]
+ public static void GetChars_EmptyElementWithAttribute_Apos()
+ {
+ using SqlConnection connection = new(DataTestUtility.TCPConnectionString);
+ // ' is normalized by SQL Server and converts to simply '
+ string xml = "";
+ string expected = "";
+ int expectedLength = expected.Length;
+ const string commandText = "SELECT @xmlParam";
+
+ using SqlCommand command = connection.CreateCommand();
+ connection.Open();
+ command.CommandText = commandText;
+ command.Parameters.Add(new SqlParameter("@xmlParam", SqlDbType.Xml) { Value = xml });
+
+ using SqlDataReader sqlDataReader = command.ExecuteReader(CommandBehavior.SequentialAccess);
+ Assert.True(sqlDataReader.Read(), "Expected to read a row");
+
+ (long length, string result) = ReadAllChars(sqlDataReader, expectedLength);
+
+ Assert.Equal(expectedLength, length);
+ Assert.Equal(expected, result.Substring(0, (int)length));
+ }
+
+ [ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup))]
+ public static void GetChars_ElementWithNamespacePrefix()
+ {
+ using SqlConnection connection = new(DataTestUtility.TCPConnectionString);
+ string xml = "content";
+ int expectedLength = xml.Length;
+ const string commandText = "SELECT @xmlParam";
+
+ using SqlCommand command = connection.CreateCommand();
+ connection.Open();
+ command.CommandText = commandText;
+ command.Parameters.Add(new SqlParameter("@xmlParam", SqlDbType.Xml) { Value = xml });
+
+ using SqlDataReader sqlDataReader = command.ExecuteReader(CommandBehavior.SequentialAccess);
+ Assert.True(sqlDataReader.Read(), "Expected to read a row");
+
+ (long length, string result) = ReadAllChars(sqlDataReader, expectedLength);
+
+ Assert.Equal(expectedLength, length);
+ Assert.Equal(xml, result.Substring(0, (int)length));
+ }
+
+ [ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup))]
+ public static void GetChars_MixedContent()
+ {
+ using SqlConnection connection = new(DataTestUtility.TCPConnectionString);
+ string xml = "textinnermore";
+ int expectedLength = xml.Length;
+ const string commandText = "SELECT @xmlParam";
+
+ using SqlCommand command = connection.CreateCommand();
+ connection.Open();
+ command.CommandText = commandText;
+ command.Parameters.Add(new SqlParameter("@xmlParam", SqlDbType.Xml) { Value = xml });
+
+ using SqlDataReader sqlDataReader = command.ExecuteReader(CommandBehavior.SequentialAccess);
+ Assert.True(sqlDataReader.Read(), "Expected to read a row");
+
+ (long length, string result) = ReadAllChars(sqlDataReader, expectedLength);
+
+ Assert.Equal(expectedLength, length);
+ Assert.Equal(xml, result.Substring(0, (int)length));
+ }
+
+ [ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup))]
+ public static void GetChars_CDATASection()
+ {
+ using SqlConnection connection = new(DataTestUtility.TCPConnectionString);
+ string xml = " content]]>";
+ string expected = "some content";
+ int expectedLength = expected.Length;
+ string commandText = $"SELECT Convert(xml, N'{xml}')";
+
+ using SqlCommand command = connection.CreateCommand();
+ connection.Open();
+ command.CommandText = commandText;
+
+ using SqlDataReader sqlDataReader = command.ExecuteReader(CommandBehavior.SequentialAccess);
+ Assert.True(sqlDataReader.Read(), "Expected to read a row");
+
+ (long length, string result) = ReadAllChars(sqlDataReader, expectedLength);
+
+ Assert.Equal(expectedLength, length);
+ Assert.Equal(expected, result.Substring(0, (int)length));
+ }
+
+ [ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup))]
+ public static void GetChars_WhitespaceAndSignificantWhitespace()
+ {
+ using SqlConnection connection = new(DataTestUtility.TCPConnectionString);
+ string xml = " \t\n ";
+ int expectedLength = xml.Length;
+ const string commandText = "SELECT @xmlParam";
+
+ using SqlCommand command = connection.CreateCommand();
+ connection.Open();
+ command.CommandText = commandText;
+ command.Parameters.Add(new SqlParameter("@xmlParam", SqlDbType.Xml) { Value = xml });
+
+ using SqlDataReader sqlDataReader = command.ExecuteReader(CommandBehavior.SequentialAccess);
+ Assert.True(sqlDataReader.Read(), "Expected to read a row");
+
+ (long length, string result) = ReadAllChars(sqlDataReader, expectedLength);
+
+ Assert.Equal(expectedLength, length);
+ Assert.Equal(xml, result.Substring(0, (int)length));
+ }
+
+ [ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup))]
+ public static void GetChars_EntityReferences_Normalized()
+ {
+ using SqlConnection connection = new(DataTestUtility.TCPConnectionString);
+ string xml = "<>&"'";
+ const string expected = "<>&\"'";
+ int expectedLength = expected.Length;
+ string commandText = $"SELECT Convert(xml, N'{xml}')";
+
+ using SqlCommand command = connection.CreateCommand();
+ connection.Open();
+ command.CommandText = commandText;
+
+ using SqlDataReader sqlDataReader = command.ExecuteReader(CommandBehavior.SequentialAccess);
+ Assert.True(sqlDataReader.Read());
+
+ char[] buffer = new char[expectedLength];
+ // Use 6 for dataIndex to skip ""
+ long charsRead = sqlDataReader.GetChars(0, 6, buffer, 0, buffer.Length);
+
+ Assert.Equal(expectedLength, charsRead);
+ string text = new(buffer, 0, (int)charsRead);
+ Assert.Equal(expected, text);
+ }
+
+ [ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup))]
+ public static void GetChars_ProcessingInstructions()
+ {
+ using SqlConnection connection = new(DataTestUtility.TCPConnectionString);
+ string xml = "";
+ int expectedLength = xml.Length;
+ const string commandText = "SELECT @xmlParam";
+
+ using SqlCommand command = connection.CreateCommand();
+ connection.Open();
+ command.CommandText = commandText;
+ command.Parameters.Add(new SqlParameter("@xmlParam", SqlDbType.Xml) { Value = xml });
+
+ using SqlDataReader sqlDataReader = command.ExecuteReader(CommandBehavior.SequentialAccess);
+ Assert.True(sqlDataReader.Read(), "Expected to read a row");
+
+ (long length, string result) = ReadAllChars(sqlDataReader, expectedLength);
+
+ Assert.Equal(expectedLength, length);
+ Assert.Equal(xml, result.Substring(0, (int)length));
+ }
+
+ [ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup))]
+ public static void GetChars_XmlDeclaration_Normalized()
+ {
+ using SqlConnection connection = new(DataTestUtility.TCPConnectionString);
+ string xml = "";
+ string expected = "";
+ int expectedLength = expected.Length;
+ const string commandText = "SELECT @xmlParam";
+
+ using SqlCommand command = connection.CreateCommand();
+ connection.Open();
+ command.CommandText = commandText;
+ command.Parameters.Add(new SqlParameter("@xmlParam", SqlDbType.Xml) { Value = xml });
+
+ using SqlDataReader sqlDataReader = command.ExecuteReader(CommandBehavior.SequentialAccess);
+ Assert.True(sqlDataReader.Read(), "Expected to read a row");
+
+ (long length, string result) = ReadAllChars(sqlDataReader, expectedLength);
+
+ Assert.Equal(expectedLength, length);
+ Assert.Equal(expected, result.Substring(0, (int)length));
+ }
+
+ [ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup))]
+ public static void GetChars_CommentNode()
+ {
+ using SqlConnection connection = new(DataTestUtility.TCPConnectionString);
+ string xml = "";
+ int expectedLength = xml.Length;
+ string commandText = $"SELECT Convert(xml, N'{xml}')";
+
+ using SqlCommand command = connection.CreateCommand();
+ connection.Open();
+ command.CommandText = commandText;
+
+ using SqlDataReader sqlDataReader = command.ExecuteReader(CommandBehavior.SequentialAccess);
+ Assert.True(sqlDataReader.Read(), "Expected to read a row");
+
+ (long length, string result) = ReadAllChars(sqlDataReader, expectedLength);
+
+ Assert.Equal(expectedLength, length);
+ Assert.Equal(xml, result.Substring(0, (int)length));
+ }
+ [ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup))]
+ public static void GetChars_MultipleComments()
+ {
+ using SqlConnection connection = new(DataTestUtility.TCPConnectionString);
+ string xml = "";
+ int expectedLength = xml.Length;
+ string commandText = $"SELECT Convert(xml, N'{xml}')";
+
+ using SqlCommand command = connection.CreateCommand();
+ connection.Open();
+ command.CommandText = commandText;
+
+ using SqlDataReader sqlDataReader = command.ExecuteReader(CommandBehavior.SequentialAccess);
+ Assert.True(sqlDataReader.Read(), "Expected to read a row");
+
+ (long length, string result) = ReadAllChars(sqlDataReader, expectedLength);
+
+ Assert.Equal(expectedLength, length);
+ Assert.Equal(xml, result.Substring(0, (int)length));
+ }
+
+ [ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup))]
+ public static void GetChars_CommentWithSpecialChars()
+ {
+ using SqlConnection connection = new(DataTestUtility.TCPConnectionString);
+ string xml = "";
+ int expectedLength = xml.Length;
+ const string commandText = "SELECT @xmlParam";
+
+ using SqlCommand command = connection.CreateCommand();
+ connection.Open();
+ command.CommandText = commandText;
+ command.Parameters.Add(new SqlParameter("@xmlParam", SqlDbType.Xml) { Value = xml });
+
+ using SqlDataReader sqlDataReader = command.ExecuteReader(CommandBehavior.SequentialAccess);
+ Assert.True(sqlDataReader.Read(), "Expected to read a row");
+
+ (long length, string result) = ReadAllChars(sqlDataReader, expectedLength);
+
+ Assert.Equal(expectedLength, length);
+ Assert.Equal(xml, result.Substring(0, (int)length));
+ }
+
+ [ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup))]
+ public static void GetChars_EntityReferencesInsideComment()
+ {
+ using SqlConnection connection = new(DataTestUtility.TCPConnectionString);
+ string xml = "";
+ int expectedLength = xml.Length;
+ string commandText = $"SELECT Convert(xml, N'{xml}')";
+
+ using SqlCommand command = connection.CreateCommand();
+ connection.Open();
+ command.CommandText = commandText;
+
+ using SqlDataReader sqlDataReader = command.ExecuteReader(CommandBehavior.SequentialAccess);
+ Assert.True(sqlDataReader.Read(), "Expected to read a row");
+
+ (long length, string result) = ReadAllChars(sqlDataReader, expectedLength);
+
+ Assert.Equal(expectedLength, length);
+ Assert.Equal(xml, result.Substring(0, (int)length));
+ }
+
+ [ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup))]
+ public static void GetChars_SingleCharReadsVsBulk()
+ {
+ using SqlConnection connection = new(DataTestUtility.TCPConnectionString);
+ string xml = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
+ int expectedLength = xml.Length;
+ string commandText = $"SELECT Convert(xml, N'{xml}')";
+
+ using SqlCommand command = connection.CreateCommand();
+ connection.Open();
+ command.CommandText = commandText;
+
+ // ---- single char reads ----
+ using (SqlDataReader sqlDataReader = command.ExecuteReader(CommandBehavior.SequentialAccess))
+ {
+ Assert.True(sqlDataReader.Read(), "Expected to read a row");
+
+ long position = 0;
+ string singleReadResult = string.Empty;
+ char[] buffer = new char[1];
+ position = 0;
+
+ while (true)
+ {
+ long read = sqlDataReader.GetChars(0, position, buffer, 0, 1);
+ if (read == 0)
+ {
+ break;
+ }
+
+ singleReadResult += buffer[0];
+ position += read;
+ }
+
+ Assert.Equal(expectedLength, position);
+ Assert.Equal(xml, singleReadResult);
+ }
+
+ // Reuse the same command to verify that bulk read returns the same result, and that the two approaches can be used interchangeably.
+ // ---- bulk read ----
+ using (SqlDataReader sqlDataReader = command.ExecuteReader(CommandBehavior.SequentialAccess))
+ {
+ Assert.True(sqlDataReader.Read(), "Expected to read a row");
+
+ char[] buffer = new char[expectedLength];
+ long bulkRead = sqlDataReader.GetChars(0, 0, buffer, 0, buffer.Length);
+ string bulkResult = new(buffer, 0, (int)bulkRead);
+
+ Assert.Equal(expectedLength, bulkRead);
+ Assert.Equal(xml, bulkResult);
+ }
+ }
+
+ [ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup))]
+ public static void GetChars_TwoXmlColumns()
+ {
+ using SqlConnection connection = new(DataTestUtility.TCPConnectionString);
+ string xml1 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
+ int expectedLength1 = xml1.Length;
+ string xml2 = "0123456789";
+ int expectedLength2 = xml2.Length;
+ string commandText = $"SELECT Convert(xml, N'{xml1}'), Convert(xml, N'{xml2}')";
+
+ using SqlCommand command = connection.CreateCommand();
+ connection.Open();
+ command.CommandText = commandText;
+
+ using SqlDataReader sqlDataReader = command.ExecuteReader(CommandBehavior.SequentialAccess);
+ Assert.True(sqlDataReader.Read(), "Expected to read a row");
+
+ // Bulk read the first column
+ char[] buffer1 = new char[expectedLength1];
+ long column1Count = sqlDataReader.GetChars(0, 0, buffer1, 0, buffer1.Length);
+ string column1 = new(buffer1, 0, (int)column1Count);
+
+ Assert.Equal(expectedLength1, column1Count);
+ Assert.Equal(xml1, column1);
+
+ // Bulk read the second column
+ char[] buffer2 = new char[expectedLength2];
+ // Change the column index to 1 to read from the second column, and verify that we get the expected result for the second column.
+ long column2Count = sqlDataReader.GetChars(1, 0, buffer2, 0, buffer2.Length);
+ string column2 = new(buffer2, 0, (int)column2Count);
+
+ Assert.Equal(expectedLength2, column2Count);
+ Assert.Equal(xml2, column2);
+ }
+
+ private static TimeSpan TimedExecution(string commandTextBase, int scale)
+ {
+ using SqlConnection connection = new(DataTestUtility.TCPConnectionString);
+ Stopwatch stopwatch = new();
+ int expectedSize = scale * 1024 * 1024;
+
+
+ using SqlCommand command = connection.CreateCommand();
+ connection.Open();
+ command.CommandText = string.Format(CultureInfo.InvariantCulture, commandTextBase, scale);
+
+ using SqlDataReader sqlDataReader = command.ExecuteReader(CommandBehavior.SequentialAccess);
+ Assert.True(sqlDataReader.Read(), "Expected to read a row");
+
+ stopwatch.Start();
+ (long length, string _) = ReadAllChars(sqlDataReader, expectedSize);
+ stopwatch.Stop();
+ Assert.Equal(expectedSize, length);
+
+ return stopwatch.Elapsed;
+ }
+
+ ///
+ /// Replicate the reading approach used with issue #1877
+ ///
+ private static (long, string) ReadAllChars(SqlDataReader sqlDataReader, long expectedSize)
+ {
+ char[] text = new char[expectedSize];
+ char[] 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 && position < expectedSize);
+
+ return (position, new string(text));
+ }
+ }
+}