Entity Framework Core - IncludeとThenIncludeを試す

IncludeメソッドとThenIncludeメソッドは、あわせて読み込む関連エンティティを指定するために使います。Eager loadingと呼ばれている機能ですね。

Loading Related Data - EF Core | Microsoft Docs

このIncludeメソッドとThenIncludeメソッドを使うとどんなSQLが実行されるのか気になったので試してみました。実行されたSQLのログをペタペタとはっていきたいと思います。

コード全体やクエリはこちらに。

EF CoreのIncludeとThenInclude · GitHub

モデルとかデータとか

まず次のようなモデルを用意してみました。1対多の関係が1つ、多対多の関係が2つあります。1対多の関係が全部で5つと言ったほうがいいのか。

// MonsterCategory - Monster : 1対多
// Monster - MonsterArea - Area : 多対多
// Monster - MonsterItem - Item : 多対多
// 地域
public class Area {
    public int Id { get; set; }
    public string Name { get; set; }
}

// アイテム
public class Item {
    public int Id { get; set; }
    public string Name { get; set; }
}

// モンスターカテゴリ
public class MonsterCategory {
    public int Id { get; set; }
    public string Name { get; set; }
    public List<Monster> Monsters { get; set; }
}

// モンスター
public class Monster {
    public int Id { get; set; }
    public string Name { get; set; }
    public int CategoryId { get; set; }
    public MonsterCategory Category { get; set; }
    public List<MonsterArea> Areas { get; set; }
    public List<MonsterItem> Items { get; set; }
}

// モンスター生息地
public class MonsterArea {
    public int MonsterId { get; set; }
    public int No { get; set; }
    public int AreaId { get; set; }
    public Monster Monster { get; set; }
    public Area Area { get; set; }
}

// モンスター保持アイテム
public class MonsterItem {
    public int MonsterId { get; set; }
    public int No { get; set; }
    public int ItemId { get; set; }
    public Monster Monster { get; set; }
    public Item Item { get; set; }
}

次にロガーとDBコンテキストを用意します。

// ロガープロバイダー
public class AppLoggerProvider : ILoggerProvider {
    public ILogger CreateLogger(string categoryName) {
        if (string.Equals(categoryName, DbLoggerCategory.Database.Command.Name)) {
            return new ConsoleLogger();
        }

        return NullLogger.Instance;
    }

    public void Dispose() {
    }

    // ロガー
    private class ConsoleLogger : ILogger {
        public IDisposable BeginScope<TState>(TState state) => null;

        // 情報レベル以上のログを有効にする
        public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Information;

        public void Log<TState>(
            LogLevel logLevel, EventId eventId,
            TState state, Exception exception,
            Func<TState, Exception, string> formatter) {
            Console.WriteLine(formatter(state, exception));
            Console.WriteLine("---");
        }
    }
}

// DBコンテキスト
public class AppDbContext : DbContext {
    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) {
        var connectionString = new SqlConnectionStringBuilder {
            DataSource = ".",
            InitialCatalog = "Test",
            IntegratedSecurity = true,
        }.ToString();

        optionsBuilder.UseSqlServer(connectionString);
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder) {
        modelBuilder.Entity<Area>().ToTable(nameof(Area));
        modelBuilder.Entity<Item>().ToTable(nameof(Item));
        modelBuilder.Entity<MonsterCategory>().ToTable(nameof(MonsterCategory));
        modelBuilder.Entity<Monster>().ToTable(nameof(Monster));
        modelBuilder.Entity<MonsterArea>().ToTable(nameof(MonsterArea))
            // 複合主キーのマッピング
            .HasKey(area => new { area.MonsterId, area.No });
        modelBuilder.Entity<MonsterItem>().ToTable(nameof(MonsterItem))
            .HasKey(item => new { item.MonsterId, item.No });
    }
}

データを投入しておきましょう。

// 地域
dbContext.Set<Area>().AddRange(
    new Area { Id = 1, Name = "サンタローズ" },
    new Area { Id = 2, Name = "レヌール" },
    new Area { Id = 3, Name = "ラインハット" });

// アイテム
dbContext.Set<Item>().AddRange(
    new Item { Id = 1, Name = "やくそう" },
    new Item { Id = 2, Name = "どくけしそう" });

// モンスターカテゴリ
dbContext.Set<MonsterCategory>().AddRange(
    new MonsterCategory { Id = 1, Name = "スライム" },
    new MonsterCategory { Id = 2, Name = "悪魔" });

