diff --git a/eng/Versions.props b/eng/Versions.props index bba3a3f37d..df01f21c29 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -12,7 +12,7 @@ --> false release - net9.0 + net10.0 16.0.461 diff --git a/src/EntityFramework/Core/Objects/ELinq/LinqExpressionNormalizer.cs b/src/EntityFramework/Core/Objects/ELinq/LinqExpressionNormalizer.cs index 680c2499fc..3e08a4d8b8 100644 --- a/src/EntityFramework/Core/Objects/ELinq/LinqExpressionNormalizer.cs +++ b/src/EntityFramework/Core/Objects/ELinq/LinqExpressionNormalizer.cs @@ -7,6 +7,7 @@ namespace System.Data.Entity.Core.Objects.ELinq using System.Data.Entity.Utilities; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; + using System.Linq; using System.Linq.Expressions; using System.Reflection; @@ -38,6 +39,20 @@ internal class LinqExpressionNormalizer : EntityExpressionVisitor // private readonly Dictionary _patterns = new Dictionary(); +#if NETSTANDARD2_1 + // Cache for Enumerable.Contains method lookup to avoid repeated reflection + private static readonly Lazy _enumerableContainsMethod = new Lazy(() => + typeof(System.Linq.Enumerable).GetMethods() + .Where(method => method.Name == nameof(Enumerable.Contains) && method.GetParameters().Length == 2) + .Single()); + + // Cache for Enumerable.SequenceEqual method lookup to avoid repeated reflection + private static readonly Lazy _enumerableSequenceEqualMethod = new Lazy(() => + typeof(System.Linq.Enumerable).GetMethods() + .Where(method => method.Name == nameof(Enumerable.SequenceEqual) && method.GetParameters().Length == 2) + .Single()); +#endif + // // Handle binary patterns: // - VB 'Is' operator @@ -124,6 +139,32 @@ private static bool IsConstantZero(Expression expression) ((ConstantExpression)expression).Value.Equals(0); } +#if NETSTANDARD2_1 + // + // Attempts to unwrap an implicit Span or ReadOnlySpan cast expression to get the underlying array or collection. + // This is used to rewrite MemoryExtensions methods that operate on Span/ReadOnlySpan back to Enumerable methods. + // + private static bool TryUnwrapSpanImplicitCast(Expression expression, out Expression result) + { + if (expression is MethodCallExpression methodCallExpr + && methodCallExpr.Method.Name == "op_Implicit" + && methodCallExpr.Method.DeclaringType != null + && methodCallExpr.Method.DeclaringType.IsGenericType + && methodCallExpr.Arguments.Count == 1) + { + var genericTypeDefinition = methodCallExpr.Method.DeclaringType.GetGenericTypeDefinition(); + if (genericTypeDefinition == typeof(Span<>) || genericTypeDefinition == typeof(ReadOnlySpan<>)) + { + result = methodCallExpr.Arguments[0]; + return true; + } + } + + result = null; + return false; + } +#endif + // // Handles MethodCall patterns: // - Operator overloads @@ -132,6 +173,45 @@ private static bool IsConstantZero(Expression expression) [SuppressMessage("Microsoft.Maintainability", "CA1502:AvoidExcessiveComplexity")] internal override Expression VisitMethodCall(MethodCallExpression m) { +#if NETSTANDARD2_1 + // .NET 10 made changes to overload resolution to prefer Span-based overloads when those exist ("first-class spans"). + // Unfortunately, EF6's query translator does not recognize MemoryExtensions methods, so we rewrite e.g. + // MemoryExtensions.Contains to Enumerable.Contains here. + // See: + // https://github.com/dotnet/ef6/issues/2322 + // https://github.com/dotnet/runtime/issues/109757 + // https://github.com/dotnet/efcore/pull/35339 + // Note: This check must occur before base.VisitMethodCall() to detect op_Implicit before it's normalized to Convert. + if (m.Method.DeclaringType == typeof(MemoryExtensions)) + { + switch (m.Method.Name) + { + // Note that MemoryExtensions.Contains has an optional 3rd ComparisonType parameter; we only match when + // it's null. + case nameof(MemoryExtensions.Contains) + when m.Arguments.Count >= 2 + && (m.Arguments.Count == 2 + || m.Arguments.Count == 3 && m.Arguments[2] is ConstantExpression constantExpr && constantExpr.Value == null) + && TryUnwrapSpanImplicitCast(m.Arguments[0], out var unwrappedSpanArg): + { + var containsMethod = _enumerableContainsMethod.Value.MakeGenericMethod(m.Method.GetGenericArguments()[0]); + + return Visit(Expression.Call(containsMethod, unwrappedSpanArg, m.Arguments[1])); + } + + case nameof(MemoryExtensions.SequenceEqual) + when m.Arguments.Count == 2 + && TryUnwrapSpanImplicitCast(m.Arguments[0], out var unwrappedSpanArg1) + && TryUnwrapSpanImplicitCast(m.Arguments[1], out var unwrappedSpanArg2): + { + var sequenceEqualMethod = _enumerableSequenceEqualMethod.Value.MakeGenericMethod(m.Method.GetGenericArguments()[0]); + + return Visit(Expression.Call(sequenceEqualMethod, unwrappedSpanArg1, unwrappedSpanArg2)); + } + } + } +#endif + m = (MethodCallExpression)base.VisitMethodCall(m); if (m.Method.IsStatic) diff --git a/test/FunctionalTests/Query/LinqToEntities/ContainsTests.cs b/test/FunctionalTests/Query/LinqToEntities/ContainsTests.cs index fe948ea948..d59d078095 100644 --- a/test/FunctionalTests/Query/LinqToEntities/ContainsTests.cs +++ b/test/FunctionalTests/Query/LinqToEntities/ContainsTests.cs @@ -288,8 +288,8 @@ public void Contains_throws_for_constant_null_list() public void Contains_on_non_static_collection_of_enums() { const string expectedSql = - @"SELECT -CASE WHEN ( EXISTS (SELECT + @"SELECT +CASE WHEN ( EXISTS (SELECT 1 AS [C1] FROM [dbo].[Books] AS [Extent2] WHERE 0 = [Extent2].[Genre] @@ -305,5 +305,32 @@ FROM [dbo].[Books] AS [Extent2] QueryTestHelpers.VerifyDbQuery(query, expectedSql); } } + + [Fact] + public void EnumerableContains_with_string_array_on_net10_is_translated_to_expected_sql() + { + // On .NET 10+, the C# compiler prefers Span-based overloads ("first-class spans"). + // This means array.Contains(value) compiles to MemoryExtensions.Contains(array.AsSpan(), value) + // instead of Enumerable.Contains(array, value). EF6 needs to rewrite these back to + // Enumerable.Contains for the LINQ interpreter to work. + const string expectedSql = + @"SELECT +[Extent1].[Id] AS [Id] +FROM [dbo].[Books] AS [Extent1] +WHERE [Extent1].[Title] IN (N'Title1', N'Title2')"; + + var array = new[] { "Title1", "Title2" }; + + using (var context = new UnicodeContext()) + { + context.Configuration.UseDatabaseNullSemantics = true; + + var query = from book in context.Books + where array.Contains(book.Title) + select book.Id; + + QueryTestHelpers.VerifyDbQuery(query, expectedSql); + } + } } } diff --git a/test/UnitTests/Core/Objects/ELinq/LinqExpressionNormalizerTests.cs b/test/UnitTests/Core/Objects/ELinq/LinqExpressionNormalizerTests.cs index a93553bb31..26436665c7 100644 --- a/test/UnitTests/Core/Objects/ELinq/LinqExpressionNormalizerTests.cs +++ b/test/UnitTests/Core/Objects/ELinq/LinqExpressionNormalizerTests.cs @@ -1,8 +1,13 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.txt in the project root for license information. +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.txt in the project root for license information. namespace System.Data.Entity.Core.Objects.ELinq { using Xunit; +#if NET5_0_OR_GREATER + using System.Linq; + using System.Linq.Expressions; + using System.Reflection; +#endif public class LinqExpressionNormalizerTests { @@ -11,5 +16,177 @@ public void MethodInfo_fields_are_initialized() { Assert.NotNull(LinqExpressionNormalizer.RelationalOperatorPlaceholderMethod); } + +#if NET5_0_OR_GREATER + + [Fact] + public void MemoryExtensions_Contains_with_ReadOnlySpan_is_rewritten_to_Enumerable_Contains() + { + // Test: MemoryExtensions.Contains(ReadOnlySpan, T) + var array = new[] { "Title1", "Title2", "Title3" }; + var testValue = "Title1"; + + var arrayExpr = Expression.Constant(array); + var testValueExpr = Expression.Constant(testValue); + + // Get ReadOnlySpan.op_Implicit method + var spanType = typeof(ReadOnlySpan<>).MakeGenericType(typeof(string)); + var implicitMethod = spanType.GetMethod("op_Implicit", new[] { typeof(string[]) }); + var spanExpr = Expression.Call(implicitMethod, arrayExpr); + + // Get MemoryExtensions.Contains(ReadOnlySpan, T) + // Note: There are both Span and ReadOnlySpan versions, we want ReadOnlySpan + var memExtContainsGeneric = typeof(MemoryExtensions).GetMethods(BindingFlags.Public | BindingFlags.Static) + .Where(m => m.Name == nameof(MemoryExtensions.Contains) + && m.IsGenericMethodDefinition + && m.GetParameters().Length == 2 + && m.GetParameters()[0].ParameterType.GetGenericTypeDefinition() == typeof(ReadOnlySpan<>)) + .Single(); + var memExtContains = memExtContainsGeneric.MakeGenericMethod(typeof(string)); + + var methodCall = Expression.Call(memExtContains, spanExpr, testValueExpr); + + // Normalize the expression + var normalizer = new LinqExpressionNormalizer(); + var normalized = normalizer.Visit(methodCall); + + // Verify it was rewritten to Enumerable.Contains + var normalizedCall = Assert.IsAssignableFrom(normalized); + Assert.Equal(nameof(Enumerable.Contains), normalizedCall.Method.Name); + Assert.Equal(typeof(Enumerable), normalizedCall.Method.DeclaringType); + Assert.Equal(2, normalizedCall.Arguments.Count); + + // The first argument should be the unwrapped array + Assert.IsAssignableFrom(normalizedCall.Arguments[0]); + var unwrappedArray = ((ConstantExpression)normalizedCall.Arguments[0]).Value; + Assert.Same(array, unwrappedArray); + } + + [Fact] + public void MemoryExtensions_Contains_with_Span_is_rewritten_to_Enumerable_Contains() + { + // Test: MemoryExtensions.Contains(Span, T) + var array = new[] { "Title1", "Title2", "Title3" }; + var testValue = "Title1"; + + var arrayExpr = Expression.Constant(array); + var testValueExpr = Expression.Constant(testValue); + + // Get Span.op_Implicit method + var spanType = typeof(Span<>).MakeGenericType(typeof(string)); + var implicitMethod = spanType.GetMethod("op_Implicit", new[] { typeof(string[]) }); + var spanExpr = Expression.Call(implicitMethod, arrayExpr); + + // Get MemoryExtensions.Contains(Span, T) + var memExtContainsGeneric = typeof(MemoryExtensions).GetMethods(BindingFlags.Public | BindingFlags.Static) + .Where(m => m.Name == nameof(MemoryExtensions.Contains) + && m.IsGenericMethodDefinition + && m.GetParameters().Length == 2 + && m.GetParameters()[0].ParameterType.GetGenericTypeDefinition() == typeof(Span<>)) + .Single(); + var memExtContains = memExtContainsGeneric.MakeGenericMethod(typeof(string)); + + var methodCall = Expression.Call(memExtContains, spanExpr, testValueExpr); + + // Normalize the expression + var normalizer = new LinqExpressionNormalizer(); + var normalized = normalizer.Visit(methodCall); + + // Verify it was rewritten to Enumerable.Contains + var normalizedCall = Assert.IsAssignableFrom(normalized); + Assert.Equal(nameof(Enumerable.Contains), normalizedCall.Method.Name); + Assert.Equal(typeof(Enumerable), normalizedCall.Method.DeclaringType); + Assert.Equal(2, normalizedCall.Arguments.Count); + + // The first argument should be the unwrapped array + Assert.IsAssignableFrom(normalizedCall.Arguments[0]); + var unwrappedArray = ((ConstantExpression)normalizedCall.Arguments[0]).Value; + Assert.Same(array, unwrappedArray); + } + +#if NET10_0_OR_GREATER + [Fact] + public void MemoryExtensions_Contains_with_null_comparer_is_rewritten() + { + // Test: MemoryExtensions.Contains(ReadOnlySpan, T, IEqualityComparer) with null comparer + // Note: This 3-parameter overload was added in .NET 10 + // The fix rewrites this when the comparer parameter is null + var array = new[] { "Title1", "Title2", "Title3" }; + var testValue = "Title1"; + + var arrayExpr = Expression.Constant(array); + var testValueExpr = Expression.Constant(testValue); + var nullComparerExpr = Expression.Constant(null, typeof(System.Collections.Generic.IEqualityComparer)); + + // Get ReadOnlySpan.op_Implicit method + var spanType = typeof(ReadOnlySpan<>).MakeGenericType(typeof(string)); + var implicitMethod = spanType.GetMethod("op_Implicit", new[] { typeof(string[]) }); + var spanExpr = Expression.Call(implicitMethod, arrayExpr); + + // Get MemoryExtensions.Contains(ReadOnlySpan, T, IEqualityComparer) + var memExtContainsGeneric = typeof(MemoryExtensions).GetMethods(BindingFlags.Public | BindingFlags.Static) + .Where(m => m.Name == nameof(MemoryExtensions.Contains) + && m.IsGenericMethodDefinition + && m.GetParameters().Length == 3 + && m.GetParameters()[0].ParameterType.GetGenericTypeDefinition() == typeof(ReadOnlySpan<>) + && m.GetParameters()[2].ParameterType.GetGenericTypeDefinition() == typeof(System.Collections.Generic.IEqualityComparer<>)) + .Single(); + var memExtContains = memExtContainsGeneric.MakeGenericMethod(typeof(string)); + + var methodCall = Expression.Call(memExtContains, spanExpr, testValueExpr, nullComparerExpr); + + // Normalize the expression + var normalizer = new LinqExpressionNormalizer(); + var normalized = normalizer.Visit(methodCall); + + // Verify it was rewritten to Enumerable.Contains + var normalizedCall = Assert.IsAssignableFrom(normalized); + Assert.Equal(nameof(Enumerable.Contains), normalizedCall.Method.Name); + Assert.Equal(typeof(Enumerable), normalizedCall.Method.DeclaringType); + Assert.Equal(2, normalizedCall.Arguments.Count); + } +#endif + + [Fact] + public void MemoryExtensions_SequenceEqual_is_rewritten_to_Enumerable_SequenceEqual() + { + // Test that SequenceEqual is also rewritten correctly + + var array1 = new[] { 1, 2, 3 }; + var array2 = new[] { 1, 2, 3 }; + + var array1Expr = Expression.Constant(array1); + var array2Expr = Expression.Constant(array2); + + // Get the Span op_Implicit method + var spanType = typeof(ReadOnlySpan<>).MakeGenericType(typeof(int)); + var implicitMethod = spanType.GetMethod("op_Implicit", new[] { typeof(int[]) }); + var span1Expr = Expression.Call(implicitMethod, array1Expr); + var span2Expr = Expression.Call(implicitMethod, array2Expr); + + // Get MemoryExtensions.SequenceEqual(ReadOnlySpan, ReadOnlySpan) - the generic method with 2 parameters + // Note: There are both Span and ReadOnlySpan versions, we want ReadOnlySpan + var memExtSequenceEqualGeneric = typeof(MemoryExtensions).GetMethods(BindingFlags.Public | BindingFlags.Static) + .Where(m => m.Name == nameof(MemoryExtensions.SequenceEqual) + && m.IsGenericMethodDefinition + && m.GetParameters().Length == 2 + && m.GetParameters()[0].ParameterType.GetGenericTypeDefinition() == typeof(ReadOnlySpan<>)) + .Single(); + var memExtSequenceEqual = memExtSequenceEqualGeneric.MakeGenericMethod(typeof(int)); + + var methodCall = Expression.Call(memExtSequenceEqual, span1Expr, span2Expr); + + // Normalize the expression + var normalizer = new LinqExpressionNormalizer(); + var normalized = normalizer.Visit(methodCall); + + // Verify it was rewritten to Enumerable.SequenceEqual + var normalizedCall = Assert.IsAssignableFrom(normalized); + Assert.Equal(nameof(Enumerable.SequenceEqual), normalizedCall.Method.Name); + Assert.Equal(typeof(Enumerable), normalizedCall.Method.DeclaringType); + Assert.Equal(2, normalizedCall.Arguments.Count); + } + +#endif } } diff --git a/test/UnitTests/UnitTests.csproj b/test/UnitTests/UnitTests.csproj index 0ae59776ae..7cfba91091 100644 --- a/test/UnitTests/UnitTests.csproj +++ b/test/UnitTests/UnitTests.csproj @@ -1,12 +1,12 @@ - + System.Data.Entity EntityFramework.UnitTests - $(NetFrameworkToolCurrent) + $(NetFrameworkToolCurrent);$(DefaultNetCoreTargetFramework) - + @@ -16,6 +16,17 @@ + + + + + +