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
2 changes: 1 addition & 1 deletion eng/Versions.props
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
-->
<StabilizePackageVersion Condition="'$(StabilizePackageVersion)' == ''">false</StabilizePackageVersion>
<DotNetFinalVersionKind Condition="'$(StabilizePackageVersion)' == 'true'">release</DotNetFinalVersionKind>
<DefaultNetCoreTargetFramework>net9.0</DefaultNetCoreTargetFramework>
<DefaultNetCoreTargetFramework>net10.0</DefaultNetCoreTargetFramework>
</PropertyGroup>
<PropertyGroup Label="Dependencies from nuget.org">
<MicrosoftBuildFrameworkVersion>16.0.461</MicrosoftBuildFrameworkVersion>
Expand Down
80 changes: 80 additions & 0 deletions src/EntityFramework/Core/Objects/ELinq/LinqExpressionNormalizer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -38,6 +39,20 @@ internal class LinqExpressionNormalizer : EntityExpressionVisitor
// </summary>
private readonly Dictionary<Expression, Pattern> _patterns = new Dictionary<Expression, Pattern>();

#if NETSTANDARD2_1
// Cache for Enumerable.Contains method lookup to avoid repeated reflection
private static readonly Lazy<MethodInfo> _enumerableContainsMethod = new Lazy<MethodInfo>(() =>
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<MethodInfo> _enumerableSequenceEqualMethod = new Lazy<MethodInfo>(() =>
typeof(System.Linq.Enumerable).GetMethods()
.Where(method => method.Name == nameof(Enumerable.SequenceEqual) && method.GetParameters().Length == 2)
.Single());
#endif

// <summary>
// Handle binary patterns:
// - VB 'Is' operator
Expand Down Expand Up @@ -124,6 +139,32 @@ private static bool IsConstantZero(Expression expression)
((ConstantExpression)expression).Value.Equals(0);
}

#if NETSTANDARD2_1
// <summary>
// 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.
// </summary>
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

// <summary>
// Handles MethodCall patterns:
// - Operator overloads
Expand All @@ -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)
Expand Down
31 changes: 29 additions & 2 deletions test/FunctionalTests/Query/LinqToEntities/ContainsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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);
}
}
}
}
179 changes: 178 additions & 1 deletion test/UnitTests/Core/Objects/ELinq/LinqExpressionNormalizerTests.cs
Original file line number Diff line number Diff line change
@@ -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
{
Expand All @@ -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<T>(ReadOnlySpan<T>, T)
var array = new[] { "Title1", "Title2", "Title3" };
var testValue = "Title1";

var arrayExpr = Expression.Constant(array);
var testValueExpr = Expression.Constant(testValue);

// Get ReadOnlySpan<T>.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<T>(ReadOnlySpan<T>, T)
// Note: There are both Span<T> and ReadOnlySpan<T> 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<MethodCallExpression>(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<ConstantExpression>(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<T>(Span<T>, T)
var array = new[] { "Title1", "Title2", "Title3" };
var testValue = "Title1";

var arrayExpr = Expression.Constant(array);
var testValueExpr = Expression.Constant(testValue);

// Get Span<T>.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<T>(Span<T>, 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<MethodCallExpression>(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<ConstantExpression>(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<T>(ReadOnlySpan<T>, T, IEqualityComparer<T>) 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<string>));

// Get ReadOnlySpan<T>.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<T>(ReadOnlySpan<T>, T, IEqualityComparer<T>)
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<MethodCallExpression>(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<T> 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<T>(ReadOnlySpan<T>, ReadOnlySpan<T>) - the generic method with 2 parameters
// Note: There are both Span<T> and ReadOnlySpan<T> 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<MethodCallExpression>(normalized);
Assert.Equal(nameof(Enumerable.SequenceEqual), normalizedCall.Method.Name);
Assert.Equal(typeof(Enumerable), normalizedCall.Method.DeclaringType);
Assert.Equal(2, normalizedCall.Arguments.Count);
}

#endif
}
}
17 changes: 14 additions & 3 deletions test/UnitTests/UnitTests.csproj
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<RootNamespace>System.Data.Entity</RootNamespace>
<AssemblyName>EntityFramework.UnitTests</AssemblyName>
<TargetFramework>$(NetFrameworkToolCurrent)</TargetFramework>
<TargetFrameworks>$(NetFrameworkToolCurrent);$(DefaultNetCoreTargetFramework)</TargetFrameworks>
Comment thread
ericsampson marked this conversation as resolved.
</PropertyGroup>

<ItemGroup>
<ItemGroup Condition="'$(TargetFrameworkIdentifier)' == '.NETFramework'">
<Reference Include="Microsoft.VisualBasic" />
<Reference Include="Microsoft.CSharp" />
<Reference Include="System.ComponentModel.DataAnnotations" />
Expand All @@ -16,6 +16,17 @@
<Reference Include="System.Windows.Forms" />
</ItemGroup>

<!--
For .NET 10+ builds, only include Span-related tests. The vast majority of the UnitTests project
depends on .NET Framework-specific infrastructure (SqlServerCe, Remoting, BinaryFormatter, etc.)
and was not designed for cross-platform use. Rather than exclude hundreds of tests, we include
only the tests relevant for .NET 10+ scenarios.
-->
<ItemGroup Condition="'$(TargetFramework)' == '$(DefaultNetCoreTargetFramework)'">
<Compile Remove="**\*.cs" />
<Compile Include="**\LinqExpressionNormalizerTests.cs" />
</ItemGroup>

<ItemGroup>
<None Remove="TestDataFiles\SqlOperation_Basic.sql" />
</ItemGroup>
Expand Down