EFCore10より前でEFCoreのSplitQueryを利用する場合、子供側の並び順を明示的に指定するのを忘れずに

## はじめに

EFCoreの[Includeを使った関連の読み込み機能](https://learn.microsoft.com/ja-jp/ef/core/querying/related-data/eager)は便利なのですが、1対nの関係がある複数の関連を一度に読み込もうとすると、[クエリ結果が膨大になる](https://learn.microsoft.com/ja-jp/ef/core/querying/single-split-queries#cartesian-explosion)ことがあります。

[AsSplitQueryを利用した分割クエリー](https://learn.microsoft.com/ja-jp/ef/core/querying/single-split-queries#split-queries)を利用すると、EFCoreがクエリを複数に分けて実行してくれるので直積を回避することができるのですが、並び順を正しく指定していない場合並び順が崩れてしまう可能性がありました。

最新のEFCoreのドキュメントを確認すると、EFCore 10.0だとこの問題が解消されているらしき記載があります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/36535/7980f700-2d5e-4428-9ee7-ec2e6ba424b8.png)

今回はEFCore 10でどのような修正があったかを確認後、実際にEFCore 9.0と10.0で発行されるクエリの違いを確認していきます。

## EFCore 10で修正されたIssueと概要

該当の文章は、この修正の後に追加されたようです。
分割クエリになった場合、親クエリとソートが分割クエリ側にないため結果が安定しないので、分割クエリ側にも親クエリと同じソート順を適用するという問題のようです。

https://github.com/dotnet/efcore/pull/34097

EFCore 10 以前にはバックポートされないので、EFCore 10以前でこの問題を回避する場合は、従属するテーブルのクエリ発行時に並び順を指定するようにコメントが付いています。

## 環境を作る

EFCore 10は.NET 10を必要とするので、今回もDockerの中で確認をしていきます。
ネットワークを作り、SQLServerと.NET 10 Previewのコンテナを起動して、そのネットワークに参加させます。ネットワークを作らない場合、コンテナ間ｎ名前解決が自動的に行われないので、IPアドレスでホストを指定するか名前解決の方法を別途考慮する必要があります。

```shell
❯ docker network create sample
❯ docker run -e "ACCEPT_EULA=Y" -e "MSSQL_SA_PASSWORD=P@ssw0rd" `
   --rm --network sample -p 1433:1433 --name sql1 --hostname sql1 -d `
   mcr.microsoft.com/mssql/server:2025-latest
❯ docker run `
   -it --rm --network sample -v .:/src -d `
   mcr.microsoft.com/dotnet/sdk:10.0-preview
```

## サンプルコードと実行

VSCode の [DevContainers](https://code.visualstudio.com/docs/devcontainers/containers) で接続し、[ここにあるサンプル](https://gist.github.com/kev-andrews/9cb4cc4954ed9cff177beaaeada59a5a)をちょっとだけ修正して実行します。

<details><summary>Sample.cs</summary>

```Sample.cs
#:package Microsoft.EntityFrameworkCore.SqlServer@9.0.5
//#:package Microsoft.EntityFrameworkCore.SqlServer@10.0.0-preview.4.25258.110

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

var context = new ClientContext();
context.Database.EnsureDeleted();
context.Database.EnsureCreated();

for (var i = 0; i < 100; i++)
{
    var author = new Author { Name = $"Author {i}" };
    context.Authors.Add(author);
    for (var j = 0; j < 10; j++)
    {
        var post = new Post { Title = $"Post {j} by Author {author.Name}", Content = $"Content {j} by Author {author.Name}", Author = author };
        context.Posts.Add(post);
    }
}
context.SaveChanges();

var query = context.Authors
    .OrderByDescending(x => x.AlwaysSameValue)
    .Skip(10)
    .Take(3)
    .Select(a => new AuthorDto
    {
        Id = a.Id,
        Name = a.Name,
        PostTitles = a.Posts.Select(p => p.Title).ToList()
    });

    Console.WriteLine("SingleQuery");
    await query.ToListAsync();
    Console.WriteLine("SplitQuery");
    await query.AsSplitQuery().ToListAsync();

internal class AuthorDto
{
    public int Id { get; set; }
    public string Name { get; set; } = null!;
    public List<string> PostTitles { get; set; } = null!;
}

internal class Author
{
    public int Id { get; set; }
    public string Name { get; set; } = null!;
    public string AlwaysSameValue { get; set; } = "Always the same value";
    public List<Post> Posts { get; set; } = null!;
}

internal class Post
{
    public int Id { get; set; }
    public string Title { get; set; } = null!;
    public string Content { get; set; } = null!;
    public Author Author { get; set; } = null!;
}

internal class ClientContext : DbContext
{
    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) =>
        optionsBuilder
            .LogTo(Console.WriteLine, LogLevel.Information)
            .EnableSensitiveDataLogging()
            .UseSqlServer("Server=sql1,1433;Database=MyDb;User Id=sa;Password=P@ssw0rd;MultipleActiveResultSets=true;TrustServerCertificate=True;");
    public DbSet<Post> Posts { get; set; } = null!;
    public DbSet<Author> Authors { get; set; } = null!;
}
```
</details>

実行します。単一ファイルにまとまっているのでソースコードを指定するだけです。
データ投入などのログもあるのでクエリのSQL部分だけ引用します。

```Microsoft.EntityFrameworkCore.SqlServer@9.0.5の実行結果
❯ dotnet run Sample.cs
... 
SingleQuery
info: 6/10/2025 05:04:31.808 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command) 
      Executed DbCommand (84ms) [Parameters=[@__p_0='10', @__p_1='3'], CommandType='Text', CommandTimeout='30']
      SELECT [a0].[Id], [a0].[Name], [p].[Title], [p].[Id]
      FROM (
          SELECT [a].[Id], [a].[Name], [a].[AlwaysSameValue]
          FROM [Authors] AS [a]
          ORDER BY [a].[AlwaysSameValue] DESC
          OFFSET @__p_0 ROWS FETCH NEXT @__p_1 ROWS ONLY
      ) AS [a0]
      LEFT JOIN [Posts] AS [p] ON [a0].[Id] = [p].[AuthorId]
      ORDER BY [a0].[AlwaysSameValue] DESC, [a0].[Id]
SplitQuery
info: 6/10/2025 05:04:31.929 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command) 
      Executed DbCommand (7ms) [Parameters=[@__p_0='10', @__p_1='3'], CommandType='Text', CommandTimeout='30']
      SELECT [a].[Id], [a].[Name]
      FROM [Authors] AS [a]
      ORDER BY [a].[AlwaysSameValue] DESC, [a].[Id]
      OFFSET @__p_0 ROWS FETCH NEXT @__p_1 ROWS ONLY
info: 6/10/2025 05:04:31.973 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command) 
      Executed DbCommand (15ms) [Parameters=[@__p_0='10', @__p_1='3'], CommandType='Text', CommandTimeout='30']
      SELECT [p1].[Title], [a0].[Id]
      FROM (
          SELECT [a].[Id], [a].[AlwaysSameValue]
          FROM [Authors] AS [a]
          ORDER BY [a].[AlwaysSameValue] DESC
          OFFSET @__p_0 ROWS FETCH NEXT @__p_1 ROWS ONLY
      ) AS [a0]
      INNER JOIN [Posts] AS [p1] ON [a0].[Id] = [p1].[AuthorId]
      ORDER BY [a0].[AlwaysSameValue] DESC, [a0].[Id]
```

EFCoreを10.0.0-preview.4.25258.110で再実行します。
頭のパッケージ指定を変えるだけです。
こちらも最後のSQL部分だけ引用します。

```diff:Sample.cs
-#:package Microsoft.EntityFrameworkCore.SqlServer@9.0.5
-//#:package Microsoft.EntityFrameworkCore.SqlServer@10.0.0-preview.4.25258.110
+//#:package Microsoft.EntityFrameworkCore.SqlServer@9.0.5
+#:package Microsoft.EntityFrameworkCore.SqlServer@10.0.0-preview.4.25258.110
```

```Microsoft.EntityFrameworkCore.SqlServer@10.0.0-preview.4.25258.110の実行結果
❯ dotnet run Sample.cs
... 
SingleQuery
info: 6/10/2025 05:15:53.501 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command) 
      Executed DbCommand (47ms) [Parameters=[@p='10', @p0='3'], CommandType='Text', CommandTimeout='30']
      SELECT [a0].[Id], [a0].[Name], [p].[Title], [p].[Id]
      FROM (
          SELECT [a].[Id], [a].[Name], [a].[AlwaysSameValue]
          FROM [Authors] AS [a]
          ORDER BY [a].[AlwaysSameValue] DESC
          OFFSET @p ROWS FETCH NEXT @p0 ROWS ONLY
      ) AS [a0]
      LEFT JOIN [Posts] AS [p] ON [a0].[Id] = [p].[AuthorId]
      ORDER BY [a0].[AlwaysSameValue] DESC, [a0].[Id]
SplitQuery
info: 6/10/2025 05:15:53.589 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command) 
      Executed DbCommand (6ms) [Parameters=[@p='10', @p0='3'], CommandType='Text', CommandTimeout='30']
      SELECT [a].[Id], [a].[Name]
      FROM [Authors] AS [a]
      ORDER BY [a].[AlwaysSameValue] DESC, [a].[Id]
      OFFSET @p ROWS FETCH NEXT @p0 ROWS ONLY
info: 6/10/2025 05:15:53.622 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command) 
      Executed DbCommand (13ms) [Parameters=[@p='10', @p0='3'], CommandType='Text', CommandTimeout='30']
      SELECT [p1].[Title], [a0].[Id]
      FROM (
          SELECT [a].[Id], [a].[AlwaysSameValue]
          FROM [Authors] AS [a]
          ORDER BY [a].[AlwaysSameValue] DESC, [a].[Id]
          OFFSET @p ROWS FETCH NEXT @p0 ROWS ONLY
      ) AS [a0]
      INNER JOIN [Posts] AS [p1] ON [a0].[Id] = [p1].[AuthorId]
      ORDER BY [a0].[AlwaysSameValue] DESC, [a0].[Id]
```

## 結果

わかりにくいのでWinMergeで比較してみます。
SingleQueryで発行されるSQLはパラメーターの表現が若干違うことを除いて動作に違いはなさそうです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/36535/bc05595c-f723-4423-9055-65a41a62c144.png)

SplitQueryで発行されるSQLは内部クエリに関しては、9.0.5では親のキーでのみソートされているのに対し、10.0.0-preview.4.25258.110では子供も忘れずにソートされています。
確かに9.0.5の取り方だと、RDBによってはAuthersの取得結果が変わることもありそうですね。
IDでソートするのが本当に正しいかは業務要件なので注意が必要ですが、並び順が明確になり検索のたびに結果が変わるという状況は防げそうです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/36535/91626309-28d9-49b1-b149-cc271f205a80.png)

## 10以前の場合どうすれば？

明示的に並び替えてあげればよいです。

```diff_c
var query = context.Authors
    .OrderByDescending(x => x.AlwaysSameValue)
+   .ThenBy(p => p.Id)
    .Skip(10)
    .Take(3)
    .Select(a => new AuthorDto
    {
        Id = a.Id,
        Name = a.Name,
        PostTitles = a.Posts.Select(p => p.Title).ToList()
    });
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/36535/4a473a69-d21c-4977-b373-9a2f82294195.png)

## おわりに

並び順って後になって気づくので厄介ですよね。