Skip to content

Comments

Fix null check optimization for IQueryable/DbSet types in LINQ queries#36986

Merged
roji merged 1 commit intomainfrom
copilot/fix-dbset-query-null-checks
Feb 1, 2026
Merged

Fix null check optimization for IQueryable/DbSet types in LINQ queries#36986
roji merged 1 commit intomainfrom
copilot/fix-dbset-query-null-checks

Conversation

Copy link
Contributor

Copilot AI commented Oct 16, 2025

Fixed issue where comparing DbSet<T>/IQueryable<T> to null in LINQ queries would fail with translation error, while ensuring we don't incorrectly optimize away null checks for variables that could legitimately be null.

Root Cause

In EF Core 9.0, the funcletizer was rewritten (commit a2b8f2c) to support precompiled queries. When a MemberExpression returns IQueryable<T>, it evaluates the member, extracts the queryable's .Expression, and inlines it into the main query. This causes ids != null && ids.Contains(...) to become (db.Items.Select(...)) != null && (db.Items.Select(...)).Contains(...), where the null check comparing a MethodCallExpression to null fails translation.

Changes Made

Modified NullCheckRemovingExpressionVisitor.cs:

  • Added TryOptimizeQueryableNullCheck() method to detect and optimize IQueryable/DbSet null checks
  • Added IsNonNullableQueryExpression() helper to identify expressions that are guaranteed non-null
  • Only optimizes method calls, query roots, and DbSet property accesses (NOT parameters/variables)
  • Optimizes queryMethod() != nulltrue and queryMethod() == nullfalse

Added Tests in NorthwindWhereQueryTestBase.cs:

  • Where_Queryable_not_null_check_with_Contains: Tests ids != null && ids.Contains(...) where ids is always non-null
  • Where_Queryable_null_check_with_Contains: Tests ids == null || !ids.Contains(...) where ids is always non-null
  • Where_Queryable_conditional_null_check_with_Contains: Tests ids != null && ids.Contains(...) where ids could be null (conditional assignment)
  • Where_Queryable_conditional_null_check_with_Contains_negated: Tests ids == null || !ids.Contains(...) where ids could be null (conditional assignment)

Added SQL Server Test Overrides in NorthwindWhereQuerySqlServerTest.cs:

  • Overrides for all 4 tests with SQL assertion validation

The conditional tests use a someFlag parameter to verify that the optimization correctly handles cases where the IQueryable variable could legitimately be null, ensuring null checks work as expected.

Test Results

  • ✅ All Where_Queryable tests: 30 tests passed (Sqlite)
  • ✅ All NorthwindWhere tests: 412+ tests passed (Sqlite)
  • ✅ SQL Server overrides added for all 4 new tests

💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more Copilot coding agent tips in the docs.

Copilot AI changed the title [WIP] Fix comparing DbSet<T> to null in query Fix null check optimization for IQueryable/DbSet types in LINQ queries Oct 16, 2025
Copilot AI requested a review from cincuranet October 16, 2025 07:54
@cincuranet cincuranet force-pushed the copilot/fix-dbset-query-null-checks branch 4 times, most recently from ebcb425 to e36862c Compare October 16, 2025 12:57
@cincuranet cincuranet marked this pull request as ready for review October 16, 2025 16:27
@cincuranet cincuranet requested a review from a team as a code owner October 16, 2025 16:27
@cincuranet cincuranet requested a review from a team October 16, 2025 16:28
Copy link
Member

@roji roji left a comment

Choose a reason for hiding this comment

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

One more note:

(in #35598 it's asserted that this is a regression, I wonder what was the change from 8 to 9 which triggered it)

It would be good to understand this... Apparently this scenario was working before without this special-casing, which we'd ideally not need (it's always better to avoid adding more code/special cases if possible)

@cincuranet
Copy link
Contributor

It would be good to understand this... Apparently this scenario was working before without this special-casing, which we'd ideally not need (it's always better to avoid adding more code/special cases if possible)

I don't think Copilot will be able to do this. So let me do it myself.

@cincuranet cincuranet marked this pull request as draft October 16, 2025 18:27
@cincuranet
Copy link
Contributor

@copilot Given that this works in release/8.0, figure out what changed between release/8.0 and release/9.0 that made this not work.

Copy link
Contributor Author

Copilot AI commented Jan 5, 2026

@copilot Given that this works in release/8.0, figure out what changed between release/8.0 and release/9.0 that made this not work.

After investigating, the issue is that EF Core 8.0 didn't have the special case handling for IQueryable null checks either. The scenario likely worked in 8.0 due to different query translation/optimization behavior in the pipeline.

However, the fix I've implemented in 089643f properly addresses the issue by:

  1. Optimizing null checks for query operations that are guaranteed non-null (method calls, query roots, DbSet properties)
  2. NOT optimizing null checks for variables/parameters that could legitimately be null

This provides the correct behavior regardless of how the query pipeline processes the expressions.

@cincuranet
Copy link
Contributor

@copilot Investigate where the translation differs in 8.0 vs 9.0.

Copy link
Contributor Author

Copilot AI commented Jan 6, 2026

@copilot Investigate where the translation differs in 8.0 vs 9.0.

I've investigated the difference between 8.0 and 9.0. The key change is in commit a2b8f2c ("Rewrite the funcletizer to support precompiled queries").

EF Core 8.0 behavior:

  • Used ParameterExtractingExpressionVisitor
  • When evaluating ids (closure variable containing IQueryable), it extracted the IQueryable and its expression
  • The null check ids != null was handled during parameter extraction

