Skip to content

Avoid double-enumerating IEnumerable<T> in AsTableValuedParameter - #2217

Open
hexonal wants to merge 3 commits into
DapperLib:mainfrom
hexonal:fix-tvp-double-enumeration
Open

Avoid double-enumerating IEnumerable<T> in AsTableValuedParameter#2217
hexonal wants to merge 3 commits into
DapperLib:mainfrom
hexonal:fix-tvp-double-enumeration

Conversation

@hexonal

@hexonal hexonal commented Jul 15, 2026

Copy link
Copy Markdown

SqlDataRecordListTVPParameter<T>.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.) that meant the real enumeration during command execution either restarted from scratch or failed outright with something like "There is already an open DataReader...".

This now only short-circuits to null when the source is a known IReadOnlyCollection<T>, checked via Count as @mgravell suggested in the issue thread. Everything else flows straight through untouched, so it's enumerated exactly once, by whoever actually executes the command.

Added three tests: one mirrors the original repro end-to-end (needs SQL Server to run), and two run offline against SqlDataRecordListTVPParameter<T>.Set directly, checking that a non-collection source is never touched and a known-empty collection still nulls out.

Fixes #2064

hexonal added 3 commits July 15, 2026 04:19
SqlDataRecordListTVPParameter<T>.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<T> and we can check Count for free; anything else
now flows straight through untouched, so it's enumerated exactly once
by the provider.

Fixes DapperLib#2064
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.
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<SqlDataRecord>" - a hard runtime interface
check, not an assignment-compatibility check. Generic interface
implementations are invariant on their concrete type parameter even
though IEnumerable<T> itself is declared covariant, so a wrapper
class instantiated as SingleEnumerationEnumerable<IDataRecord> 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<IDataRecord> helper, so AsTableValuedParameter<T> always
inferred T as IDataRecord and the wrapper only ever implemented
IEnumerable<IDataRecord>. This has nothing to do with the
double-enumeration fix itself; SqlDataRecordListTVPParameter<T>.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.
@hexonal

hexonal commented Aug 15, 2026

Copy link
Copy Markdown
Author

@mgravell — a gentle bump on this one; it has had no eyes on it since it was opened a month ago, and it is small.

Short version: SqlDataRecordListTVPParameter<T>.Set calls data.Any() to swap an empty sequence for null, which enumerates the source once before the caller ever executes the command. For single-pass sources that is fatal — an open reader either restarts from scratch or throws "There is already an open DataReader…" (#2064). The fix only short-circuits when the source is a known IReadOnlyCollection<T>, checked via Count, which is the approach you suggested in that issue thread; anything else flows through untouched and is enumerated exactly once, by whoever executes the command.

Three tests: one end-to-end repro (needs SQL Server), and two offline ones against Set directly — a non-collection source is never touched, a known-empty collection still nulls out. Branch is current and CI is green.

Happy to change the shape if you would rather it were done differently.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Multiple enumerations of IEnumerable<SqlDataRecord> passed as TVP

1 participant