【LINQ】GroupJoinを理解する

2024/09/16に公開

概要

LINQのGroupJoinがどんな挙動をするのか、たまに忘れます。

そろそろ正確に理解したいなと思い、この記事を書きました。

https://learn.microsoft.com/ja-jp/dotnet/api/system.linq.enumerable.groupjoin?view=net-8.0

GroupJoinの使い方

GroupJoinは、1対多の関係にある2つのコレクションを、キーで関連付けして、親とその親に関連する子のグループを作るメソッドです。親のデータごとに、対応する子のコレクションを取得したい時に便利です。

まず、各引数の役割について整理します。

var test = parents.GroupJoin(
    children, // 子のリスト
    parent => parent.Id, // 親のキー
    child => child.ParentId, // 子のキー
    // ↓ parentは親の要素 / childrenは上で指定したキーで関連付けされたchildをグループ化したもの(IEnumerable<Child>)
    (parent, children) => new // 結合結果の調整
    {
        ParentName = parent.Name,
        Children = children.Select(c => c.Name),
    }
);

続いて、実例を元に動きを確認します。

以下のようなクラスとサンプルデータがあるとします。ここでは、親がTeam(チーム)で、子がPlayer(選手)です。Team:Playerが1:多の関係になります。

public class Team
{
    public string Name { get; set; } = string.Empty;
    public string Stadium { get; set; } = string.Empty;
}

public class Player
{
    public string Name { get; set; } = string.Empty;
    public string TeamName { get; set; } = string.Empty;
}

var teams = new List<Team>
{
    new() { Name = "横浜DeNAベイスターズ", Stadium = "横浜スタジアム" },
    new() { Name = "読売ジャイアンツ", Stadium = "東京ドーム" },
    new() { Name = "東京ヤクルトスワローズ", Stadium = "神宮球場" },
    new() { Name = "阪神タイガース", Stadium = "甲子園球場" },
    new() { Name = "広島東洋カープ", Stadium = "マツダ スタジアム" },
    new() { Name = "中日ドラゴンズ", Stadium = "バンテリンドーム ナゴヤ" },
};

var players = new List<Player>
{
    new() { Name = "牧秀悟", TeamName = "横浜DeNAベイスターズ" },
    new() { Name = "宮崎敏郎", TeamName = "横浜DeNAベイスターズ" },
    new() { Name = "筒香嘉智", TeamName = "横浜DeNAベイスターズ" },
    new() { Name = "村上宗隆", TeamName = "東京ヤクルトスワローズ" },
    new() { Name = "坂本勇人", TeamName = "読売ジャイアンツ" },
    new() { Name = "山田哲人", TeamName = "東京ヤクルトスワローズ" },
};

では、GroupJoinを用いて、チームごとに選手をグループ化して、その結果を出力します。

var results = teams.GroupJoin(
    players, // 結合対象(子側)
    team => team.Name, // 親(チーム)のキー
    player => player.TeamName, // 子(選手)のキー
    (team, teamPlayers) => new
    {
        // チーム名、球場名、(チームに紐づく)選手名のコレクションを持つオブジェクトを作成
        TeamName = team.Name,
        Stadium = team.Stadium,
        Players = teamPlayers.Select(p => p.Name),
    }
);

foreach (var team in results)
{
    Console.WriteLine($"{team.TeamName}{team.Stadium})の選手一覧:");
    foreach (var player in team.Players)
    {
        Console.WriteLine($" - {player}");
    }
}

結果は以下のようになります。

横浜DeNAベイスターズ(横浜スタジアム)の選手一覧:
 - 牧秀悟
 - 宮崎敏郎
 - 筒香嘉智
読売ジャイアンツ(東京ドーム)の選手一覧:
 - 坂本勇人
東京ヤクルトスワローズ(神宮球場)の選手一覧:
 - 村上宗隆
 - 山田哲人
阪神タイガース(甲子園球場)の選手一覧:
広島東洋カープ(マツダ スタジアム)の選手一覧:
中日ドラゴンズ(バンテリンドーム ナゴヤ)の選手一覧:

