Skip to content

Linq: Fixes .NET 10 MemoryExtensions.Contains breaking change in LINQ queries#5819

Open
adityasa wants to merge 12 commits intomainfrom
users/adityasa/investigate-linq-memoryextensions
Open

Linq: Fixes .NET 10 MemoryExtensions.Contains breaking change in LINQ queries#5819
adityasa wants to merge 12 commits intomainfrom
users/adityasa/investigate-linq-memoryextensions

Conversation

@adityasa
Copy link
Copy Markdown
Contributor

@adityasa adityasa commented Apr 29, 2026

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

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')"]
Loading

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")
Verification app code (click to expand)
// 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; } = [];
}

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/ReadOnlySpan extension methods ("first-class span" support). The .NET runtime team's mitigation is [OverloadResolutionPriority] on Span overloads so the compiler prefers IEnumerable 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:

kirankumarkolli and others added 5 commits February 1, 2026 14:13
In .NET 10, array.Contains() may resolve to MemoryExtensions.Contains(ReadOnlySpan<T>, T)
instead of Enumerable.Contains. Since ReadOnlySpan doesn't implement IEnumerable,
the existing IsEnumerable() check fails.

Changes:
- Add IsMemoryExtensionsMethod() helper to detect MemoryExtensions methods
- Route MemoryExtensions.Contains to ArrayBuiltinFunctions for proper SQL translation
- Add unit tests for MemoryExtensions detection

Fixes #5518
- TestMemoryExtensionsContainsExpressionDetection: Verifies MemoryExtensions.Contains<T>(ReadOnlySpan<T>, T) is properly detected
- TestBuiltinFunctionVisitorMemoryExtensionsCheck: Simulates .NET 10 expression and validates our fix
- TestEnumerableContainsNotDetectedAsMemoryExtensions: Ensures Enumerable.Contains is unaffected

These tests programmatically create the expression trees that .NET 10 generates for array.Contains(), verifying the fix handles them correctly.
…uction

Addresses review comment: replaced superficial type-name checks with a test
that constructs an actual MemoryExtensions.Contains MethodCallExpression and
verifies the DeclaringType matches. Removed redundant tests, kept array
IsEnumerable and Enumerable negative check.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…moryExtensions.Contains

In .NET 10+, array.Contains(x) resolves to MemoryExtensions.Contains(ReadOnlySpan<T>, T)
instead of Enumerable.Contains. The expression tree includes an op_Implicit conversion
from T[] to ReadOnlySpan<T>.

This caused two issues:
1. ConstantEvaluator.PartialEval tried to evaluate op_Implicit(array) as a constant,
   but ReadOnlySpan<T> is a ref struct that cannot be boxed, causing NotSupportedException.
2. ArrayContainsVisitor.VisitImplicit couldn't find the underlying ConstantExpression
   (the array) because it was wrapped in the op_Implicit call.

Fix:
- ConstantEvaluator: Exclude MemoryExtensions methods and op_Implicit on Span/ReadOnlySpan
  types from constant evaluation (similar to how Enumerable/Queryable are excluded).
- ArrayBuiltinFunctions: Add UnwrapSpanImplicitConversion to unwrap op_Implicit and expose
  the underlying array constant for IN clause generation.

This builds on top of the IsMemoryExtensionsMethod detection in BuiltinFunctionVisitor.

Fixes #5518

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@azure-pipelines
Copy link
Copy Markdown

Azure Pipelines:
Successfully started running 1 pipeline(s).

Comment thread Microsoft.Azure.Cosmos/src/Linq/ConstantEvaluator.cs Outdated
Comment thread Microsoft.Azure.Cosmos/src/Linq/BuiltinFunctions/BuiltinFunctionVisitor.cs Outdated
Comment thread Microsoft.Azure.Cosmos/src/Linq/BuiltinFunctions/BuiltinFunctionVisitor.cs Outdated
Comment thread Microsoft.Azure.Cosmos/src/Linq/ConstantEvaluator.cs
- ConstantEvaluator: Replace string comparison with typeof(MemoryExtensions),
  scope exclusion to Contains method only
