From f5d9affda8385450d55cadbcf12a5dc8828cd48d Mon Sep 17 00:00:00 2001 From: Aleksandr Korchak Date: Tue, 24 Mar 2026 10:53:41 +0000 Subject: [PATCH 01/11] fix: handle InvocationExpression pattern for MemoryExtensions.Contains in .NET 10 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In .NET 10, array.Contains(value) in LINQ expression trees may generate two patterns: 1. MethodCallExpression(op_Implicit, [array]) — for simple local captures 2. InvocationExpression(ConstantExpression(pre_compiled_delegate), []) — when array is captured in a nested closure (e.g. passed as a method parameter) The original 10.8.0 fix only handled pattern 1. This fix adds: - TryExtractArrayFromSpanExpression() helper that handles both patterns - For pattern 2: extracts the array from the delegate's closure target via reflection - Broader condition check: mc.Arguments[0].Type is ReadOnlySpan (not just MethodCallExpression) New test ArrayContains_PassedAsParameter_TranslatesToSqlIn reproduces pattern 2. --- .../ArrayContainsTranslationTests.cs | 33 ++++++++++++ .../SqlClient/Query/QueryConverter.cs | 50 ++++++++++++++++--- 2 files changed, 76 insertions(+), 7 deletions(-) diff --git a/Mindbox.Data.Linq.Tests/SqlGeneration/ArrayContainsTranslationTests.cs b/Mindbox.Data.Linq.Tests/SqlGeneration/ArrayContainsTranslationTests.cs index fdee899..a147c8f 100644 --- a/Mindbox.Data.Linq.Tests/SqlGeneration/ArrayContainsTranslationTests.cs +++ b/Mindbox.Data.Linq.Tests/SqlGeneration/ArrayContainsTranslationTests.cs @@ -9,6 +9,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 +36,35 @@ public void ArrayContains_TranslatesToSqlIn() command.CommandText); } + /// + /// Reproduces the InvocationExpression pattern: when array is passed as a parameter + /// to a helper method and used in a Where clause, .NET 10 compiler may generate + /// InvocationExpression(ConstantExpression(pre_compiled_delegate), []) instead of + /// MethodCallExpression(op_Implicit, array_expr) for the implicit T[]→ReadOnlySpan conversion. + /// + [TestMethod] + public void ArrayContains_PassedAsParameter_TranslatesToSqlIn() + { + TranslateContainsQuery(new[] { 1, 2, 3 }); + } + + private static void TranslateContainsQuery(int[] ids) + { + using var connection = new DbConnectionStub(); + using var context = new DataContext(connection); + + var query = context.GetTable() + .Where(t => ids.Contains(t.Id)); + + 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/SqlClient/Query/QueryConverter.cs b/Mindbox.Data.Linq/SqlClient/Query/QueryConverter.cs index 4323504..5064bd5 100644 --- a/Mindbox.Data.Linq/SqlClient/Query/QueryConverter.cs +++ b/Mindbox.Data.Linq/SqlClient/Query/QueryConverter.cs @@ -1774,6 +1774,38 @@ private SqlExpression GetAggregate(SqlNodeType aggType, Type clrType, SqlExpress return new SqlUnary(aggType, clrType, sqlType, exp, this.dominatingExpression); } + /// + /// Extracts the underlying array/collection expression from a ReadOnlySpan expression. + /// In .NET 10, array.Contains() may generate two patterns for the implicit T[]→ReadOnlySpan conversion: + /// 1. MethodCallExpression(op_Implicit, [array_expr]) — extract array_expr directly + /// 2. InvocationExpression(ConstantExpression(pre_compiled_delegate), []) — extract array + /// from the delegate's closure target via reflection + /// + private static Expression TryExtractArrayFromSpanExpression(Expression spanExpr) { + // Pattern 1: MethodCallExpression — op_Implicit(array) + if (spanExpr is MethodCallExpression mc && mc.Arguments.Count == 1) + return mc.Arguments[0]; + + // Pattern 2: InvocationExpression(ConstantExpression(delegate), []) + // The compiler pre-compiled the implicit conversion to a zero-arg delegate. + // Extract the array from the delegate's closure. + if (spanExpr is InvocationExpression { Arguments.Count: 0 } invoke + && invoke.Expression is ConstantExpression constExpr + && constExpr.Value is Delegate del + && del.Target != null) { + var target = del.Target; + var elementType = spanExpr.Type.GetGenericArguments()[0]; + var arrayType = elementType.MakeArrayType(); + var arrayField = target.GetType() + .GetFields(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance) + .FirstOrDefault(f => f.FieldType == arrayType); + if (arrayField != null) + return Expression.Constant(arrayField.GetValue(target), arrayType); + } + + return null; + } + private SqlNode VisitContains(Expression sequence, Expression value) { Type elemType = TypeSystem.GetElementType(sequence.Type); SqlNode seqNode = this.Visit(sequence); @@ -1880,15 +1912,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); From 40cc40082958f6f1e83d5b5853c408388e16e7e7 Mon Sep 17 00:00:00 2001 From: Aleksandr Korchak Date: Tue, 24 Mar 2026 10:54:04 +0000 Subject: [PATCH 02/11] chore: bump version to 10.8.1 --- Mindbox.Data.Linq/Mindbox.Data.Linq.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From f7a9f1b6d8574777518fcfd646f8badaaac2d752 Mon Sep 17 00:00:00 2001 From: Aleksandr Korchak Date: Tue, 24 Mar 2026 12:46:44 +0000 Subject: [PATCH 03/11] refactor: simplify TryExtractArrayFromSpanExpression using pattern matching --- .../SqlClient/Query/QueryConverter.cs | 36 +++++++------------ 1 file changed, 12 insertions(+), 24 deletions(-) diff --git a/Mindbox.Data.Linq/SqlClient/Query/QueryConverter.cs b/Mindbox.Data.Linq/SqlClient/Query/QueryConverter.cs index 5064bd5..6923013 100644 --- a/Mindbox.Data.Linq/SqlClient/Query/QueryConverter.cs +++ b/Mindbox.Data.Linq/SqlClient/Query/QueryConverter.cs @@ -1774,33 +1774,21 @@ private SqlExpression GetAggregate(SqlNodeType aggType, Type clrType, SqlExpress return new SqlUnary(aggType, clrType, sqlType, exp, this.dominatingExpression); } - /// - /// Extracts the underlying array/collection expression from a ReadOnlySpan expression. - /// In .NET 10, array.Contains() may generate two patterns for the implicit T[]→ReadOnlySpan conversion: - /// 1. MethodCallExpression(op_Implicit, [array_expr]) — extract array_expr directly - /// 2. InvocationExpression(ConstantExpression(pre_compiled_delegate), []) — extract array - /// from the delegate's closure target via reflection - /// + // In .NET 10, array.Contains() generates two compiler patterns for T[]→ReadOnlySpan: + // 1. MethodCallExpression(op_Implicit, [array_expr]) — simple local capture + // 2. InvocationExpression(ConstantExpression(pre_compiled_delegate), []) — nested closure private static Expression TryExtractArrayFromSpanExpression(Expression spanExpr) { - // Pattern 1: MethodCallExpression — op_Implicit(array) - if (spanExpr is MethodCallExpression mc && mc.Arguments.Count == 1) - return mc.Arguments[0]; - - // Pattern 2: InvocationExpression(ConstantExpression(delegate), []) - // The compiler pre-compiled the implicit conversion to a zero-arg delegate. - // Extract the array from the delegate's closure. - if (spanExpr is InvocationExpression { Arguments.Count: 0 } invoke - && invoke.Expression is ConstantExpression constExpr - && constExpr.Value is Delegate del - && del.Target != null) { - var target = del.Target; - var elementType = spanExpr.Type.GetGenericArguments()[0]; + 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 arrayField = target.GetType() - .GetFields(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance) + var field = target.GetType() + .GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) .FirstOrDefault(f => f.FieldType == arrayType); - if (arrayField != null) - return Expression.Constant(arrayField.GetValue(target), arrayType); + if (field?.GetValue(target) is { } array) + return Expression.Constant(array, arrayType); } return null; From fe338ae4b3ef10c9329ed92858b304acc7bb3ddd Mon Sep 17 00:00:00 2001 From: Aleksandr Korchak Date: Tue, 24 Mar 2026 12:48:16 +0000 Subject: [PATCH 04/11] refactor: inline TranslateContainsQuery as local function --- .../ArrayContainsTranslationTests.cs | 34 +++++++++---------- 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/Mindbox.Data.Linq.Tests/SqlGeneration/ArrayContainsTranslationTests.cs b/Mindbox.Data.Linq.Tests/SqlGeneration/ArrayContainsTranslationTests.cs index a147c8f..3d6d8c2 100644 --- a/Mindbox.Data.Linq.Tests/SqlGeneration/ArrayContainsTranslationTests.cs +++ b/Mindbox.Data.Linq.Tests/SqlGeneration/ArrayContainsTranslationTests.cs @@ -37,32 +37,30 @@ public void ArrayContains_TranslatesToSqlIn() } /// - /// Reproduces the InvocationExpression pattern: when array is passed as a parameter - /// to a helper method and used in a Where clause, .NET 10 compiler may generate - /// InvocationExpression(ConstantExpression(pre_compiled_delegate), []) instead of - /// MethodCallExpression(op_Implicit, array_expr) for the implicit T[]→ReadOnlySpan conversion. + /// Reproduces the InvocationExpression pattern: when array is passed as a method parameter, + /// .NET 10 compiler pre-compiles the implicit T[]→ReadOnlySpan conversion to a zero-arg delegate + /// (InvocationExpression) instead of keeping it as MethodCallExpression(op_Implicit). /// [TestMethod] public void ArrayContains_PassedAsParameter_TranslatesToSqlIn() { - TranslateContainsQuery(new[] { 1, 2, 3 }); - } + RunWithIds(new[] { 1, 2, 3 }); - private static void TranslateContainsQuery(int[] ids) - { - using var connection = new DbConnectionStub(); - using var context = new DataContext(connection); + static void RunWithIds(int[] ids) + { + using var connection = new DbConnectionStub(); + using var context = new DataContext(connection); - var query = context.GetTable() - .Where(t => ids.Contains(t.Id)); + var query = context.GetTable().Where(t => ids.Contains(t.Id)); - using var command = context.GetCommand(query); + 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); + 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] From 1e20c83060a4cb306e3bc3928c61a378f924f9e2 Mon Sep 17 00:00:00 2001 From: Aleksandr Korchak Date: Tue, 24 Mar 2026 12:51:26 +0000 Subject: [PATCH 05/11] docs: explain why RunWithIds local function is intentional --- .../SqlGeneration/ArrayContainsTranslationTests.cs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/Mindbox.Data.Linq.Tests/SqlGeneration/ArrayContainsTranslationTests.cs b/Mindbox.Data.Linq.Tests/SqlGeneration/ArrayContainsTranslationTests.cs index 3d6d8c2..33b2620 100644 --- a/Mindbox.Data.Linq.Tests/SqlGeneration/ArrayContainsTranslationTests.cs +++ b/Mindbox.Data.Linq.Tests/SqlGeneration/ArrayContainsTranslationTests.cs @@ -37,9 +37,12 @@ public void ArrayContains_TranslatesToSqlIn() } /// - /// Reproduces the InvocationExpression pattern: when array is passed as a method parameter, - /// .NET 10 compiler pre-compiles the implicit T[]→ReadOnlySpan conversion to a zero-arg delegate - /// (InvocationExpression) instead of keeping it as MethodCallExpression(op_Implicit). + /// Reproduces the InvocationExpression compiler pattern. + /// When the array is captured via a method parameter (not a local variable), .NET 10 compiler + /// pre-compiles the implicit T[]→ReadOnlySpan conversion to a zero-arg delegate, producing + /// InvocationExpression(ConstantExpression(delegate), []) instead of MethodCallExpression(op_Implicit). + /// The nested local function RunWithIds is intentional — it is the minimal structure that triggers + /// this compiler pattern. Without it the test would be identical to ArrayContains_TranslatesToSqlIn. /// [TestMethod] public void ArrayContains_PassedAsParameter_TranslatesToSqlIn() From 6066b0c2e17e739bd6139aa9a0356436023511ef Mon Sep 17 00:00:00 2001 From: Aleksandr Korchak Date: Tue, 24 Mar 2026 13:35:34 +0000 Subject: [PATCH 06/11] fix: handle nested Closure pattern in TryExtractArrayFromSpanExpression The compiled delegate from Expression.Lambda(...).Compile() may have a two-level closure: outer Closure.Constants contains another compiled delegate whose Closure.Constants contains the actual array. Added FindArrayInClosure() that recursively searches delegate closures (up to depth 3) to handle all nesting patterns produced by the DLR expression compiler. Also updated ArrayContains_InvocationExpressionPattern_TranslatesToSqlIn test to directly construct the InvocationExpression using Expression.Lambda().Compile() so it genuinely exercises Pattern 2 (RED without fix, GREEN with fix). --- .../ArrayContainsTranslationTests.cs | 54 +++++++++++++++++++ .../SqlClient/Query/QueryConverter.cs | 25 +++++++-- 2 files changed, 75 insertions(+), 4 deletions(-) diff --git a/Mindbox.Data.Linq.Tests/SqlGeneration/ArrayContainsTranslationTests.cs b/Mindbox.Data.Linq.Tests/SqlGeneration/ArrayContainsTranslationTests.cs index 33b2620..ff80312 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 @@ -66,6 +68,58 @@ static void RunWithIds(int[] ids) } } + /// + /// 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/SqlClient/Query/QueryConverter.cs b/Mindbox.Data.Linq/SqlClient/Query/QueryConverter.cs index 6923013..75bedb9 100644 --- a/Mindbox.Data.Linq/SqlClient/Query/QueryConverter.cs +++ b/Mindbox.Data.Linq/SqlClient/Query/QueryConverter.cs @@ -1777,6 +1777,25 @@ private SqlExpression GetAggregate(SqlNodeType aggType, Type clrType, SqlExpress // In .NET 10, array.Contains() generates two compiler patterns for T[]→ReadOnlySpan: // 1. MethodCallExpression(op_Implicit, [array_expr]) — simple local capture // 2. InvocationExpression(ConstantExpression(pre_compiled_delegate), []) — nested closure + // Recursively searches for an array of the given type in a delegate's closure. + // Handles display class (direct field), runtime Closure (Object[] fields), and nested delegates. + private static object FindArrayInClosure(object target, Type arrayType, int depth) { + 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; @@ -1784,10 +1803,8 @@ private static Expression TryExtractArrayFromSpanExpression(Expression spanExpr) if (spanExpr is InvocationExpression { Arguments.Count: 0, Expression: ConstantExpression { Value: Delegate { Target: { } target } } } && spanExpr.Type.GetGenericArguments() is [var elementType]) { var arrayType = elementType.MakeArrayType(); - var field = target.GetType() - .GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) - .FirstOrDefault(f => f.FieldType == arrayType); - if (field?.GetValue(target) is { } array) + var array = FindArrayInClosure(target, arrayType, depth: 0); + if (array != null) return Expression.Constant(array, arrayType); } From 1d49432e4084f215d6c98390fc8a44ae44a6001d Mon Sep 17 00:00:00 2001 From: Aleksandr Korchak Date: Tue, 24 Mar 2026 13:40:47 +0000 Subject: [PATCH 07/11] refactor: replace recursion with BFS in FindArrayInClosure, add braces, remove unused test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FindArrayInClosure: iterative BFS instead of recursion — no depth limit needed, works for any nesting produced by DLR; no risk of stack overflow - TryExtractArrayFromSpanExpression: added braces on all return paths - Removed ArrayContains_PassedAsParameter_TranslatesToSqlIn — it did not reproduce Pattern 2 (was identical to ArrayContains_TranslatesToSqlIn) --- .../ArrayContainsTranslationTests.cs | 30 ------------- .../SqlClient/Query/QueryConverter.cs | 43 ++++++++++++------- 2 files changed, 28 insertions(+), 45 deletions(-) diff --git a/Mindbox.Data.Linq.Tests/SqlGeneration/ArrayContainsTranslationTests.cs b/Mindbox.Data.Linq.Tests/SqlGeneration/ArrayContainsTranslationTests.cs index ff80312..74d4c85 100644 --- a/Mindbox.Data.Linq.Tests/SqlGeneration/ArrayContainsTranslationTests.cs +++ b/Mindbox.Data.Linq.Tests/SqlGeneration/ArrayContainsTranslationTests.cs @@ -38,36 +38,6 @@ public void ArrayContains_TranslatesToSqlIn() command.CommandText); } - /// - /// Reproduces the InvocationExpression compiler pattern. - /// When the array is captured via a method parameter (not a local variable), .NET 10 compiler - /// pre-compiles the implicit T[]→ReadOnlySpan conversion to a zero-arg delegate, producing - /// InvocationExpression(ConstantExpression(delegate), []) instead of MethodCallExpression(op_Implicit). - /// The nested local function RunWithIds is intentional — it is the minimal structure that triggers - /// this compiler pattern. Without it the test would be identical to ArrayContains_TranslatesToSqlIn. - /// - [TestMethod] - public void ArrayContains_PassedAsParameter_TranslatesToSqlIn() - { - RunWithIds(new[] { 1, 2, 3 }); - - static void RunWithIds(int[] ids) - { - using var connection = new DbConnectionStub(); - using var context = new DataContext(connection); - - var query = context.GetTable().Where(t => ids.Contains(t.Id)); - - 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); - } - } - /// /// Directly constructs the InvocationExpression(ConstantExpression(delegate), []) pattern /// using Expression API — guaranteed to exercise Pattern 2 regardless of compiler version. diff --git a/Mindbox.Data.Linq/SqlClient/Query/QueryConverter.cs b/Mindbox.Data.Linq/SqlClient/Query/QueryConverter.cs index 75bedb9..a0fec36 100644 --- a/Mindbox.Data.Linq/SqlClient/Query/QueryConverter.cs +++ b/Mindbox.Data.Linq/SqlClient/Query/QueryConverter.cs @@ -1777,35 +1777,48 @@ private SqlExpression GetAggregate(SqlNodeType aggType, Type clrType, SqlExpress // In .NET 10, array.Contains() generates two compiler patterns for T[]→ReadOnlySpan: // 1. MethodCallExpression(op_Implicit, [array_expr]) — simple local capture // 2. InvocationExpression(ConstantExpression(pre_compiled_delegate), []) — nested closure - // Recursively searches for an array of the given type in a delegate's closure. + // Searches for an array of the given type in a delegate's closure using BFS. // Handles display class (direct field), runtime Closure (Object[] fields), and nested delegates. - private static object FindArrayInClosure(object target, Type arrayType, int depth) { - 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; + private static object FindArrayInClosure(object root, Type arrayType) { + var queue = new System.Collections.Generic.Queue(); + queue.Enqueue(root); + while (queue.Count > 0) { + var target = queue.Dequeue(); + if (target == null) { + continue; + } + 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) { + queue.Enqueue(nested.Target); + } } } + } } return null; } private static Expression TryExtractArrayFromSpanExpression(Expression spanExpr) { - if (spanExpr is MethodCallExpression { Arguments: [var arrayExpr] }) + 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, depth: 0); - if (array != null) + var array = FindArrayInClosure(target, arrayType); + if (array != null) { return Expression.Constant(array, arrayType); + } } return null; From b079a4a6caa4b40beab4cb898a77a1b049b4be67 Mon Sep 17 00:00:00 2001 From: Aleksandr Korchak Date: Tue, 24 Mar 2026 13:50:22 +0000 Subject: [PATCH 08/11] docs: add XML summary to FindArrayInClosure --- Mindbox.Data.Linq/SqlClient/Query/QueryConverter.cs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/Mindbox.Data.Linq/SqlClient/Query/QueryConverter.cs b/Mindbox.Data.Linq/SqlClient/Query/QueryConverter.cs index a0fec36..8636b5a 100644 --- a/Mindbox.Data.Linq/SqlClient/Query/QueryConverter.cs +++ b/Mindbox.Data.Linq/SqlClient/Query/QueryConverter.cs @@ -1777,8 +1777,15 @@ private SqlExpression GetAggregate(SqlNodeType aggType, Type clrType, SqlExpress // In .NET 10, array.Contains() generates two compiler patterns for T[]→ReadOnlySpan: // 1. MethodCallExpression(op_Implicit, [array_expr]) — simple local capture // 2. InvocationExpression(ConstantExpression(pre_compiled_delegate), []) — nested closure - // Searches for an array of the given type in a delegate's closure using BFS. - // Handles display class (direct field), runtime Closure (Object[] fields), and nested delegates. + /// + /// Searches for an array of the given type in a delegate's closure using BFS. + /// Handles two closure structures produced by the .NET runtime: + /// + /// Display class — a generated class with a direct T[] field (regular C# closure) + /// Runtime Closure — Object[] fields (Constants/Locals) used by compiled expression lambdas, + /// may be nested: outer Closure → inner Delegate → inner Closure with the actual array + /// + /// private static object FindArrayInClosure(object root, Type arrayType) { var queue = new System.Collections.Generic.Queue(); queue.Enqueue(root); From 80b043de733e5cdd4cdef56ff7d46a7c5e1a27f5 Mon Sep 17 00:00:00 2001 From: Aleksandr Korchak Date: Tue, 24 Mar 2026 13:54:19 +0000 Subject: [PATCH 09/11] =?UTF-8?q?docs:=20clean=20up=20comments=20=E2=80=94?= =?UTF-8?q?=20remove=20//=20before=20summary,=20shorten=20wording?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Mindbox.Data.Linq/SqlClient/Query/QueryConverter.cs | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/Mindbox.Data.Linq/SqlClient/Query/QueryConverter.cs b/Mindbox.Data.Linq/SqlClient/Query/QueryConverter.cs index 8636b5a..ff73af3 100644 --- a/Mindbox.Data.Linq/SqlClient/Query/QueryConverter.cs +++ b/Mindbox.Data.Linq/SqlClient/Query/QueryConverter.cs @@ -1774,17 +1774,10 @@ private SqlExpression GetAggregate(SqlNodeType aggType, Type clrType, SqlExpress return new SqlUnary(aggType, clrType, sqlType, exp, this.dominatingExpression); } - // In .NET 10, array.Contains() generates two compiler patterns for T[]→ReadOnlySpan: - // 1. MethodCallExpression(op_Implicit, [array_expr]) — simple local capture - // 2. InvocationExpression(ConstantExpression(pre_compiled_delegate), []) — nested closure /// - /// Searches for an array of the given type in a delegate's closure using BFS. - /// Handles two closure structures produced by the .NET runtime: - /// - /// Display class — a generated class with a direct T[] field (regular C# closure) - /// Runtime Closure — Object[] fields (Constants/Locals) used by compiled expression lambdas, - /// may be nested: outer Closure → inner Delegate → inner Closure with the actual array - /// + /// Searches for an array of the given type in a delegate's closure (BFS). + /// Handles direct T[] fields (display class) and Object[] fields (runtime Closure), + /// including nested delegates whose closures contain the array. /// private static object FindArrayInClosure(object root, Type arrayType) { var queue = new System.Collections.Generic.Queue(); From 3656d73021fe3aa297c4024b67e125dba4c3b2a7 Mon Sep 17 00:00:00 2001 From: Aleksandr Korchak Date: Tue, 24 Mar 2026 14:00:58 +0000 Subject: [PATCH 10/11] =?UTF-8?q?refactor:=20replace=20BFS=20Queue=20with?= =?UTF-8?q?=20recursion=20=E2=80=94=20no=20heap=20allocation=20for=20shall?= =?UTF-8?q?ow=20closure=20depth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SqlClient/Query/QueryConverter.cs | 44 +++++++++---------- 1 file changed, 21 insertions(+), 23 deletions(-) diff --git a/Mindbox.Data.Linq/SqlClient/Query/QueryConverter.cs b/Mindbox.Data.Linq/SqlClient/Query/QueryConverter.cs index ff73af3..140b2a6 100644 --- a/Mindbox.Data.Linq/SqlClient/Query/QueryConverter.cs +++ b/Mindbox.Data.Linq/SqlClient/Query/QueryConverter.cs @@ -1775,30 +1775,28 @@ private SqlExpression GetAggregate(SqlNodeType aggType, Type clrType, SqlExpress } /// - /// Searches for an array of the given type in a delegate's closure (BFS). + /// 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 nested delegates whose closures contain the array. + /// including delegates nested inside Object[] fields (up to depth 3). /// - private static object FindArrayInClosure(object root, Type arrayType) { - var queue = new System.Collections.Generic.Queue(); - queue.Enqueue(root); - while (queue.Count > 0) { - var target = queue.Dequeue(); - if (target == null) { - continue; - } - 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) { - queue.Enqueue(nested.Target); + 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; } } } @@ -1815,7 +1813,7 @@ private static Expression TryExtractArrayFromSpanExpression(Expression spanExpr) 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); + var array = FindArrayInClosure(target, arrayType, depth: 0); if (array != null) { return Expression.Constant(array, arrayType); } From 658dd3f9420850470c23bcad9424c5e77191ede7 Mon Sep 17 00:00:00 2001 From: Aleksandr Korchak Date: Tue, 24 Mar 2026 14:03:09 +0000 Subject: [PATCH 11/11] fix: remove redundant depth: 0 in FindArrayInClosure call --- Mindbox.Data.Linq/SqlClient/Query/QueryConverter.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mindbox.Data.Linq/SqlClient/Query/QueryConverter.cs b/Mindbox.Data.Linq/SqlClient/Query/QueryConverter.cs index 140b2a6..557a3a2 100644 --- a/Mindbox.Data.Linq/SqlClient/Query/QueryConverter.cs +++ b/Mindbox.Data.Linq/SqlClient/Query/QueryConverter.cs @@ -1813,7 +1813,7 @@ private static Expression TryExtractArrayFromSpanExpression(Expression spanExpr) 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, depth: 0); + var array = FindArrayInClosure(target, arrayType); if (array != null) { return Expression.Constant(array, arrayType); }