Datan hierarkioita Osa 1.3: Aineellinen polku EF-ytimellä (Suomi (Finnish))

Datan hierarkioita Osa 1.3: Aineellinen polku EF-ytimellä

Saturday, 06 December 2025

//

14 minute read

Aineellistetut polut tallentavat täydellisen syntyperän rajatuksi merkkijonoksi - kuten /1/3/7/ - esi-isien tekeminen heti luettavaksi ilman liikkeitä. Täydellinen korppujauhosukupolvelle ja ihmisluettavalle vianetsintälle, vaikka subtreesien siirtäminen tarkoittaa jokaisen jälkeläisen polkujonon päivittämistä.

Sarjanavigointi


Mikä on materialisoitu polku?

Aineellinen polkumalli (jota kutsutaan myös nimellä "Path E numberation" in Joe Celkon puu- ja hierarkioita) tallentaa jokaisen solmun täydellisen syntyperän rajatuksi merkkijonoksi, kuten tiedostopoluksi tai postiosoitteeksi. Sen sijaan, että tallentaisit vain "vanhempani on solmu 5", tallennamme "Minua tavoitetaan solmujen kautta 1 → 3 → 5 → 7" suoraan riviin.

Ajattele sitä kuin tallentaisi koko URL-osoitteen pelkän sivun nimen sijaan. /blog/posts/2024/my-article kertoo tarkalleen, missä hierarkiassa olet, ei tarvitse etsiä.

Avainymmärrys: Kyselemme monimutkaisuutta varaston irtisanomiseen. Esi-isät denormalisoidaan jokaiseen riviin, mutta tämä tekee esi-isien kyselyistä vähäpätöisiä - vain muotoile merkkijono.

Käsite visualisoitu

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

Polku kuvaa itseään:

  • Esi-isien lukeminen: Parse /1/3/4/ → Esi-isät ovat [1, 3, 4]
  • Jälkeläisten löytäminen: Kysely WHERE path LIKE '/1/3/%' → saa kaikki solmun alle 3
  • Lasketaan syvyys: Laske erottimet miinus yksi
  • Sisarusten löytäminen: Kysely WHERE path LIKE '/1/3/_/' (välittömät lapset, joilla 3 lasta)

Yksikön määritelmä

Yhteisö lisää yhden Path-sarakkeen:

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:n ydinasetukset

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

Tietokantasarja

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"

Toiminnot

Lisää uusi kommentti

Lisääminen edellyttää polun rakentamista vanhemman polulta:

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

Hanki heti lapsia

Vanhemman ja lapsen välisen suhteen käyttäminen (pidimme ParentCommentdin vaivattomana):

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

Hanki kaikki esi-isät

Tässä kohtaa materialisoidut polut loistivat – luettele polku, ei tarvita tietokantahakuja:

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

Hanki kaikki loppupäätelmät

Käytä like-ohjelmaa polun etuliitteen kanssa:

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

Hanki Descendants kanssa Syvyys

Voimme laskea syvyyden polulta:

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

Poista Subtree

Yksinkertaista polkujen kanssa:

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

Siirrä Subtreetiä

Tämä on kallis toiminta materialisoiduille poluille - meidän täytyy päivittää KAIKKI jälkeläispolut:

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

Query Flow Visualisointi

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>

Suorituskykyä koskevat ominaisuudet

Toiminta Monimutkaisuus Tietokanta Kysymyksiä Muistiinpanoja |-----------|------------|------------------|-------| Lisää O(1) Lisää + päivitä polku Hae lapsia O(1) Käytä ParentCommentId -indeksiä Hae esi-isät O(d) 2 Hae polku + nouta esi-isät Hanki jälkeläisiä O(1)* Hae polkua + LIKE query Move subtreet O(s) 1 Päivitä jälkeläispolkuja O(1) Poista alapuuta* Hae polkua + irtotavaraa poista

*Oikealla hakemistolla polun saralla

Hakemiston huomioita

Polkuindeksi on kriittinen. PostgreSQL:n kohdalla harkitse text_pattern_ops, joka mahdollistaa tehokkaan etuliitteen SICK-kyselyt muilla kuin C-paikkakunnilla:

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

Lisää tämä muuttoliikkeen kautta:

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

Polkumuotoisia pohdintoja

Erilaisilla rajauksilla on myönnytyksiä:

Esimerkki Pros pros pros pros pros pros pros pros pros |--------|---------|------|------| | /1/3/7/ This article Clear, URL-tyyppinen, helppo jäsentely Käyttää enemmän tilaa | 1.3.7 PostgreSQL ltreen tyyliin Compact, toimii puukauden ristiriitojen kanssa desimaalien tarkkuudella | 1,3,7 "Yksinkertaiset tiedot voivat aiheuttaa ongelmia" | 001.003.007 Kiinteät leveydet Lajiteltavissa, yhtenäiset raja-arvot ID-alue, jätealue

Erytropoietiini /id/ Muodostaminen johtavien ja jälkiviiltojen kanssa on suositeltavaa, koska:

  1. SAMANKALTAISET kuviot toimivat oikein (/1/% ottelut /1/ mutta ei /10/)
  2. Helppo jakaa ja jäsentää
  3. Ihmisen luettavissa vianetsintään

Hyödyt ja haitat

Plussat ja miinukset |------|------| Esi-isät saatavilla jäsentämällä (ei kyselyä) Liikkuvat subtreet vaativat kaikkien jälkeläisten päivittämistä Päättyjät yksinkertaisella SAMANKALTAisella kyselyllä Polun pituus rajoittaa puun syvyyttä Syvyys laskettavissa polulta String-manipulointi on ohi. Ihmisen luettavissa vianetsintään KESKUSkyselyt voivat olla hitaita ilman kunnollista indeksiä. Hyvä korppujauhosukupolvelle Polku on pidettävä yhteneväisenä vanhempien kommenttien kanssa Yhden sarakkeen lisäys Ei voi käyttää vakiona olevaa B-puuta loppusumman sovittamiseen

Milloin käyttää materialisoitua polkua

Valitse aineellinen polku, kun:

  • Korppujauhot ovat yleinen vaatimus
  • Esi-isiä tiedustellaan useammin kuin jälkeläisiä
  • Puiden syvyys on rajattu (ei sinulla ole 100+-tasoisia puita)
  • Subtreet ovat harvinaisia
  • Haluat ihmisen luettavat hierarkiatiedot vianetsintään

Vältä materialisoitua polkua, kun:

  • Kuljetat usein subtreetä (kaikkien polkujen päivittäminen on kallista)
  • Puut voivat olla hyvin syviä (polun naruista tulee kankeita)
  • Tarvitset tehokasta loppuliitettä (kaikki puut päätyvät kuvioon)
  • Olet mukavampi ltreen kanssa (PostgreSQL-spesifinen, mutta optimoitu)

Vertailua ltreen

Jos olet PostgreSQL:llä, harkitse Osa 1.5: puu Sen sijaan ltre on pääasiassa tietokantaan perustuva, optimoitu ja toteutettu polku, jossa on:

  • GiST-indeksituki tehokkaisiin kyselyihin
  • Sisäänrakennetut toimijat (@>, <@, ~, jne.)
  • Polkujen manipulointitoiminnot
  • Villikorttien kanssa yhteensopiva malli

Vaihtokauppa on PostgreSQL-lukitus. Huomaa, että Npgsql-toimittaja tukee nyt LINQ-käännöksiä ltreeniä varten LTree Type, vaikka rekursiiviset CTE:t vaativat vielä raakaa SQL:tä.

Sarjanavigointi

Finding related posts...
logo

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