Datenhierarchien Teil 1.3: Materialisierter Pfad mit EF-Kern (Deutsch (German))

Datenhierarchien Teil 1.3: Materialisierter Pfad mit EF-Kern

Saturday, 06 December 2025

//

14 minute read

Materialisierte Pfade speichern die gesamte Abstammung als abgegrenzte Saite - wie /1/3/7/ - Ahnen sofort lesbar machen, ohne sich zu verbinden. Perfekt für breadcrumb Generation und menschenlesbare Debugging, obwohl bewegliche Unterbäume bedeutet, die Pfadfolge jedes Nachkommen zu aktualisieren.

Reihennavigation


Was ist ein materialisierter Pfad?

Das materialisierte Pfadmuster (auch "Path Enumeration" genannt) in Joe Celkos Bäume und Hierarchien) speichert die gesamte Abstammung jedes Knotens als abgegrenzte Zeichenkette - wie ein Dateipfad oder Postadresse. Anstatt nur "mein Elternteil ist Knoten 5" zu speichern, speichern wir "Ich bin über Knoten 1 → 3 → 5 → 7" direkt in der Zeile.

Denken Sie daran, wie das Speichern der vollständigen URL statt nur den Seitennamen. Der Pfad /blog/posts/2024/my-article sagt Ihnen genau, wo Sie sich in der Hierarchie befinden, keine Lookups benötigt.

Wichtige Erkenntnisse: Wir traden Abfragekomplexität für Speicherredundanz. Die Abstammung wird in jede Zeile desnormiert, aber das macht Vorfahrenabfragen trivial - nur die Zeichenkette parsieren.

Das Konzept visualisiert

flowchart TD
    subgraph "Comment Tree"
        C1["Comment 1<br/>Path: /1/"]
        C2["Comment 2<br/>Path: /1/2/"]
        C3["Comment 3<br/>Path: /1/3/"]
        C4["Comment 4<br/>Path: /1/3/4/"]
    end

    C1 --> C2
    C1 --> C3
    C3 --> C4

    subgraph "What the paths tell us"
        P1["Comment 4's path /1/3/4/ means:<br/>• Ancestors are 1, 3 (parse the path)<br/>• Depth is 3 (count separators - 1)<br/>• Root is 1 (first element)"]
    end

    style C1 stroke:#6366f1,stroke-width:2px
    style C2 stroke:#8b5cf6,stroke-width:2px
    style C3 stroke:#8b5cf6,stroke-width:2px
    style C4 stroke:#a855f7,stroke-width:2px

Der Pfad beschreibt sich selbst:

  • Lesende Vorfahren: Parse /1/3/4/ → Vorfahren sind [1, 3, 4]
  • Nachkommen finden: Abfrage WHERE path LIKE '/1/3/%' → bekommt alle unter Knoten 3
  • Berechnung der Tiefe: Zählen Sie die Separatoren minus eins
  • Geschwister finden: Abfrage WHERE path LIKE '/1/3/_/' (unmittelbare Kinder von 3)

Definition des Rechtsträgers

Das Entity fügt eine einzige Pfadspalte hinzu:

public class Comment
{
    public int Id { get; set; }
    public string Content { get; set; } = string.Empty;
    public string Author { get; set; } = string.Empty;
    public DateTime CreatedAt { get; set; }

    public int PostId { get; set; }
    public BlogPost Post { get; set; } = null!;

    // ========== MATERIALISED PATH ==========

    // The complete path from root to this node
    // Format: /ancestor1/ancestor2/.../thisNode/
    // Examples:
    //   Root comment: "/1/"
    //   Child of 1: "/1/5/"
    //   Grandchild: "/1/5/12/"
    //
    // The leading and trailing slashes make pattern matching easier:
    // - LIKE '/1/%' finds all descendants of 1 (includes /1/ itself)
    // - LIKE '/1/5/%' finds all descendants of 5 under 1
    public string Path { get; set; } = string.Empty;

