0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

EF Core の HierarchyId クエリ編 〜IsDescendantOf(子孫判定)で再帰なしの階層取得〜

Last updated at Posted at 2025-12-11

はじめに

業務で 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() は万能ではありませんが、「自身を除外する」という点さえ押さえておけば、再帰なしで階層データを自在に取得できます。

実務でのポイント:

  1. 全子孫 → IsDescendantOf() + 自身除外
  2. 直属の子 → GetAncestor(1) で親が一致
  3. 全祖先 → IsDescendantOf() の逆方向
  4. 迷ったら → クエリパターン早見表を参照

次回の「実践編」では、ノードの追加・移動といった更新操作と、本番運用で避けて通れない「同時実行の競合防止」について解説します。


参考文献

本記事は以下の情報源を参考に、筆者の実務経験を加えて執筆しました。

公式ドキュメント


最後まで読んでいただきありがとうございました!
質問やフィードバックがあれば、コメントでお知らせください。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?