はじめに
業務で HierarchyId を導入したものの、「部下を全員取得するクエリをどう書けばいいのか」で手が止まりました。
調べてみると、IsDescendantOf() や GetAncestor() といったメソッドを使えば、再帰 CTE なしで階層データを自在に取得できることがわかりました。
ただ、IsDescendantOf() が自分自身も含むという仕様にハマったので、その注意点も含めて記事にまとめます。
この記事で学べること
- HierarchyId の 6 つの主要メソッド
- 実務でよく使う 5 つのクエリパターン
- 生成される SQL の確認方法
- インデックス戦略(深さ優先 vs 幅優先)
対象読者
- 入門編を読んで環境構築が完了している方
- HierarchyId のクエリを書きたい方
⚠️ 本記事は EF Core 8.0 以降 を対象としています。
入門編の環境構築が前提となります。
シリーズ記事
本記事は 3 部構成の第 2 回です。
| # | タイトル | 内容 |
|---|---|---|
| 1 | 入門編 | HierarchyId とは、環境構築 |
| 2 | クエリ編(本記事) | IsDescendantOf、クエリパターン |
| 3 | 実践編 | ノード追加・移動、同時実行対策 |
サンプルデータ
本記事では以下の組織図データを使用します。
CEO (/)
├── 営業部長 (/1/)
│ ├── 営業1課長 (/1/1/)
│ │ ├── 田中 (/1/1/1/)
│ │ └── 佐藤 (/1/1/2/)
│ └── 営業2課長 (/1/2/)
│ └── 鈴木 (/1/2/1/)
└── 開発部長 (/2/)
└── 開発1課長 (/2/1/)
├── 山田 (/2/1/1/)
└── 伊藤 (/2/1/2/)
エンティティ定義(入門編と同じ):
public class Employee
{
public int Id { get; set; }
public required string Name { get; set; }
public HierarchyId PathId { get; set; } = null!;
}
主要メソッド解説
HierarchyId には階層操作のためのメソッドが用意されています。EF Core はこれらを LINQ から SQL に変換します。
メソッド一覧
| メソッド | 用途 | 戻り値 |
|---|---|---|
GetLevel() |
ノードの深さを取得 |
int(ルート = 0) |
IsDescendantOf(parent) |
子孫かどうか判定 |
bool(自身も true) |
GetAncestor(n) |
n 階層上の祖先を取得 | HierarchyId |
GetRoot() |
ルートノードを取得 |
HierarchyId(静的メソッド) |
Parse(string) |
文字列から変換 |
HierarchyId(静的メソッド) |
GetDescendant(c1, c2) |
新しい子の位置を生成 |
HierarchyId(実践編で詳説) |
重要な注意点:IsDescendantOf は自身を含む
⚠️
IsDescendantOf()は、自分自身に対してもtrueを返します。
var path = HierarchyId.Parse("/1/");
path.IsDescendantOf(path); // ← true を返す!
子孫のみを取得したい場合は、自身を除外する条件が必要です。
// ❌ NG: 自分自身も含まれてしまう
.Where(e => e.PathId.IsDescendantOf(parentPath))
// ✅ OK: 自分自身を除外
.Where(e => e.PathId.IsDescendantOf(parentPath) && e.Id != parentId)
📌 公式ドキュメントの記載: Microsoft Learn には「Parent is considered its own descendant(親は自身の子孫とみなされる)」と明記されています。
クエリ実践① 全子孫を取得する
シナリオ: 営業部長配下の社員を全員取得したい
// 営業部長を取得
var salesManager = await context.Employees
.SingleAsync(e => e.Name == "営業部長");
// ✅ 営業部長配下の全員を取得(自身を除外)
var descendants = await context.Employees
.Where(e => e.PathId.IsDescendantOf(salesManager.PathId))
.Where(e => e.Id != salesManager.Id) // ← 自身を除外!
.OrderBy(e => e.PathId)
.ToListAsync();
// 結果: 営業1課長, 田中, 佐藤, 営業2課長, 鈴木
生成される SQL:
SELECT [e].[Id], [e].[Name], [e].[PathId]
FROM [Employees] AS [e]
WHERE [e].[PathId].IsDescendantOf(@parentPath) = CAST(1 AS bit)
AND [e].[Id] <> @parentId
ORDER BY [e].[PathId]
ポイントは、再帰 CTE を使わず単一の WHERE 句で子孫を全て取得できる点です。
クエリ実践② 直属の子のみ取得する
シナリオ: 営業部長の直属の部下(課長たち)だけを取得したい
GetAncestor(1) を使って「1階層上の祖先が親と一致する」条件で絞り込みます。
var salesManager = await context.Employees
.SingleAsync(e => e.Name == "営業部長");
// ✅ 直属の部下のみ取得
var directReports = await context.Employees
.Where(e => e.PathId.GetAncestor(1) == salesManager.PathId)
.ToListAsync();
// 結果: 営業1課長, 営業2課長
生成される SQL:
SELECT [e].[Id], [e].[Name], [e].[PathId]
FROM [Employees] AS [e]
WHERE [e].[PathId].GetAncestor(1) = @parentPath
GetAncestor(1) は「自分の1つ上の親」を返すので、その親が指定ノードと一致すれば直属の子です。
クエリ実践③ 祖先を全て取得する(パンくずリスト)
シナリオ: 田中さんの上司を CEO まで全員取得したい(パンくずリスト)
var tanaka = await context.Employees
.SingleAsync(e => e.Name == "田中");
// ✅ 田中の祖先を全て取得(自身を除外)
var ancestors = await context.Employees
.Where(e => tanaka.PathId.IsDescendantOf(e.PathId))
.Where(e => e.Id != tanaka.Id) // ← 自身を除外
.OrderBy(e => e.PathId.GetLevel()) // ← 浅い順にソート
.ToListAsync();
// 結果: CEO, 営業部長, 営業1課長
生成される SQL:
SELECT [e].[Id], [e].[Name], [e].[PathId]
FROM [Employees] AS [e]
WHERE @tanakaPath.IsDescendantOf([e].[PathId]) = CAST(1 AS bit)
AND [e].[Id] <> @tanakaId
ORDER BY [e].[PathId].GetLevel()
子孫取得の逆パターンです。「田中のパスが、各社員のパスの子孫かどうか」で判定しています。
クエリ実践④ 兄弟ノードを取得する
シナリオ: 田中さんと同じ上司を持つ同僚を取得したい
var tanaka = await context.Employees
.SingleAsync(e => e.Name == "田中");
// 田中の親パスを取得
var tanakaParentPath = tanaka.PathId.GetAncestor(1);
// ✅ 同じ親を持つノードを取得(自身を除外)
var siblings = await context.Employees
.Where(e => e.PathId.GetAncestor(1) == tanakaParentPath)
.Where(e => e.Id != tanaka.Id)
.ToListAsync();
// 結果: 佐藤
生成される SQL:
SELECT [e].[Id], [e].[Name], [e].[PathId]
FROM [Employees] AS [e]
WHERE [e].[PathId].GetAncestor(1) = @parentPath
AND [e].[Id] <> @tanakaId
「直属の子を取得」のパターンを応用しています。
クエリ実践⑤ 共通の祖先を検索する
シナリオ: 田中さんと山田さんの共通の上司(最も近い共通祖先)を見つけたい
var tanaka = await context.Employees
.SingleAsync(e => e.Name == "田中");
var yamada = await context.Employees
.SingleAsync(e => e.Name == "山田");
// ✅ 両方の祖先であり、最も深い位置にあるノードを取得
var commonAncestor = await context.Employees
.Where(e => tanaka.PathId.IsDescendantOf(e.PathId))
.Where(e => yamada.PathId.IsDescendantOf(e.PathId))
.OrderByDescending(e => e.PathId.GetLevel()) // ← 最も深いものを選択
.FirstOrDefaultAsync();
// 結果: CEO(田中は営業部、山田は開発部なので、共通の祖先は CEO)
生成される SQL:
SELECT TOP(1) [e].[Id], [e].[Name], [e].[PathId]
FROM [Employees] AS [e]
WHERE @tanakaPath.IsDescendantOf([e].[PathId]) = CAST(1 AS bit)
AND @yamadaPath.IsDescendantOf([e].[PathId]) = CAST(1 AS bit)
ORDER BY [e].[PathId].GetLevel() DESC
組織図での「2人の共通の上司は誰か」といった質問に答えられます。
クエリパターン早見表
| やりたいこと | LINQ パターン |
|---|---|
| 全子孫を取得 | .Where(e => e.PathId.IsDescendantOf(parent) && e.Id != parentId) |
| 直属の子のみ | .Where(e => e.PathId.GetAncestor(1) == parentPath) |
| 全祖先を取得 | .Where(e => targetPath.IsDescendantOf(e.PathId) && e.Id != targetId) |
| 兄弟を取得 | .Where(e => e.PathId.GetAncestor(1) == target.PathId.GetAncestor(1) && e.Id != targetId) |
| 特定レベル | .Where(e => e.PathId.GetLevel() == level) |
| 共通祖先 | 両方の IsDescendantOf を満たし、GetLevel() が最大 |
インデックス戦略
HierarchyId カラムには適切なインデックスを設定することで、クエリ性能を大幅に向上できます。
深さ優先インデックス(推奨)
サブツリー全体を取得するクエリに最適です。
-- SQL Server でのインデックス作成
CREATE CLUSTERED INDEX IX_Employees_PathId ON Employees(PathId);
-- 一意制約も追加推奨
CREATE UNIQUE INDEX UX_Employees_PathId ON Employees(PathId);
深さ優先インデックスでは、親子が物理的に近い位置に格納されるため、IsDescendantOf を使ったサブツリークエリが効率的になります。
幅優先インデックス
同じ階層のノードをまとめて取得するクエリに最適です。
-- レベルの計算列を追加
ALTER TABLE Employees ADD Level AS (PathId.GetLevel()) PERSISTED;
-- レベル + パスの複合インデックス
CREATE INDEX IX_Employees_Level_PathId ON Employees(Level, PathId);
EF Core での設定:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Employee>()
.Property(e => e.Level)
.HasComputedColumnSql("[PathId].GetLevel()", stored: true);
modelBuilder.Entity<Employee>()
.HasIndex(e => new { e.Level, e.PathId });
}
どちらを選ぶか
| インデックス | 得意なクエリ | 使用例 |
|---|---|---|
| 深さ優先 | サブツリー取得 | 「営業部配下の全員」 |
| 幅優先 | 同一レベル取得 | 「全ての課長」「レベル2の全員」 |
多くの場合、深さ優先インデックスをクラスタ化インデックスとして設定し、必要に応じて幅優先インデックスを追加するのがおすすめです。サブツリークエリが圧倒的に多いためです。
まとめ
| メソッド | 用途 | 注意点 |
|---|---|---|
GetLevel() |
深さ取得 | ルート = 0 |
IsDescendantOf() |
子孫判定 | 自身も true |
GetAncestor(n) |
祖先取得 | n=1 で直属の親 |
IsDescendantOf() は万能ではありませんが、「自身を除外する」という点さえ押さえておけば、再帰なしで階層データを自在に取得できます。
実務でのポイント:
- 全子孫 →
IsDescendantOf()+ 自身除外 - 直属の子 →
GetAncestor(1)で親が一致 - 全祖先 →
IsDescendantOf()の逆方向 - 迷ったら → クエリパターン早見表を参照
次回の「実践編」では、ノードの追加・移動といった更新操作と、本番運用で避けて通れない「同時実行の競合防止」について解説します。
参考文献
本記事は以下の情報源を参考に、筆者の実務経験を加えて執筆しました。
公式ドキュメント
- IsDescendantOf (Database Engine) - Microsoft Learn
- GetAncestor (Database Engine) - Microsoft Learn
- Hierarchical Data - EF Core - Microsoft Learn
最後まで読んでいただきありがとうございました!
質問やフィードバックがあれば、コメントでお知らせください。