Skip to content

Commit 343ecd0

Browse files
committed
Handle column-pruning for cached data
1 parent 855e63c commit 343ecd0

4 files changed

Lines changed: 210 additions & 2 deletions

File tree

src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlBulkCopy.cs

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,10 @@ private int RowNumber
238238
// Metadata caching fields for CacheMetadata option
239239
private BulkCopySimpleResultSet _cachedMetadata;
240240
private string _cachedDestinationTableName;
241+
// Per-operation clone of the destination table metadata, used when CacheMetadata is
242+
// enabled so that column-pruning in AnalyzeTargetAndCreateUpdateBulkCommand does not
243+
// mutate the cached BulkCopySimpleResultSet.
244+
private _SqlMetaDataSet _operationMetaData;
241245

242246
#if DEBUG
243247
internal static bool s_setAlwaysTaskOnWrite; //when set and in DEBUG mode, TdsParser::WriteBulkCopyValue will always return a task
@@ -612,7 +616,17 @@ private string AnalyzeTargetAndCreateUpdateBulkCommand(BulkCopySimpleResultSet i
612616
bool appendComma = false;
613617

614618
// Loop over the metadata for each result column.
619+
// When using cached metadata, clone the metadata set so that null-pruning of
620+
// unmatched/rejected columns does not mutate the shared cache. Without this,
621+
// changing ColumnMappings between WriteToServer calls (e.g. mapping fewer columns
622+
// on the first call, then more on the second) would permanently lose metadata
623+
// entries from the cache.
615624
_SqlMetaDataSet metaDataSet = internalResults[MetaDataResultId].MetaData;
625+
if (IsCopyOption(SqlBulkCopyOptions.CacheMetadata) && _cachedMetadata != null)
626+
{
627+
metaDataSet = metaDataSet.Clone();
628+
}
629+
_operationMetaData = metaDataSet;
616630
_sortedColumnMappings = new List<_ColumnMapping>(metaDataSet.Length);
617631
for (int i = 0; i < metaDataSet.Length; i++)
618632
{
@@ -909,7 +923,7 @@ private void WriteMetaData(BulkCopySimpleResultSet internalResults)
909923
{
910924
_stateObj.SetTimeoutSeconds(BulkCopyTimeout);
911925

912-
_SqlMetaDataSet metadataCollection = internalResults[MetaDataResultId].MetaData;
926+
_SqlMetaDataSet metadataCollection = _operationMetaData ?? internalResults[MetaDataResultId].MetaData;
913927
_stateObj._outputMessageType = TdsEnums.MT_BULK;
914928
_parser.WriteBulkCopyMetaData(metadataCollection, _sortedColumnMappings.Count, _stateObj);
915929
}
@@ -944,6 +958,7 @@ private void Dispose(bool disposing)
944958
_parser = null;
945959
_cachedMetadata = null;
946960
_cachedDestinationTableName = null;
961+
_operationMetaData = null;
947962
try
948963
{
949964
// Just in case there is a lingering transaction (which there shouldn't be)
@@ -2711,7 +2726,7 @@ private Task CopyBatchesAsyncContinued(BulkCopySimpleResultSet internalResults,
27112726

27122727
// Load encryption keys now (if needed)
27132728
_parser.LoadColumnEncryptionKeys(
2714-
internalResults[MetaDataResultId].MetaData,
2729+
_operationMetaData ?? internalResults[MetaDataResultId].MetaData,
27152730
_connection);
27162731

27172732
Task task = CopyRowsAsync(0, _savedBatchSize, cts); // This is copying 1 batch of rows and setting _hasMoreRowToCopy = true/false.
@@ -3238,6 +3253,7 @@ private void ResetWriteToServerGlobalVariables()
32383253
_dataTableSource = null;
32393254
_dbDataReaderRowSource = null;
32403255
_isAsyncBulkCopy = false;
3256+
_operationMetaData = null;
32413257
_rowEnumerator = null;
32423258
_rowSource = null;
32433259
_rowSourceType = ValueSourceType.Unspecified;

src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/SqlBulkCopyTest/CacheMetadata.cs

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,66 @@ public static void Test(string dstConstr, string dstTable)
339339
}
340340
}
341341

342+
public class CacheMetadataColumnSubsetChange
343+
{
344+
private static readonly string initialQueryTemplate = "create table {0} (col1 int, col2 nvarchar(50), col3 nvarchar(50))";
345+
346+
// Test that mapping a subset of columns on the first call, then all columns on the
347+
// second call, works correctly with CacheMetadata. This verifies that null-pruning of
348+
// unmatched columns in AnalyzeTargetAndCreateUpdateBulkCommand does not mutate the
349+
// cached metadata, which would cause a NullReferenceException on the second call.
350+
public static void Test(string dstConstr, string dstTable)
351+
{
352+
string initialQuery = string.Format(initialQueryTemplate, dstTable);
353+
354+
using DataTable sourceData = new DataTable();
355+
sourceData.Columns.Add("id", typeof(int));
356+
sourceData.Columns.Add("firstName", typeof(string));
357+
sourceData.Columns.Add("lastName", typeof(string));
358+
sourceData.Rows.Add(1, "Alice", "Smith");
359+
sourceData.Rows.Add(2, "Bob", "Jones");
360+
361+
using SqlConnection dstConn = new(dstConstr);
362+
using SqlCommand dstCmd = dstConn.CreateCommand();
363+
dstConn.Open();
364+
365+
try
366+
{
367+
Helpers.TryExecute(dstCmd, initialQuery);
368+
369+
using SqlBulkCopy bulkcopy = new(dstConn, SqlBulkCopyOptions.CacheMetadata, null);
370+
bulkcopy.DestinationTableName = dstTable;
371+
372+
// First write: map only col1 and col2 (col3 is unmatched and will be pruned).
373+
bulkcopy.ColumnMappings.Add("id", "col1");
374+
bulkcopy.ColumnMappings.Add("firstName", "col2");
375+
bulkcopy.WriteToServer(sourceData);
376+
Helpers.VerifyResults(dstConn, dstTable, 3, 2);
377+
378+
// Second write: map all three columns including col3.
379+
// Without the clone fix, this would fail because col3 metadata was
380+
// permanently nulled in the cache during the first call.
381+
bulkcopy.ColumnMappings.Clear();
382+
bulkcopy.ColumnMappings.Add("id", "col1");
383+
bulkcopy.ColumnMappings.Add("firstName", "col2");
384+
bulkcopy.ColumnMappings.Add("lastName", "col3");
385+
bulkcopy.WriteToServer(sourceData);
386+
Helpers.VerifyResults(dstConn, dstTable, 3, 4);
387+
388+
// Verify col3 has the expected data from the second write.
389+
using (SqlCommand verifyCmd = new("select col3 from " + dstTable + " where col1 = 1 and col3 is not null", dstConn))
390+
{
391+
object result = verifyCmd.ExecuteScalar();
392+
Assert.Equal("Smith", result);
393+
}
394+
}
395+
finally
396+
{
397+
Helpers.TryExecute(dstCmd, "drop table " + dstTable);
398+
}
399+
}
400+
}
401+
342402
public class CacheMetadataCombinedWithKeepNulls
343403
{
344404
private static readonly string initialQueryTemplate = "create table {0} (col1 int, col2 nvarchar(50) default 'DefaultVal', col3 nvarchar(50))";

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,12 @@ public void CacheMetadataColumnMappingsChangeTest()
345345
CacheMetadataColumnMappingsChange.Test(_connStr, AddGuid("SqlBulkCopyTest_CacheMetadataColMap"));
346346
}
347347

348+
[ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup), nameof(DataTestUtility.IsNotAzureServer))]
349+
public void CacheMetadataColumnSubsetChangeTest()
350+
{
351+
CacheMetadataColumnSubsetChange.Test(_connStr, AddGuid("SqlBulkCopyTest_CacheMetadataSubset"));
352+
}
353+
348354
[ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup), nameof(DataTestUtility.IsNotAzureServer))]
349355
public void CacheMetadataCombinedWithKeepNullsTest()
350356
{
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for more information.
4+
5+
using Xunit;
6+
7+
namespace Microsoft.Data.SqlClient.UnitTests
8+
{
9+
/// <summary>
10+
/// Tests that verify _SqlMetaDataSet.Clone() produces independent copies,
11+
/// ensuring that null-pruning of unmatched columns in AnalyzeTargetAndCreateUpdateBulkCommand
12+
/// does not corrupt the cached metadata when CacheMetadata is enabled.
13+
/// </summary>
14+
public class SqlBulkCopyCacheMetadataTest
15+
{
16+
[Fact]
17+
public void SqlMetaDataSet_Clone_ProducesIndependentCopy()
18+
{
19+
// Arrange: create a metadata set with 3 columns simulating a destination table
20+
_SqlMetaDataSet original = new _SqlMetaDataSet(3);
21+
original[0].column = "col1";
22+
original[1].column = "col2";
23+
original[2].column = "col3";
24+
25+
// Act: clone and then null out an entry in the clone (simulating column pruning)
26+
_SqlMetaDataSet clone = original.Clone();
27+
clone[2] = null;
28+
29+
// Assert: the original is not affected by the mutation of the clone
30+
Assert.NotNull(original[0]);
31+
Assert.NotNull(original[1]);
32+
Assert.NotNull(original[2]);
33+
Assert.Equal("col1", original[0].column);
34+
Assert.Equal("col2", original[1].column);
35+
Assert.Equal("col3", original[2].column);
36+
}
37+
38+
[Fact]
39+
public void SqlMetaDataSet_Clone_NullingMultipleEntries_OriginalRetainsAll()
40+
{
41+
// Arrange: simulate a table with 4 columns
42+
_SqlMetaDataSet original = new _SqlMetaDataSet(4);
43+
original[0].column = "id";
44+
original[1].column = "name";
45+
original[2].column = "email";
46+
original[3].column = "phone";
47+
48+
// Act: clone and null out entries 1 and 3 (simulating mapping only id and email)
49+
_SqlMetaDataSet clone = original.Clone();
50+
clone[1] = null;
51+
clone[3] = null;
52+
53+
// Assert: clone has nulls where expected
54+
Assert.NotNull(clone[0]);
55+
Assert.Null(clone[1]);
56+
Assert.NotNull(clone[2]);
57+
Assert.Null(clone[3]);
58+
59+
// Assert: original retains all entries
60+
for (int i = 0; i < 4; i++)
61+
{
62+
Assert.NotNull(original[i]);
63+
}
64+
Assert.Equal("name", original[1].column);
65+
Assert.Equal("phone", original[3].column);
66+
}
67+
68+
[Fact]
69+
public void SqlMetaDataSet_Clone_RepeatedCloneAndPrune_OriginalSurvives()
70+
{
71+
// Arrange: simulate the scenario where multiple WriteToServer calls each
72+
// clone and prune different subsets of columns
73+
_SqlMetaDataSet original = new _SqlMetaDataSet(3);
74+
original[0].column = "col1";
75+
original[1].column = "col2";
76+
original[2].column = "col3";
77+
78+
// First operation: map only col1 and col2 (prune col3)
79+
_SqlMetaDataSet clone1 = original.Clone();
80+
clone1[2] = null;
81+
82+
// Second operation: map only col1 and col3 (prune col2)
83+
_SqlMetaDataSet clone2 = original.Clone();
84+
clone2[1] = null;
85+
86+
// Third operation: map all columns (no pruning needed)
87+
_SqlMetaDataSet clone3 = original.Clone();
88+
89+
// Assert: original is fully intact after all operations
90+
Assert.NotNull(original[0]);
91+
Assert.NotNull(original[1]);
92+
Assert.NotNull(original[2]);
93+
Assert.Equal("col1", original[0].column);
94+
Assert.Equal("col2", original[1].column);
95+
Assert.Equal("col3", original[2].column);
96+
97+
// Assert: each clone reflects its own pruning
98+
Assert.Null(clone1[2]);
99+
Assert.NotNull(clone1[1]);
100+
101+
Assert.Null(clone2[1]);
102+
Assert.NotNull(clone2[2]);
103+
104+
Assert.NotNull(clone3[0]);
105+
Assert.NotNull(clone3[1]);
106+
Assert.NotNull(clone3[2]);
107+
}
108+
109+
[Fact]
110+
public void SqlMetaDataSet_Clone_PreservesOrdinals()
111+
{
112+
// Verify that cloned entries maintain correct ordinal values,
113+
// which are used for column matching in AnalyzeTargetAndCreateUpdateBulkCommand
114+
_SqlMetaDataSet original = new _SqlMetaDataSet(3);
115+
original[0].column = "col1";
116+
original[1].column = "col2";
117+
original[2].column = "col3";
118+
119+
_SqlMetaDataSet clone = original.Clone();
120+
121+
Assert.Equal(original[0].ordinal, clone[0].ordinal);
122+
Assert.Equal(original[1].ordinal, clone[1].ordinal);
123+
Assert.Equal(original[2].ordinal, clone[2].ordinal);
124+
}
125+
}
126+
}

0 commit comments

Comments
 (0)