-
Notifications
You must be signed in to change notification settings - Fork 3.3k
Open
Labels
area-querycustomer-reportedpriority-bugIssues which requires API breaks and have bigger impact hence should be fixed earlier in the releaseIssues which requires API breaks and have bigger impact hence should be fixed earlier in the release
Milestone
Description
Bug description
The attempt to use a nullable navigation property to iterate over related records seems to work only when unfiltered.
Based on the provided code, EF seems to translate the code
context.People
.Where(person => person.Name == "Name")
.Select(subject => new
{
Name = subject.Name,
Coworkers = subject.Employer!.Employees!
.Select(coworker => new { coworker.Name })
.ToList(),
})
.ToList();
to something which narrows down to
SELECT *
FROM People people
LEFT JOIN Employers employers ON employers.EmployerId = people.EmployerId
LEFT JOIN People coworkers ON coworkers.EmployerId = employers.EmployerId
WHERE people.Name = N'Name'
However, when you apply a filter, like
context.People
.Where(person => person.Name == "Name")
.Select(subject => new
{
Name = subject.Name,
Coworkers = subject.Employer!.Employees!
.Where(employee => employee != subject) // changed, compared to above
.Select(coworker => new { coworker.Name })
.ToList(),
})
.ToList();
this tranlates to a different join criteria:
SELECT *
FROM People people
LEFT JOIN Employers employers ON employers.EmployerId = people.EmployerId
LEFT JOIN People coworkers ON (coworkers.EmployerId = employers.EmployerId OR (coworkers.EmployerId IS NULL AND employers.EmployerId IS NULL)) AND coworkers.PersonId != people.PersonId
WHERE people.Name = N'Name'
Expectation:
- People without employer have an empty list of coworkers even with additional filter criteria.
Observation:
- People without employer have all other people without employer listed as their coworkers.
This is not subject to the SQL Server provider. I was able to reproduce this with the most recent PostgreSQL provider, as well.
Your code
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Migrations;
public class Employer
{
public int EmployerId { get; set; }
public required string Name { get; set; }
public IList<Person>? Employees { get; set; }
}
public class Person
{
public int PersonId { get; set; }
public required string Name { get; set; }
public int? EmployerId { get; set; }
public Employer? Employer { get; set; }
}
public sealed class TestContext : DbContext
{
public DbSet<Employer> Employers { get; set; }
public DbSet<Person> People { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.UseSqlServer("Data Source=localhost; Initial Catalog=Test; Integrated Security=True");
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
var employerBuilder = modelBuilder.Entity<Employer>();
var peopleBuilder = modelBuilder.Entity<Person>();
peopleBuilder.HasKey(x => x.PersonId);
employerBuilder.HasKey(x => x.EmployerId);
employerBuilder.HasMany(x => x.Employees).WithOne(x => x.Employer).HasForeignKey(x => x.EmployerId).HasPrincipalKey(x => x.EmployerId);
employerBuilder.HasData(
new Employer { EmployerId = 1, Name = "Microsoft" },
new Employer { EmployerId = 2, Name = "Apple" },
new Employer { EmployerId = 3, Name = "Google" });
peopleBuilder.HasData(
new Person { PersonId = 1, Name = "Satya", EmployerId = 1 },
new Person { PersonId = 2, Name = "Brad", EmployerId = 1 },
new Person { PersonId = 3, Name = "Amy", EmployerId = 1 },
new Person { PersonId = 4, Name = "Tim", EmployerId = 2 },
new Person { PersonId = 5, Name = "Katherine", EmployerId = 2 },
new Person { PersonId = 6, Name = "Eddy", EmployerId = 2 },
new Person { PersonId = 7, Name = "Sundar", EmployerId = 3 },
new Person { PersonId = 8, Name = "Ruth", EmployerId = 3 },
new Person { PersonId = 9, Name = "Anat", EmployerId = 3 },
new Person { PersonId = 10, Name = "Donald", Employer = null },
new Person { PersonId = 11, Name = "Elon", Employer = null });
}
}
[TestClass]
public sealed class Reproduction
{
[TestMethod]
public void Employees_have_coworkers()
{
using var context = new TestContext();
context.Database.EnsureCreated();
var satya = context.People
.Where(person => person.Name == "Satya")
.Select(satya => new
{
Name = satya.Name,
Coworkers = satya.Employer!.Employees!
.Where(employee => employee != satya)
.Select(coworker => new { coworker.Name })
.ToList(),
})
.Single();
Assert.IsTrue(satya.Coworkers.Count == 2);
Assert.IsTrue(satya.Coworkers.Any(coworker => coworker.Name == "Brad"));
Assert.IsTrue(satya.Coworkers.Any(coworker => coworker.Name == "Amy"));
}
[TestMethod]
public void Unemployed_people_do_not_have_coworkers()
{
using var context = new TestContext();
context.Database.EnsureCreated();
var donald = context.People
.Where(person => person.Name == "Donald")
.Select(donald => new
{
Name = donald.Name,
Coworkers = donald.Employer!.Employees!
.Where(employee => employee != donald)
.Select(coworker => new { coworker.Name })
.ToList(),
})
.Single();
Assert.IsTrue(donald.Coworkers.Count == 0); // This fails. Donald and Elon are coworkers.
}
}
public class TestMigration : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Employers",
columns: table => new
{
EmployerId = table.Column<int>("int", false).Annotation("SqlServer:Identity", "1, 1"),
Name = table.Column<string>("nvarchar(max)", false)
},
constraints: table =>
{
table.PrimaryKey("PK_Employers", x => x.EmployerId);
});
migrationBuilder.CreateTable(
name: "People",
columns: table => new
{
PersonId = table.Column<int>("int", false).Annotation("SqlServer:Identity", "1, 1"),
Name = table.Column<string>("nvarchar(max)", false),
EmployerId = table.Column<int>("int", true)
},
constraints: table =>
{
table.PrimaryKey("PK_People", x => x.PersonId);
table.ForeignKey("FK_People_Employers_EmployerId", x => x.EmployerId, "Employers", "EmployerId");
});
migrationBuilder.InsertData(
table: "Employers",
columns: ["EmployerId", "Name"],
values: new object[,]
{
{ 1, "Microsoft" },
{ 2, "Apple" },
{ 3, "Google" }
});
migrationBuilder.InsertData(
table: "People",
columns: ["PersonId", "EmployerId", "Name"],
values: new object?[,]
{
{ 10, null, "Donald" },
{ 11, null, "Elon" },
{ 1, 1, "Satya" },
{ 2, 1, "Brad" },
{ 3, 1, "Amy" },
{ 4, 2, "Tim" },
{ 5, 2, "Katherine" },
{ 6, 2, "Eddy" },
{ 7, 3, "Sundar" },
{ 8, 3, "Ruth" },
{ 9, 3, "Anat" }
});
}
}
Stack traces
Verbose output
EF Core version
9.0.2
Database provider
Microsoft.EntityFrameworkCore.SqlServer 9.0.2
Target framework
.net 9.0
Operating system
Windows 10
simonhenke
Metadata
Metadata
Assignees
Labels
area-querycustomer-reportedpriority-bugIssues which requires API breaks and have bigger impact hence should be fixed earlier in the releaseIssues which requires API breaks and have bigger impact hence should be fixed earlier in the release