- BuiltinFunctionVisitor: Replace FullName string check with typeof(MemoryExtensions),
  scope IsMemoryExtensionsMethod to Contains only
- op_Implicit check remains type-scoped (Span/ReadOnlySpan) since ref structs
  are inherently unevaluable regardless of calling context

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@azure-pipelines
Copy link
Copy Markdown

Azure Pipelines:
Successfully started running 1 pipeline(s).

Test coverage validates each condition in the fix:

ConstantEvaluator layer:
- MemoryExtensions.Contains is not evaluated (preserved for SQL translation)
- op_Implicit on ReadOnlySpan<T> is not evaluated (ref struct cannot be boxed)
- op_Implicit on Span<T> is not evaluated
- Enumerable.Contains still excluded (regression)
- Non-Span op_Implicit (e.g., numeric) is still evaluated (no over-blocking)

BuiltinFunctionVisitor layer (supported):
- MemoryExtensions.Contains with string[]  SQL IN clause
- MemoryExtensions.Contains with int[]  SQL IN clause
- MemoryExtensions.Contains with Guid[]  SQL IN clause
- Enumerable.Contains still works (regression)

BuiltinFunctionVisitor layer (unsupported  error):
- MemoryExtensions.IndexOf  throws DocumentQueryException
- MemoryExtensions.SequenceEqual  throws DocumentQueryException

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@azure-pipelines
Copy link
Copy Markdown

Azure Pipelines:
Successfully started running 1 pipeline(s).

Comment thread Microsoft.Azure.Cosmos/src/Linq/ConstantEvaluator.cs
…runtimes)

The previous test used double.op_Implicit(int) which isn't exposed via reflection
on .NET 6. Switched to Decimal.op_Implicit(int) which is reliably available.

All 12 tests now pass with 0 skipped.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@azure-pipelines
Copy link
Copy Markdown

Azure Pipelines:
Successfully started running 1 pipeline(s).

The explicit check for MemoryExtensions.Contains in CanBeEvaluated is redundant
because the Nominator processes bottom-up. The child op_Implicit node on
ReadOnlySpan<T>/Span<T> is already blocked, which propagates up to prevent
the parent Contains call from being nominated for evaluation.

Tests confirm all 12 pass without this check.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@azure-pipelines
Copy link
Copy Markdown

Azure Pipelines:
Successfully started running 1 pipeline(s).

@azure-pipelines
Copy link
Copy Markdown

Azure Pipelines:
Successfully started running 1 pipeline(s).

@azure-pipelines
Copy link
Copy Markdown

Azure Pipelines:
Successfully started running 1 pipeline(s).

@adityasa adityasa enabled auto-merge (squash) April 29, 2026 22:01
@NaluTripician
Copy link
Copy Markdown
Contributor

@sdkReviewAgent-2

Comment thread Microsoft.Azure.Cosmos/src/Linq/ConstantEvaluator.cs
@xinlian12
Copy link
Copy Markdown
Member

Review complete (38:00)

Posted 2 inline comment(s).

Steps: ✓ context, correctness, cross-sdk, design, history, past-prs, synthesis, test-coverage

kirankumarkolli
kirankumarkolli previously approved these changes May 1, 2026
@kirankumarkolli kirankumarkolli disabled auto-merge May 1, 2026 17:08
neildsh
neildsh previously approved these changes May 1, 2026
@azure-pipelines
Copy link
Copy Markdown

Azure Pipelines:
Successfully started running 1 pipeline(s).

@neildsh neildsh added QUERY auto-merge Enables automation to merge PRs labels May 1, 2026
Use BuildNet10ContainsExpression<T>() consistently across all
translation tests instead of manual reflection, addressing
reviewer feedback for code consistency.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@adityasa adityasa dismissed stale reviews from neildsh and kirankumarkolli via fbf86d7 May 1, 2026 18:35
@azure-pipelines
Copy link
Copy Markdown

Azure Pipelines:
Successfully started running 1 pipeline(s).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

auto-merge Enables automation to merge PRs QUERY

Projects

None yet

Development

Successfully merging this pull request may close these issues.

No support for Linq expressions using ReadOnlySpan methods

6 participants