Entity Framework - 条件付きで関連エンティティを読み込む

Includeメソッドを使って関連エンティティを読み込むとき、読み込む関連エンティティをフィルタしたいことがあると思います。というかありました。

Includeメソッドで書けるのかなと思って調べましたがどうもできない様子。代替案がないかなと思って調べたところ、少し古いですが次の記事が見つかりました。

Tip 37 – How to do a Conditional Include – Meta-Me

Includeメソッドは使いませんがやりたいことはまさにこれ。記事を読んで終ってしまうのもあれですし、なるほどねと思う部分もあったので試してみました。

エンティティとDBコンテキストを用意します。ユーザがグループに属する1対多の関係です。

// ユーザ
class User {
    public int Id { get; set; }
    public int GroupId { get; set; }  // Groupをさす外部キー
    public string Name { get; set; }
    public bool IsDeleted { get; set; }   // 削除済みか

    public Group Group { get; set; }   // ナビゲーション:グループ
}

// グループ
class Group {
    public int Id { get; set; }
    public string Name { get; set; }

    public ICollection<User> Users { get; set; } // ナビゲーション:ユーザ一覧
}

// DBコンテキスト
class AppDbContext : DbContext {
    public AppDbContext(string nameOrConnectionString)
        : base(nameOrConnectionString) {
    }

    public IDbSet<Group> Groups { get; set; }

    protected override void OnModelCreating(DbModelBuilder modelBuilder) {
        base.OnModelCreating(modelBuilder);
        modelBuilder.Conventions.Remove<PluralizingTableNameConvention>();
    }
}

そしてサンプルデータ。

using(var dbContext = new AppDbContext(_connectionString)) {
    dbContext.Groups.Add(new Group {
        Name = "X",
        Users = new[] {
            new User { Name = "Aさん", IsDeleted = false, },
            new User { Name = "Bさん", IsDeleted = true, }, // 削除済みとする
        },
    });
    dbContext.Groups.Add(new Group {
        Name = "Y",
        Users = new[] {
            new User { Name = "Cさん", IsDeleted = true, }, // 削除済みとする
        },
    });
    dbContext.Groups.Add(new Group {
        Name = "Z",
        // ユーザなし
    });
    dbContext.SaveChanges();
}

Includeメソッドを使って関連エンティティを読み込む

まずはIncludeメソッドを使ってすべて読み込んでデータとクエリを確認します。

using(var dbContext = new AppDbContext(_connectionString)) {
    dbContext.Database.Log = Console.WriteLine;

    var groups = dbContext.Groups
        // 関連エンティティをすべて読み込む
        .Include(group => group.Users)
        .ToList();
    foreach (var group in groups) {
        Console.WriteLine($"Group: {{ Id: {group.Id}, Name: {group.Name} }}");

        foreach (var user in group.Users) {
            Console.WriteLine($"User: {{ Id: {user.Id}, Name: {user.Name}, GroupId: {user.GroupId}, IsDeleted: {user.IsDeleted} }}");
        }
    }
    // Group: { Id: 1, Name: X }
    // User: { Id: 1, Name: Aさん, GroupId: 1, IsDeleted: False }
    // User: { Id: 2, Name: Bさん, GroupId: 1, IsDeleted: True }
    // Group: { Id: 2, Name: Y }
    // User: { Id: 3, Name: Cさん, GroupId: 2, IsDeleted: True }
    // Group: { Id: 3, Name: Z }
}

// クエリ
/*
SELECT
    [Project1].[Id] AS [Id],
    [Project1].[Name] AS [Name],
    [Project1].[C1] AS [C1],
    [Project1].[Id1] AS [Id1],
    [Project1].[GroupId] AS [GroupId],
    [Project1].[Name1] AS [Name1],
    [Project1].[IsDeleted] AS [IsDeleted]
    FROM ( SELECT
        [Extent1].[Id] AS [Id],
        [Extent1].[Name] AS [Name],
        [Extent2].[Id] AS [Id1],
        [Extent2].[GroupId] AS [GroupId],
        [Extent2].[Name] AS [Name1],
        [Extent2].[IsDeleted] AS [IsDeleted],
        CASE WHEN ([Extent2].[Id] IS NULL) THEN CAST(NULL AS int) ELSE 1 END AS [C1]
        FROM  [dbo].[Group] AS [Extent1]
        LEFT OUTER JOIN [dbo].[User] AS [Extent2]
            ON [Extent1].[Id] = [Extent2].[GroupId]
    )  AS [Project1]
*/

条件付きで関連エンティティを読み込む

そしてここからが本題。グループをすべて取得しつつ、削除されていないユーザだけをあわせて読み込みたいとします。この場合は次のように書きます。

using(var dbContext = new AppDbContext(_connectionString)) {
    dbContext.Database.Log = Console.WriteLine;

    var entries = dbContext.Groups
        .Select(group => new {
            Group = group,
            // 削除されていないユーザを読み込む
            Users = group.Users.Where(user => !user.IsDeleted),
        })
        .ToList();
    var groups = entries.Select(entry => entry.Group);

    foreach (var group in groups) {
        Console.WriteLine($"Group: {{ Id: {group.Id}, Name: {group.Name} }}");

        // 見つからない場合は空のコレクションではなくnullになる
        if (group.Users != null ) {
            foreach (var user in group.Users) {
                Console.WriteLine($"User: {{ Id: {user.Id}, Name: {user.Name}, GroupId: {user.GroupId}, IsDeleted: {user.IsDeleted} }}");
            }
        }
    }
    // Group: { Id: 1, Name: X }
    // User: { Id: 1, Name: Aさん, GroupId: 1, IsDeleted: False }
    // Group: { Id: 2, Name: Y }
    // Group: { Id: 3, Name: Z }
}

// クエリ
/*
SELECT
    [Project1].[Id] AS [Id],
    [Project1].[Name] AS [Name],
    [Project1].[C1] AS [C1],
    [Project1].[Id1] AS [Id1],
    [Project1].[GroupId] AS [GroupId],
    [Project1].[Name1] AS [Name1],
    [Project1].[IsDeleted] AS [IsDeleted]
    FROM ( SELECT
        [Extent1].[Id] AS [Id],
        [Extent1].[Name] AS [Name],
        [Extent2].[Id] AS [Id1],
        [Extent2].[GroupId] AS [GroupId],
        [Extent2].[Name] AS [Name1],
        [Extent2].[IsDeleted] AS [IsDeleted],
        CASE WHEN ([Extent2].[Id] IS NULL) THEN CAST(NULL AS int) ELSE 1 END AS [C1]
        FROM  [dbo].[Group] AS [Extent1]
        LEFT OUTER JOIN [dbo].[User] AS [Extent2]
            ON ([Extent1].[Id] = [Extent2].[GroupId]) AND ([Extent2].[IsDeleted] <> 1)
    )  AS [Project1]
    ORDER BY [Project1].[Id] ASC, [Project1].[C1] ASC
*/

Group.UsersナビゲーションプロパティにUserエンティティのコレクションを設定するコードを書いていませんが、設定されていて参照できていることがわかります。Entity Frameworkがナビゲーションプロパティに割り当てしてくれるみたいです。さすが。

また該当するUserエンティティが見つからない場合、IncludeメソッドではGroup.Usersナビゲーションプロパティは空のコレクションになるのに対して、Includeメソッドを使わない読み込みではこのプロパティはnullになるようです。

参考