Skip to content

Incorrect SQL for Any() with predicate that contains comparison with null #32103

@bkoelman

Description

@bkoelman

When using the Any() function with a predicate that checks for a property being null on an optional to-one navigation, a LEFT JOIN is produced instead of an INNER JOIN. The effect is that the returned result set contains too many matches. See the repro steps below.

This occurs with SQLite and PostgreSQL. I haven't tried other providers.

Include your code

#nullable enable

using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;

var services = new ServiceCollection();
services.AddDbContext<AppDbContext>(options =>
{
    options.UseSqlite("Data Source=sample.db");
    options.LogTo(Console.WriteLine, LogLevel.Information);
});
var serviceProvider = services.BuildServiceProvider();

await using (var scope = serviceProvider.CreateAsyncScope())
{
    var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
    await dbContext.Database.EnsureDeletedAsync();
    await dbContext.Database.EnsureCreatedAsync();

    dbContext.People.AddRange(
        new Person
        {
            // No match expected: No assignee exists.
            OwnedTodoItems =
            {
                new TodoItem()
            }
        },
        new Person
        {
            // Match expected: Assignee.Age is null.
            OwnedTodoItems =
            {
                new TodoItem
                {
                    Assignee = new Person()
                }
            }
        },
        new Person
        {
            // No match expected: Assignee.Age is non-null.
            OwnedTodoItems =
            {
                new TodoItem
                {
                    Assignee = new Person
                    {
                        Age = 21
                    }
                }
            }
        });

    await dbContext.SaveChangesAsync();
}

await using (var scope = serviceProvider.CreateAsyncScope())
{
    var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();

    var query = dbContext.People
        .Where(person => person.OwnedTodoItems.Any(todoItem => todoItem.Assignee!.Age == null));

    /*
      SELECT "p"."Id", "p"."Age"
      FROM "People" AS "p"
      WHERE EXISTS (
          SELECT 1
          FROM "TodoItems" AS "t"
          LEFT JOIN "People" AS "p0" ON "t"."AssigneeId" = "p0"."Id"
          WHERE "p"."Id" = "t"."OwnerId" AND ("p0"."Age" IS NULL))
    */

    var people = await query.ToArrayAsync();
    Console.WriteLine($"Found matches: {string.Join(", ", people.Select(person => person.Id))}");
}

public class TodoItem
{
    public long Id { get; set; }

    public Person Owner { get; set; } = null!;
    public Person? Assignee { get; set; }
}

public class Person
{
    public long Id { get; set; }
    public int? Age { get; set; }

    public HashSet<TodoItem> OwnedTodoItems { get; set; } = new();
    public HashSet<TodoItem> AssignedTodoItems { get; set; } = new();
}

public class AppDbContext : DbContext
{
    public DbSet<TodoItem> TodoItems => Set<TodoItem>();
    public DbSet<Person> People => Set<Person>();

    public AppDbContext(DbContextOptions options) : base(options)
    {
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Person>()
            .HasMany(person => person.AssignedTodoItems)
            .WithOne(todoItem => todoItem.Assignee);

        modelBuilder.Entity<Person>()
            .HasMany(person => person.OwnedTodoItems)
            .WithOne(todoItem => todoItem.Owner);
    }
}

The code above returns two matches instead of one:

Found matches: 1, 2

Changing the LEFT JOIN to an INNER JOIN produces a single match (2), which is expected.

Include provider and version information

EF Core version: v7.0.12
Database provider: Microsoft.EntityFrameworkCore.Sqlite and Npgsql.EntityFrameworkCore.PostgreSQL
Target framework: .NET 7
Operating system: Windows
IDE: Visual Studio 2022 17.7.5

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions