Skip to content

Translate List<T>.Exists to Queryable.Any#38226

Open
m-x-shokhzod wants to merge 3 commits into
dotnet:mainfrom
m-x-shokhzod:feat/translate-list-exists
Open

Translate List<T>.Exists to Queryable.Any#38226
m-x-shokhzod wants to merge 3 commits into
dotnet:mainfrom
m-x-shokhzod:feat/translate-list-exists

Conversation

@m-x-shokhzod
Copy link
Copy Markdown
Contributor

@m-x-shokhzod m-x-shokhzod commented May 5, 2026

Rewrites List<T>.Exists(predicate) to AsQueryable().Any(predicate) in QueryableMethodNormalizingExpressionVisitor, mirroring the existing ICollection<T>.Contains conversion. The Predicate<T> lambda is rebuilt as Func<T, bool> so the resulting Queryable.Any call type-checks.

  • New TryConvertListExistsToQueryableAny helper alongside TryConvertCollectionContainsToQueryableContains
  • Detection branch gated to List<T> (other types like Array.Exists are out of scope)
  • Bails out when the receiver is a literal (new List<T> / new {...}) or the predicate is a non-lambda delegate
  • Flips four placeholder tests (Where_Join_Exists, _Inequality, _Constant, Where_Join_Not_Exists) tagged with // Issue #17762 from AssertTranslationFailed to AssertQuery
  • Populates SqlServer SQL baselines (EXISTS subquery + optimizer-simplified forms for constant-false predicates)
  • Cosmos overrides wrapped in AssertTranslationFailed per Cosmos's existing Issue #17246 pattern for navigation-collection Any translations

Fixes #17762

Rewrites List<T>.Exists(predicate) to AsQueryable().Any(predicate) in
QueryableMethodNormalizingExpressionVisitor, mirroring the existing
ICollection<T>.Contains conversion. The Predicate<T> lambda is rebuilt
as Func<T, bool> so the resulting Queryable.Any call type-checks.

Flips four placeholder tests (Where_Join_Exists, _Inequality, _Constant,
Where_Join_Not_Exists) from AssertTranslationFailed to AssertQuery, and
populates the SqlServer SQL baselines with the EXISTS subquery output.

Fixes dotnet#17762
@m-x-shokhzod m-x-shokhzod requested a review from a team as a code owner May 5, 2026 18:31
- Wrap Cosmos overrides for the 4 Where_Join_*Exists* tests in
  AssertTranslationFailed, matching the existing Cosmos pattern for
  navigation-collection Any-style queries (Issue dotnet#17246). The base test
  used to be wrapped in AssertTranslationFailed which covered Cosmos;
  after flipping the base to AssertQuery for the providers that can now
  translate, the failure-assertion has to live on Cosmos's override.

- Correct the expected SqlServer SQL for Where_Join_Exists_Constant and
  Where_Join_Not_Exists. EF's optimizer simplifies Any(o => false) and
  !Any(o => false) at translation time, so the EXISTS subquery does not
  appear in the emitted SQL. Pattern matches the existing Not_Any_false
  baseline in NorthwindAggregateOperatorsQuerySqlServerTest.
EF's optimizer recognizes that c.CustomerID == \"ALFKI\" && Exists(o => false)
short-circuits to false and eliminates the entire predicate, including the
customer-id filter. Actual emitted SQL is just \`WHERE 0 = 1\`, not
\`WHERE [c].[CustomerID] = N'ALFKI' AND 0 = 1\`.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR extends EF Core’s query normalization to translate List<T>.Exists(predicate) into Queryable.Any(predicate) (via AsQueryable()), enabling server translation for scenarios like Northwind navigation collections. It also updates Northwind tests and SQL Server baselines to validate the new translation behavior, while keeping Cosmos behavior as translation-failing per existing limitations.

Changes:

  • Add List<T>.Exists(...) normalization to Queryable.Any(...) in QueryableMethodNormalizingExpressionVisitor, including rebuilding the predicate delegate type to Func<T, bool>.
  • Flip several Northwind “Issue #17762” tests from translation-failure placeholders to actual AssertQuery execution (with SQL Server baselines).
  • Keep Cosmos overrides asserting translation failure for these tests in line with existing client-evaluation limitations.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

File Description
src/EFCore/Query/Internal/QueryableMethodNormalizingExpressionVisitor.cs Adds normalization rewriting List<T>.Exists to Queryable.Any with a rewritten predicate delegate type.
test/EFCore.Specification.Tests/Query/NorthwindMiscellaneousQueryTestBase.cs Converts Exists tests from AssertTranslationFailed to AssertQuery and adjusts expectations for empty-result cases.
test/EFCore.SqlServer.FunctionalTests/Query/NorthwindMiscellaneousQuerySqlServerTest.cs Adds SQL baselines validating translation output for the updated Exists tests.
test/EFCore.Cosmos.FunctionalTests/Query/NorthwindMiscellaneousQueryCosmosTest.cs Wraps the updated base tests in AssertTranslationFailed to preserve Cosmos behavior.

Comment on lines +518 to +521
if (methodCallExpression.Object is MemberInitExpression or NewExpression)
{
return base.VisitMethodCall(methodCallExpression);
}
@m-x-shokhzod
Copy link
Copy Markdown
Contributor Author

Thanks for the review. I dug into this and inline-list Exists already translates correctly — it does not fall back or fail, so I don't think ListInitExpression should be added to the bailout.

The literal-receiver bailout (MemberInitExpression or NewExpression) was deliberately narrowed in c0f9164797 "Support primitive collections" — it used to also cover ConstantExpression and the query-parameter ParameterExpression, and those were removed precisely because primitive-collection support made inline collections server-translatable. A populated new List<T> { ... } is a ListInitExpression — exactly the inline-collection shape that feature translates — so it's intentionally not in the bailout (the sibling Contains method omits it for the same reason).

Verified against SQL Server (ToQueryString, no DB needed):

// new List<int> { 1, 2, 3 }.Exists(x => x == b.Id)
SELECT [b].[Id], [b].[Name] FROM [Blogs] AS [b]
WHERE [b].[Id] IN (1, 2, 3)

// new List<int> { 1, 2, 3 }.Exists(x => x > b.Id)   (non-IN-able predicate)
SELECT [b].[Id], [b].[Name] FROM [Blogs] AS [b]
WHERE EXISTS (
    SELECT 1
    FROM (VALUES (CAST(1 AS int)), (2), (3)) AS [v]([Value])
    WHERE [v].[Value] > [b].[Id])

Both translate, identically to the explicit .AsQueryable().Any(...) form — equality predicates collapse to IN, general predicates become a VALUES … EXISTS subquery. Adding ListInitExpression to the bailout would only block that translation.

I've added a regression test (Where_inline_collection_Exists_translates) that runs new List<string> { ... }.Exists(id => id == c.CustomerID) end-to-end against Northwind and asserts the IN translation, so this behaviour stays pinned.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Query: Translate List.Exists

3 participants