1. はじめに
よくあるパターンを想像してみて:2つのコレクションがあって、それらは論理的に関連してる。例えば、商品のカテゴリリストと商品自体のリスト。各カテゴリごとにそのカテゴリの商品全部を取得したい。あるいは、会社の部署リストと従業員リストがあって、各部署の全従業員を表示したい場合とかね。
SQLだとこれは「グループ結合」(GROUP JOIN、正確にはLEFT OUTER JOIN+グループ化)って呼ばれるやつ。LINQにはこれ専用のオペレーターGroupJoinがある。これは普通のJoin(左の各要素に右の1要素が対応)と、キーでグループ化するやつの中間みたいなもの。GroupJoinは、1つのコレクションの各要素に、もう1つのコレクションの関連要素全部をコレクションとしてくっつけてくれる。
イメージで説明
普通のJoinが「親子ペア(父と息子)を名字で結合」だとしたら、GroupJoinは「各父に全ての子供リストをくっつけてツリー構造を作る」感じ。
図で見ると
カテゴリ 商品
+--------------+ +---------------------+
| Id | 名前 | | 名前 | CatId |
+----+---------+ +-----------+---------+
| 1 | パン | ---> | バトン | 1 |
| 2 | 飲み物 | | コルバサ | 3 |
| 3 | 肉 | | ペプシ | 2 |
| | | | お茶 | 2 |
+----+---------+ +-----------+---------+
GroupJoinの後はこうなる:
- パン — [バトン]
- 飲み物 — [ペプシ, お茶]
- 肉 — [コルバサ]
2. メソッドのシグネチャと基本概念
拡張メソッド
public static IEnumerable<TResult> GroupJoin<TOuter, TInner, TKey, TResult>(
this IEnumerable<TOuter> outer, // 「外側」コレクション(例:カテゴリ)
IEnumerable<TInner> inner, // 「内側」コレクション(例:商品)
Func<TOuter, TKey> outerKeySelector, // 外側要素からキーを取得する方法
Func<TInner, TKey> innerKeySelector, // 内側要素からキーを取得する方法
Func<TOuter, IEnumerable<TInner>, TResult> resultSelector // 結果オブジェクト/レコードの作り方
)
- outer: ループするコレクションで、ここに要素がくっつく(例:カテゴリ)。
- inner: くっつける要素を選ぶコレクション(例:商品)。
- outerKeySelector: 「左側」要素のキーを返すラムダ。
- innerKeySelector: 「右側」要素のキーを返すラムダ。
- resultSelector: 各ペア(左+右グループ)の結果の形を決める関数。
3. 実例:カテゴリと商品
例えば、こんなモデルがあるとする:
public class Category
{
public int Id { get; set; }
public string Name { get; set; }
}
public class Product
{
public string Name { get; set; }
public int CategoryId { get; set; }
}
サンプル用コレクション:
var categories = new List<Category>
{
new Category { Id = 1, Name = "パン" },
new Category { Id = 2, Name = "飲み物" },
new Category { Id = 3, Name = "肉" }
};
var products = new List<Product>
{
new Product { Name = "バトン", CategoryId = 1 },
new Product { Name = "ペプシ", CategoryId = 2 },
new Product { Name = "お茶", CategoryId = 2 },
new Product { Name = "コルバサ", CategoryId = 3 }
};
GroupJoinの使い方(メソッド構文)
var groupJoin = categories.GroupJoin(
products,
category => category.Id, // カテゴリのキー
product => product.CategoryId, // 商品のキー
(category, prods) => new // 結果をその場で作る
{
CategoryName = category.Name,
Products = prods.Select(p => p.Name).ToList() // このカテゴリの商品名リスト
}
);
結果の回し方:
foreach (var group in groupJoin)
{
Console.WriteLine($"カテゴリ: {group.CategoryName}");
foreach (var product in group.Products)
{
Console.WriteLine($" - {product}");
}
}
出力例:
カテゴリ: パン
- バトン
カテゴリ: 飲み物
- ペプシ
- お茶
カテゴリ: 肉
- コルバサ
4. GroupJoin:クエリ構文
LINQはSQLっぽい構文もサポートしてる。group joinにはjoin ... into ...ってキーワードを使う。これも上の例とほぼ同じ動き。
var groupJoin2 = from c in categories
join p in products on c.Id equals p.CategoryId into prodGroup
select new
{
CategoryName = c.Name,
Products = prodGroup.Select(p => p.Name).ToList()
};
これはSQLのLEFT OUTER JOIN ... GROUP BYにかなり近い感じ。
図解:GroupJoinの動き
[カテゴリ] [商品] グループ化 (GroupJoin)
パン --------> バトン => パン: [バトン]
飲み物 --------> ペプシ => 飲み物: [ペプシ, お茶]
飲み物 --------> お茶
肉 --------> コルバサ => 肉: [コルバサ]
各カテゴリは自分専用の「ポケット」(IEnumerable<Product>)を持ってて、そこにそのカテゴリの商品が全部入るイメージ。
5. 特徴とハマりやすいポイント
GroupJoinと普通のJoinの違い
普通のJoinとGroupJoinの違いは、結果の数。Joinは一致ごとに1ペア返すけど、GroupJoinは外側コレクションの各要素ごとに1つ返して、その中に一致した要素のコレクションが入ってる。
もしカテゴリに商品が1つもなかった場合でも、GroupJoinならそのカテゴリもちゃんと出てくる(商品リストは空になるだけ)。これはSQLのLEFT OUTER JOIN(左外部結合)と同じ動き。
商品がないカテゴリの例:
categories.Add(new Category { Id = 4, Name = "チーズ" });
var groupJoin3 = categories.GroupJoin(
products,
c => c.Id,
p => p.CategoryId,
(c, prods) => new
{
CategoryName = c.Name,
Products = prods.Select(p => p.Name).ToList()
});
foreach (var group in groupJoin3)
{
Console.WriteLine($"カテゴリ: {group.CategoryName}");
if (group.Products.Count == 0)
Console.WriteLine(" (商品なし)");
else
foreach (var product in group.Products)
Console.WriteLine($" - {product}");
}
カテゴリ: パン
- バトン
カテゴリ: 飲み物
- ペプシ
- お茶
カテゴリ: 肉
- コルバサ
カテゴリ: チーズ
(商品なし)
こういうパターンは業務アプリでよくある:全カテゴリ(またはグループ)を表示したいけど、中身が空のやつもちゃんと見せたい場合。
GroupByで代用できる?→ダメ!
よくある勘違いで、GroupJoinをダブルGroupByで「エミュレート」しようとする人がいるけど、やめとこう。GroupJoinはまさにこの用途のためにあるし、「左結合」をネイティブにやってくれる。
6. 実データでの使い方
前のレクチャーに加えて、学習アプリに「各カテゴリごとにその商品のリストを出す」レポート機能を追加してみよう。ネットショップやCRM、会計・レポート系システムでよく使うやつだね。
デモアプリにコードを追加:
// CategoryとProductクラスとコレクションはすでにあると仮定
Console.WriteLine("カテゴリと商品のレポート:");
var categoryReport = categories.GroupJoin(
products,
cat => cat.Id,
prod => prod.CategoryId,
(cat, prods) => new
{
cat.Name,
ProductNames = prods.Select(p => p.Name).ToList()
});
foreach (var row in categoryReport)
{
Console.WriteLine($"カテゴリ: {row.Name}");
if (row.ProductNames.Count == 0)
Console.WriteLine(" (商品なし)");
else
foreach (var prodName in row.ProductNames)
Console.WriteLine($" - {prodName}");
}
7. ネストしたグループ化と集計
GroupJoinは集計関数と組み合わせて、もっと複雑なレポートも作れるよ。
例:各カテゴリの商品数を数える
var reportWithCount = categories.GroupJoin(
products,
category => category.Id,
product => product.CategoryId,
(category, prods) => new
{
Category = category.Name,
Count = prods.Count() // 集計関数!
});
foreach (var rec in reportWithCount)
{
Console.WriteLine($"{rec.Category}: {rec.Count} 商品");
}
例えば、categoriesとproductsコレクションがこんなデータだったとする:
var categories = new[]
{
new { Id = 1, Name = "フルーツ" },
new { Id = 2, Name = "野菜" },
new { Id = 3, Name = "乳製品" }
};
var products = new[]
{
new { Id = 1, Name = "りんご", CategoryId = 1 },
new { Id = 2, Name = "バナナ", CategoryId = 1 },
new { Id = 3, Name = "にんじん", CategoryId = 2 }
};
コンソール出力はこうなる:
カテゴリ: フルーツ — 2 商品
カテゴリ: 野菜 — 1 商品
カテゴリ: 乳製品 — 0 商品
8. GroupJoinと初心者がやりがちなミス
よくあるミスは、GroupJoinの結果がフラットなテーブル(普通のJoinみたいなペアのリスト)になると思い込むこと。「フラット」ってのは、各行が「外側要素+対応する内側要素1つ」って形(SQLのINNER JOIN後のテーブルみたいなやつ)。
DBをちょっと触ったことある人ほど混乱しやすい:GroupJoinがLEFT JOINみたいに動くと思っても、返ってくるのは「ペアの行」じゃなくて「グループ」なんだよね。GroupJoinは外側コレクションの要素+関連する内側要素のコレクション、つまりネスト構造を返す。
必要なら、ネストしたコレクションをSelectManyで「展開」して、普通のペアのシーケンスにすることもできるよ。
もう1つのよくあるミスは、一致しない要素のグループが空リストになるのを忘れること。これはデフォルトの動作で、バグじゃないから、出力が「何もない」時も焦らないでね。
GroupJoinを使うべき時・使わない時
GroupJoinを使うのはこんな時:
- 2つのデータセット(例:部署と従業員、カテゴリと商品)があって、「親ごとに全ての子」を階層的に表示したい時。
- 「子がいない親」も含めて全部表示したい複雑なレポートを作りたい時。
- SQLのLEFT OUTER JOIN+キーでグループ化したい時。
単にコレクション同士を突き合わせたい、または一致ごとに1ペアだけ欲しい時は、普通のJoinを使おう。
GO TO FULL VERSION