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);