diff --git a/Mindbox.Data.Linq.Tests/SqlGeneration/ArrayContainsTranslationTests.cs b/Mindbox.Data.Linq.Tests/SqlGeneration/ArrayContainsTranslationTests.cs index fdee899..74d4c85 100644 --- a/Mindbox.Data.Linq.Tests/SqlGeneration/ArrayContainsTranslationTests.cs +++ b/Mindbox.Data.Linq.Tests/SqlGeneration/ArrayContainsTranslationTests.cs @@ -1,6 +1,8 @@ using System; using System.Data.Linq; using System.Linq; +using System.Linq.Expressions; +using System.Reflection; using Microsoft.VisualStudio.TestTools.UnitTesting; namespace Mindbox.Data.Linq.Tests @@ -9,6 +11,10 @@ namespace Mindbox.Data.Linq.Tests /// Tests that array.Contains(value) in LINQ queries is correctly translated to SQL IN clause. /// In .NET 10, the C# compiler generates MemoryExtensions.Contains(ReadOnlySpan, value) /// for this pattern instead of Enumerable.Contains — this must produce the same SQL. + /// Two compiler patterns exist: + /// 1. MethodCallExpression(op_Implicit, array) — simple closure capture + /// 2. InvocationExpression(ConstantExpression(delegate), []) — pre-compiled closure + /// Both must be handled. /// [TestClass] public class ArrayContainsTranslationTests @@ -32,6 +38,58 @@ public void ArrayContains_TranslatesToSqlIn() command.CommandText); } + /// + /// Directly constructs the InvocationExpression(ConstantExpression(delegate), []) pattern + /// using Expression API — guaranteed to exercise Pattern 2 regardless of compiler version. + /// The MemoryExtensions.Contains call is built by hand with a pre-compiled zero-arg delegate + /// as the span argument, matching exactly what .NET 10 generates in nested closure contexts. + /// + [TestMethod] + public void ArrayContains_InvocationExpressionPattern_TranslatesToSqlIn() + { + var ids = new[] { 1, 2, 3 }; + + // Build MemoryExtensions.Contains(ReadOnlySpan, int) method + var containsMethod = typeof(MemoryExtensions) + .GetMethods(BindingFlags.Public | BindingFlags.Static) + .Single(m => m.Name == "Contains" + && m.IsGenericMethod + && m.GetParameters() is [{ ParameterType: var p0 }, { ParameterType: { IsGenericParameter: true } }] + && p0.IsGenericType + && p0.GetGenericTypeDefinition() == typeof(ReadOnlySpan<>)) + .MakeGenericMethod(typeof(int)); + + // Build op_Implicit: int[] → ReadOnlySpan + var opImplicit = typeof(ReadOnlySpan) + .GetMethod("op_Implicit", new[] { typeof(int[]) }); + + // Pattern 2: the compiler pre-compiles the implicit conversion into a zero-arg delegate + // InvocationExpression(ConstantExpression(Func>), []) + // Build: Func> spanFactory = () => op_Implicit(ids) + var spanFactoryBody = Expression.Call(opImplicit, Expression.Constant(ids)); + var spanFactory = Expression.Lambda(spanFactoryBody).Compile(); + var spanExpr = Expression.Invoke(Expression.Constant(spanFactory)); + + // Build: WHERE t.Id IN (ids) via MemoryExtensions.Contains(spanExpr, t.Id) + var tParam = Expression.Parameter(typeof(SimpleEntity), "t"); + var idMember = Expression.Property(tParam, nameof(SimpleEntity.Id)); + var containsCall = Expression.Call(containsMethod, spanExpr, idMember); + var predicate = Expression.Lambda>(containsCall, tParam); + + using var connection = new DbConnectionStub(); + using var context = new DataContext(connection); + + var query = context.GetTable().Where(predicate); + + using var command = context.GetCommand(query); + + Assert.AreEqual( + "SELECT [t0].[Id], [t0].[Discriminator], [t0].[X]" + Environment.NewLine + + "FROM [SimpleTable] AS [t0]" + Environment.NewLine + + "WHERE [t0].[Id] IN (@p0, @p1, @p2)", + command.CommandText); + } + [TestMethod] public void ArrayContains_EmptyArray_TranslatesToFalse() { diff --git a/Mindbox.Data.Linq/Mindbox.Data.Linq.csproj b/Mindbox.Data.Linq/Mindbox.Data.Linq.csproj index 61f7142..fc5788e 100644 --- a/Mindbox.Data.Linq/Mindbox.Data.Linq.csproj +++ b/Mindbox.Data.Linq/Mindbox.Data.Linq.csproj @@ -8,7 +8,7 @@ true true snupkg - 10.8.0$(VersionTag) + 10.8.1$(VersionTag) SYSLIB0003;SYSLIB0011 diff --git a/Mindbox.Data.Linq/SqlClient/Query/QueryConverter.cs b/Mindbox.Data.Linq/SqlClient/Query/QueryConverter.cs index 4323504..557a3a2 100644 --- a/Mindbox.Data.Linq/SqlClient/Query/QueryConverter.cs +++ b/Mindbox.Data.Linq/SqlClient/Query/QueryConverter.cs @@ -1774,6 +1774,54 @@ private SqlExpression GetAggregate(SqlNodeType aggType, Type clrType, SqlExpress return new SqlUnary(aggType, clrType, sqlType, exp, this.dominatingExpression); } + /// + /// Searches for an array of the given type in a delegate's closure. + /// Handles direct T[] fields (display class) and Object[] fields (runtime Closure), + /// including delegates nested inside Object[] fields (up to depth 3). + /// + private static object FindArrayInClosure(object target, Type arrayType, int depth = 0) { + if (target == null || depth > 3) { + return null; + } + foreach (var f in target.GetType().GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)) { + var val = f.GetValue(target); + if (val?.GetType() == arrayType) { + return val; + } + if (val is object[] items) { + foreach (var item in items) { + if (item?.GetType() == arrayType) { + return item; + } + if (item is Delegate nested && nested.Target != null) { + var found = FindArrayInClosure(nested.Target, arrayType, depth + 1); + if (found != null) { + return found; + } + } + } + } + } + return null; + } + + private static Expression TryExtractArrayFromSpanExpression(Expression spanExpr) { + if (spanExpr is MethodCallExpression { Arguments: [var arrayExpr] }) { + return arrayExpr; + } + + if (spanExpr is InvocationExpression { Arguments.Count: 0, Expression: ConstantExpression { Value: Delegate { Target: { } target } } } + && spanExpr.Type.GetGenericArguments() is [var elementType]) { + var arrayType = elementType.MakeArrayType(); + var array = FindArrayInClosure(target, arrayType); + if (array != null) { + return Expression.Constant(array, arrayType); + } + } + + return null; + } + private SqlNode VisitContains(Expression sequence, Expression value) { Type elemType = TypeSystem.GetElementType(sequence.Type); SqlNode seqNode = this.Visit(sequence); @@ -1880,15 +1928,19 @@ private SqlNode VisitMethodCall(MethodCallExpression mc) { return this.VisitSequenceOperatorCall(mc); } // In .NET 10, array.Contains(value) in expression trees compiles to - // MemoryExtensions.Contains(ReadOnlySpan.op_Implicit(array), value). - // Translate the same as Enumerable.Contains(array, value). + // MemoryExtensions.Contains(ReadOnlySpan span, value). + // Two compiler patterns exist for the span argument: + // 1. MethodCallExpression(op_Implicit, [array]) — simple local capture + // 2. InvocationExpression(ConstantExpression(pre_compiled_delegate), []) — nested closure + // Both must be unwrapped to extract the underlying array for SQL IN translation. else if (mc.Method.DeclaringType == typeof(MemoryExtensions) && mc.Method.Name == "Contains" - && mc.Arguments[0] is MethodCallExpression spanConversion - && spanConversion.Method.ReturnType is { IsGenericType: true } returnType - && returnType.GetGenericTypeDefinition() == typeof(ReadOnlySpan<>) - && spanConversion.Arguments.Count == 1) { - return this.VisitContains(spanConversion.Arguments[0], mc.Arguments[1]); + && mc.Arguments[0].Type is { IsGenericType: true } spanType + && spanType.GetGenericTypeDefinition() == typeof(ReadOnlySpan<>)) { + var arrayExpr = TryExtractArrayFromSpanExpression(mc.Arguments[0]); + if (arrayExpr != null) { + return this.VisitContains(arrayExpr, mc.Arguments[1]); + } } else if (IsDataManipulationCall(mc)) { return this.VisitDataManipulationCall(mc);