dotnet / dotnet/efcore

GroupJoin in EF Core 9/10 returns single row for SQL Server temporal tables

Open
#37,288 3 comments 1 reaction 0 assignees View on GitHub
area-query area-sqlserver area-temporal-tables customer-reported
Dominant language
C#
Stars
14.8k
Forks
3.4k
PR merge metrics
PR metrics pending

Description

### Bug description

Use case: Get one entity by Id (PK), and all its history from the temporal table.

When I'm trying to collect the historical rows, EF Core returns only a single history row when using `.TemporalAll()` and `GroupJoin` together, however the generated SQL query looks fine. I've attached a runnable `Program.cs` that reproduces the problem.
Note, that the same problem can be observed when manual left join is used (see in code example), the generated SQL query is the same.

The problem happens with EF Core versions 9.0.11 and 10.0.0.

### Your code

```csharp
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;

var options = new DbContextOptionsBuilder()
.UseSqlServer("Data Source=(LocalDb)\\MSSQLLocalDB;Initial Catalog=Ef10TemporalSample;Integrated Security=true;")
.EnableSensitiveDataLogging()
.LogTo(Console.WriteLine, LogLevel.Information)
.Options;

int blogId;
await using (var context = new MyContext(options))
{
await context.Database.EnsureDeletedAsync();
await context.Database.EnsureCreatedAsync();

Blog[] blogs = [
new Blog { Url = "https://1.blog.com" },
new Blog { Url = "https://2.blog.com" }
];

context.Blogs.AddRange(blogs);
await context.SaveChangesAsync();

var updateBlog = blogs[0];

updateBlog.Url = "https://foo.blog.com";
await context.SaveChangesAsync();

updateBlog.Url = "https://bar.blog.com";
await context.SaveChangesAsync();

// Note the updated blog id.
blogId = updateBlog.Id;
}

var expectedHistoryCount = 3; // 1 create + 2 update

await using (var context = new MyContext(options))
{
var actualCount = await context.Blogs.TemporalAll()
.Where(x => x.Id == blogId)
.CountAsync();

Console.WriteLine($"=== Temporal table direct query: Expected {expectedHistoryCount}, actual {actualCount}");
}

await using (var context = new MyContext(options))
{
var blogWithHistory = await context.Blogs
.Where(x => x.Id == blogId)
.GroupJoin(
context.Blogs.TemporalAll(),
blog => blog.Id,
history => history.Id,
(blog, histories) => new
{
blog.Id,
History = histories
.Select(x => new
{
From = EF.Property(x, "ValidFrom"),
To = EF.Property(x, "ValidTo"),
x.Url
})
.ToArray()
})
.FirstAsync();

Console.WriteLine($"=== GroupJoin: Expected {expectedHistoryCount}, actual {blogWithHistory.History.Length}");
}

await using (var context = new MyContext(options))
{
var blogWithHistory = await context.Blogs
.Where(x => x.Id == blogId)
.Select(blog => new
{
blog.Id,
History = context.Blogs.TemporalAll()
.Where(x => x.Id == blog.Id)
.Select(x => new
{
From = EF.Property(x, "ValidFrom"),
To = EF.Property(x, "ValidTo"),
x.Url
})
.ToArray()
})
.FirstAsync();

Console.WriteLine($"=== LEFT JOIN: Expected {expectedHistoryCount}, actual {blogWithHistory.History.Length}");
}

class MyContext : DbContext
{
public MyContext(DbContextOptions options)
: base(options)
{
}

public DbSet Blogs { get; set; }

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity(e =>
{
e.ToTable(
"Blogs",
table => table.IsTemporal(x =>
{
x.UseHistoryTable("BlogHistory");
x.HasPeriodStart("ValidFrom");
x.HasPeriodEnd("ValidTo");
}));

e.HasKey(x => x.Id);
});
}
}

class Blog
{
public int Id { get; set; }
public required string Url { get; set; }
}
```

### Stack traces

```text

```

### Verbose output

```text
info: 2025. 12. 03. 11:17:12.556 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
Executed DbCommand (6ms) [Parameters=[@blogId='1'], CommandType='Text', CommandTimeout='30']
SELECT COUNT(*)
FROM [Blogs] FOR SYSTEM_TIME ALL AS [b]
WHERE [b].[Id] = @blogId
=== Temporal table direct query: Expected 3, actual 3

info: 2025. 12. 03. 11:17:12.680 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
Executed DbCommand (4ms) [Parameters=[@blogId='1'], CommandType='Text', CommandTimeout='30']
SELECT [b1].[Id], [b0].[ValidFrom], [b0].[ValidTo], [b0].[Url], [b0].[Id]
FROM (
SELECT TOP(1) [b].[Id]
FROM [Blogs] AS [b]
WHERE [b].[Id] = @blogId
) AS [b1]
LEFT JOIN [Blogs] FOR SYSTEM_TIME ALL AS [b0] ON [b1].[Id] = [b0].[Id]
ORDER BY [b1].[Id]
=== GroupJoin: Expected 3, actual 1

info: 2025. 12. 03. 11:17:12.702 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
Executed DbCommand (1ms) [Parameters=[@blogId='1'], CommandType='Text', CommandTimeout='30']
SELECT [b1].[Id], [b0].[ValidFrom], [b0].[ValidTo], [b0].[Url], [b0].[Id]
FROM (
SELECT TOP(1) [b].[Id]
FROM [Blogs] AS [b]
WHERE [b].[Id] = @blogId
) AS [b1]
LEFT JOIN [Blogs] FOR SYSTEM_TIME ALL AS [b0] ON [b1].[Id] = [b0].[Id]
ORDER BY [b1].[Id]
=== LEFT JOIN: Expected 3, actual 1
```

### EF Core version

10.0.0

### Database provider

Microsoft.EntityFrameworkCore.SqlServer

### Target framework

.NET 10

### Operating system

Windows 11

### IDE

_No response_

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.