    // We still keep ParentCommentId for:
    // 1. Quick "who is my parent" without parsing
    // 2. EF Core navigation properties
    // 3. Data integrity (can validate path matches parent relationship)
    public int? ParentCommentId { get; set; }
    public Comment? ParentComment { get; set; }
    public ICollection<Comment> Children { get; set; } = new List<Comment>();

    // ========== COMPUTED HELPERS ==========

    // Parse ancestors from path - not stored, computed on demand
    public IEnumerable<int> GetAncestorIds()
    {
        if (string.IsNullOrEmpty(Path)) yield break;

        // Split "/1/3/4/" into ["", "1", "3", "4", ""]
        var parts = Path.Split('/', StringSplitOptions.RemoveEmptyEntries);

        // Return all except the last (which is this node's ID)
        for (int i = 0; i < parts.Length - 1; i++)
        {
            if (int.TryParse(parts[i], out var id))
                yield return id;
        }
    }

    // Calculate depth from path
    public int GetDepth()
    {
        if (string.IsNullOrEmpty(Path)) return 0;
        // Count segments: "/1/3/4/" has 3 segments, depth is 2 (0-indexed from root)
        return Path.Split('/', StringSplitOptions.RemoveEmptyEntries).Length - 1;
    }
}

EF-Kernkonfiguration

public class CommentConfiguration : IEntityTypeConfiguration<Comment>
{
    public void Configure(EntityTypeBuilder<Comment> builder)
    {
        builder.HasKey(c => c.Id);

        builder.Property(c => c.Content)
            .IsRequired()
            .HasMaxLength(10000);

        builder.Property(c => c.Author)
            .IsRequired()
            .HasMaxLength(200);

        // ========== PATH COLUMN ==========
        // Set a reasonable max length - this limits your tree depth
        // /1/12345/12346/12347/...
        // Each segment is up to ~7 chars (ID + slash), so 1000 chars ≈ 140 levels
        builder.Property(c => c.Path)
            .IsRequired()
            .HasMaxLength(1000);

        // Relationship to blog post
        builder.HasOne(c => c.Post)
            .WithMany(p => p.Comments)
            .HasForeignKey(c => c.PostId)
            .OnDelete(DeleteBehavior.Cascade);

        // Self-referencing (optional but useful)
        builder.HasOne(c => c.ParentComment)
            .WithMany(c => c.Children)
            .HasForeignKey(c => c.ParentCommentId)
            .OnDelete(DeleteBehavior.Restrict);

        // ========== INDEXES ==========

        // Standard indexes
        builder.HasIndex(c => c.PostId);
        builder.HasIndex(c => c.ParentCommentId);

        // PATH INDEX - Critical for performance!
        // This makes LIKE 'prefix%' queries efficient
        // PostgreSQL can use a B-tree index for prefix LIKE patterns
        // (but NOT for '%suffix' or '%contains%' patterns)
        builder.HasIndex(c => c.Path);

        // For PostgreSQL, a text_pattern_ops index is even better for LIKE:
        // CREATE INDEX ix_comments_path ON comments (path text_pattern_ops);
        // You may want to add this via a raw migration
    }
}

Datenbankschema

