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 実践編 〜GetReparentedValue(親変更)で組織変更を実装〜

Posted at

はじめに

業務で HierarchyId を使った組織図システムを運用していて、「社員の異動処理」の実装で苦戦しました。

調べてみると、GetDescendant() で子ノードを追加し、GetReparentedValue() でサブツリーを移動できることがわかりました。ただ、同時に複数の異動処理が走るとパスの重複が発生するという問題にも遭遇しました。

本記事では、これらの更新操作と、本番運用で避けて通れない「同時実行の競合防止」について解説します。

この記事で学べること

  • GetDescendant() による子ノードの追加
  • GetReparentedValue() によるサブツリーの移動
  • 同時実行時の競合を防ぐ 3 つの方法
  • HierarchyId を使うべき場面・避けるべき場面

対象読者

  • 入門編・クエリ編を読んで基本操作を理解している方
  • HierarchyId を本番運用したい方

⚠️ 本記事は EF Core 8.0 以降 を対象としています。
入門編・クエリ編の内容が前提となります。

シリーズ記事

本記事は 3 部構成の第 3 回(最終回)です。

# タイトル 内容
1 入門編 HierarchyId とは、環境構築
2 クエリ編 IsDescendantOf、クエリパターン
3 実践編(本記事) ノード追加・移動、同時実行対策

新しいノードの追加

新しい社員を組織に追加するには、GetDescendant() メソッドを使用します。

GetDescendant の基本

GetDescendant(child1, child2) は、指定した 2 つの子ノードの間に位置する新しいパスを生成します。

引数 結果 説明
(null, null) /1/1/ 最初の子を生成
(child1, null) /1/2/ child1 の後に生成
(child1, child2) /1/1.1/ 2つの間に生成
// parent: /1/
parent.GetDescendant(null, null);      // → /1/1/(最初の子)
parent.GetDescendant(child1, null);    // → /1/2/(child1=/1/1/ の後)
parent.GetDescendant(child1, child2);  // → /1/1.1/(間に挿入)

実装例:新入社員の追加

営業1課に新入社員を追加する例です。

public async Task<Employee> AddEmployeeAsync(int managerId, string name)
{
    var manager = await context.Employees.FindAsync(managerId)
        ?? throw new ArgumentException("Manager not found");
    
    // ✅ 既存の最後の部下を取得
    var lastChild = await context.Employees
        .Where(e => e.PathId.GetAncestor(1) == manager.PathId)
        .OrderByDescending(e => e.PathId)
        .Select(e => e.PathId)
        .FirstOrDefaultAsync();
    
    // ✅ 新しいパスを生成(最後の部下の後に追加)
    var newPath = manager.PathId.GetDescendant(lastChild, null);
    
    var newEmployee = new Employee
    {
        Name = name,
        PathId = newPath
    };
    
    context.Employees.Add(newEmployee);
    await context.SaveChangesAsync();
    
    return newEmployee;
}

既存ノード間への挿入

順序を維持したい場合、既存ノードの間に挿入できます。

// 田中(/1/1/1/)と佐藤(/1/1/2/)の間に新人を挿入
var tanaka = await context.Employees.SingleAsync(e => e.Name == "田中");
var sato = await context.Employees.SingleAsync(e => e.Name == "佐藤");

var manager = await context.Employees
    .SingleAsync(e => e.PathId == tanaka.PathId.GetAncestor(1));

// ✅ 田中と佐藤の間のパスを生成
var betweenPath = manager.PathId.GetDescendant(tanaka.PathId, sato.PathId);
// 結果: /1/1/1.1/ のような小数パス

var newEmployee = new Employee
{
    Name = "新人",
    PathId = betweenPath
};

小数パス /1/1/1.1/ が生成され、既存データを変更せずに挿入できます。


組織変更(異動)の実装

GetReparentedValue() を使って、ノードを別の親の下に移動できます。

GetReparentedValue の基本

// oldRoot: 現在の祖先パス
// newRoot: 移動先の祖先パス
node.PathId.GetReparentedValue(oldRoot, newRoot);

例えば、/1/1/1/(田中)を /2/1/(開発1課長)の下に移動する場合:

var oldRoot = HierarchyId.Parse("/1/1/");   // 営業1課長
var newRoot = HierarchyId.Parse("/2/1/");   // 開発1課長

var tanaka = HierarchyId.Parse("/1/1/1/");
var newPath = tanaka.GetReparentedValue(oldRoot, newRoot);
// 結果: /2/1/1/

実装例:サブツリー全体の移動

部署異動では、対象者だけでなくその部下も含めて移動する必要があります。