チームごとに選手をグループ化できていることが確認できます。また、グループ化する対象(ここでは選手)がいない場合も結果に含まれることが分かります。

SelectManyと一緒に使うと左外部結合になる

SelectManyとは

SelectManyは、親データに紐づく子データのコレクションをフラット化し、各親と子の組み合わせを1つずつ展開するメソッドです。このとき、生成されるデータの数は、各親に対してフラット化される子要素の数と同じになります。

https://learn.microsoft.com/ja-jp/dotnet/api/system.linq.enumerable.selectmany?view=net-8.0

GroupJoinとSelectManyをセットで使う

SelectManyは、GroupJoinとセットで使うことで、左外部結合(LEFT JOIN)に近い動作を実現できます。これはよく使われる手法なので、GroupJoin単体の使い方と合わせて確認していきます。

では、先ほどと同じデータを使って、GroupJoinの後にSelectManyを追加して、選手単位でフラット化してみます。

var results = teams.GroupJoin(
    players,
    team => team.Name,
    player => player.TeamName,
    (team, teamPlayers) => new
    {
        TeamName = team.Name,
        Stadium = team.Stadium,
        Players = teamPlayers.DefaultIfEmpty(),
    }
)
.SelectMany(
    // 第一引数には、フラット化する対象のコレクションを指定
    teamWithPlayers => teamWithPlayers.Players,
    // 第二引数では、フラット化されたコレクションの各要素と親のデータを使って、新しいオブジェクトを作成する方法を指定
    // teamWithPlayers: GroupJoinで作成した選手のコレクションを持つチームのデータ
    // player: 第一引数で指定した選手のコレクションから1つずつ取り出した要素
    (teamWithPlayers, player) => new
    {
        TeamName = teamWithPlayers.TeamName,
        Stadium = teamWithPlayers.Stadium,
        PlayerName = player?.Name ?? "なし" 
    }
);

出力結果は次のようになります。

横浜DeNAベイスターズ(横浜スタジアム)の選手: 牧秀悟
横浜DeNAベイスターズ(横浜スタジアム)の選手: 宮崎敏郎
横浜DeNAベイスターズ(横浜スタジアム)の選手: 筒香嘉智
読売ジャイアンツ(東京ドーム)の選手: 坂本勇人
東京ヤクルトスワローズ(神宮球場)の選手: 村上宗隆
東京ヤクルトスワローズ(神宮球場)の選手: 山田哲人
阪神タイガース(甲子園球場)の選手: なし
広島東洋カープ(マツダ スタジアム)の選手: なし
中日ドラゴンズ(バンテリンドーム ナゴヤ)の選手: なし

子要素(選手)ごとにデータが生成されていることが確認できます。

注意する点としては、GroupJoinDefaultIfEmpty()を指定している点です。

    (team, teamPlayers) => new
    {
        ..略
        Players = teamPlayers.DefaultIfEmpty(),
    }

DefaultIfEmpty()を指定しない場合、選手コレクションを持たないチームは表示されません。これは SelectMany が子のコレクション(選手)をフラット化する際に、子の要素が空(存在しない)場合、その親のデータが除外されるためです。この動作は、SQLの内部結合(INNER JOIN)に似た挙動です。

そのため、左外部結合(LEFT JOIN)のように、選手を持たないチームも表示したい場合には、DefaultIfEmpty() を使用する必要があります。DefaultIfEmpty()を使うと、選手が存在しなくても、Playersがnullとして指定され、親のレコードが残るようになります。これにより、選手がいない場合でもチームは結果に残り、選手名が "なし" として表示されます。

参考

https://learn.microsoft.com/ja-jp/dotnet/csharp/language-reference/builtin-types/default-values

最後に

以上です。

LINQのGroupJoinSelectManyについて整理しました。

未来の自分のために書いた備忘的な要素が強めですが、何かのお役に立てばなお嬉しいです。

Discussion