データベース操作における「左外部結合(LEFT OUTER JOIN)」は、左側のテーブルの全レコードを保持しつつ、右側のテーブルにマッチするレコードがあれば結合し、なければNULLとして扱う操作です。
C#のLINQにおいてこの操作をメソッド構文で実現するには、GroupJoinメソッドとSelectManyメソッド、そしてDefaultIfEmptyメソッドを組み合わせるテクニックが必要です。
今回は、タスク管理システムにおける「タスク」と「カテゴリ」の関係を題材に、カテゴリが削除されていたり未設定であったりする場合でも、タスク一覧を欠損させずに取得する実装方法を解説します。
外部結合(Left Outer Join)の仕組み
LINQのメソッド構文で外部結合を行う手順は以下の通りです。
- GroupJoin: 親(左側)と子(右側)をキーで紐付け、階層構造を作ります。
- SelectMany: 階層化された子要素をフラットに展開します。
- DefaultIfEmpty: 子要素が存在しない(紐付くデータがない)場合に、その部分をデフォルト値(null)として扱います。
実践的なコード例:タスクとカテゴリの結合
以下のコードは、タスクリスト(左側)に対し、カテゴリリスト(右側)を外部結合する例です。カテゴリIDが一致しないタスクも、一覧から消えることなく表示されます。
using System;
using System.Collections.Generic;
using System.Linq;
namespace TaskManager
{
// タスク情報(左側のデータソース)
public class TaskItem
{
public string Title { get; set; }
public int CategoryId { get; set; }
}
// カテゴリ情報(右側のデータソース)
public class Category
{
public int Id { get; set; }
public string Name { get; set; }
}
class Program
{
static void Main()
{
// シナリオ:
// タスク一覧を表示したいが、中には「存在しないカテゴリID」が紐付いているタスクがある。
// 内部結合(Join)を使うと、そのようなタスクは結果から消えてしまう。
// 外部結合(Left Join)を使い、カテゴリ不明のタスクも表示させる。
var tasks = new List<TaskItem>
{
new TaskItem { Title = "牛乳を買う", CategoryId = 1 }, // Category: 生活
new TaskItem { Title = "C#の学習", CategoryId = 2 }, // Category: 勉強
new TaskItem { Title = "部屋の掃除", CategoryId = 1 }, // Category: 生活
new TaskItem { Title = "謎の用事", CategoryId = 99 }, // Category: 存在しないID
};
var categories = new List<Category>
{
new Category { Id = 1, Name = "生活" },
new Category { Id = 2, Name = "勉強" },
new Category { Id = 3, Name = "仕事" },
};
// LINQによる外部結合の実装
var query = tasks
// 1. GroupJoinでタスクに該当するカテゴリのグループを作成
.GroupJoin(
categories,
task => task.CategoryId, // 左側のキー
category => category.Id, // 右側のキー
(task, categoryCollection) => new { Task = task, Categories = categoryCollection }
)
// 2. SelectManyでフラット化しつつ、DefaultIfEmptyで空対策を行う
.SelectMany(
x => x.Categories.DefaultIfEmpty(), // マッチするカテゴリがない場合はnullを含む空集合にする
(x, category) => new
{
TaskTitle = x.Task.Title,
// categoryがnullの場合は未設定扱いとする
CategoryName = category == null ? "(カテゴリ未設定)" : category.Name
}
);
// 結果の出力
Console.WriteLine("--- タスク一覧 ---");
foreach (var item in query)
{
Console.WriteLine($"タスク: {item.TaskTitle,-10} | カテゴリ: {item.CategoryName}");
}
}
}
}
実行結果
--- タスク一覧 ---
タスク: 牛乳を買う | カテゴリ: 生活
タスク: C#の学習 | カテゴリ: 勉強
タスク: 部屋の掃除 | カテゴリ: 生活
タスク: 謎の用事 | カテゴリ: (カテゴリ未設定)
「謎の用事」はカテゴリIDが99であり、categoriesリストには存在しませんが、結果から除外されずに「(カテゴリ未設定)」として出力されています。これが外部結合の効果です。
技術的なポイント
1. DefaultIfEmptyの役割
このメソッドが外部結合の核となります。GroupJoinの結果、マッチする相手がいない場合、空のシーケンスが生成されます。DefaultIfEmpty()を呼び出すことで、「空のシーケンス」を「null要素を1つ含むシーケンス」に変換します。これにより、後続のSelectManyがループを回せるようになり、結果として左側のデータが残ります。
2. クエリ構文(Query Syntax)の場合
メソッド構文では記述がやや冗長になりますが、クエリ構文を使用するとSQLに近い形で直感的に記述可能です。
// クエリ構文での記述例
var querySyntax = from t in tasks
join c in categories on t.CategoryId equals c.Id into groupJoin
from subC in groupJoin.DefaultIfEmpty() // ここで外部結合
select new
{
TaskTitle = t.Title,
CategoryName = subC?.Name ?? "(カテゴリ未設定)"
};
チームのコーディング規約や可読性の好みに応じて使い分けてください。
まとめ
GroupJoin、SelectMany、DefaultIfEmptyを組み合わせることで、LINQでもSQLのLeft Outer Joinと同様の処理が可能です。マスタデータが存在しないトランザクションデータを扱う際や、オプション項目を結合する際など、データの欠損を防ぎたい場面で必須となるテクニックです。