erDiagram
    COMMENT {
        int id PK
        string content
        string author
        datetime created_at
        int post_id FK
        int parent_comment_id FK "optional"
        string path "e.g. /1/3/7/"
    }

    BLOG_POST {
        int id PK
        string title
        string content
    }

    BLOG_POST ||--o{ COMMENT : "has"
    COMMENT ||--o{ COMMENT : "parent-child"

Vorhaben

Neuen Kommentar einfügen

Das Einfügen erfordert den Aufbau des Pfades aus dem Pfad des Elternteils:

public async Task<Comment> AddCommentAsync(
    int postId,
    int? parentId,
    string author,
    string content,
    CancellationToken ct = default)
{
    string path;

    if (parentId.HasValue)
    {
        // Get parent's path to extend it
        var parentPath = await context.Comments
            .Where(c => c.Id == parentId.Value)
            .Select(c => c.Path)
            .FirstOrDefaultAsync(ct);

        if (parentPath == null)
        {
            throw new InvalidOperationException($"Parent comment {parentId} not found");
        }

        // We need the ID first, so we'll update the path after saving
        // (Chicken-and-egg: path contains our ID, but we don't have ID until saved)

        var comment = new Comment
        {
            PostId = postId,
            ParentCommentId = parentId,
            Author = author,
            Content = content,
            CreatedAt = DateTime.UtcNow,
            Path = string.Empty  // Temporary - will update after save
        };

        context.Comments.Add(comment);
        await context.SaveChangesAsync(ct);

        // Now we have the ID - build the real path
        // Parent path "/1/3/" + our ID "7" = "/1/3/7/"
        comment.Path = $"{parentPath}{comment.Id}/";
        await context.SaveChangesAsync(ct);

        logger.LogInformation("Added comment {CommentId} with path {Path}", comment.Id, comment.Path);
        return comment;
    }
    else
    {
        // Root comment - path is just our ID
        var comment = new Comment
        {
            PostId = postId,
            ParentCommentId = null,
            Author = author,
            Content = content,
            CreatedAt = DateTime.UtcNow,
            Path = string.Empty  // Temporary
        };

        context.Comments.Add(comment);
        await context.SaveChangesAsync(ct);

        comment.Path = $"/{comment.Id}/";
        await context.SaveChangesAsync(ct);

        logger.LogInformation("Added root comment {CommentId} with path {Path}", comment.Id, comment.Path);
        return comment;
    }
}

Sofortige Kinder bekommen

Verwenden der Eltern-Kind-Beziehung (wir hielten ParentCommentId für Bequemlichkeit):

public async Task<List<Comment>> GetChildrenAsync(int commentId, CancellationToken ct = default)
{
    // Option 1: Use ParentCommentId (simple, always works)
    return await context.Comments
        .AsNoTracking()
        .Where(c => c.ParentCommentId == commentId)
        .OrderBy(c => c.CreatedAt)
        .ToListAsync(ct);

    // Option 2: Use path pattern (demonstrates path power)
    // var parentPath = await context.Comments
    //     .Where(c => c.Id == commentId)
    //     .Select(c => c.Path)
    //     .FirstOrDefaultAsync(ct);
    //
    // if (parentPath == null) return new List<Comment>();
    //
    // // Find paths that extend parent by exactly one segment
    // // Parent: /1/3/  Children: /1/3/X/ where X is one number
    // var childPathPattern = $"{parentPath}%";
    //
    // return await context.Comments
    //     .AsNoTracking()
    //     .Where(c => EF.Functions.Like(c.Path, childPathPattern)
    //              && c.Path != parentPath
    //              && c.ParentCommentId == commentId)  // Ensures immediate children only
    //     .ToListAsync(ct);
}

Holen Sie sich alle Vorfahren

Hier glänzen materialisierte Pfade - parse der Pfad, keine Datenbank-Suchen erforderlich:

public async Task<List<Comment>> GetAncestorsAsync(int commentId, CancellationToken ct = default)
{
    // Step 1: Get the path (single query)
    var path = await context.Comments
        .Where(c => c.Id == commentId)
        .Select(c => c.Path)
        .FirstOrDefaultAsync(ct);

    if (string.IsNullOrEmpty(path))
        return new List<Comment>();

    // Step 2: Parse ancestor IDs from path
    // Path "/1/3/7/" -> split -> ["1", "3", "7"] -> take all but last -> [1, 3]
    var ancestorIds = path
        .Split('/', StringSplitOptions.RemoveEmptyEntries)
        .SkipLast(1)  // Exclude self
        .Select(int.Parse)
        .ToList();

    if (!ancestorIds.Any())
        return new List<Comment>();

    // Step 3: Fetch ancestors (single query, uses primary key index)
    var ancestors = await context.Comments
        .AsNoTracking()
        .Where(c => ancestorIds.Contains(c.Id))
        .ToListAsync(ct);

    // Step 4: Order by position in path (root first)
    return ancestorIds
        .Select(id => ancestors.First(a => a.Id == id))
        .ToList();
}

Holen Sie sich alle Nachkommen

Verwenden Sie LIKE mit dem Pfad-Präfix:

public async Task<List<Comment>> GetDescendantsAsync(int commentId, CancellationToken ct = default)
{
    // Get the path first
    var path = await context.Comments
        .Where(c => c.Id == commentId)
        .Select(c => c.Path)
        .FirstOrDefaultAsync(ct);

    if (string.IsNullOrEmpty(path))
        return new List<Comment>();

    // LIKE 'path%' finds all paths that START with this path
    // Path "/1/3/" matches "/1/3/", "/1/3/5/", "/1/3/5/9/", etc.
    // Using EF.Functions.Like for proper SQL generation
    return await context.Comments
        .AsNoTracking()
        .Where(c => EF.Functions.Like(c.Path, $"{path}%") && c.Id != commentId)
        .OrderBy(c => c.Path)  // Gives us depth-first order!
        .ToListAsync(ct);
}

Erhalten Sie Nachkommen mit Tiefe

Wir können Tiefe aus dem Pfad berechnen:

public async Task<List<CommentWithDepth>> GetDescendantsWithDepthAsync(
    int commentId,
    int? maxDepth = null,
    CancellationToken ct = default)
{
    var comment = await context.Comments
        .AsNoTracking()
        .FirstOrDefaultAsync(c => c.Id == commentId, ct);

    if (comment == null)
        return new List<CommentWithDepth>();

    var basePath = comment.Path;
    var baseDepth = basePath.Split('/', StringSplitOptions.RemoveEmptyEntries).Length;

    // Get all descendants
    var query = context.Comments
        .AsNoTracking()
        .Where(c => EF.Functions.Like(c.Path, $"{basePath}%") && c.Id != commentId);

    var descendants = await query.ToListAsync(ct);

    // Calculate relative depth and filter if needed
    var result = descendants
        .Select(d =>
        {
            var absoluteDepth = d.Path.Split('/', StringSplitOptions.RemoveEmptyEntries).Length;
            var relativeDepth = absoluteDepth - baseDepth;
            return new CommentWithDepth
            {
                Id = d.Id,
                Content = d.Content,
                Author = d.Author,
                CreatedAt = d.CreatedAt,
                PostId = d.PostId,
                ParentCommentId = d.ParentCommentId,
                Path = d.Path,
                Depth = relativeDepth
            };
        })
        .Where(d => !maxDepth.HasValue || d.Depth <= maxDepth.Value)
        .OrderBy(d => d.Path)
        .ToList();

    return result;
}

public class CommentWithDepth
{
    public int Id { get; set; }
    public string Content { get; set; } = string.Empty;
    public string Author { get; set; } = string.Empty;
    public DateTime CreatedAt { get; set; }
    public int PostId { get; set; }
    public int? ParentCommentId { get; set; }
    public string Path { get; set; } = string.Empty;
    public int Depth { get; set; }
}

Einen Unterbaum löschen

Einfach mit Pfad-Matching:

public async Task DeleteSubtreeAsync(int commentId, CancellationToken ct = default)
{
    var path = await context.Comments
        .Where(c => c.Id == commentId)
        .Select(c => c.Path)
        .FirstOrDefaultAsync(ct);

    if (string.IsNullOrEmpty(path))
    {
        throw new InvalidOperationException($"Comment {commentId} not found");
    }

    // Delete all comments whose path starts with this path
    // This includes the comment itself and ALL descendants
    var deleted = await context.Comments
        .Where(c => EF.Functions.Like(c.Path, $"{path}%"))
        .ExecuteDeleteAsync(ct);

    logger.LogInformation("Deleted {Count} comments with path prefix {Path}", deleted, path);
}

Einen Unterbaum verschieben

Dies ist die teure Operation für materialisierte Wege - wir müssen ALLE Nachfahrwege aktualisieren:

public async Task MoveSubtreeAsync(
    int commentId,
    int newParentId,
    CancellationToken ct = default)
{
    await using var transaction = await context.Database.BeginTransactionAsync(ct);

    try
    {
        // Get the node being moved
        var comment = await context.Comments
            .FirstOrDefaultAsync(c => c.Id == commentId, ct);

        if (comment == null)
            throw new InvalidOperationException($"Comment {commentId} not found");

        // Get the new parent
        var newParent = await context.Comments
            .FirstOrDefaultAsync(c => c.Id == newParentId, ct);

        if (newParent == null)
            throw new InvalidOperationException($"New parent {newParentId} not found");

        // Prevent cycles: can't move under own descendant
        if (newParent.Path.StartsWith(comment.Path))
        {
            throw new InvalidOperationException("Cannot move a node under its own descendant");
        }

        var oldPath = comment.Path;
        var newPath = $"{newParent.Path}{comment.Id}/";

        // Get all descendants (including the node itself)
        var descendants = await context.Comments
            .Where(c => EF.Functions.Like(c.Path, $"{oldPath}%"))
            .ToListAsync(ct);

        // Update all paths by replacing the old prefix with the new one
        foreach (var descendant in descendants)
        {
            // Replace old path prefix with new one
            // Old: /1/3/7/  Node 7 moving under /2/
            // Node 7: /1/3/7/ -> /2/7/
            // Node 9 (child of 7): /1/3/7/9/ -> /2/7/9/
            descendant.Path = newPath + descendant.Path.Substring(oldPath.Length);
        }

        // Update the direct parent reference
        comment.ParentCommentId = newParentId;

        await context.SaveChangesAsync(ct);
        await transaction.CommitAsync(ct);

        logger.LogInformation("Moved subtree of {Count} nodes from {OldPath} to {NewPath}",
            descendants.Count, oldPath, newPath);
    }
    catch
    {
        await transaction.RollbackAsync(ct);
        throw;
    }
}

Abfragestrom-Visualisierung

sequenceDiagram
    participant App as Application
    participant EF as EF Core
    participant DB as PostgreSQL

    Note over App,DB: Getting Ancestors (Path parsing)
    App->>EF: GetAncestorsAsync(commentId)
    EF->>DB: SELECT path FROM comments WHERE id = @id
    DB-->>EF: Path "/1/3/7/"
    Note over App: Parse path → [1, 3]
    EF->>DB: SELECT * FROM comments WHERE id IN (1, 3)
    DB-->>EF: Ancestor comments
    EF-->>App: List<Comment>

    Note over App,DB: Getting Descendants (LIKE query)
    App->>EF: GetDescendantsAsync(commentId)
    EF->>DB: SELECT path FROM comments WHERE id = @id
    DB-->>EF: Path "/1/3/"
    EF->>DB: SELECT * FROM comments WHERE path LIKE '/1/3/%'
    DB-->>EF: All descendants
    EF-->>App: List<Comment>

Leistungsmerkmale

Bedienung Komplexität Datenbank Abfragen Anmerkungen |-----------|------------|------------------|-------| Einfügen von O(1).2. Einfügen von + Update-Pfad. Besorgen Sie Kinder O(1). Verwenden Sie ParentCommentId-Index Bekommen Sie Ahnen Bekommen Sie O(d) Bekommen Sie 2 Bekommen Sie Pfad + holen Sie d Ahnen Besorgen Sie Nachkommen, O(1)* Beenden Sie Pfad + LIKE-Abfrage Subtree(n) verschieben , 1 , Aktualisieren s Nachfahrpfade , Unterbaum O(1) streichen* Beenden Sie Pfad + Schüttgut löschen

*Mit dem richtigen Index auf Pfadspalte

Indexüberlegungen

Der Pfadindex ist kritisch. Betrachten Sie für PostgreSQL die Verwendung von text_pattern_ops, was effiziente Präfix-LIKE-Abfragen in Nicht-C-Lokalitäten ermöglicht:

-- Standard B-tree index (works for LIKE 'prefix%')
CREATE INDEX ix_comments_path ON comments (path);

-- Better for pattern matching in PostgreSQL
CREATE INDEX ix_comments_path_pattern ON comments (path text_pattern_ops);

Fügen Sie dies über eine Migration hinzu:

protected override void Up(MigrationBuilder migrationBuilder)
{
    migrationBuilder.Sql(
        "CREATE INDEX ix_comments_path_pattern ON comments (path text_pattern_ops)");
}

Überlegungen zum Pfadformat

Unterschiedliche Abgrenzungen haben Kompromisse:

Format-Beispiel-Proben-Konsens |--------|---------|------|------| | /1/3/7/ Dieser Artikel - Klare, URL-ähnliche, einfache Parsing - Verwendet mehr Leerzeichen | 1.3.7 • PostgreSQL ltree style • Kompakt, arbeitet mit ltree • Periodenkonflikte mit Dezimalstellen • | 1,3,7 Komma-getrennt Einfach Komma in Daten könnte Probleme verursachen | 001.003.007 Behoben-Breitweite , Sortierbar, konsistent , Grenzen ID-Bereich , Abfälle Raum ,

Das /id/ Format mit führenden und hinteren Schrägstrichen wird empfohlen, weil:

  1. LIKE-Muster funktionieren korrekt (/1/% Streichhölzer /1/ aber nicht /10/)
  2. Leicht zu teilen und zu parsen
  3. Menschenlesbar zum Debuggen

Vor- und Nachteile

Pros. Cons. |------|------| Ahnen, die durch Parsing verfügbar sind (keine Abfrage) Bewegte Unterbäume erfordern die Aktualisierung aller Nachkommen. Deszendenten über einfache LIKE-Abfrage Pfadlänge begrenzt Baumtiefe Tiefe kalkulierbar vom Pfad aus. Die String-Manipulation hat Overhead. Menschenlesbar für das Debugging.LIKE-Abfragen können langsam sein, ohne den richtigen Index. Gut für die breadcrumb Generation. Pfad muss mit ParentCommentId synchron gehalten werden Einsäulen-Addition: Standard B-Baum kann nicht für Suffix-Matching verwendet werden

Wann materialisierter Pfad verwendet werden soll

Wählen Sie materialisierten Pfad, wenn:

  • Brotkrumen sind eine gemeinsame Anforderung
  • Ahnen werden häufiger befragt als Nachkommen
  • Die Tiefe des Baumes ist begrenzt (Sie haben keine 100+ Level-Bäume)
  • Bewegte Unterbäume sind selten
  • Sie wollen menschenlesbare Hierarchiedaten zum Debuggen

Vermeiden Sie materialisierten Pfad, wenn:

  • Sie bewegen häufig Unterbäume (das Aktualisieren aller Pfade ist teuer)
  • Bäume können sehr tief sein (Pfadsaiten werden unhandlich)
  • Sie benötigen effizientes Suffix-Matching (finden Sie alle Bäume, die in einem Muster enden)
  • Mit ltree sind Sie bequemer (PostgreSQL-spezifischer, aber optimierter)

Vergleich mit ltree

Wenn Sie bei PostgreSQL sind, sollten Sie Teil 1.5: Baum statt. ltree ist im Wesentlichen ein datenbanknativer, optimierter materialisierter Pfad mit:

  • Unterstützung von GiST Index für effiziente Abfragen
  • Eingebaute Betreiber (@>, <@, ~, usw.)
  • Funktionen der Pfadmanipulation
  • Muster passend zu Wildcards

Der Trade-off ist PostgreSQL Lock-in. Beachten Sie, dass die Npgsql-Anbieter unterstützt jetzt LINQ-Übersetzungen für ltree über die LTree Typ, obwohl rekursive CTEs immer noch Roh-SQL benötigen.

Reihennavigation

Finding related posts...
logo

© 2026 Scott Galloway — Unlicense — All content and source code on this site is free to use, copy, modify, and sell.