From 136426ca6ac9c2a47f194412f716553c43ec70de Mon Sep 17 00:00:00 2001 From: "Dailey, Alaric" Date: Sat, 24 Oct 2026 07:39:26 -0500 Subject: [PATCH 1/2] Fix list expansion parameter name collisions --- Dapper/SqlMapper.cs | 33 +++++++++++++++++---- Directory.Packages.props | 1 + tests/Dapper.Tests/Dapper.Tests.csproj | 5 ++-- tests/Dapper.Tests/FakeDbTests.Issue1040.cs | 33 +++++++++++++++++++++ 4 files changed, 64 insertions(+), 8 deletions(-) create mode 100644 tests/Dapper.Tests/FakeDbTests.Issue1040.cs diff --git a/Dapper/SqlMapper.cs b/Dapper/SqlMapper.cs index 8f3956cab..2107a1567 100644 --- a/Dapper/SqlMapper.cs +++ b/Dapper/SqlMapper.cs @@ -2663,12 +2663,33 @@ private static bool IsValueTuple(Type? type) => (type?.IsValueType == true propsList.Sort(new PropertyInfoByNameComparer()); props = propsList; } - if (filterParams) - { - props = FilterParameters(props, identity.Sql); - } - - var callOpCode = isStruct ? OpCodes.Call : OpCodes.Callvirt; + if (filterParams) + { + props = FilterParameters(props, identity.Sql); + } + + // Expand longer list-parameter names first. Otherwise a generated + // name such as @values11 can be mistaken for the original @values11 + // placeholder when @values1 was expanded first. +#pragma warning disable 618 + var orderedProps = props.ToList(); + var listProps = orderedProps + .Where(p => LookupDbType(p.PropertyType, p.Name, true, out _) == DynamicParameters.EnumerableMultiParameter) + .OrderByDescending(p => p.Name.Length) + .ThenBy(p => p.Name, StringComparer.Ordinal) + .ToList(); + var listIndex = 0; + for (var i = 0; i < orderedProps.Count; i++) + { + if (LookupDbType(orderedProps[i].PropertyType, orderedProps[i].Name, true, out _) == DynamicParameters.EnumerableMultiParameter) + { + orderedProps[i] = listProps[listIndex++]; + } + } + props = orderedProps; +#pragma warning restore 618 + + var callOpCode = isStruct ? OpCodes.Call : OpCodes.Callvirt; foreach (var prop in props) { if (typeof(ICustomQueryParameter).IsAssignableFrom(prop.PropertyType)) 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..e1028204a --- /dev/null +++ b/tests/Dapper.Tests/FakeDbTests.Issue1040.cs @@ -0,0 +1,33 @@ +#if !NET481 +using System.Collections.Generic; +using System.Linq; +using pengdows.crud.fakeDb; +using Xunit; + +namespace Dapper.Tests +{ + public sealed class FakeDbIssue1040Tests + { + [Fact] + public void ListExpansionDoesNotReplaceAParameterNamePrefix() + { + using var conn = new fakeDbConnection(new FakeDataStore()); + conn.EnqueueNonQueryResult(1); + conn.Open(); + + conn.Execute( + "select 1 where Value in @values1 or Value in @values11", + new + { + values1 = new[] { 1, 2 }, + values11 = new[] { 3, 4 } + }); + + var commandText = Assert.Single(conn.ExecutedNonQueryTexts); + Assert.Equal( + "select 1 where Value in (@values11,@values12) or Value in (@values111,@values112)", + commandText); + } + } +} +#endif From 1255c02632904bdbad6425ea4518669da4a94c44 Mon Sep 17 00:00:00 2001 From: "Dailey, Alaric" Date: Sat, 24 Oct 2026 10:32:25 -0500 Subject: [PATCH 2/2] Make list-expansion collision handling cover DynamicParameters and dictionaries Generated names (values1 + 1) can equal another list's name (values11), which token matching cannot distinguish; only expansion order can. Move longest-first ordering into a shared helper used by both the anonymous-object and DynamicParameters paths, applied only when a collision is possible so unrelated lists keep their order. Co-Authored-By: Claude Sonnet 5.5 --- Dapper/DynamicParameters.cs | 17 ++- Dapper/SqlMapper.cs | 88 +++++++++++----- tests/Dapper.Tests/FakeDbTests.Issue1040.cs | 111 ++++++++++++++------ 3 files changed, 155 insertions(+), 61 deletions(-) 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 2107a1567..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; @@ -2663,33 +2714,16 @@ private static bool IsValueTuple(Type? type) => (type?.IsValueType == true propsList.Sort(new PropertyInfoByNameComparer()); props = propsList; } - if (filterParams) - { - props = FilterParameters(props, identity.Sql); - } - - // Expand longer list-parameter names first. Otherwise a generated - // name such as @values11 can be mistaken for the original @values11 - // placeholder when @values1 was expanded first. -#pragma warning disable 618 - var orderedProps = props.ToList(); - var listProps = orderedProps - .Where(p => LookupDbType(p.PropertyType, p.Name, true, out _) == DynamicParameters.EnumerableMultiParameter) - .OrderByDescending(p => p.Name.Length) - .ThenBy(p => p.Name, StringComparer.Ordinal) - .ToList(); - var listIndex = 0; - for (var i = 0; i < orderedProps.Count; i++) - { - if (LookupDbType(orderedProps[i].PropertyType, orderedProps[i].Name, true, out _) == DynamicParameters.EnumerableMultiParameter) - { - orderedProps[i] = listProps[listIndex++]; - } - } - props = orderedProps; -#pragma warning restore 618 - - var callOpCode = isStruct ? OpCodes.Call : OpCodes.Callvirt; + if (filterParams) + { + 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) { if (typeof(ICustomQueryParameter).IsAssignableFrom(prop.PropertyType)) diff --git a/tests/Dapper.Tests/FakeDbTests.Issue1040.cs b/tests/Dapper.Tests/FakeDbTests.Issue1040.cs index e1028204a..4f6448977 100644 --- a/tests/Dapper.Tests/FakeDbTests.Issue1040.cs +++ b/tests/Dapper.Tests/FakeDbTests.Issue1040.cs @@ -1,33 +1,78 @@ -#if !NET481 -using System.Collections.Generic; -using System.Linq; -using pengdows.crud.fakeDb; -using Xunit; - -namespace Dapper.Tests -{ - public sealed class FakeDbIssue1040Tests - { - [Fact] - public void ListExpansionDoesNotReplaceAParameterNamePrefix() - { - using var conn = new fakeDbConnection(new FakeDataStore()); - conn.EnqueueNonQueryResult(1); - conn.Open(); - - conn.Execute( - "select 1 where Value in @values1 or Value in @values11", - new - { - values1 = new[] { 1, 2 }, - values11 = new[] { 3, 4 } - }); - - var commandText = Assert.Single(conn.ExecutedNonQueryTexts); - Assert.Equal( - "select 1 where Value in (@values11,@values12) or Value in (@values111,@values112)", - commandText); - } - } -} -#endif +#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