efcore icon indicating copy to clipboard operation
efcore copied to clipboard

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

Open attilavlacsil opened this issue 3 weeks ago • 1 comments

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

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

var options = new DbContextOptionsBuilder<MyContext>()
    .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<DateTime>(x, "ValidFrom"),
                        To = EF.Property<DateTime>(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<DateTime>(x, "ValidFrom"),
                    To = EF.Property<DateTime>(x, "ValidTo"),
                    x.Url
                })
                .ToArray()
        })
        .FirstAsync();

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

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

    public DbSet<Blog> Blogs { get; set; }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Blog>(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


Verbose output

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

attilavlacsil avatar Dec 03 '25 10:12 attilavlacsil

Hello EFCore community As far as I understand, this issue is related to the same ID/key values in the temporal table. So EFCore skips reading next rows because it assumes that it's the same record. I thought that it could be an easy fix to add additional TemporalPeriodStartColum and TemporalPeriodEndColumn into the identifier collection. Tried to add it to SqlServerQueryableMethodTranslatingExpressionVisitor.cs file, but there is some additional issue with that. I cannot fetch column names for period columns - GetPeriodStartPropertyName() and GetPeriodEndPropertyName(). Does anyone know what could be the reason for it?

        if (entityType.IsTemporal())
        {
            var periodStartName = table[SqlServerAnnotationNames.TemporalPeriodStartColumnName];// ?? "PeriodStart";
            var periodEndName = table[SqlServerAnnotationNames.TemporalPeriodEndColumnName];// ?? "PeriodEnd";
            if (periodStartName != null)
            {
                var psProperty = entityType.FindProperty(periodStartName.ToString());
                if (psProperty != null)
                {
                    var psColumn = CreateColumnExpression(psProperty, table, alias, nullable: false);
                    identifier.Add((psColumn, psProperty.GetKeyValueComparer()));
                }
            }

            if (periodEndName != null)
            {
                var peProperty = entityType.FindProperty(periodEndName.ToString());
                if (peProperty != null)
                {
                    var peColumn = CreateColumnExpression(peProperty, table, alias, nullable: false);
                    identifier.Add((peColumn, peProperty.GetKeyValueComparer()));
                }
            }
        }

v-yavorskyi avatar Dec 10 '25 17:12 v-yavorskyi

As far as I understand, ID (primary key) column could be duplicated in temporal table. That's why EFCore can't identify if the "history" record is same or not.

There is some idea on how to fix this issue. Please let me know how it should be adjusted

  1. RuntimeEntityType should contain annotations for TemporalPeriodStartPropertyName and TemporalPeriodEndPropertyName. This data is important for next steps
  2. SqlServerQueryableMethodTranslatingExpressionVisitor.cs could check if it's temporal table and add Start and End date properties to identifier collection. So code will check if temporal record is same based on ID, StartPeriod and EndPeriod.
  3. Not sure if I can adjust existing TemporalComplexNavigationsCollectionsQuerySqlServerTest.cs to support such cases.

p.s. do not hesitate to reply to this message that it does not make sense :-)

v-yavorskyi avatar Dec 11 '25 05:12 v-yavorskyi