Query: Fixes LINQ.Contains to handle Memory Span implicit conversion introduced in C#13/DotNet9#5477
Query: Fixes LINQ.Contains to handle Memory Span implicit conversion introduced in C#13/DotNet9#5477leminh98 wants to merge 4 commits into
Conversation
| if (type == typeof(Enumerable) || type == typeof(Queryable) || type == typeof(CosmosLinq)) | ||
|
|
||
| // Aside from the known types, we also need to avoid partial eval for op_implicit methods, which are the implicit conversions of enum to memoryextension span types (introduced in c#13) | ||
| if (type == typeof(Enumerable) || type == typeof(Queryable) || type == typeof(CosmosLinq) || methodCallExpression.Method.Name == "op_Implicit") |
There was a problem hiding this comment.
Please make the check tighter so that we only let through those on_implicit calls that we support.
There was a problem hiding this comment.
For a check that's broader than what we support, please add negative coverage for those cases.
There was a problem hiding this comment.
Please also guard consts (for downstream success).
| && methodCallExpression.Method.Name == "op_Implicit" | ||
| && methodCallExpression.Method.DeclaringType is { IsGenericType: true } implicitCastDeclaringType | ||
| && implicitCastDeclaringType.GetGenericTypeDefinition() is var genericTypeDefinition | ||
| && (genericTypeDefinition == typeof(Span<>) || genericTypeDefinition == typeof(ReadOnlySpan<>))) |
There was a problem hiding this comment.
Debug Assert inside if assuming this check is done at top level.
| using Microsoft.VisualStudio.TestTools.UnitTesting; | ||
|
|
||
| [TestClass] | ||
| public class Program |
There was a problem hiding this comment.
Please rename, turn this into a test project (if not already).
| @@ -0,0 +1,24 @@ | |||
| <?xml version="1.0" encoding="utf-8"?> | |||
There was a problem hiding this comment.
Please rename the project, class namespaces to something suitable that indicate these are .NET C#14 specific tests.
There was a problem hiding this comment.
Microsoft.Azure.Cosmos.Tests.C14
| } | ||
|
|
||
| ArrayContainsVisitor visitor = new ArrayContainsVisitor(); | ||
| return visitor.VisitIN(searchExpression, (ConstantExpression)searchList, context); |
| await container.CreateItemAsync(todoItem); | ||
| Console.WriteLine($"Created item: {todoItem.id}"); | ||
|
|
||
| string[] someStringArray = ["Learn Cosmos DB"]; |
| <EmulatorFlavor>master</EmulatorFlavor> | ||
| <DisableCopyEmulator>True</DisableCopyEmulator> | ||
| <LangVersion>$(LangVersion)</LangVersion> | ||
| <LangVersion>preview</LangVersion> |
| <TreatWarningsAsErrors>true</TreatWarningsAsErrors> | ||
| <Platform>AnyCPU</Platform> | ||
| <TargetFramework>net6.0</TargetFramework> | ||
| <TargetFramework>net9.0</TargetFramework> |
| List<LinqTestInput> inputs = new List<LinqTestInput> | ||
| { | ||
| { | ||
| //// Memory Span Conversion |
| Type type = methodCallExpression.Method.DeclaringType; | ||
| if (type == typeof(Enumerable) || type == typeof(Queryable) || type == typeof(CosmosLinq)) | ||
|
|
||
| // Aside from the known types, we also need to avoid partial eval for op_implicit methods, which are the implicit conversions of enum to memoryextension span types (introduced in c#13) |
|
Hi @leminh98 — this PR has been idle for ~145 days. What's the current ETA / status? Flagging as part of an open-PR cleanup pass; if it's no longer being pursued we can close it. No auto-close — just looking for a status update. |
|
Closed in favor of #5819 |
… queries (#5819) ## Summary Fixes #5518 - .NET 10 (C# 14) changed `array.Contains(x)` to use `MemoryExtensions.Contains` with `ReadOnlySpan<T>` instead of `Enumerable.Contains`. This breaks the LINQ-to-SQL translator. ## Architecture / Code Flow ```mermaid flowchart TD A["<b>User LINQ Query</b><br/>.Where(x => array.Contains(x))"] --> B["<b>C# Compiler</b>"] B -->|".NET 6-9"| C["Enumerable.Contains<T>(array, x)"] B -->|".NET 10+"| D["MemoryExtensions.Contains<T>(<br/>ReadOnlySpan<T>.op_Implicit(array), x)"] C --> E["<b>SQLTranslator.TranslateExpression()</b>"] D --> E E --> F["<b>Step 1: ConstantEvaluator.PartialEval</b><br/>Collapse evaluable sub-expressions to constants"] F --> G["<b>Step 2: ExpressionToSql</b><br/>Translate expression tree to SQL AST"] subgraph step1["Step 1: ConstantEvaluator (Nominator - bottom-up)"] direction TB F1["Visit op_Implicit(array)<br/><i>child node, visited first</i>"] F2{"DeclaringType is<br/>ReadOnlySpan or Span?"} F3["Block: return false<br/><i>ref struct cannot be boxed</i>"] F4["Propagates up:<br/>parent Contains also<br/>not nominated"] F1 --> F2 F2 -->|"Yes"| F3 F3 --> F4 end F --> step1 step1 --> G subgraph step2["Step 2: BuiltinFunctionVisitor (routing)"] direction TB G1{"IsMemoryExtensionsMethod?<br/>DeclaringType == typeof MemoryExtensions<br/>AND Name == Contains"} G2["Route to<br/>ArrayBuiltinFunctions"] G3["Not matched<br/>(IndexOf, SequenceEqual, etc.)"] G4["Throws DocumentQueryException<br/><i>Method X not supported</i>"] G1 -->|"Yes"| G2 G1 -->|"No"| G3 G3 --> G4 end G --> step2 subgraph step3["Step 3: ArrayBuiltinFunctions.VisitImplicit"] direction TB H1["UnwrapSpanImplicitConversion<br/>Detects op_Implicit on Span types"] H2["Extracts original array constant<br/>from args[0].Arguments[0]"] H3["Translates to SQL IN clause"] H1 --> H2 H2 --> H3 end G2 --> step3 step3 --> I["<b>Output SQL</b><br/>SELECT * FROM c WHERE c.Name IN ('a', 'b', 'c')"] ``` ### Without this fix (on .NET 10) The `ConstantEvaluator` attempts to evaluate `op_Implicit(array)` which invokes `ReadOnlySpan<T>.op_Implicit(T[])` and tries to box the ref struct result into `Expression.Constant`. This throws `NotSupportedException` which surfaces as a **"Method not supported"** error to the user. ## End-to-End Verification (.NET 10) Verified with a standalone .NET 10 (`net10.0`, `LangVersion=preview`) console app that references the SDK via `ProjectReference`. This app could not be checked in because CI pipelines do not yet support .NET 9/10, but the verification was run locally against the Cosmos DB Emulator. **All 8 scenarios pass:** | # | Scenario | Generated SQL | |---|----------|--------------| | 1 | `string[].Contains(x.Field)` | `root["Name"] IN ("Alice", "Bob")` | | 2 | `int[].Contains(x.Field)` | `root["Score"] IN (42, 99)` | | 3 | `Guid[].Contains(x.Field)` | `root["Tag"] IN ("aaaa...")` | | 4 | Full round-trip (insert + query + read) | 1 item returned | | 5 | `x.Children.Contains("value")` (array field on doc) | `ARRAY_CONTAINS(root["Children"], "child1")` | | 6 | `x.Children.Contains` round-trip | 1 item returned | | 7 | Customer exact repro: `array.Contains(x)` on simple type | `root IN ("a")` | | 8 | `enumerable.Contains(x)` still works | `root IN ("a")` | <details> <summary>Verification app code (click to expand)</summary> ```csharp // Temporary .NET 10 verification app for issue #5518 fix // Target: net10.0, LangVersion=preview // References Microsoft.Azure.Cosmos.csproj via ProjectReference using Microsoft.Azure.Cosmos; using Microsoft.Azure.Cosmos.Linq; const string EndpointUrl = "https://localhost:443"; const string PrimaryKey = "<emulator-key>"; const string DatabaseId = "VerifyLinqNet10"; const string ContainerId = "Items"; int passed = 0, failed = 0; using CosmosClient client = new( accountEndpoint: EndpointUrl, authKeyOrResourceToken: PrimaryKey, new CosmosClientOptions { ConnectionMode = ConnectionMode.Gateway }); Database database = await client.CreateDatabaseIfNotExistsAsync(DatabaseId); Container container = await database.CreateContainerIfNotExistsAsync(ContainerId, "/pk"); var item = new TestItem { id = "test-1", pk = "p1", Name = "Alice", Score = 42, Tag = Guid.Parse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"), Children = ["child1", "child2", "child3"] }; await container.UpsertItemAsync(item, new PartitionKey("p1")); // Test 1: string[].Contains variable-side, triggers MemoryExtensions path on .NET 10 string[] names = ["Alice", "Bob"]; var q1 = container.GetItemLinqQueryable<TestItem>().Where(x => names.Contains(x.Name)); Console.WriteLine(q1.ToQueryDefinition().QueryText); // Output: SELECT VALUE root FROM root WHERE (root["Name"] IN ("Alice", "Bob")) // Test 5: item.Children.Contains document-field-side var q5 = container.GetItemLinqQueryable<TestItem>().Where(x => x.Children.Contains("child1")); Console.WriteLine(q5.ToQueryDefinition().QueryText); // Output: SELECT VALUE root FROM root WHERE ARRAY_CONTAINS(root["Children"], "child1") // Test 7: Customer exact repro from issue #5518 var array = new[] { "a" }; var q7 = container.GetItemLinqQueryable<string>().Where(x => array.Contains(x)); Console.WriteLine(q7.ToQueryDefinition().QueryText); // Output: SELECT VALUE root FROM root WHERE (root IN ("a")) // Test 8: Enumerable.Contains still works (regression check) var enumerable = new[] { "a" }.AsEnumerable(); var q8 = container.GetItemLinqQueryable<string>().Where(x => enumerable.Contains(x)); Console.WriteLine(q8.ToQueryDefinition().QueryText); // Output: SELECT VALUE root FROM root WHERE (root IN ("a")) await database.DeleteAsync(); class TestItem { public string id { get; set; } = ""; public string pk { get; set; } = ""; public string Name { get; set; } = ""; public int Score { get; set; } public Guid Tag { get; set; } public string[] Children { get; set; } = []; } ``` </details> ## Changes - **ConstantEvaluator.cs**: Exclude `op_Implicit` on `ReadOnlySpan<>/Span<>` types from partial evaluation (ref structs cannot be boxed) - **ArrayBuiltinFunctions.cs**: `UnwrapSpanImplicitConversion()` unwraps `op_Implicit` to extract the underlying array before translating to SQL - **BuiltinFunctionVisitor.cs**: Route `MemoryExtensions.Contains` to the array translator (scoped to `Contains` only; other methods produce a clear "method not supported" error) - **LinqMemoryExtensionsTests.cs**: 12 unit tests covering: - ConstantEvaluator exclusions (op_Implicit on Span types blocked, non-Span still evaluable) - Supported translations (string/int/Guid arrays, Enumerable.Contains regression) - Unsupported methods produce clear errors (IndexOf, SequenceEqual) ## Supersedes This PR supersedes both #5585 and #5477 with a more targeted fix. ## .NET Breaking Change Context This issue stems from a **by-design breaking change** in C# 14 / .NET 10 where overload resolution now prefers Span<T>/ReadOnlySpan<T> extension methods ("first-class span" support). The .NET runtime team's mitigation is [OverloadResolutionPriority] on Span overloads so the compiler prefers IEnumerable<T> versions. However, LINQ providers (EF Core, Cosmos DB, etc.) still need to handle the case where the compiler emits the Span-based call. **Related discussions:** - [dotnet/runtime#109757](dotnet/runtime#109757) "First class span's break EFC" (main discussion) - [dotnet/runtime#109549](dotnet/runtime#109549) API Proposal: Apply [OverloadResolutionPriority] to Span-based overloads - [dotnet/efcore#35547](dotnet/efcore#35547) "Some usages of Contains no longer compile" - [dotnet/docs#43952](dotnet/docs#43952) Breaking change documentation - [Official docs](https://learn.microsoft.com/en-us/dotnet/core/compatibility/core-libraries/10.0/csharp-overload-resolution) C# 14 overload resolution with span parameters --------- Co-authored-by: Kiran Kumar Kolli <kirankk@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: neildsh <35383880+neildsh@users.noreply.github.com>
Pull Request Template
Description
Please include a summary of the change and which issue is fixed. Include samples if adding new API, and include relevant motivation and context. List any dependencies that are required for this change.
Type of change
Please delete options that are not relevant.
Closing issues
To automatically close an issue: closes #IssueNumber