// モンスター(とモンスター生息地・モンスター保持アイテム)
dbContext.Set<Monster>().AddRange(
    new Monster {
        Id = 1,
        Name = "スライム",
        CategoryId = 1,    // スライム
        Areas = new List<MonsterArea> {
            new MonsterArea { No = 1, AreaId = 1 }, // サンタローズ
        },
        Items = new List<MonsterItem> {
            new MonsterItem { No = 1, ItemId = 1 }, // やくそう
        },
    },
    new Monster {
        Id = 2,
        Name = "ドラキー",
        CategoryId = 2,    // 悪魔
        Areas = new List<MonsterArea> {
            new MonsterArea { No = 1, AreaId = 1 }, // サンタローズ
            new MonsterArea { No = 2, AreaId = 2 }, // レヌール
        },
    },
    new Monster {
        Id = 3,
        Name = "バブルスライム",
        CategoryId = 1,    // スライム
        Areas = new List<MonsterArea> {
            new MonsterArea { No = 1, AreaId = 3 }, // ラインハット
        },
        Items = new List<MonsterItem> {
            new MonsterItem { No = 1, ItemId = 1 }, // やくそう
            new MonsterItem { No = 2, ItemId = 2 }, // どくけしそう
        },
    });

dbContext.SaveChanges();

Includeメソッドで1対多の1を読み込む

まずはIncludeメソッドを使って1対多の多から1を読み込んでみます。

// モンスターカテゴリを含めてモンスターを読み込む
var monsters = dbContext.Set<Monster>()
    .Include(monster => monster.Category)
    .ToList();
foreach (var monster in monsters) {
    Console.WriteLine($"#{monster.Id} {monster.Name} [{monster.Category.Name}]");
}
/*
#1 スライム [スライム]
#2 ドラキー [悪魔]
#3 バブルスライム [スライム]
*/

ログを確認してみます。実行されるSQLはINNER JOINされてます。

Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT [monster].[Id], [monster].[CategoryId], [monster].[Name], [monster.Category].[Id], [monster.Category].[Name]
FROM [Monster] AS [monster]
INNER JOIN [MonsterCategory] AS [monster.Category] ON [monster].[CategoryId] = [monster.Category].[Id]
---

Includeメソッドで1対多の多を読み込む

今度はIncludeメソッドを使って1対多の1から多を読み込んでみます。

var monsters = dbContext.Set<Monster>()
    .Include(monster => monster.Areas)
    .Include(monster => monster.Items)
    .ToList();
foreach (var monster in monsters) {
    var areas = string.Join(", ", monster.Areas.Select(area => area.AreaId));
    var items = string.Join(", ", monster.Items.Select(item => item.ItemId));
    Console.WriteLine($"#{monster.Id} {monster.Name} in {areas} has {items}");
}
/*
#1 スライム in 1 has 1
#2 ドラキー in 1, 2 has
#3 バブルスライム in 3 has 1, 2
*/

