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 入門 〜階層データ(Hierarchical Data)の新しい選択肢〜

Last updated at Posted at 2025-12-11

はじめに

業務で 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 環境でサブツリークエリが頻繁に必要な場合には非常に効果的です。

実務での判断ポイント:

  1. SQL Server / Azure SQL を使用している → HierarchyId を検討
  2. 「配下の全員取得」が頻繁にある → HierarchyId が有効
  3. PostgreSQL や MySQL も使う可能性がある → 隣接リストを選択

次回の「クエリ編」では、HierarchyId の主要メソッド(GetLevel()IsDescendantOf()GetAncestor() など)を使った実践的なクエリパターンを解説します。


参考文献

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

公式ドキュメント

NuGet パッケージ


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

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?