はじめに
業務で EF Core を使っていて、「部下の部下の部下を全員取得したい」という要件に遭遇しました。
調べてみると、親子関係を辿る再帰 CTE(Common Table Expression)は複雑でパフォーマンスにも不安があり、SQL Server には hierarchyid という専用のデータ型があることがわかりました。
ただ、日本語の情報が少なかったので、学んだことを整理して記事にまとめます。
この記事で学べること
- HierarchyId とは何か、なぜ高速なのか
- 従来の階層データ実装手法(隣接リスト、入れ子集合など)との違い
- EF Core での環境構築方法
- 基本的なデータ挿入と表示
対象読者
- EF Core を業務で使用している方
- SQL Server / Azure SQL を使用している方
- 階層データの実装で困った経験がある方
⚠️ 本記事は EF Core 8.0 以降 を対象としています。
HierarchyIdの公式サポートは EF Core 8.0 で追加された機能です。
シリーズ記事
本記事は 3 部構成の第 1 回です。
| # | タイトル | 内容 |
|---|---|---|
| 1 | 入門編(本記事) | HierarchyId とは、環境構築 |
| 2 | クエリ編 | IsDescendantOf、クエリパターン |
| 3 | 実践編 | ノード追加・移動、同時実行対策 |
階層データとは
階層データとは、親子関係を持つツリー構造のデータです。業務システムでは頻繁に登場します。
よくある例
- 組織図: CEO → 部長 → 課長 → 一般社員
- 商品カテゴリ: 家電 → PC → ノートPC → ゲーミングノート
- フォルダ構造: ルート → プロジェクト → ドキュメント → 仕様書
- コメントスレッド: 投稿 → 返信 → 返信への返信
CEO
├── 営業部長
│ ├── 営業1課長
│ │ ├── 田中
│ │ └── 佐藤
│ └── 営業2課長
│ └── 鈴木
└── 開発部長
└── 開発1課長
├── 山田
└── 伊藤
こうしたデータに対して「営業部長配下の全社員を取得する」「山田さんの上司を全員取得する」といったクエリが必要になります。
従来手法の課題
階層データをリレーショナルデータベースで扱う手法はいくつかあります。それぞれに特徴と課題があります。
隣接リストモデル(ParentId 方式)
最もシンプルな方法です。各行に親の ID を持たせます。
public class Employee
{
public int Id { get; set; }
public string Name { get; set; }
public int? ParentId { get; set; } // ← 親の ID
}
一見問題なさそうですが、子孫を全て取得するには再帰 CTE が必要です。
-- 再帰 CTE による子孫取得(複雑!)
WITH EmployeeCTE AS (
SELECT Id, Name, ParentId, 0 AS Level
FROM Employees WHERE Id = @managerId
UNION ALL
SELECT e.Id, e.Name, e.ParentId, Level + 1
FROM Employees e
INNER JOIN EmployeeCTE cte ON e.ParentId = cte.Id
)
SELECT * FROM EmployeeCTE;
階層が深くなるとパフォーマンスが低下し、コードも複雑になります。
4つの手法の比較
| 観点 | 隣接リスト | 入れ子集合 | 閉包テーブル | HierarchyId |
|---|---|---|---|---|
| 実装の容易さ | ◎ 簡単 | △ 複雑 | ○ 中程度 | ○ 中程度 |
| 読み取り性能 | △ 再帰必要 | ◎ 高速 | ◎ 高速 | ◎ 高速 |
| 書き込み性能 | ◎ 高速 | ❌ 多行更新 | ○ 中程度 | ◎ 高速 |
| ストレージ効率 | ◎ 最小 | ○ 中程度 | △ O(n²) | ◎ コンパクト |
| ノード間挿入 | ◎ 容易 | ❌ 複雑 | ○ 中程度 | ◎ ネイティブ対応 |
| ポータビリティ | ◎ どのDBでも | ◎ どのDBでも | ◎ どのDBでも | ❌ SQL Server のみ |
- 入れ子集合モデル: 読み取りは高速ですが、ノード追加・削除時に多くの行を更新する必要があります
- 閉包テーブル: 全ての祖先・子孫関係を別テーブルに持つため、ストレージ効率が悪化します
- HierarchyId: 読み書き両方で高速ですが、SQL Server 専用という制約があります
📌 筆者の所感: 日本の開発現場では、ポータビリティを重視して隣接リストが選ばれることが多い印象です。ただ、「SQL Server 固定」「配下の全員取得が頻繁」という条件が揃えば、HierarchyId の導入で再帰 CTE の複雑さから解放されるメリットは大きいと感じています。
HierarchyId とは
hierarchyid は SQL Server 2008 で導入されたネイティブデータ型です。ツリー内のノード位置をパス形式で表現します。
パス表現の仕組み
各ノードの位置は /1/2/3/ のような文字列で表現できます。
/ ← ルート(CEO)
/1/ ← ルートの1番目の子(営業部長)
/1/1/ ← /1/ の1番目の子(営業1課長)
/1/1/1/ ← /1/1/ の1番目の子(田中)
/1/1/2/ ← /1/1/ の2番目の子(佐藤)
/2/ ← ルートの2番目の子(開発部長)
この表現の重要な特徴は、子孫かどうかがプレフィックス比較だけで判定できる点です。
/1/1/1/ は /1/ で始まる → /1/ の子孫である ✓
/2/1/ は /1/ で始まらない → /1/ の子孫ではない ✗
なぜ高速なのか
HierarchyId が高速な理由は、内部でのバイナリエンコーディングにあります。
| 特徴 | 説明 |
|---|---|
| コンパクトな格納 | 10万ノード・平均ファンアウト6のツリーで、1ノードあたり約5バイト |
| 再帰不要 | 子孫取得が単一クエリで完結(再帰 CTE 不要) |
| 効率的なインデックス | 深さ優先順序でソート済み、近いノードが物理的に近くに配置 |
📌 公式ドキュメントの記載: Microsoft のドキュメントでは、サブツリークエリで再帰 CTE と比較して大幅な I/O 削減が報告されています。
小数・負数による柔軟な挿入
既存ノード間に新しいノードを挿入することも可能です。
/1/1/ と /1/2/ の間に挿入 → /1/1.1/
/1/1/ の前に挿入 → /1/0/ や /1/-1/
これにより、既存データを書き換えることなく任意の位置にノードを追加できます。
EF Core での環境構築
EF Core 8 以降では、HierarchyId の公式サポートが追加されました。
パッケージのインストール
dotnet add package Microsoft.EntityFrameworkCore.SqlServer.HierarchyId
このパッケージをインストールすると、以下が自動的に追加されます:
-
Microsoft.EntityFrameworkCore.SqlServer.Abstractions(HierarchyId 型) -
Microsoft.SqlServer.Types(SQL Server との連携)
DbContext の設定
UseSqlServer 内で UseHierarchyId() を呼び出します。
// Program.cs
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(
connectionString,
sqlOptions => sqlOptions.UseHierarchyId())); // ← これが必須!
または OnConfiguring 内で設定することもできます。
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.UseSqlServer(
"Your Connection String",
x => x.UseHierarchyId()); // ← これを忘れると動かない
}
エンティティの定義
HierarchyId 型をプロパティとして使用します。
using Microsoft.EntityFrameworkCore;
public class Employee
{
public int Id { get; set; }
public required string Name { get; set; }
public HierarchyId PathId { get; set; } = null!; // ← 階層パス
}
DbContext でエンティティを登録します。
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options)
: base(options) { }
public DbSet<Employee> Employees => Set<Employee>();
}
マイグレーションを実行すると、hierarchyid 型のカラムが自動生成されます。
dotnet ef migrations add InitialCreate
dotnet ef database update
動作確認
環境構築ができたら、実際にデータを挿入してツリーを確認してみましょう。
データの挿入
using var context = new AppDbContext(options);
// ✅ ルートノード(CEO)
var ceo = new Employee
{
Name = "CEO",
PathId = HierarchyId.GetRoot() // "/"
};
// ✅ 部長たち
var salesManager = new Employee
{
Name = "営業部長",
PathId = HierarchyId.Parse("/1/")
};
var devManager = new Employee
{
Name = "開発部長",
PathId = HierarchyId.Parse("/2/")
};
// ✅ 課長
var salesLeader = new Employee
{
Name = "営業1課長",
PathId = HierarchyId.Parse("/1/1/")
};
// ✅ 一般社員
var tanaka = new Employee
{
Name = "田中",
PathId = HierarchyId.Parse("/1/1/1/")
};
var sato = new Employee
{
Name = "佐藤",
PathId = HierarchyId.Parse("/1/1/2/")
};
context.Employees.AddRange(ceo, salesManager, devManager, salesLeader, tanaka, sato);
await context.SaveChangesAsync();
ツリーの表示
var employees = await context.Employees
.OrderBy(e => e.PathId) // ← 深さ優先順でソート
.ToListAsync();
foreach (var emp in employees)
{
var level = emp.PathId.GetLevel();
var indent = new string(' ', level * 2);
Console.WriteLine($"{indent}{emp.Name} ({emp.PathId})");
}
出力結果:
CEO (/)
営業部長 (/1/)
営業1課長 (/1/1/)
田中 (/1/1/1/)
佐藤 (/1/1/2/)
開発部長 (/2/)
GetLevel() でノードの深さを取得し、インデントを付けてツリー表示しています。
まとめ
| 観点 | 隣接リスト(ParentId) | HierarchyId |
|---|---|---|
| サブツリー取得 | 再帰 CTE 必要 | 単一クエリ |
| 実装難易度 | 低い | 中程度 |
| ポータビリティ | ◎ どのDBでも | ❌ SQL Server のみ |
HierarchyId は万能ではありませんが、SQL Server 環境でサブツリークエリが頻繁に必要な場合には非常に効果的です。
実務での判断ポイント:
- SQL Server / Azure SQL を使用している → HierarchyId を検討
- 「配下の全員取得」が頻繁にある → HierarchyId が有効
- PostgreSQL や MySQL も使う可能性がある → 隣接リストを選択
次回の「クエリ編」では、HierarchyId の主要メソッド(GetLevel()、IsDescendantOf()、GetAncestor() など)を使った実践的なクエリパターンを解説します。
参考文献
本記事は以下の情報源を参考に、筆者の実務経験を加えて執筆しました。
公式ドキュメント
- 階層データ (SQL Server) - Microsoft Learn
- Hierarchical Data - EF Core - Microsoft Learn
- hierarchyid データ型メソッド リファレンス - Microsoft Learn
NuGet パッケージ
最後まで読んでいただきありがとうございました!
質問やフィードバックがあれば、コメントでお知らせください。