Skip to content

Query: Fixes LINQ.Contains to handle Memory Span implicit conversion introduced in C#13/DotNet9#5477

Closed
leminh98 wants to merge 4 commits into
masterfrom
users/leminh/LINQMemoryContainsFix
Closed

Query: Fixes LINQ.Contains to handle Memory Span implicit conversion introduced in C#13/DotNet9#5477
leminh98 wants to merge 4 commits into
masterfrom
users/leminh/LINQMemoryContainsFix

Conversation

@leminh98
Copy link
Copy Markdown
Contributor

@leminh98 leminh98 commented Nov 6, 2025

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.

  • Bug fix (non-breaking change which fixes an issue)
  • [] New feature (non-breaking change which adds functionality)
  • [] Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • [] This change requires a documentation update

Closing issues

To automatically close an issue: closes #IssueNumber

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")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please make the check tighter so that we only let through those on_implicit calls that we support.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For a check that's broader than what we support, please add negative coverage for those cases.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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<>)))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Debug Assert inside if assuming this check is done at top level.

using Microsoft.VisualStudio.TestTools.UnitTesting;

[TestClass]
public class Program
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please rename, turn this into a test project (if not already).

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
Copy link
Copy Markdown
Contributor

@adityasa adityasa Dec 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please rename the project, class namespaces to something suitable that indicate these are .NET C#14 specific tests.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Microsoft.Azure.Cosmos.Tests.C14

}

ArrayContainsVisitor visitor = new ArrayContainsVisitor();
return visitor.VisitIN(searchExpression, (ConstantExpression)searchList, context);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(ConstantExpression)searchList

Can we always guarantee this? Either we should assert before or do a check.

await container.CreateItemAsync(todoItem);
Console.WriteLine($"Created item: {todoItem.id}");

string[] someStringArray = ["Learn Cosmos DB"];
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

["Learn Cosmos DB"]

Can you add a test variation with non-constant array?

<EmulatorFlavor>master</EmulatorFlavor>
<DisableCopyEmulator>True</DisableCopyEmulator>
<LangVersion>$(LangVersion)</LangVersion>
<LangVersion>preview</LangVersion>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please revert.

<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<Platform>AnyCPU</Platform>
<TargetFramework>net6.0</TargetFramework>
<TargetFramework>net9.0</TargetFramework>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please revert.

List<LinqTestInput> inputs = new List<LinqTestInput>
{
{
//// Memory Span Conversion
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please revert.

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)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

13

14

@NaluTripician
Copy link
Copy Markdown
Contributor

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.

@leminh98
Copy link
Copy Markdown
Contributor Author

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.

I have since left Cosmos DB. Please ping @adityasa for any update.

@NaluTripician
Copy link
Copy Markdown
Contributor

Closed in favor of #5819

@NaluTripician NaluTripician deleted the users/leminh/LINQMemoryContainsFix branch April 29, 2026 22:10
adityasa added a commit that referenced this pull request May 4, 2026
… 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&lt;T&gt;(array, x)"]
    B -->|".NET 10+"| D["MemoryExtensions.Contains&lt;T&gt;(<br/>ReadOnlySpan&lt;T&gt;.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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants