Skip to content

Commit e2dec42

Browse files
committed
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
1 parent 3cc826e commit e2dec42

2 files changed

Lines changed: 262 additions & 16 deletions

File tree

src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlStream.cs

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -477,7 +477,12 @@ private long TotalLength
477477

478478
sealed internal class SqlStreamingXml
479479
{
480-
private static readonly XmlWriterSettings s_writerSettings = new() { CloseOutput = true, ConformanceLevel = ConformanceLevel.Fragment, Encoding = new UTF8Encoding(false) };
480+
private static readonly XmlWriterSettings s_writerSettings = new() {
481+
CloseOutput = true,
482+
ConformanceLevel = ConformanceLevel.Fragment,
483+
// Potentially limits XML to not supporting UTF-16 characters, but this is required to avoid writing
484+
// a byte order mark and is consistent with prior default used within StringWriter/StringBuilder.
485+
Encoding = new UTF8Encoding(false) };
481486

482487
private readonly int _columnOrdinal;
483488
private SqlDataReader _reader;
@@ -525,7 +530,8 @@ public long GetChars(long dataIndex, char[] buffer, int bufferIndex, int length)
525530
}
526531
else if (dataIndex > _charsRemoved)
527532
{
528-
charsToSkip = (int)(dataIndex - _charsRemoved);
533+
//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
534+
charsToSkip = dataIndex - _charsRemoved;
529535
}
530536

531537
// 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)
550556
//_xmlWriter.WriteNode(_xmlReader, true);
551557
// _xmlWriter.Flush();
552558
WriteXmlElement();
553-
// Update memoryStreamRemaining based on the number of chars just written to the MemoryStream
559+
// Update memoryStreamRemaining based on the number of bytes/chars just written to the MemoryStream
554560
memoryStreamRemaining = _memoryStream.Length - _memoryStream.Position;
555561
if (charsToSkip > 0)
556562
{
@@ -583,6 +589,7 @@ public long GetChars(long dataIndex, char[] buffer, int bufferIndex, int length)
583589
cnt = memoryStreamRemaining < length ? memoryStreamRemaining : length;
584590
for (int i = 0; i < cnt; i++)
585591
{
592+
// ReadByte moves the Position forward
586593
buffer[bufferIndex + i] = (char)_memoryStream.ReadByte();
587594
}
588595
_charsRemoved += cnt;
@@ -598,10 +605,15 @@ private void WriteXmlElement()
598605
const int WriteNodeBufferSize = 1024;
599606

600607
long memoryStreamPosition = _memoryStream.Position;
608+
// Move the Position to the end of the MemoryStream since we are always appending.
609+
_memoryStream.Seek(0, SeekOrigin.End);
601610

602611
_xmlReader.Read();
603612
switch (_xmlReader.NodeType)
604613
{
614+
// Note: Whitespace, CDATA, EntityReference, XmlDeclaration, ProcessingInstruction, DocumentType, and Comment node types
615+
// are not expected in the XML returned from SQL Server as it normalizes them out, but handle them just in case.
616+
// SignificantWhitespace will occur when used with xml:space="preserve"
605617
case XmlNodeType.Element:
606618
_xmlWriter.WriteStartElement(_xmlReader.Prefix, _xmlReader.LocalName, _xmlReader.NamespaceURI);
607619
_xmlWriter.WriteAttributes(_xmlReader, true);
@@ -651,6 +663,7 @@ private void WriteXmlElement()
651663
break;
652664
}
653665
_xmlWriter.Flush();
666+
// Reset the Position back to where it was before writing this element so that the caller can continue reading from the expected position.
654667
_memoryStream.Position = memoryStreamPosition;
655668
}
656669
}

src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/SqlStreamingXmlTest/SqlStreamingXmlTest.cs

Lines changed: 246 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,17 @@
66
using System.Data;
77
using System.Diagnostics;
88
using System.Globalization;
9+
using System.Xml.Linq;
910
using Xunit;
1011

1112
namespace Microsoft.Data.SqlClient.ManualTesting.Tests
1213
{
1314
public static class SqlStreamingXmlTest
1415
{
1516
[ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup))]
16-
public static void LinearSingleNode()
17+
public static void Linear_SingleNode()
1718
{
18-
SqlConnection connection = new(DataTestUtility.TCPConnectionString);
19-
// 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.
19+
// Use literal XML column constructed by replicating a string of 'B' characters to reach the desired size, and wrapping it in XML tags.
2020
const string commandTextBase = "SELECT Convert(xml, N'<foo>' + REPLICATE(CAST('' AS nvarchar(max)) +N'B', ({0} * 1024 * 1024) - 11) + N'</foo>')";
2121

2222
TimeSpan time1 = TimedExecution(commandTextBase, 1);
@@ -27,10 +27,9 @@ public static void LinearSingleNode()
2727
}
2828

