# Jerarquías de datos Parte 1.3: Ruta materializada con el núcleo de EF

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

Rutas materializadas almacenan la ascendencia completa como una cadena delimitada - como `/1/3/7/` Perfecto para la generación de migajas de pan y depuración de lectura humana, aunque mover subárboles significa actualizar la cadena de ruta de cada descendiente.

## Navegación en serie

- [Parte 1: Sinopsis](/blog/efcore-hierarchical-data) - Introducción y comparación
- [Parte 1.1: Lista de Adyacencias](/blog/efcore-hierarchical-data-adjacency)
- [Parte 1.2: Tabla de cierre](/blog/efcore-hierarchical-data-closure)
- **Parte 1.3: Ruta materializada** (este artículo)
- [Parte 1.4: Conjuntos anidados](/blog/efcore-hierarchical-data-nested)
- [Parte 1.5: ltree](/blog/efcore-hierarchical-data-ltree)

---


## ¿Qué es un Camino Materializado?

El patrón del Sendero Materializado (también llamado "Enumeración del Camino" en [Árboles y jerarquías de Joe Celko](https://www.amazon.com/Hierarchies-Smarties-Kaufmann-Management-Systems/dp/0123877334)) almacena la ascendencia completa de cada nodo como una cadena delimitada - como una ruta de archivo o dirección postal. En lugar de almacenar sólo "mi padre es el nodo 5", almacenamos "Me llegan a través de los nodos 1 → 3 → 5 → 7" directamente en la fila.

Piense en ello como almacenar la URL completa en lugar de sólo el nombre de la página. `/blog/posts/2024/my-article` Te dice exactamente dónde estás en la jerarquía, no se necesitan búsquedas.

**Perspicacia clave:** Estamos intercambiando la complejidad de la consulta por redundancia de almacenamiento. La ascendencia se desnormaliza en cada fila, pero esto hace que las consultas antepasadas sean triviales - sólo analiza la cadena.

[TOC]

## El concepto visualizado

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

El camino es auto-describiendo:

- **Leyendo ancestros:** Análisis `/1/3/4/` → los antepasados son [1, 3, 4]
- **Encontrando descendientes:** Consulta `WHERE path LIKE '/1/3/%'` → obtiene todo bajo el nodo 3
- **Cálculo de la profundidad:** Contar los separadores menos uno
- **Encontrar hermanos:** Consulta `WHERE path LIKE '/1/3/_/'` (hijos inmediatos de 3)

## Definición de entidad

La entidad añade una sola columna de ruta:

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

## Configuración del núcleo de EF

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

## Esquema de base de datos

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

## Operaciones

### Insertar un nuevo comentario

Insertar requiere construir el camino desde el camino del padre:

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

### Obtenga hijos inmediatos

Usando la relación padre-hijo (mantuvimos ParentCommentId por conveniencia):

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

### Obtener todos los antepasados

Aquí es donde brillan las rutas materializadas - analizar la ruta, no se necesitan búsquedas en la base de datos:

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

### Obtener todos los descendientes

Use COMO con el prefijo de ruta:

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

### Obtener descendientes con profundidad

Podemos calcular la profundidad desde el camino:

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

### Eliminar un subárbol

Simple con la coincidencia de rutas:

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

### Mover un subárbol

Esta es la operación costosa para rutas materializadas - debemos actualizar TODAS las rutas descendientes:

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

## Visualización del flujo de la consulta

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

## Características del rendimiento

Operación  Complejidad  Consultas de base de datos  Notas
|-----------|------------|------------------|-------|
Insertar  O(1)  2  Insertar + actualizar ruta
Obtener hijos  O(1)  1  Use ParentCommentId index
# Obtener ancestros # # O(d) # 2 # # # Conseguir camino # # # # # # # Conseguir ancestros # # O(d) # # 2 # # Conseguir camino # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #  # # # #  # # #  # # # # # # #  # # # # # # # # # # # # # # # # # # # # # # #  # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #  # # # #  #  #
# Conseguir descendientes # # O(1) #* 2  Obtener la ruta + la consulta COMO
Mover el subárbol  O(s)  1  Actualizar los caminos descendientes
Suprímase el subárbol O(1)* 2  Obtener ruta + eliminar a granel

*Con el índice adecuado en la columna de ruta

## Consideraciones relativas al índice

El índice de ruta es crítico. Para PostgreSQL, considere usar [`text_pattern_ops`](https://www.postgresql.org/docs/current/indexes-opclass.html), lo que permite un prefijo eficiente de consultas COMO en lugares no C:

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

Agregue esto a través de una migración:

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

## Consideraciones sobre el formato de ruta

Diferentes delimitadores tienen compensaciones:

Formato  Ejemplo  Pros  Cons
|--------|---------|------|------|
| `/1/3/7/` Este artículo  claro, URL-como, fácil de analizar  Utiliza más espacio
| `1.3.7` PostgreSQL ltree style  Compacto, trabaja con ltree  Conflictos periodo con decimales
| `1,3,7` Comma-separado  Simple  Comma en los datos podría causar problemas
| `001.003.007` Ancho fijo  Clasificable, consistente  Límites rango ID, espacio de desechos

Los `/id/` se recomienda el formato con barras delanteras y traseras porque:

1. Los patrones COMO funcionan correctamente (`/1/%` coincidencias `/1/` pero no `/10/`)
2. Fácil de dividir y analizar
3. Leíble para humanos para depuración

## Pros y Contras

Pros  Cons
|------|------|
Ancestros disponibles por análisis (sin consulta)  Mover subárboles requiere actualizar todos los descendientes
Descendentes a través de simple consulta COMO la longitud del sendero limita la profundidad del árbol
Profundidad calculable de la trayectoria  La manipulación de la cuerda tiene por encima
Las consultas similares pueden ser lentas sin un índice adecuado
Bueno para la generación de migajas de pan El camino debe mantenerse en sincronía con ParentCommentId
No se puede usar el árbol B estándar para hacer coincidir el sufijo

## Cuándo utilizar ruta materializada

**Elija la ruta materializada cuando:**

- Las migajas de pan son un requisito común
- A los antepasados se les pregunta más a menudo que a los descendientes
- La profundidad del árbol está limitada (no tendrás más de 100 árboles de nivel)
- Los subárboles en movimiento son raros
- Quieres datos de jerarquía legibles por el ser humano para depurar

**Evite la ruta materializada cuando:**

- Usted mueve con frecuencia los subárboles (actualizar todos los caminos es caro)
- Los árboles pueden ser muy profundos (las cuerdas del camino se vuelven difíciles de manejar)
- Usted necesita el ajuste eficiente del sufijo (encontrar todos los árboles que terminan en un patrón)
- Usted es más cómodo con ltree (Específico de PostgreSQL pero más optimizado)

## Comparación con ltree

Si estás en PostgreSQL, considera [Parte 1.5: ltree](/blog/efcore-hierarchical-data-ltree) ltree es esencialmente un camino materializado nativo de la base de datos, optimizado con:

- Soporte de índice GiST para consultas eficientes
- Operadores incorporados (`@>`, `<@`, `~`, etc.)
- Funciones de manipulación de rutas
- Coincidencia de patrones con comodines

La compensación es PostgreSQL lock-in. Tenga en cuenta que el [El proveedor de Npgsql ahora soporta traducciones de LINQ](https://www.npgsql.org/efcore/mapping/translations.html#ltree-functions) para ltree a través de la `LTree` type, aunque los CTE recursivos todavía requieren SQL en bruto.

## Navegación en serie

- [Parte 1: Sinopsis](/blog/efcore-hierarchical-data)
- [Parte 1.1: Lista de Adyacencias](/blog/efcore-hierarchical-data-adjacency)
- [Parte 1.2: Tabla de cierre](/blog/efcore-hierarchical-data-closure)
- **Parte 1.3: Ruta materializada** (este artículo)
- [Parte 1.4: Conjuntos anidados](/blog/efcore-hierarchical-data-nested)
- [Parte 1.5: ltree](/blog/efcore-hierarchical-data-ltree)