diff --git a/Dapper/DynamicParameters.cs b/Dapper/DynamicParameters.cs
index 34f0848d9..d4cb7282c 100644
--- a/Dapper/DynamicParameters.cs
+++ b/Dapper/DynamicParameters.cs
@@ -127,6 +127,19 @@ public void Add(string name, object? value = null, DbType? dbType = null, Parame
};
}
+ private static bool IsListParameter(ParamInfo param)
+ {
+ if (param.CameFromTemplate || param.Value is null || param.Value is SqlMapper.ICustomQueryParameter) return false;
+ var dbType = param.DbType;
+ if (dbType is null)
+ {
+#pragma warning disable 618
+ dbType = SqlMapper.LookupDbType(param.Value.GetType(), Clean(param.Name), true, out _);
+#pragma warning restore 618
+ }
+ return dbType == EnumerableMultiParameter;
+ }
+
private static string Clean(string name)
{
if (!string.IsNullOrEmpty(name))
@@ -228,7 +241,9 @@ protected void AddParameters(IDbCommand command, SqlMapper.Identity identity)
outputCallbacks?.ForEach(generator => generator());
}
- foreach (var param in parameters.Values)
+ // lists whose names could collide with another list's generated names must expand longest-name first
+ var ordered = SqlMapper.OrderForListExpansion(parameters.Values.ToList(), p => Clean(p.Name), IsListParameter);
+ foreach (var param in ordered)
{
if (param.CameFromTemplate) continue;
diff --git a/Dapper/SqlMapper.cs b/Dapper/SqlMapper.cs
index 8f3956cab..bf9034920 100644
--- a/Dapper/SqlMapper.cs
+++ b/Dapper/SqlMapper.cs
@@ -2313,6 +2313,57 @@ public static void PackListParameters(IDbCommand command, string namePrefix, obj
}
}
+ ///
+ /// List expansion rewrites the command text one list parameter at a time, replacing @name with
+ /// (@name1,@name2,...). If list values1 is expanded before list values11, the generated
+ /// @values11 is indistinguishable from the original @values11 placeholder, so the later
+ /// expansion rewrites the wrong token. Token matching cannot tell them apart; only expansion order can.
+ /// A generated name is always longer than its list name and extends it with digits, so expanding longer
+ /// names first can never produce a token that a later (shorter) list name matches. Lists are only
+ /// reordered (among themselves, leaving other parameters in place) when such a collision is possible.
+ ///
+ internal static IList OrderForListExpansion(IList items, Func getName, Func isList)
+ {
+ List? slots = null;
+ for (int i = 0; i < items.Count; i++)
+ {
+ if (isList(items[i])) (slots ??= new List()).Add(i);
+ }
+ if (slots is null || slots.Count < 2) return items;
+
+ bool collides = false;
+ for (int a = 0; a < slots.Count && !collides; a++)
+ {
+ var shorter = getName(items[slots[a]]);
+ for (int b = 0; b < slots.Count; b++)
+ {
+ var longer = getName(items[slots[b]]);
+ if (longer.Length > shorter.Length
+ && longer.StartsWith(shorter, StringComparison.OrdinalIgnoreCase)
+ && AllDigits(longer, shorter.Length))
+ {
+ collides = true;
+ break;
+ }
+ }
+ }
+ if (!collides) return items;
+
+ var sorted = slots.Select(i => items[i]).OrderByDescending(x => getName(x).Length).ToList();
+ var result = new List(items);
+ for (int i = 0; i < slots.Count; i++) result[slots[i]] = sorted[i];
+ return result;
+
+ static bool AllDigits(string s, int from)
+ {
+ for (int i = from; i < s.Length; i++)
+ {
+ if (s[i] < '0' || s[i] > '9') return false;
+ }
+ return true;
+ }
+ }
+
private static bool TryStringSplit(ref IEnumerable? list, int splitAt, string namePrefix, IDbCommand command, bool byPosition)
{
if (list is null || splitAt < 0) return false;
@@ -2668,6 +2719,10 @@ private static bool IsValueTuple(Type? type) => (type?.IsValueType == true
props = FilterParameters(props, identity.Sql);
}
+#pragma warning disable 618
+ props = OrderForListExpansion(props.ToList(), p => p.Name, p => LookupDbType(p.PropertyType, p.Name, true, out _) == DynamicParameters.EnumerableMultiParameter);
+#pragma warning restore 618
+
var callOpCode = isStruct ? OpCodes.Call : OpCodes.Callvirt;
foreach (var prop in props)
{
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 2f91a3cca..8f2772419 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -35,6 +35,7 @@
+
diff --git a/tests/Dapper.Tests/Dapper.Tests.csproj b/tests/Dapper.Tests/Dapper.Tests.csproj
index 93b498618..09947a89a 100644
--- a/tests/Dapper.Tests/Dapper.Tests.csproj
+++ b/tests/Dapper.Tests/Dapper.Tests.csproj
@@ -31,8 +31,9 @@
-
-
+
+
+
diff --git a/tests/Dapper.Tests/FakeDbTests.Issue1040.cs b/tests/Dapper.Tests/FakeDbTests.Issue1040.cs
new file mode 100644
index 000000000..4f6448977
--- /dev/null
+++ b/tests/Dapper.Tests/FakeDbTests.Issue1040.cs
@@ -0,0 +1,78 @@
+#if !NET481
+using System.Collections.Generic;
+using pengdows.crud.fakeDb;
+using Xunit;
+
+namespace Dapper.Tests
+{
+ public sealed class FakeDbIssue1040Tests
+ {
+ private static string Run(string sql, object args)
+ {
+ using var conn = new fakeDbConnection(new FakeDataStore());
+ conn.EnqueueNonQueryResult(1);
+ conn.Open();
+ conn.Execute(sql, args);
+ return Assert.Single(conn.ExecutedNonQueryTexts);
+ }
+
+ [Fact]
+ public void ListExpansionDoesNotCollideWithLongerListName()
+ {
+ var text = Run(
+ "select 1 where Value in @values1 or Value in @values11",
+ new { values1 = new[] { 1, 2 }, values11 = new[] { 3, 4 } });
+ Assert.Equal("select 1 where Value in (@values11,@values12) or Value in (@values111,@values112)", text);
+ }
+
+ [Fact]
+ public void ListExpansionIsIndependentOfDeclarationOrder()
+ {
+ var text = Run(
+ "select 1 where Value in @values11 or Value in @values1",
+ new { values11 = new[] { 3, 4 }, values1 = new[] { 1, 2 } });
+ Assert.Equal("select 1 where Value in (@values111,@values112) or Value in (@values11,@values12)", text);
+ }
+
+ [Fact]
+ public void ThreeOverlappingListNamesExpandIndependently()
+ {
+ var text = Run(
+ "select 1 where a in @v or b in @v1 or c in @v11",
+ new { v = new[] { 1, 2 }, v1 = new[] { 3, 4 }, v11 = new[] { 5, 6 } });
+ Assert.Equal("select 1 where a in (@v1,@v2) or b in (@v11,@v12) or c in (@v111,@v112)", text);
+ }
+
+ [Fact]
+ public void DynamicParametersListExpansionDoesNotCollide()
+ {
+ var args = new DynamicParameters();
+ args.Add("values1", new[] { 1, 2 });
+ args.Add("values11", new[] { 3, 4 });
+ var text = Run("select 1 where Value in @values1 or Value in @values11", args);
+ Assert.Equal("select 1 where Value in (@values11,@values12) or Value in (@values111,@values112)", text);
+ }
+
+ [Fact]
+ public void DictionaryListExpansionDoesNotCollide()
+ {
+ var args = new Dictionary
+ {
+ ["values1"] = new[] { 1, 2 },
+ ["values11"] = new[] { 3, 4 },
+ };
+ var text = Run("select 1 where Value in @values1 or Value in @values11", args);
+ Assert.Equal("select 1 where Value in (@values11,@values12) or Value in (@values111,@values112)", text);
+ }
+
+ [Fact]
+ public void UnrelatedListNamesKeepOrderAndExpandNormally()
+ {
+ var text = Run(
+ "select 1 where a in @ids or b in @codes",
+ new { ids = new[] { 1, 2 }, codes = new[] { 3 } });
+ Assert.Equal("select 1 where a in (@ids1,@ids2) or b in (@codes1)", text);
+ }
+ }
+}
+#endif