public async Task MoveSubtreeAsync(int nodeId, int newParentId)
{
    using var transaction = await context.Database.BeginTransactionAsync();
    
    try
    {
        var nodeToMove = await context.Employees.FindAsync(nodeId)
            ?? throw new ArgumentException("Node not found");
        var newParent = await context.Employees.FindAsync(newParentId)
            ?? throw new ArgumentException("New parent not found");
        
        // ✅ 移動対象とその全子孫を取得
        var subtree = await context.Employees
            .Where(e => e.PathId.IsDescendantOf(nodeToMove.PathId))
            .ToListAsync();
        
        // ✅ 新しい親の下での位置を決定
        var lastSiblingUnderNewParent = await context.Employees
            .Where(e => e.PathId.GetAncestor(1) == newParent.PathId)
            .OrderByDescending(e => e.PathId)
            .Select(e => e.PathId)
            .FirstOrDefaultAsync();
        
        // ✅ 移動先のパスを生成
        var newNodePath = newParent.PathId.GetDescendant(lastSiblingUnderNewParent, null);
        var oldNodePath = nodeToMove.PathId;
        
        // ✅ サブツリー全体のパスを更新
        foreach (var node in subtree)
        {
            node.PathId = node.PathId.GetReparentedValue(oldNodePath, newNodePath);
        }
        
        await context.SaveChangesAsync();
        await transaction.CommitAsync();
    }
    catch
    {
        await transaction.RollbackAsync();
        throw;
    }
}

⚠️ トランザクション必須: 途中で失敗した場合にロールバックできるよう、必ずトランザクションを使用してください。

GetReparentedValue の注意点

oldRoot が実際の祖先でない場合、例外がスローされます。

// ❌ NG: /1/1/1/ の祖先は /1/1/ であり、/2/ ではない
var invalidPath = tanaka.PathId.GetReparentedValue(
    HierarchyId.Parse("/2/"),      // ← 祖先ではない!
    HierarchyId.Parse("/3/")
);
// → HierarchyIdException がスローされる

同時実行時の競合防止

複数のリクエストが同時に子ノードを追加すると、同じパスが生成されてしまう可能性があります。

問題のシナリオ

リクエストA: 最後の子を取得 → /1/1/2/ を発見
リクエストB: 最後の子を取得 → /1/1/2/ を発見
リクエストA: /1/1/3/ を生成して INSERT
リクエストB: /1/1/3/ を生成して INSERT ← 重複!

この問題を防ぐ 3 つの方法を紹介します。

方法1:UNIQUE インデックス(必須)

データベースレベルで重複を防ぎます。最もシンプルで確実な方法です。

CREATE UNIQUE INDEX UX_Employees_PathId ON Employees(PathId);

重複挿入時には例外が発生するため、アプリケーション側でリトライ処理を実装します。

public async Task<Employee> AddEmployeeSafelyAsync(int managerId, string name)
{
    const int maxRetries = 3;
    
    for (int attempt = 0; attempt < maxRetries; attempt++)
    {
        try
        {
            return await AddEmployeeAsync(managerId, name);
        }
        catch (DbUpdateException ex) when (IsDuplicateKeyException(ex))
        {
            if (attempt == maxRetries - 1) throw;
            // ✅ 少し待ってリトライ
            await Task.Delay(TimeSpan.FromMilliseconds(100 * (attempt + 1)));
        }
    }
    
    throw new InvalidOperationException("Failed to add employee after retries");
}

private bool IsDuplicateKeyException(DbUpdateException ex)
{
    // SQL Server の重複キーエラーコード: 2601, 2627
    return ex.InnerException is SqlException sqlEx 
        && (sqlEx.Number == 2601 || sqlEx.Number == 2627);
}

方法2:SERIALIZABLE トランザクション

トランザクション分離レベルを上げて、読み取りと書き込みの間に他のトランザクションが割り込むのを防ぎます。

public async Task<Employee> AddEmployeeWithLockAsync(int managerId, string name)
{
    using var transaction = await context.Database.BeginTransactionAsync(
        System.Data.IsolationLevel.Serializable);  // ← 分離レベルを上げる
    
    try
    {
        var manager = await context.Employees.FindAsync(managerId)
            ?? throw new ArgumentException("Manager not found");
        
        // SERIALIZABLE により、このクエリ結果は
        // トランザクション完了まで他から変更されない
        var lastChild = await context.Employees
            .Where(e => e.PathId.GetAncestor(1) == manager.PathId)
            .OrderByDescending(e => e.PathId)
            .Select(e => e.PathId)
            .FirstOrDefaultAsync();
        
        var newPath = manager.PathId.GetDescendant(lastChild, null);
        
        var newEmployee = new Employee { Name = name, PathId = newPath };
        context.Employees.Add(newEmployee);
        
        await context.SaveChangesAsync();
        await transaction.CommitAsync();
        
        return newEmployee;
    }
    catch
    {
        await transaction.RollbackAsync();
        throw;
    }
}

⚠️ デメリット: 同時実行性が低下し、デッドロックのリスクがあります。追加頻度が低い場合に有効です。

方法3:楽観的ロック

