From e419bf9b3af6fe345e3786564a33f5d7998fce57 Mon Sep 17 00:00:00 2001 From: flink Date: Wed, 15 Jul 2026 04:19:46 -0400 Subject: [PATCH 1/3] Avoid double-enumerating IEnumerable in AsTableValuedParameter SqlDataRecordListTVPParameter.Set used data.Any() to swap an empty sequence for null, which forced an extra enumeration of the source before the caller ever executed the command. For single-pass sources (open readers, streaming iterators, etc.) the real enumeration during command execution then either restarted from scratch or failed outright. Only short-circuit to null when the source is a known IReadOnlyCollection and we can check Count for free; anything else now flows straight through untouched, so it's enumerated exactly once by the provider. Fixes #2064 --- Dapper/SqlDataRecordListTVPParameter.cs | 11 +++- tests/Dapper.Tests/ParameterTests.cs | 67 +++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/Dapper/SqlDataRecordListTVPParameter.cs b/Dapper/SqlDataRecordListTVPParameter.cs index 28a6ab27c..fdb522de3 100644 --- a/Dapper/SqlDataRecordListTVPParameter.cs +++ b/Dapper/SqlDataRecordListTVPParameter.cs @@ -2,7 +2,6 @@ using System.Collections; using System.Collections.Generic; using System.Data; -using System.Linq; using System.Reflection; using System.Reflection.Emit; @@ -37,7 +36,15 @@ void SqlMapper.ICustomQueryParameter.AddParameter(IDbCommand command, string nam internal static void Set(IDbDataParameter parameter, IEnumerable? data, string? typeName) { - parameter.Value = data is not null && data.Any() ? data : null; + // don't enumerate "data" here - it may be a single-pass source (an open reader, a + // streaming iterator, etc); only short-circuit to null when we can tell for free + // that it is empty, since providers reject an empty TVP enumerable + parameter.Value = data switch + { + null => null, + IReadOnlyCollection { Count: 0 } => null, + _ => data, + }; StructuredHelper.ConfigureTVP(parameter, typeName); } } diff --git a/tests/Dapper.Tests/ParameterTests.cs b/tests/Dapper.Tests/ParameterTests.cs index 5eb455c65..0452b2124 100644 --- a/tests/Dapper.Tests/ParameterTests.cs +++ b/tests/Dapper.Tests/ParameterTests.cs @@ -131,6 +131,25 @@ private static IEnumerable CreateSqlDataRecordList(IDbConnection co return number_list; } + // wraps a sequence to prove it is only ever enumerated once, guarding against + // https://github.com/DapperLib/Dapper/issues/2064 + private class SingleEnumerationEnumerable : IEnumerable + { + private readonly IEnumerable data; + + public SingleEnumerationEnumerable(IEnumerable data) => this.data = data; + + public bool WasEnumerated { get; private set; } + + public IEnumerator GetEnumerator() + { + Assert.False(WasEnumerated, "Source was enumerated more than once."); + WasEnumerated = true; + return data.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } private class IntDynamicParam : SqlMapper.IDynamicParameters { @@ -486,6 +505,54 @@ public void TestSqlDataRecordListParametersWithAsTableValuedParameter() } } + [Fact] + public void TestSqlDataRecordListParametersWithAsTableValuedParameterSinglePassSource() + { + try + { + connection.Execute("CREATE TYPE int_list_type AS TABLE (n int NOT NULL PRIMARY KEY)"); + connection.Execute("CREATE PROC get_ints @integers int_list_type READONLY AS select * from @integers"); + + var records = new SingleEnumerationEnumerable(CreateSqlDataRecordList(connection, new int[] { 1, 2, 3 })); + + var nums = connection.Query("get_ints", new { integers = records.AsTableValuedParameter() }, commandType: CommandType.StoredProcedure).ToList(); + Assert.Equal(new int[] { 1, 2, 3 }, nums); + } + finally + { + try + { + connection.Execute("DROP PROC get_ints"); + } + finally + { + connection.Execute("DROP TYPE int_list_type"); + } + } + } + + [Fact] + public void AsTableValuedParameterDoesNotEnumerateNonCollectionSource() + { + var records = new SingleEnumerationEnumerable(Enumerable.Empty()); + var parameter = new Microsoft.Data.SqlClient.SqlParameter(); + + SqlDataRecordListTVPParameter.Set(parameter, records, "int_list_type"); + + Assert.False(records.WasEnumerated); + Assert.Same(records, parameter.Value); + } + + [Fact] + public void AsTableValuedParameterNullsOutEmptyCollectionSource() + { + var parameter = new Microsoft.Data.SqlClient.SqlParameter(); + + SqlDataRecordListTVPParameter.Set(parameter, Array.Empty(), "int_list_type"); + + Assert.Null(parameter.Value); + } + [Fact] public void TestEmptySqlDataRecordListParametersWithAsTableValuedParameter() { From 6dbb9de1ac47d95dee3bb6aa2c68097e9f65b12a Mon Sep 17 00:00:00 2001 From: flink Date: Wed, 15 Jul 2026 04:31:49 -0400 Subject: [PATCH 2/3] Use provider-agnostic parameter in new TVP offline tests The two new offline tests hardcoded Microsoft.Data.SqlClient.SqlParameter even when running under the SystemSqlClientParameterTests variant, unlike the rest of this file which uses Provider.CreateRawParameter to stay provider-agnostic across the two test subclasses. --- tests/Dapper.Tests/ParameterTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Dapper.Tests/ParameterTests.cs b/tests/Dapper.Tests/ParameterTests.cs index 0452b2124..4ff9ab069 100644 --- a/tests/Dapper.Tests/ParameterTests.cs +++ b/tests/Dapper.Tests/ParameterTests.cs @@ -535,7 +535,7 @@ public void TestSqlDataRecordListParametersWithAsTableValuedParameterSinglePassS public void AsTableValuedParameterDoesNotEnumerateNonCollectionSource() { var records = new SingleEnumerationEnumerable(Enumerable.Empty()); - var parameter = new Microsoft.Data.SqlClient.SqlParameter(); + var parameter = Provider.CreateRawParameter("integers", DBNull.Value); SqlDataRecordListTVPParameter.Set(parameter, records, "int_list_type"); @@ -546,7 +546,7 @@ public void AsTableValuedParameterDoesNotEnumerateNonCollectionSource() [Fact] public void AsTableValuedParameterNullsOutEmptyCollectionSource() { - var parameter = new Microsoft.Data.SqlClient.SqlParameter(); + var parameter = Provider.CreateRawParameter("integers", DBNull.Value); SqlDataRecordListTVPParameter.Set(parameter, Array.Empty(), "int_list_type"); From fd9eb35a114836185e6716cc49b9418aacb8aa18 Mon Sep 17 00:00:00 2001 From: flink Date: Sat, 18 Jul 2026 07:30:02 -0400 Subject: [PATCH 3/3] Fix TVP single-pass test to preserve concrete SqlDataRecord typing TestSqlDataRecordListParametersWithAsTableValuedParameterSinglePassSource was failing against real SQL Server with: InvalidCastException: Failed to convert parameter value from a SingleEnumerationEnumerable`1 to a IEnumerable`1. Object must implement IConvertible. SqlParameter.CoerceValue recognizes a Structured/TVP value only when it is a DataTable, a DbDataReader, or satisfies "value is IEnumerable" - a hard runtime interface check, not an assignment-compatibility check. Generic interface implementations are invariant on their concrete type parameter even though IEnumerable itself is declared covariant, so a wrapper class instantiated as SingleEnumerationEnumerable never satisfies that check, no matter what concrete objects it yields. Everything else falls through to CoerceValue's scalar Convert.ChangeType path, which is exactly the IConvertible error above. The test was wrapping the shared, provider-agnostic IEnumerable helper, so AsTableValuedParameter always inferred T as IDataRecord and the wrapper only ever implemented IEnumerable. This has nothing to do with the double-enumeration fix itself; SqlDataRecordListTVPParameter.Set still only enumerates its source once either way. Wrap the provider-specific SqlDataRecord list directly instead, matching the branching already used in TestSqlDataRecordListParametersWithTypeHandlers, so the wrapper's concrete type parameter is the real SqlDataRecord type SqlClient is checking for. --- tests/Dapper.Tests/ParameterTests.cs | 30 ++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/tests/Dapper.Tests/ParameterTests.cs b/tests/Dapper.Tests/ParameterTests.cs index 4ff9ab069..9fee6a434 100644 --- a/tests/Dapper.Tests/ParameterTests.cs +++ b/tests/Dapper.Tests/ParameterTests.cs @@ -513,9 +513,35 @@ public void TestSqlDataRecordListParametersWithAsTableValuedParameterSinglePassS connection.Execute("CREATE TYPE int_list_type AS TABLE (n int NOT NULL PRIMARY KEY)"); connection.Execute("CREATE PROC get_ints @integers int_list_type READONLY AS select * from @integers"); - var records = new SingleEnumerationEnumerable(CreateSqlDataRecordList(connection, new int[] { 1, 2, 3 })); + // SingleEnumerationEnumerable has to be instantiated against the provider's + // concrete SqlDataRecord type here, not the shared IDataRecord interface. SqlClient + // recognizes a structured/TVP parameter value via a hard "value is + // IEnumerable" check (see SqlParameter.CoerceValue); that check + // inspects the object's actual runtime interface implementations, and generic + // interface implementations aren't covariant the way assignments are, so a wrapper + // built as IEnumerable never satisfies it, even though every element it + // yields really is a SqlDataRecord. Wrapping the provider-specific list directly + // (matching the pattern already used in TestSqlDataRecordListParametersWithTypeHandlers + // below) keeps that concrete typing intact while still proving single enumeration. + SqlMapper.ICustomQueryParameter tvp; +#pragma warning disable CS0618 // Type or member is obsolete + if (connection is System.Data.SqlClient.SqlConnection) + { + var records = new SingleEnumerationEnumerable(CreateSqlDataRecordList_SD(new int[] { 1, 2, 3 })); + tvp = records.AsTableValuedParameter(); + } +#pragma warning restore CS0618 // Type or member is obsolete + else if (connection is Microsoft.Data.SqlClient.SqlConnection) + { + var records = new SingleEnumerationEnumerable(CreateSqlDataRecordList_MD(new int[] { 1, 2, 3 })); + tvp = records.AsTableValuedParameter(); + } + else + { + throw new ArgumentException(nameof(connection)); + } - var nums = connection.Query("get_ints", new { integers = records.AsTableValuedParameter() }, commandType: CommandType.StoredProcedure).ToList(); + var nums = connection.Query("get_ints", new { integers = tvp }, commandType: CommandType.StoredProcedure).ToList(); Assert.Equal(new int[] { 1, 2, 3 }, nums); } finally