2929
[ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup))]
30-
public static void LinearMultipleNodes()
30+
public static void Linear_MultipleNodes()
3131
{
32-
SqlConnection connection = new(DataTestUtility.TCPConnectionString);
33-
// 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.
32+
// Use literal XML column constructed by replicating a string of 'B' characters to reach 1MB, then replicating to the desired number of elements.
3433
const string commandTextBase = "SELECT Convert(xml, REPLICATE(N'<foo>' + REPLICATE(CAST('' AS nvarchar(max)) + N'B', (1024 * 1024) - 11) + N'</foo>', {0}))";
3534

3635
TimeSpan time1 = TimedExecution(commandTextBase, 1);
@@ -40,10 +39,244 @@ public static void LinearMultipleNodes()
4039
Assert.True(time5.TotalMilliseconds <= (time1.TotalMilliseconds * 6), $"Execution time did not follow linear scale: 1x={time1.TotalMilliseconds}ms vs. 5x={time5.TotalMilliseconds}ms");
4140
}
4241

42+
[ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup))]
43+
public static void GetChars_RequiresBuffer()
44+
{
45+
SqlConnection connection = new(DataTestUtility.TCPConnectionString);
46+
const string commandText = "SELECT Convert(xml, N'<foo>bar</foo>')";
47+
long charCount = 0;
48+
49+
using (SqlCommand command = connection.CreateCommand())
50+
{
51+
connection.Open();
52+
command.CommandText = commandText;
53+
54+
SqlDataReader sqlDataReader = command.ExecuteReader(CommandBehavior.SequentialAccess);
55+
if (sqlDataReader.Read())
56+
{
57+
charCount = sqlDataReader.GetChars(0, 0, null, 0, 1);
58+
}
59+
connection.Close();
60+
}
61+
62+
//verify -1 is returned since buffer was not provided
63+
Assert.Equal(-1, charCount);
64+
}
65+
66+
[ConditionalTheory(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup))]
67+
[InlineData(true)]
68+
[InlineData(false)]
69+
public static void GetChars_SequentialDataIndex(bool backwards)
70+
{
71+
SqlConnection connection = new(DataTestUtility.TCPConnectionString);
72+
const string commandText = "SELECT Convert(xml, N'<foo>bar</foo>')";
73+
char[] buffer = new char[2];
74+
75+
using (SqlCommand command = connection.CreateCommand())
76+
{
77+
connection.Open();
78+
command.CommandText = commandText;
79+
80+
SqlDataReader sqlDataReader = command.ExecuteReader(CommandBehavior.SequentialAccess);
81+
if (sqlDataReader.Read())
82+
{
83+
sqlDataReader.GetChars(0, 0, buffer, 0, 2);
84+
// Verify that providing the same or lower index than the previous call results in an exception.
85+
// When backwards is true we test providing an index that is one less than the previous call,
86+
// otherwise we test providing the same index as the previous call - both should not be allowed.
87+
Assert.Throws<InvalidOperationException>(() => sqlDataReader.GetChars(0, backwards ? 0 : 1, buffer, 0, 2));
88+
}
89+
connection.Close();
90+
}
91+
}
92+
93+
[ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup))]
94+
public static void GetChars_PartialSingleElement()
95+
{
96+
SqlConnection connection = new(DataTestUtility.TCPConnectionString);
97+
const string commandText = "SELECT Convert(xml, N'<foo>_bar_baz</foo>')";
98+
long charCount = 0;
99+
char[] buffer = new char[3];
100+
101+
using (SqlCommand command = connection.CreateCommand())
102+
{
103+
connection.Open();
104+
command.CommandText = commandText;
105+
106+
SqlDataReader sqlDataReader = command.ExecuteReader(CommandBehavior.SequentialAccess);
107+
if (sqlDataReader.Read())
108+
{
109+
// Read just the 'bar' characters from the XML by specifying the offset, and the length of 3.
110+
// The offset is 6 to skip the entire first element '<foo>' and the initial '_' part of text.
111+
charCount = sqlDataReader.GetChars(0, 6, buffer, 0, 3);
112+
}
113+
connection.Close();
114+
}
115+
116+
Assert.Equal(3, charCount);
117+
Assert.Equal("bar", new string(buffer));
118+
}
119+
120+
[ConditionalTheory(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup))]
121+
[InlineData(true)]
122+
[InlineData(false)]
123+
public static void GetChars_PartialAcrossElements(bool initialRead)
124+
{
125+
SqlConnection connection = new(DataTestUtility.TCPConnectionString);
126+
const string commandText = "SELECT Convert(xml, N'<foobar>baz</foobar>')";
127+
long charCount = 0;
128+
char[] buffer = new char[8];
129+
130+
using (SqlCommand command = connection.CreateCommand())
131+
{
132+
connection.Open();
133+
command.CommandText = commandText;
134+
135+
SqlDataReader sqlDataReader = command.ExecuteReader(CommandBehavior.SequentialAccess);
136+
if (sqlDataReader.Read())
137+
{
138+
if (initialRead)
139+
{
140+
// When initialRead is true, we verify continuation after a previous read,
141+
// otherwise we just verify that we can read across XML elements in a single call.
142+
char[] initialBuffer = new char[2];
143+
sqlDataReader.GetChars(0, 0, initialBuffer, 0, 2);
144+
Assert.Equal("<f", new string(initialBuffer));
145+
// Verify skipping within the existing initial element.
146+
sqlDataReader.GetChars(0, 3, initialBuffer, 0, 2);
147+
Assert.Equal("ob", new string(initialBuffer));
148+
}
149+
// Read the 'r>baz</f' characters across XML elements by specifying the offset, and the length of 8.
150+
// The offset is 6 to skip the '<fooba' characters.
151+
charCount = sqlDataReader.GetChars(0, 6, buffer, 0, 8);
152+
}
153+
connection.Close();
154+
}
155+
156+
Assert.Equal(8, charCount);
157+
Assert.Equal("r>baz</f", new string(buffer));
158+
}
159+
160+
[ConditionalTheory(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup))]
161+
[InlineData(true)]
162+
[InlineData(false)]
163+
public static void GetChars_ExcessiveLength(bool initialRead)
164+
{
165+
SqlConnection connection = new(DataTestUtility.TCPConnectionString);
166+
string xml = """<foo>_bar_baz</foo>""";
167+
int expectedSize = xml.Length;
168+
string commandText = $"SELECT Convert(xml, N'{xml}')";
169+
170+
using (SqlCommand command = connection.CreateCommand())
171+
{
172+
connection.Open();
173+
command.CommandText = commandText;
174+
175+
SqlDataReader sqlDataReader = command.ExecuteReader(CommandBehavior.SequentialAccess);
176+
if (sqlDataReader.Read())
177+
{
178+
if (initialRead)
179+
{
180+
// When initialRead is true, we verify continuation after a previous read,
181+
// otherwise we just verify that we can read everything in a single call.
182+
char[] initialBuffer = new char[2];
183+
long initialLength = sqlDataReader.GetChars(0, 0, initialBuffer, 0, 2);
184+
char[] remainingBuffer = new char[98];
185+
long remainingLength = sqlDataReader.GetChars(0, 2, remainingBuffer, 0, 98);
186+
string combined = new string(initialBuffer) + new string(remainingBuffer);
187+
188+
Assert.Equal(expectedSize, initialLength + remainingLength);
189+
Assert.Equal(xml, combined.Substring(0, expectedSize));
190+
}
191+
else
192+
{
193+
// Try to read more characters than the actual XML to verify that the method returns only the actual number of characters.
194+
(long length, string text) = ReadAllChars(sqlDataReader, 100);
195+
196+
Assert.Equal(expectedSize, length);
197+
Assert.Equal(xml, text.Substring(0, expectedSize));
198+
}
199+
}
200+
connection.Close();
201+
}
202+
}
203+
204+
[ConditionalTheory(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup))]
205+
[InlineData(true)]
206+
[InlineData(false)]
207+
public static void GetChars_ExcessiveDataIndex(bool initialRead)
208+
{
209+
SqlConnection connection = new(DataTestUtility.TCPConnectionString);
210+
string xml = """<foo>_bar_baz</foo>""";
211+
string commandText = $"SELECT Convert(xml, N'{xml}')";
212+
213+
using (SqlCommand command = connection.CreateCommand())
214+
{
215+
connection.Open();
216+
command.CommandText = commandText;
217+
218+
SqlDataReader sqlDataReader = command.ExecuteReader(CommandBehavior.SequentialAccess);
219+
if (sqlDataReader.Read())
220+
{
221+
if (initialRead)
222+
{
223+
// When initialRead is true, we verify continuation after a previous read,
224+
// otherwise we just verify the large DataIndex in a single call.
225+
char[] initialBuffer = new char[2];
226+
long initialLength = sqlDataReader.GetChars(0, 0, initialBuffer, 0, 2);
227+
Assert.Equal(2, initialLength);
228+
}
229+
230+
// buffer will not be touched since the DataIndex is beyond the end of the XML, but a suitable buffer must still be provided.
231+
char[] buffer = new char[100];
232+
long length = sqlDataReader.GetChars(0, 100, buffer, 0, 2);
233+
Assert.Equal(0, length);
234+
}
235+
connection.Close();
236+
}
237+
}
238+
239+
[ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup))]
240+
public static void GetChars_AsXDocument()
241+
{
242+
SqlConnection connection = new(DataTestUtility.TCPConnectionString);
243+
// Use a more complex XML column verify through XDocument.
244+
string xml = """<Person Id="1" Role="Admin"><Name>John</Name><Children /><PreservedWhitespace xml:space="preserve"> </PreservedWhitespace></Person>""";
245+
XDocument expect = XDocument.Parse(xml);
246+
int expectedSize = xml.Length;
247+
string commandText = $"SELECT Convert(xml, N'{xml}')";
248+
249+
using (SqlCommand command = connection.CreateCommand())
250+
{
251+
connection.Open();
252+
command.CommandText = commandText;
253+
254+
SqlDataReader sqlDataReader = command.ExecuteReader(CommandBehavior.SequentialAccess);
255+
if (sqlDataReader.Read())
256+
{
257+
(long length, string xmlString) = ReadAllChars(sqlDataReader, expectedSize);
258+
259+
Assert.Equal(expectedSize, length);
260+
XDocument actual = XDocument.Parse(xmlString);
261+
Assert.Equal((int)expect.Root.Attribute("Id"), (int)actual.Root.Attribute("Id"));
262+
Assert.Equal((string)expect.Root.Attribute("Role"), (string)actual.Root.Attribute("Role"));
263+
Assert.NotNull(expect.Root.Element("Name")?.Value);
264+
Assert.Equal(expect.Root.Element("Name")!.Value, actual.Root.Element("Name")!.Value);
265+
Assert.NotNull(expect.Root.Element("Children")?.HasElements);
266+
Assert.Equal(expect.Root.Element("Children")!.HasElements, actual.Root.Element("Children")?.HasElements);
267+
Assert.NotNull(expect.Root.Element("PreservedWhitespace")?.Value);
268+
Assert.Equal(expect.Root.Element("PreservedWhitespace")!.Value, actual.Root.Element("PreservedWhitespace")!.Value);
269+
}
270+
connection.Close();
271+
}
272+
}
273+
43274
private static TimeSpan TimedExecution(string commandTextBase, int scale)
44275
{
45276
SqlConnection connection = new(DataTestUtility.TCPConnectionString);
46-
var stopwatch = new Stopwatch();
277+
Stopwatch stopwatch = new Stopwatch();
278+
int expectedSize = scale * 1024 * 1024;
279+
47280

48281
using (SqlCommand command = connection.CreateCommand())
49282
{
@@ -54,8 +287,9 @@ private static TimeSpan TimedExecution(string commandTextBase, int scale)
54287
if (sqlDataReader.Read())
55288
{
56289
stopwatch.Start();
57-
ReadAllChars(sqlDataReader, scale);
290+
(long length, string _) = ReadAllChars(sqlDataReader, expectedSize);
58291
stopwatch.Stop();
292+
Assert.Equal(expectedSize, length);
59293
}
60294
connection.Close();
61295
}
@@ -66,11 +300,10 @@ private static TimeSpan TimedExecution(string commandTextBase, int scale)
66300
/// <summary>
67301
/// Replicate the reading approach used with issue #1877
68302
/// </summary>
69-
private static void ReadAllChars(SqlDataReader sqlDataReader, int expectedMB)
303+
private static (long, string) ReadAllChars(SqlDataReader sqlDataReader, int expectedSize)
70304
{
71-
var expectedSize = expectedMB * 1024 * 1024;
72-
var text = new char[expectedSize];
73-
var buffer = new char[1];
305+
char[] text = new char[expectedSize];
306+
char[] buffer = new char[1];
74307

75308
long position = 0;
76309
long numCharsRead;
@@ -85,7 +318,7 @@ private static void ReadAllChars(SqlDataReader sqlDataReader, int expectedMB)
85318
}
86319
while (numCharsRead > 0);
87320

88-
Assert.Equal(expectedSize, position);
321+
return (position, new string(text));
89322
}
90323
}
91324
}

0 commit comments

Comments
 (0)