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