行バージョンを使って、更新時に競合を検出します。

public class Employee
{
    public int Id { get; set; }
    public required string Name { get; set; }
    public HierarchyId PathId { get; set; } = null!;
    
    [Timestamp]
    public byte[] RowVersion { get; set; } = null!;  // ← 同時実行トークン
}
// Fluent API での設定
modelBuilder.Entity<Employee>()
    .Property(e => e.RowVersion)
    .IsRowVersion();

競合時には DbUpdateConcurrencyException がスローされるので、リトライ処理を実装します。

どの方法を選ぶか

方法 特徴 推奨シーン
UNIQUE インデックス 確実・シンプル 必須(他と併用)
SERIALIZABLE 確実だがロック競合 追加頻度が低い
楽観的ロック 高並行性 追加頻度が高い

📌 筆者の推奨: UNIQUE インデックスは必ず設定し、加えて楽観的ロックまたは SERIALIZABLE を状況に応じて選択してください。


注意点・制限事項

技術的な制限

項目 制限値・注意
最大サイズ 892 バイト
プラットフォーム SQL Server / Azure SQL のみ
参照整合性 外部キー制約なし(孤児ノードはアプリ側で防止)
並び順 パス順でソートされるが、明示的な順序列が必要な場合も

📌 892バイト制限について: この制限値は十分に大きく、実務上は問題になることはほぼありません。公式ドキュメントによると、10万ノード・平均ファンアウト6のツリーでも、1ノードあたり約5バイトで表現できます。数百階層以上の深さが必要になるケースは稀で、一般的な組織図やカテゴリ階層では制限に達することはまずないでしょう。

孤児ノードの防止

HierarchyId には外部キー制約がないので、親を削除すると子が孤児になる可能性があります。

// ✅ 子孫も含めて削除する
public async Task DeleteWithChildrenAsync(int nodeId)
{
    var nodeToDelete = await context.Employees.FindAsync(nodeId)
        ?? throw new ArgumentException("Node not found");
    
    var nodesToDelete = await context.Employees
        .Where(e => e.PathId.IsDescendantOf(nodeToDelete.PathId))
        .ToListAsync();
    
    context.Employees.RemoveRange(nodesToDelete);
    await context.SaveChangesAsync();
}

使うべき場面・避けるべき場面

HierarchyId が適切なケース

場面 理由
サブツリークエリが頻繁 「部長配下の全員」「カテゴリ配下の全商品」
読み取り中心 カテゴリ階層、組織図の表示
SQL Server 固定 ポータビリティが不要
単一の親 1つのノードが持つ親は1つだけ

避けるべきケース

場面 代替案
多対多の親子関係 閉包テーブル
頻繁なサブツリー移動 隣接リスト(移動が O(1))
クロスプラットフォーム必須 マテリアライズドパス
グラフ構造(DAG) グラフデータベース

判断フローチャート

SQL Server 固定?
  └─ No → 隣接リスト or マテリアライズドパス
  └─ Yes ↓

サブツリークエリが頻繁?
  └─ No → 隣接リストでも十分
  └─ Yes ↓

多対多の親子関係?
  └─ Yes → 閉包テーブル
  └─ No ↓

→ HierarchyId が最適!

シリーズ総まとめ

3 記事を通じて、EF Core の HierarchyId について解説しました。

入門編

  • HierarchyId は SQL Server のネイティブ型
  • パス表現 /1/2/3/ で階層位置を表現
  • 再帰不要で高速なサブツリー取得
  • EF Core 8 以降で公式サポート

クエリ編

  • GetLevel(): 深さ取得
  • IsDescendantOf(): 子孫判定(自身も true
  • GetAncestor(n): n 階層上の祖先
  • 深さ優先インデックスがサブツリークエリに最適

実践編

  • GetDescendant(): 子ノードの追加
  • GetReparentedValue(): サブツリーの移動
  • 同時実行対策: UNIQUE インデックス + 楽観的ロック
  • SQL Server 固定 + サブツリークエリ頻繁 = HierarchyId が最適

最終比較表

観点 隣接リスト HierarchyId
サブツリー取得 再帰 CTE 必要 ✅ 単一クエリ
直属の子取得 単一クエリ 単一クエリ
ノード追加 簡単 GetDescendant
サブツリー移動 ✅ 1行更新 全子孫更新
ポータビリティ ✅ どのDBでも ❌ SQL Server のみ
実装難易度 低い 中程度

HierarchyId は万能ではありませんが、適切な場面で使えば階層データの実装を大幅に簡略化できます。

実務での使い分けのポイント:

  1. SQL Server 固定 + サブツリー頻繁 → HierarchyId
  2. PostgreSQL も使う可能性あり → 隣接リスト
  3. サブツリー移動が頻繁 → 隣接リスト
  4. 迷ったら → 隣接リストから始めて、必要に応じて移行

参考文献

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

公式ドキュメント

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?