# Datahierarkier Del 1.3: Materialiserad väg med EF Core

<!--category-- Entity Framework, PostgreSQL, EF Hierarchies -->
<datetime class="hidden">2025-12-06T09:30</datetime>

Materialiserade vägar lagrar hela härkomsten som en avgränsad sträng - liknande `/1/3/7/` - gör förfäder omedelbart läsbara utan att gå med. Perfekt för brödsmulor generation och mänskligt läsbara felsökning, även om rörliga underträd innebär att uppdatera varje ättlings väg sträng.

## Serienavigering

- [Del 1: Översikt](/blog/efcore-hierarchical-data) - Inledning och jämförelse
- [Del 1.1: Tilläggslista](/blog/efcore-hierarchical-data-adjacency)
- [Del 1.2: Stängningstabell](/blog/efcore-hierarchical-data-closure)
- **Del 1.3: Materialiserad väg** (denna artikel)
- [Del 1.4: Inhägnade satser](/blog/efcore-hierarchical-data-nested)
- [Del 1.5: Ltree](/blog/efcore-hierarchical-data-ltree)

---


## Vad är en materialiserad väg?

Den materialiserade vägen mönster (även kallad "Path Enumeration" i [Joe Celkos träd och hierarkier](https://www.amazon.com/Hierarchies-Smarties-Kaufmann-Management-Systems/dp/0123877334)) lagrar hela anor av varje nod som en avgränsad sträng - som en fil sökväg eller postadress. Istället för att lagra bara "min förälder är nod 5", lagrar vi "Jag nås via noder 1 → 3 → 5 → 7" direkt i raden.

Tänk på det som att lagra hela webbadressen istället för bara sidnamnet. Sökvägen `/blog/posts/2024/my-article` berättar exakt var du är i hierarkin, inga uppslag behövs.

**Nyckelinsikt:** Vi byter fråga komplexitet för lagring redundans. härkomsten denormaliseras i varje rad, men detta gör förfader frågor triviala - bara tolka strängen.

[TOC]

## Begreppet visualiserat

```mermaid
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
```

Vägen är självbeskrivande:

- **Läsa förfäder:** Prövning i sak `/1/3/4/` → förfäder är [1, 3, 4]
- **Hitta ättlingar:** Fråga `WHERE path LIKE '/1/3/%'` → får alla under nod 3
- **Beräkningsdjup:** Räkna avskiljarna minus en
- **Hitta syskon:** Fråga `WHERE path LIKE '/1/3/_/'` (omedelbara barn till 3)

## Enhetsdefinition

Företaget lägger till en enda kolumn för sökväg:

```csharp
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- kärninställning

```csharp
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
    }
}
```

## Databasschema

```mermaid
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"
```

## Verksamhet

### Lägg till en ny kommentar

För att infoga krävs att man bygger sökvägen från förälderns sökväg:

```csharp
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;
    }
}
```

### Skaffa barn omedelbart

Använda föräldra-barn relation (vi behöll ParentKommentarId för bekvämlighet):

```csharp
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);
}
```

### Få alla förfäder

Det är här materialiserade vägar lyser - tolkar vägen, inga databaslookups behövs:

```csharp
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();
}
```

### Hämta alla descendanter

Använd LIKE med sökvägen prefix:

```csharp
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);
}
```

### Hämta descendanter med djup

Vi kan beräkna djupet från stigen:

```csharp
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; }
}
```

### Ta bort ett underträd

Enkelt med sökvägmatchning:

```csharp
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);
}
```

### Flytta ett underträd

Detta är den dyra driften för materialiserade vägar - vi måste uppdatera ALL ättling vägar:

```csharp
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;
    }
}
```

## Visualisering av frågeflöde

```mermaid
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>
```

## Prestandaegenskaper

på drift  på komplexitet  på databasfrågor  på anteckningar  på
|-----------|------------|------------------|-------|
Infoga O(1)  och 2  på Infoga + uppdatera sökväg  på
på Få barn  på O(1)  på 1 Använd ParentKommentarId-index
Hämta förfäderna, O(d) 2  och hämta vägen + hämta d förfäderna
och få ättlingar . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .* Hämta sökväg + LIKE-fråga
Flytta delträdet (O(s))  på 1  på Uppdatera ättlingsvägar
och ta bort delträd  på O(1)* Hämta sökväg + bulk ta bort

*Med korrekt index på sökvägskolumnen

## Indexöverväganden

Sökvägindexet är kritiskt. För PostgreSQL, överväga att använda [`text_pattern_ops`](https://www.postgresql.org/docs/current/indexes-opclass.html), som möjliggör effektiva prefix LIKE frågor i icke-C-lokaler:

```sql
-- 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);
```

Lägg till detta via en migrering:

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

## Överväganden i sökvägformat

Olika avgränsningar har kompromisser:

på Format  på Exemple  på Pros  på Cons  på
|--------|---------|------|------|
| `/1/3/7/` på den här artikeln  på Klar, URL-liknande, enkel tolkning  och använder mer utrymme
| `1.3.7` på PostgreSQL ltree stil  på Kompakt, fungerar med ltree  på Perioden strider med decimaler
| `1,3,7` på Comma-separerad  på enkla  på Comma i data kan orsaka problem  på
| `001.003.007` på fast bredd  Sorterad, konsekvent  Gränser ID-intervall, avfall utrymme 

och `/id/` Format med inledande och avslutande snedstreck rekommenderas eftersom:

1. LIKA mönster fungerar korrekt (`/1/%` tändstickor `/1/` men inte `/10/`)
2. Lätt att dela och tolka
3. Mänskligt läsbart för felsökning

## Fördelar och nackdelar

VÄLKOMMEN TILL FÖRMÅN FÖR FÖRVÄRVSPRODUKTER
|------|------|
Förfäder tillgängliga genom tolkning (ingen fråga)  på rörliga delträd kräver uppdatering av alla efterkommande
Descendants via enkel LIKE Question  på väg längd gränser träddjup
på djup kalkylerbar från väg  på String manipulering har overhead  på
på människa-läsbar för avlusning  med LIKA frågor kan vara långsam utan korrekt index
på bra för brödsmulor generation  på vägen måste hållas i synk med ParentCommentId
tillagning av enkelkolonn  och kan inte använda standard B-träd för suffixmatchning

## När du ska använda materialiserad väg

**Välj materialiserad väg när:**

- Här-är-du-är-ett gemensamt krav
- Förfäder förhörs oftare än ättlingar
- Träddjupet är avgränsat (du kommer inte att ha 100+ plana träd)
- Rörliga underträd är sällsynta
- Du vill ha mänskligt läsbara hierarkidata för felsökning

**Undvik materialiserad väg när:**

- Du flyttar ofta underträd (att uppdatera alla stigar är dyrt)
- Träd kan vara mycket djupa (stigsträngar blir otympliga)
- Du behöver effektiv suffix matchning (hitta alla träd som slutar i ett mönster)
- Du är bekvämare med ltree (PostgreSQL-specifik men mer optimerad)

## Jämförelse med ltree

Om du är på PostgreSQL, överväga [Del 1.5: Ltree](/blog/efcore-hierarchical-data-ltree) ltree är i huvudsak en databas-nativ, optimerad materialiserad väg med

- GiST index stöd för effektiva frågor
- Inbyggda operatörer (`@>`, `<@`, `~`, etc.)
- Funktioner för manipulation av tåglägen
- Mönstermatchning med jokertecken

Avvecklingen är PostgreSQL-låsning. Observera att [Npgsql leverantör stöder nu LINQ översättningar](https://www.npgsql.org/efcore/mapping/translations.html#ltree-functions) för träd via `LTree` typ, men rekursiva CTEs fortfarande kräver rå SQL.

## Serienavigering

- [Del 1: Översikt](/blog/efcore-hierarchical-data)
- [Del 1.1: Tilläggslista](/blog/efcore-hierarchical-data-adjacency)
- [Del 1.2: Stängningstabell](/blog/efcore-hierarchical-data-closure)
- **Del 1.3: Materialiserad väg** (denna artikel)
- [Del 1.4: Inhägnade satser](/blog/efcore-hierarchical-data-nested)
- [Del 1.5: Ltree](/blog/efcore-hierarchical-data-ltree)