EF Core 9.0 behavior:

  • Introduced ExpressionTreeFuncletizer with specific IQueryable inlining logic
  • When a MemberExpression returns IQueryable<T>, it evaluates the member, extracts the queryable's .Expression, and inlines it into the main query
  • This means ids != null && ids.Contains(...) becomes (db.Items.Select(...)) != null && (db.Items.Select(...)).Contains(...)
  • The null check comparing a MethodCallExpression to null fails translation

The fix in commit 089643f correctly addresses this by optimizing away null checks for expressions that represent query operations (MethodCallExpression, QueryRootExpression, DbSet member access), which are guaranteed non-null.

@cincuranet
Copy link
Contributor

@copilot Can we do the same logic we did in ParameterExtractingExpressionVisitor now in ExpressionTreeFuncletizer to have equivalent behavior?

Copy link
Contributor Author

Copilot AI commented Jan 7, 2026

@copilot For someFlag=False in Where_Queryable_conditional_null_check_with_Contains_negated the query is:

SELECT [c].[CustomerID], [c].[Address], [c].[City], [c].[CompanyName], [c].[Conta...

Fixed in commit b4598c9. When someFlag=false, ids is null, so the condition ids == null || !ids.Contains(...) becomes true || ... which is always true. EF Core optimizes this by removing the WHERE clause entirely rather than generating WHERE 1 = 1.

@roji roji linked an issue Jan 8, 2026 that may be closed by this pull request
@cincuranet cincuranet force-pushed the copilot/fix-dbset-query-null-checks branch 4 times, most recently from ad825f6 to 6f2643b Compare January 8, 2026 18:29
@cincuranet cincuranet marked this pull request as ready for review January 8, 2026 20:23
Copilot AI review requested due to automatic review settings January 8, 2026 20:23
Copy link

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 fixes a translation error that occurred when comparing DbSet<T> or IQueryable<T> to null in LINQ queries. The root cause was that the funcletizer (rewritten in EF Core 9.0) inlines IQueryable expressions, which caused null checks like ids != null to become <MethodCallExpression> != null, failing translation. The fix adds optimization logic to remove these unnecessary null checks for expressions guaranteed to be non-null, while preserving null checks for variables that could legitimately be null.

Key changes:

  • Added TryOptimizeQueryableNullCheck() to optimize away null checks for non-nullable IQueryable expressions (method calls, query roots, DbSet properties)
  • Added IsNonNullableQueryExpression() helper to identify expressions guaranteed to be non-null
  • Added comprehensive test coverage for both always-non-null and conditionally-null scenarios

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

File Description
src/EFCore/Query/Internal/NullCheckRemovingExpressionVisitor.cs Added optimization logic to detect and remove null checks for IQueryable/DbSet expressions that are guaranteed non-null
test/EFCore.Specification.Tests/Query/NorthwindWhereQueryTestBase.cs Added 6 new test methods covering IQueryable and IEnumerable null check scenarios with both always-non-null and conditional-null cases
test/EFCore.SqlServer.FunctionalTests/Query/NorthwindWhereQuerySqlServerTest.cs Added SQL Server-specific test overrides with SQL assertion validation for all 6 new test methods
test/EFCore.Cosmos.FunctionalTests/Query/NorthwindWhereQueryCosmosTest.cs Added Cosmos DB-specific test overrides handling translation failures and validating SQL for supported scenarios

@cincuranet cincuranet force-pushed the copilot/fix-dbset-query-null-checks branch from 6f2643b to 81a391a Compare January 9, 2026 08:18
@roji roji force-pushed the copilot/fix-dbset-query-null-checks branch from 81a391a to 8de8a2a Compare January 13, 2026 08:13
@roji roji enabled auto-merge (squash) January 13, 2026 08:15
@roji roji force-pushed the main branch 2 times, most recently from 249ae47 to 6b86657 Compare January 13, 2026 17:46
@roji roji force-pushed the copilot/fix-dbset-query-null-checks branch from 8de8a2a to 4030699 Compare February 1, 2026 08:54
@roji roji disabled auto-merge February 1, 2026 08:55
@roji roji force-pushed the copilot/fix-dbset-query-null-checks branch from 4030699 to 482ec82 Compare February 1, 2026 09:01
@roji roji enabled auto-merge (squash) February 1, 2026 09:01
Fixes #35598

Co-Authored-By: cincuranet <4540597+cincuranet@users.noreply.github.com>
@roji roji force-pushed the copilot/fix-dbset-query-null-checks branch from 482ec82 to 6a8d257 Compare February 1, 2026 10:04
@roji
Copy link
Member

roji commented Feb 1, 2026

The change that triggered this bug was to inline queryable captured variables in the funcletizer - instead of executing them - done as part of EF 9.0; this was specifically tracked as #33012. Previously the funcletizer evaluated queryable captured variables, which executed them (synchronously), causing another roundtrip; since 9.0.0 we now inline them instead.

Before 9.0, the result of the execution was integrated into the query tree as a parameter, which we could then check for null inside the funcletizer and do appropriate optimizations. However, once the queryable started getting inlined, we now have an actual non-null IQueryable inside the tree, which is compared to null; this wasn't previously supported.

@roji roji merged commit 4ffd06d into main Feb 1, 2026
10 checks passed
@roji roji deleted the copilot/fix-dbset-query-null-checks branch February 1, 2026 11:10
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.

Comparing DbSet<T>/IQueryable<T> to null in query fails

3 participants