ログを確認すると、SELECT文が3つ実行されていることがわかります。どうもIncludeするごとにSELECT文が増える様子。こんなもの?

Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT [monster].[Id], [monster].[CategoryId], [monster].[Name]
FROM [Monster] AS [monster]
ORDER BY [monster].[Id]
---
Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT [monster.Areas].[MonsterId], [monster.Areas].[No], [monster.Areas].[AreaId]
FROM [MonsterArea] AS [monster.Areas]
INNER JOIN (
    SELECT [monster0].[Id]
    FROM [Monster] AS [monster0]
) AS [t] ON [monster.Areas].[MonsterId] = [t].[Id]
ORDER BY [t].[Id]
---
Executed DbCommand (2ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT [monster.Items].[MonsterId], [monster.Items].[No], [monster.Items].[ItemId]
FROM [MonsterItem] AS [monster.Items]
INNER JOIN (
    SELECT [monster1].[Id]
    FROM [Monster] AS [monster1]
) AS [t0] ON [monster.Items].[MonsterId] = [t0].[Id]
ORDER BY [t0].[Id]
---

Includeメソッドで多を読み込み、さらにThenIncludeメソッドで1を読み込む

ThenIncludeメソッドを使うと、Includeメソッドで指定した関連エンティティのさらに関連エンティティを読み込むことができます。

前述のサンプルにさらにThenIncludeメソッドを加えると、多対多のエンティティを一度に取得できます。確認してみましょう。

var monsters = dbContext.Set<Monster>()
    .Include(monster => monster.Areas)
        .ThenInclude(monsterArea => monsterArea.Area)
    .Include(monster => monster.Items)
        .ThenInclude(monsterItem => monsterItem.Item)
    .ToList();
foreach (var monster in monsters) {
    var areas = string.Join(", ", monster.Areas.Select(area => area.Area.Name));
    var items = string.Join(", ", monster.Items.Select(item => item.Item.Name));
    Console.WriteLine($"#{monster.Id} {monster.Name} in {areas} has {items}");
}
/*
#1 スライム in サンタローズ has やくそう
#2 ドラキー in サンタローズ, レヌール has
#3 バブルスライム in ラインハット has やくそう, どくけしそう
*/

多対1の多から1をThenIncludeしているので、SQLも前述のサンプルにThenIncludeした分のINNER JOINが加わった感じです。

Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT [monster].[Id], [monster].[CategoryId], [monster].[Name]
FROM [Monster] AS [monster]
ORDER BY [monster].[Id]
---
Executed DbCommand (2ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT [monster.Areas].[MonsterId], [monster.Areas].[No], [monster.Areas].[AreaId], [m.Area].[Id], [m.Area].[Name]
FROM [MonsterArea] AS [monster.Areas]
INNER JOIN [Area] AS [m.Area] ON [monster.Areas].[AreaId] = [m.Area].[Id]
INNER JOIN (
    SELECT [monster0].[Id]
    FROM [Monster] AS [monster0]
) AS [t] ON [monster.Areas].[MonsterId] = [t].[Id]
ORDER BY [t].[Id]
---
Executed DbCommand (2ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT [monster.Items].[MonsterId], [monster.Items].[No], [monster.Items].[ItemId], [m.Item].[Id], [m.Item].[Name]
FROM [MonsterItem] AS [monster.Items]
INNER JOIN [Item] AS [m.Item] ON [monster.Items].[ItemId] = [m.Item].[Id]
INNER JOIN (
    SELECT [monster1].[Id]
    FROM [Monster] AS [monster1]
) AS [t0] ON [monster.Items].[MonsterId] = [t0].[Id]
ORDER BY [t0].[Id]
---

Includeメソッドで多を読み込み、さらにThenIncludeメソッドで多を読み込む

ThenIncludeメソッドでの読み込みをもう1つ試してみましょう。多を読み込んでさらに多を読み込みます。

// モンスターとモンスター生息地を含めて、モンスターカテゴリを読み込む
var categories = dbContext.Set<MonsterCategory>()
    .Include(category => category.Monsters)
        .ThenInclude(monster => monster.Areas)
    .ToList();
foreach (var monster in categories.SelectMany(category => category.Monsters)) {
    var areas = string.Join(", ", monster.Areas.Select(area => area.AreaId));
    Console.WriteLine($"#{monster.Id} {monster.Name} [{monster.Category.Name}] in {areas}");
}
/*
#1 スライム [スライム] in 1
#3 バブルスライム [スライム] in 3
#2 ドラキー [悪魔] in 1, 2
*/

実行されるSQLは、多をIncludeまたはThenIncludeするごとにSELECT文が増えていく感じでしょうか。

Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT [category].[Id], [category].[Name]
FROM [MonsterCategory] AS [category]
ORDER BY [category].[Id]
---
Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT [category.Monsters].[Id], [category.Monsters].[CategoryId], [category.Monsters].[Name]
FROM [Monster] AS [category.Monsters]
INNER JOIN (
    SELECT [category0].[Id]
    FROM [MonsterCategory] AS [category0]
) AS [t] ON [category.Monsters].[CategoryId] = [t].[Id]
ORDER BY [t].[Id], [category.Monsters].[Id]
---
Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT [category.Monsters.Areas].[MonsterId], [category.Monsters.Areas].[No], [category.Monsters.Areas].[AreaId]
FROM [MonsterArea] AS [category.Monsters.Areas]
INNER JOIN (
    SELECT DISTINCT [category.Monsters0].[Id], [t0].[Id] AS [Id0]
    FROM [Monster] AS [category.Monsters0]
    INNER JOIN (
        SELECT [category1].[Id]
        FROM [MonsterCategory] AS [category1]
    ) AS [t0] ON [category.Monsters0].[CategoryId] = [t0].[Id]
) AS [t1] ON [category.Monsters.Areas].[MonsterId] = [t1].[Id]
ORDER BY [t1].[Id0], [t1].[Id]
---

IncludeメソッドやThenIncludeメソッドを使うとSELECT文が複数実行される場合があるということがなんだか気になるところでした。