Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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.
/// </summary>
[TestClass]
public class ArrayContainsTranslationTests
Expand All @@ -32,6 +38,58 @@ public void ArrayContains_TranslatesToSqlIn()
command.CommandText);
}

/// <summary>
/// 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.
/// </summary>
[TestMethod]
public void ArrayContains_InvocationExpressionPattern_TranslatesToSqlIn()
{
var ids = new[] { 1, 2, 3 };

// Build MemoryExtensions.Contains<int>(ReadOnlySpan<int>, 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<int>
var opImplicit = typeof(ReadOnlySpan<int>)
.GetMethod("op_Implicit", new[] { typeof(int[]) });

// Pattern 2: the compiler pre-compiles the implicit conversion into a zero-arg delegate
// InvocationExpression(ConstantExpression(Func<ReadOnlySpan<int>>), [])
// Build: Func<ReadOnlySpan<int>> 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<Func<SimpleEntity, bool>>(containsCall, tParam);

using var connection = new DbConnectionStub();
using var context = new DataContext(connection);

var query = context.GetTable<SimpleEntity>().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()
{
Expand Down
2 changes: 1 addition & 1 deletion Mindbox.Data.Linq/Mindbox.Data.Linq.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
<PackageVersion>10.8.0$(VersionTag)</PackageVersion>
<PackageVersion>10.8.1$(VersionTag)</PackageVersion>
<NoWarn>SYSLIB0003;SYSLIB0011</NoWarn>
</PropertyGroup>

Expand Down
66 changes: 59 additions & 7 deletions Mindbox.Data.Linq/SqlClient/Query/QueryConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1774,6 +1774,54 @@ private SqlExpression GetAggregate(SqlNodeType aggType, Type clrType, SqlExpress
return new SqlUnary(aggType, clrType, sqlType, exp, this.dominatingExpression);
}

/// <summary>
/// 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).
/// </summary>
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);
Expand Down Expand Up @@ -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<T>.op_Implicit(array), value).
// Translate the same as Enumerable.Contains(array, value).
// MemoryExtensions.Contains(ReadOnlySpan<T> 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);
Expand Down
Loading