From b1a0668dca381a33724bf9b882ada19ca04f7663 Mon Sep 17 00:00:00 2001 From: Alexey Kulakov Date: Fri, 20 Mar 2026 12:35:16 +0500 Subject: [PATCH 1/2] Handle .net10 ReadOnlySpan.Contains() Thought we do not build for .net 10, there is a chance that our assembly can be used with it. Extensions already exist, so they can be handled just fine --- .../Core/Extensions/ExpressionExtensions.cs | 26 +++++++ Orm/Xtensive.Orm/Linq/ExpressionExtensions.cs | 67 +++++++++++++++++++ .../Linq/Rewriters/EntitySetAccessRewriter.cs | 2 +- .../Orm/Linq/Translator.Expressions.cs | 11 +++ .../Expressions/ExpressionProcessor.cs | 4 ++ 5 files changed, 109 insertions(+), 1 deletion(-) diff --git a/Orm/Xtensive.Orm/Core/Extensions/ExpressionExtensions.cs b/Orm/Xtensive.Orm/Core/Extensions/ExpressionExtensions.cs index 7612bf4b9..23e2a8fa3 100644 --- a/Orm/Xtensive.Orm/Core/Extensions/ExpressionExtensions.cs +++ b/Orm/Xtensive.Orm/Core/Extensions/ExpressionExtensions.cs @@ -315,6 +315,32 @@ public static Expression StripMemberAccessChain(this Expression expression) return expression; } + /// + /// Strips implicit cast operators calls. + /// + /// Expression to process. + /// with chan of implicit casts removed (if any). + public static Expression StripImplicitCast(this Expression expression) + { + while (expression.NodeType is ExpressionType.Call or ExpressionType.Convert or ExpressionType.ConvertChecked) { + if (expression.NodeType == ExpressionType.Call) { + var mc = expression as MethodCallExpression; + if (mc.Method.Name.Equals(WellKnown.Operator.Implicit, StringComparison.Ordinal)) + expression = mc.Arguments[0]; + else + break; + } + else { + var unary = expression as UnaryExpression; + if (unary.Method is not null && unary.Method.Name.Equals(WellKnown.Operator.Implicit, StringComparison.Ordinal)) + expression = unary.Operand; + else + break; + } + } + return expression; + } + #endregion } } \ No newline at end of file diff --git a/Orm/Xtensive.Orm/Linq/ExpressionExtensions.cs b/Orm/Xtensive.Orm/Linq/ExpressionExtensions.cs index 1b8c31d5e..60b92e9d4 100644 --- a/Orm/Xtensive.Orm/Linq/ExpressionExtensions.cs +++ b/Orm/Xtensive.Orm/Linq/ExpressionExtensions.cs @@ -6,6 +6,7 @@ using System; using System.Collections.Concurrent; +using System.Collections.Generic; using System.Linq; using System.Linq.Expressions; using System.Reflection; @@ -27,6 +28,10 @@ public static class ExpressionExtensions private static readonly Func TupleValueAccessorFactory; + private static readonly Type MemoryExtensionsType = typeof(MemoryExtensions); + private static readonly int[] MemoryExtensionsContainsMethodTokens; + private static readonly MethodInfo EnumerableContains; + /// /// Makes method call. /// @@ -72,6 +77,46 @@ public static Expression LiftToNullable(this Expression expression) => /// Expression tree that wraps . public static ExpressionTree ToExpressionTree(this Expression expression) => new ExpressionTree(expression); + /// + /// Transforms applied call into + /// if detected. + /// + /// Possible candidate for transformation. + /// New instance of expression, if transformation was required, otherwise, the same expression. + public static MethodCallExpression TryTransformToOldFashionContains(this MethodCallExpression mc) + { + if (mc.Method.DeclaringType == MemoryExtensionsType) { + var genericMethod = mc.Method.GetGenericMethodDefinition(); + if (MemoryExtensionsContainsMethodTokens.Contains(genericMethod.MetadataToken)) { + var arguments = mc.Arguments; + + Type elementType; + Expression[] newArguments; + + if (arguments[0] is MethodCallExpression mcInner && mcInner.Method.Name.Equals(WellKnown.Operator.Implicit, StringComparison.Ordinal)) { + var wrappedArray = mcInner.Arguments[0]; + elementType = wrappedArray.Type.GetElementType(); + newArguments = new[] { wrappedArray, arguments[1] }; + } + else if (arguments[0] is UnaryExpression uInner + && uInner.Method is not null + && uInner.Method.Name.Equals(WellKnown.Operator.Implicit, StringComparison.Ordinal)) { + + elementType = uInner.Operand.Type.GetElementType(); + newArguments = new[] { uInner.Operand, arguments[1] }; + } + else { + return mc; + } + + var genericContains = EnumerableContains.CachedMakeGenericMethod(elementType); + var replacement = Expression.Call(genericContains, newArguments); + return replacement; + } + return mc; + } + return mc; + } // Type initializer @@ -80,6 +125,28 @@ static ExpressionExtensions() var tupleGenericAccessor = WellKnownOrmTypes.Tuple.GetMethods() .Single(mi => mi.Name == nameof(Tuple.GetValueOrDefault) && mi.IsGenericMethod); TupleValueAccessorFactory = type => tupleGenericAccessor.CachedMakeGenericMethod(type); + + var genericReadOnlySpan = typeof(ReadOnlySpan<>); + var genericSpan = typeof(Span<>); + + var filteredByNameItems = MemoryExtensionsType.GetMethods(BindingFlags.Public | BindingFlags.Static) + .Where(m => m.Name.Equals(nameof(System.MemoryExtensions.Contains), StringComparison.OrdinalIgnoreCase)); + + var candiates = new List(); + + foreach (var method in filteredByNameItems) { + var parameters = method.GetParameters(); + var genericDef = parameters[0].ParameterType.GetGenericTypeDefinition(); + if (genericDef == genericReadOnlySpan) { + if (parameters.Length == 2 || parameters.Length == 3) + candiates.Add(method.MetadataToken); + } + else if (genericDef == genericSpan && parameters.Length == 2) { + candiates.Add(method.MetadataToken); + } + } + MemoryExtensionsContainsMethodTokens = candiates.ToArray(); + EnumerableContains = typeof(System.Linq.Enumerable).GetMethodEx(nameof(System.Linq.Enumerable.Contains), BindingFlags.Public | BindingFlags.Static, new string[1], new object[2]); } } } \ No newline at end of file diff --git a/Orm/Xtensive.Orm/Orm/Linq/Rewriters/EntitySetAccessRewriter.cs b/Orm/Xtensive.Orm/Orm/Linq/Rewriters/EntitySetAccessRewriter.cs index d03828779..df96e1923 100644 --- a/Orm/Xtensive.Orm/Orm/Linq/Rewriters/EntitySetAccessRewriter.cs +++ b/Orm/Xtensive.Orm/Orm/Linq/Rewriters/EntitySetAccessRewriter.cs @@ -26,7 +26,7 @@ protected override Expression VisitMethodCall(MethodCallExpression mc) return base.VisitMethodCall(mc); var method = mc.Method; - if (method.Name=="Contains" && mc.Object!=null) { + if (method.Name == Reflection.WellKnown.Queryable.Contains && mc.Object!=null) { var elementType = GetEntitySetElementType(mc.Object.Type); var actualMethod = WellKnownMembers.Queryable.Contains.CachedMakeGenericMethod(elementType); return Expression.Call(actualMethod, Visit(mc.Object), Visit(mc.Arguments[0])); diff --git a/Orm/Xtensive.Orm/Orm/Linq/Translator.Expressions.cs b/Orm/Xtensive.Orm/Orm/Linq/Translator.Expressions.cs index 8f5e0a6a4..02894d177 100644 --- a/Orm/Xtensive.Orm/Orm/Linq/Translator.Expressions.cs +++ b/Orm/Xtensive.Orm/Orm/Linq/Translator.Expressions.cs @@ -530,6 +530,17 @@ protected override Expression VisitMethodCall(MethodCallExpression mc) } } + if (methodDeclaringType == typeof(MemoryExtensions)) { + var parameters = method.GetParameters(); + + if (methodName.Equals(nameof(MemoryExtensions.Contains), StringComparison.Ordinal)) { + // There might be 2 or 3 arguments. + // In case of three, last one is IEqualityComparer which will probably have default value + // Comparer doesn't matter in context of our queries, so we ignore it + return VisitContains(mc.Arguments[0].StripImplicitCast(), mc.Arguments[1], false); + } + } + // Process local collections if (mc.Object.IsLocalCollection(context)) { diff --git a/Orm/Xtensive.Orm/Orm/Providers/Expressions/ExpressionProcessor.cs b/Orm/Xtensive.Orm/Orm/Providers/Expressions/ExpressionProcessor.cs index 97ad4c715..a3fb4f667 100644 --- a/Orm/Xtensive.Orm/Orm/Providers/Expressions/ExpressionProcessor.cs +++ b/Orm/Xtensive.Orm/Orm/Providers/Expressions/ExpressionProcessor.cs @@ -412,6 +412,10 @@ protected override SqlExpression VisitMethodCall(MethodCallExpression mc) if (mc.AsTupleAccess(activeParameters) != null) return VisitTupleAccess(mc); + if (mc.Method.Name.Equals(nameof(Enumerable.Contains), StringComparison.Ordinal)) { + // there might be "innovative" implicit cast to ReadOnlySpan inside, which is not supported by expression tree but yet existing + mc = mc.TryTransformToOldFashionContains(); + } var arguments = mc.Arguments.SelectToArray(a => Visit(a)); var mi = mc.Method; From a46bbc1d8a957dcdb08d7308e61f146e8ad3a1d0 Mon Sep 17 00:00:00 2001 From: Alexey Kulakov Date: Mon, 23 Mar 2026 11:34:23 +0500 Subject: [PATCH 2/2] Improve changelog --- ChangeLog/7.2.2-dev.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ChangeLog/7.2.2-dev.txt b/ChangeLog/7.2.2-dev.txt index 38b6795c5..bfa2b3645 100644 --- a/ChangeLog/7.2.2-dev.txt +++ b/ChangeLog/7.2.2-dev.txt @@ -1,2 +1,3 @@ [main] Query.CreateDelayedQuery(key, Func>) applies external key instead of default computed, as it suppose to -[main] QueryEndpoint.SingleAsync()/SingleOrDefaultAsync() get overloads that can recieve one key value as parameter without need to create array explicitly \ No newline at end of file +[main] QueryEndpoint.SingleAsync()/SingleOrDefaultAsync() get overloads that can recieve one key value as parameter without need to create array explicitly +[main] Support for C#14+ optimization that applies ReadOnlySpan.Contains() extension instead of IEnumerable.Contains() one to arrays \ No newline at end of file