# Jerarquías de datos Parte 1.5: PostgreSQL ltree con EF Core

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

La extensión ltree de PostgreSQL le da rutas materializadas con superpoderes nativos de bases de datos: índices GiST, operadores especializados como `@>` y `<@`Si usted está comprometido con PostgreSQL y desea el mejor rendimiento de la consulta de jerarquía, ltree es difícil de superar.

**Buenas noticias:** Los [Npgsql EF El proveedor de Core admite traducciones de LINQ para operaciones de ltree](https://www.npgsql.org/efcore/mapping/translations.html#ltree-functions) a través de la `LTree` type. Usted puede utilizar métodos como `IsAncestorOf()`, `IsDescendantOf()`, y `MatchesLQuery()` directamente en consultas de LINQ. Sin embargo, EF Core todavía no soporta CTE recursivos, por lo que necesitará SQL sin procesar para operaciones que los requieran (como construir resultados de subárboles completos con profundidades calculadas).

*Gracias a [Shay Rojansky](mailto:roji@roji.org) por señalar el soporte de traducción LINQ!*

## 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](/blog/efcore-hierarchical-data-path)
- [Parte 1.4: Conjuntos anidados](/blog/efcore-hierarchical-data-nested)
- **Parte 1.5: ltree** (este artículo)

---


## ¿Qué es ltree?

[`ltree`](https://www.postgresql.org/docs/current/ltree.html) es una extensión PostgreSQL que proporciona un tipo de datos nativo para rutas jerárquicas de etiquetas. [Ruta materializada](/blog/efcore-hierarchical-data-path) con superpoderes - la base de datos entiende la estructura y proporciona operadores optimizados, funciones y soporte de índice GiST.

En lugar de tratar la ruta como una cadena tonta y usando consultas COMO, PostgreSQL puede:

- Utilizar operadores especializados (`@>` porque "es antepasado de", `<@` por "es descendiente de")
- Aplicar índices GiST para consultas de jerarquía eficientes
- Coincidir patrones con comodines (`Top.*.Europe`)
- Realizar operaciones de ajuste en rutas

**Perspicacia clave:** ltree es el mejor de ambos mundos - la simplicidad de las rutas materializadas con optimización nativa de la base de datos. La compensación es PostgreSQL lock-in, y mientras que muchas operaciones ltree trabajan a través de LINQ, los CTE recursivos todavía requieren SQL en bruto.

[TOC]

## Formato de ruta ltree

Rutas en ltree usan períodos como separadores y etiquetas alfanuméricas:

```
Top.Countries.Europe.UK
Top.Countries.Asia.Japan.Tokyo
Top.Products.Electronics.Computers.Laptops
```

Reglas:

- Las etiquetas pueden contener letras, dígitos y subrayados
- Las etiquetas son sensibles a las mayúsculas
- La longitud máxima de la etiqueta es 256 caracteres
- La longitud máxima del camino es de 65535 etiquetas

Para los sistemas de comentarios, usamos IDs como etiquetas: `1.3.7` por el que se entiende "comentario 7 en el comentario 3 en el comentario 1".

## Configuración de ltree

En primer lugar, habilite la extensión (requiere privilegios de superusuario de base de datos):

```sql
CREATE EXTENSION IF NOT EXISTS ltree;
```

O a través de la migración EF Core:

```csharp
protected override void Up(MigrationBuilder migrationBuilder)
{
    migrationBuilder.Sql("CREATE EXTENSION IF NOT EXISTS ltree");
}
```

## Definición de entidad

El proveedor de Npgsql incluye un `LTree` tipo que mapea directamente al ltree de PostgreSQL y proporciona métodos traducibles de LINQ:

```csharp
using Microsoft.EntityFrameworkCore;

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

    // ========== LTREE PATH ==========

    // The hierarchical path in ltree format
    // Format: ancestor1.ancestor2.thisNode
    // Examples:
    //   Root comment: "1"
    //   Child of 1: "1.5"
    //   Grandchild: "1.5.12"
    //
    // Using the LTree type enables LINQ translations for ltree operators
    public LTree Path { get; set; }

    // Keep ParentCommentId for convenience
    public int? ParentCommentId { get; set; }
    public Comment? ParentComment { get; set; }
    public ICollection<Comment> Children { get; set; } = new List<Comment>();

    // ========== HELPER METHODS ==========

    // Helper to get depth - LTree has NLevel property for this
    public int GetDepth() => Path.NLevel - 1;

    public IEnumerable<int> GetAncestorIds()
    {
        var pathString = Path.ToString();
        if (string.IsNullOrEmpty(pathString)) yield break;

        var parts = pathString.Split('.');
        // All except last (which is this node)
        for (int i = 0; i < parts.Length - 1; i++)
        {
            if (int.TryParse(parts[i], out var id))
                yield return id;
        }
    }
}
```

## 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 ==========
        // The LTree type is automatically mapped to PostgreSQL's ltree type
        // by the Npgsql provider - no explicit column type needed
        builder.Property(c => c.Path)
            .IsRequired();

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

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

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

Añadir el índice GiST a través de la migración:

```csharp
protected override void Up(MigrationBuilder migrationBuilder)
{
    // GiST index for ltree - enables efficient @>, <@, and ~ operators
    migrationBuilder.Sql(
        "CREATE INDEX ix_comments_path_gist ON comments USING GIST (path)");

    // Alternative: B-tree index for exact match and sorting
    // migrationBuilder.Sql(
    //     "CREATE INDEX ix_comments_path_btree ON comments USING BTREE (path)");
}
```

## Operadores de ltree

ltree proporciona operadores potentes. El proveedor Npgsql EF Core traduce `LTree` métodos para estos operadores:

Operador  Significado  LINQ Método  SQL Ejemplo
|----------|---------|-------------|-------------|
| `@>` Es ancestro de (contiene) `ltree1.IsAncestorOf(ltree2)` | `'1.3'::ltree @> '1.3.7'::ltree` → verdadero
| `<@` Es descendiente de (contenido por) `ltree1.IsDescendantOf(ltree2)` | `'1.3.7'::ltree <@ '1.3'::ltree` → verdadero
| `~` Matches lquery pattern `ltree.MatchesLQuery(pattern)` | `'1.3.7'::ltree ~ '1.*'::lquery` → verdadero
| `@` Coincide con ltxtquery `ltree.MatchesLTxtQuery(query)` | `'1.3.7'::ltree @ '3 & 7'::ltxtquery` → verdadero
| `||` Rutas de concatenación (usar cadena de concatenación) `'1.3'::ltree || '7'::ltree` → '1.3.7' |
| `<`, `>`, `<=`, `>=` Comparación  Operadores estándar  Para la clasificación

Propiedades y métodos traducibles de LINQ adicionales:

- `ltree.NLevel` → `nlevel(ltree)` - número de etiquetas en camino
- `ltree.Subtree(start, end)` → `subltree(ltree, start, end)` - gama de extractos de etiquetas
- `ltree.Subpath(offset)` → `subpath(ltree, offset)` - sufijo de offset
- `ltree.Subpath(offset, len)` → `subpath(ltree, offset, len)` - subcadena
- `ltree.Index(subpath)` → `index(ltree, subpath)` - encontrar la posición del subcamino
- `LTree.LongestCommonAncestor(ltree1, ltree2)` → `lca(ltree1, ltree2)` - antepasado común más bajo

## Operaciones

### Insertar un nuevo comentario

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

        // Create comment first to get the ID
        var comment = new Comment
        {
            PostId = postId,
            ParentCommentId = parentId,
            Author = author,
            Content = content,
            CreatedAt = DateTime.UtcNow,
            Path = string.Empty  // Temporary
        };

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

        // Build path: parentPath.newId
        // ltree uses periods as separators
        comment.Path = $"{parentPath}.{comment.Id}";
        await context.SaveChangesAsync(ct);

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

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

        comment.Path = comment.Id.ToString();
        await context.SaveChangesAsync(ct);

        return comment;
    }
}
```

### Obtenga hijos inmediatos

Usando ParentCommentId (simple) o el patrón de ltree coincide:

```csharp
public async Task<List<Comment>> GetChildrenAsync(int commentId, CancellationToken ct = default)
{
    // Option 1: Simple ParentCommentId lookup
    return await context.Comments
        .AsNoTracking()
        .Where(c => c.ParentCommentId == commentId)
        .OrderBy(c => c.CreatedAt)
        .ToListAsync(ct);
}

// Option 2: Using ltree pattern (demonstration)
public async Task<List<Comment>> GetChildrenLtreeAsync(int commentId, CancellationToken ct = default)
{
    // Get parent path first
    var parentPath = await context.Comments
        .Where(c => c.Id == commentId)
        .Select(c => c.Path)
        .FirstOrDefaultAsync(ct);

    if (parentPath == null)
        return new List<Comment>();

    // Children match pattern: parentPath.*{1}
    // The {1} means exactly one more label (immediate children only)
    var sql = @"
        SELECT * FROM comments
        WHERE path ~ ($1 || '.*{1}')::lquery
        ORDER BY created_at";

    return await context.Comments
        .FromSqlRaw(sql, parentPath)
        .AsNoTracking()
        .ToListAsync(ct);
}
```

### Obtener todos los antepasados

Uso de LINQ con la `IsAncestorOf` método (traduce a `@>` operador):

```csharp
public async Task<List<Comment>> GetAncestorsAsync(int commentId, CancellationToken ct = default)
{
    var targetPath = await context.Comments
        .Where(c => c.Id == commentId)
        .Select(c => c.Path)
        .FirstOrDefaultAsync(ct);

    if (targetPath == default)
        return new List<Comment>();

    // Find all nodes whose path is an ancestor of this path
    // Using IsAncestorOf which translates to @> operator
    return await context.Comments
        .AsNoTracking()
        .Where(c => c.Path.IsAncestorOf(targetPath) && c.Id != commentId)
        .OrderBy(c => c.Path.NLevel)
        .ToListAsync(ct);
}
```

### Obtener todos los descendientes

Uso de LINQ con la `IsDescendantOf` método (traduce a `<@` operador):

```csharp
public async Task<List<Comment>> GetDescendantsAsync(int commentId, CancellationToken ct = default)
{
    var parentPath = await context.Comments
        .Where(c => c.Id == commentId)
        .Select(c => c.Path)
        .FirstOrDefaultAsync(ct);

    if (parentPath == default)
        return new List<Comment>();

    // Find all nodes whose path is a descendant of this path
    // Using IsDescendantOf which translates to <@ operator
    return await context.Comments
        .AsNoTracking()
        .Where(c => c.Path.IsDescendantOf(parentPath) && c.Id != commentId)
        .OrderBy(c => c.Path)
        .ToListAsync(ct);
}
```

### Obtener descendientes a máxima profundidad

Uso de LINQ con `NLevel` para limitar la profundidad:

```csharp
public async Task<List<Comment>> GetDescendantsToDepthAsync(
    int commentId,
    int maxDepth,
    CancellationToken ct = default)
{
    var comment = await context.Comments
        .FirstOrDefaultAsync(c => c.Id == commentId, ct);

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

    var basePath = comment.Path;
    var baseLevel = comment.Path.NLevel;

    // NLevel property translates to nlevel() function
    // Filter descendants within maxDepth levels
    return await context.Comments
        .AsNoTracking()
        .Where(c => c.Path.IsDescendantOf(basePath) 
                 && c.Id != commentId
                 && c.Path.NLevel - baseLevel <= maxDepth)
        .OrderBy(c => c.Path)
        .ToListAsync(ct);
}

// If you need the depth value in results, you can project it:
public async Task<List<CommentWithDepth>> GetDescendantsWithDepthAsync(
    int commentId,
    int maxDepth,
    CancellationToken ct = default)
{
    var comment = await context.Comments
        .FirstOrDefaultAsync(c => c.Id == commentId, ct);

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

    var basePath = comment.Path;
    var baseLevel = comment.Path.NLevel;

    return await context.Comments
        .AsNoTracking()
        .Where(c => c.Path.IsDescendantOf(basePath) 
                 && c.Id != commentId
                 && c.Path.NLevel - baseLevel <= maxDepth)
        .OrderBy(c => c.Path)
        .Select(c => new CommentWithDepth
        {
            Id = c.Id,
            Content = c.Content,
            Author = c.Author,
            CreatedAt = c.CreatedAt,
            PostId = c.PostId,
            ParentCommentId = c.ParentCommentId,
            Path = c.Path.ToString(),
            Depth = c.Path.NLevel - baseLevel
        })
        .ToListAsync(ct);
}
```

### Consultas de coincidencia de patrones

ltree soporta potentes patrones de lquery. `MatchesLQuery` en LINQ:

```csharp
// Find all comments at exactly depth 2 under comment 1
public async Task<List<Comment>> GetAtDepthAsync(int commentId, int depth, CancellationToken ct = default)
{
    var path = await context.Comments
        .Where(c => c.Id == commentId)
        .Select(c => c.Path)
        .FirstOrDefaultAsync(ct);

    if (path == default) return new List<Comment>();

    // Pattern: path.*{depth} matches exactly 'depth' more levels
    var pattern = $"{path}.*{{{depth}}}";
    
    return await context.Comments
        .AsNoTracking()
        .Where(c => c.Path.MatchesLQuery(pattern))
        .OrderBy(c => c.Path)
        .ToListAsync(ct);
}

// Find all paths matching a pattern like "1.*.7" (any path through 1 ending in 7)
public async Task<List<Comment>> MatchPatternAsync(string pattern, CancellationToken ct = default)
{
    // MatchesLQuery translates to the ~ operator
    return await context.Comments
        .AsNoTracking()
        .Where(c => c.Path.MatchesLQuery(pattern))
        .OrderBy(c => c.Path)
        .ToListAsync(ct);
}
```

### Eliminar un subárbol

Puede usar LINQ para seleccionar el subárbol y luego eliminar:

```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 (path == default)
        throw new InvalidOperationException($"Comment {commentId} not found");

    // Delete all descendants (nodes where path is descendant of this path)
    // Note: ExecuteDeleteAsync requires EF Core 7+
    var deleted = await context.Comments
        .Where(c => c.Path.IsDescendantOf(path))
        .ExecuteDeleteAsync(ct);

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

### Mover un subárbol

ltree proporciona funciones para ayudar con la manipulación de rutas:

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

    try
    {
        var node = await context.Comments.FirstOrDefaultAsync(c => c.Id == commentId, ct);
        var newParent = await context.Comments.FirstOrDefaultAsync(c => c.Id == newParentId, ct);

        if (node == null || newParent == null)
            throw new InvalidOperationException("Node or parent not found");

        // Prevent cycles
        if (newParent.Path.StartsWith(node.Path))
            throw new InvalidOperationException("Cannot move under own descendant");

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

        // Update all descendants: replace old path prefix with new one
        // subpath(path, nlevel(oldPath)) gets the suffix after oldPath
        // We concatenate newPath with that suffix
        var sql = @"
            UPDATE comments
            SET path = $2::ltree || subpath(path, nlevel($1::ltree))
            WHERE path <@ $1::ltree";

        await context.Database.ExecuteSqlRawAsync(
            sql,
            new object[] { oldPath, newPath },
            ct);

        // Update parent reference
        node.ParentCommentId = newParentId;
        await context.SaveChangesAsync(ct);

        await transaction.CommitAsync(ct);

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

## Referencia de las funciones ltree

PostgreSQL ofrece muchas funciones útiles de ltree:

# Función # # Descripción # # Ejemplo #
|----------|-------------|---------|
| `nlevel(ltree)` Número de etiquetas `nlevel('1.3.7')` → 3 |
| `subpath(ltree, offset)` Sufijo de compensación `subpath('1.3.7', 1)` → '3.7' |
| `subpath(ltree, offset, len)` # Subcadena # `subpath('1.3.7', 1, 1)` → '3' |
| `subltree(ltree, start, end)` Gama de etiquetas `subltree('1.3.7', 0, 2)` → '1.3' |
| `lca(ltree, ltree)` Ancestro común más bajo `lca('1.3.7', '1.3.9')` → '1.3' |
| `text2ltree(text)` Convertir texto en árbol `text2ltree('1.3.7')` |
| `ltree2text(ltree)` Convertir ltree en texto `ltree2text('1.3.7'::ltree)` |

## Visualización del flujo de la consulta

```mermaid
sequenceDiagram
    participant App as Application
    participant EF as EF Core
    participant PG as PostgreSQL + ltree

    Note over App,PG: Getting Descendants (GiST index)
    App->>EF: GetDescendantsAsync(commentId)
    EF->>PG: SELECT path FROM comments WHERE id = @id
    PG-->>EF: Path "1.3"
    EF->>PG: SELECT * FROM comments WHERE path <@ '1.3'::ltree
    Note over PG: Uses GiST index - O(log n)
    PG-->>EF: All descendants
    EF-->>App: List<Comment>

    Note over App,PG: Pattern Match Query
    App->>EF: MatchPatternAsync("1.*.7")
    EF->>PG: SELECT * FROM comments WHERE path ~ '1.*.7'::lquery
    Note over PG: GiST index supports pattern matching
    PG-->>EF: Matching comments
    EF-->>App: List<Comment>
```

## Características del rendimiento

Operación Complejidad Notas
|-----------|------------|-------|
Insertar  O(1)  Sólo establecer la cadena de ruta
Obtener hijos  O(1)  Coincidencia de patrón con el índice GiST
Obtener antepasados  O(1)  operador con índice GiST
Obtener descendientes  O(1)  operador con índice GiST
Coincidencia de patrones  O(log n)  GiST index soporta lquery
Mover el subárbol  O(s)  Actualizar los caminos descendientes
Suprímase el subárbol O(1)  operador para la selección

Con los índices GiST, las consultas de ltree son extremadamente eficientes - típicamente O(log n) independientemente de la profundidad del árbol.

## Pros y Contras

Pros  Cons
|------|------|
Optimización nativa de la base de datos  PostgreSQL-solo
Índice GiST para todas las consultas de jerarquía  Dependencia de extensión
Etiquetas limitadas a alfanuméricas
Funciones de manipulación de rutas incorporadas Los CTE recursivos requieren SQL en bruto
O(1) consultas ancestro/descendiente  Menos portátiles que las soluciones puras de EF Core
Almacenamiento compacto
LINQ soporte a través de Npgsql's `LTree` tipo

## Cuándo usar ltree

**Elija ltree cuando:**

- Estás comprometido con PostgreSQL
- El rendimiento es fundamental para las consultas de jerarquía
- Necesitas hacer coincidir los patrones (encontrar todas las X.*.Y caminos)
- Quieres lo mejor de los caminos materializados
- Quiere soporte de LINQ para la mayoría de las operaciones de jerarquía

**Evite ltree cuando:**

- Necesita portabilidad de la base de datos (SQL Server, MySQL, etc.)
- Su equipo no está familiarizado con las extensiones PostgreSQL
- Las etiquetas necesitan caracteres no alphanuméricos
- Usted necesita CTES recursivos y quiere evitar cualquier SQL en bruto

## Comparación con el camino materializado

# Aspecto # # sendero materializado # # árbol #
|--------|-------------------|-------|
Tipo de índice  Árbol B (sólo prefijo)  GiST (todos los patrones)
Patrón de coincidencia  COMO 'prefijo%' sólo  comodines completos
Operadores  Comparación de cuerdas  Nativo , , ~
Portabilidad  Cualquier base de datos  PostgreSQL solamente
EF Core support  Full LINQ  LINQ via `LTree` tipo (los CTE necesitan SQL en bruto)
Rendimiento  Bueno con índice  Excelente con GiST
Funciones  Ninguno (análisis manual)  Biblioteca de funciones ricas

## Ejemplo: Consulta completa del árbol de comentarios

Poner todo junto - obtener un árbol de comentarios entero con profundidad para un post de blog:

```csharp
public async Task<List<CommentTreeItem>> GetPostCommentTreeAsync(
    int postId,
    int maxDepth = 5,
    CancellationToken ct = default)
{
    // Get all comments for the post with calculated depth
    // nlevel() counts the labels in the path
    var sql = @"
        WITH root_comments AS (
            -- Find root comments for this post (no dot in path = root)
            SELECT path, nlevel(path) as root_level
            FROM comments
            WHERE post_id = $1 AND path !~ '*.*'
        )
        SELECT
            c.id,
            c.content,
            c.author,
            c.created_at,
            c.post_id,
            c.parent_comment_id,
            c.path::text as path,
            nlevel(c.path) - COALESCE(
                (SELECT root_level FROM root_comments r
                 WHERE c.path <@ r.path
                 ORDER BY nlevel(r.path) DESC LIMIT 1),
                nlevel(c.path)
            ) as depth
        FROM comments c
        WHERE c.post_id = $1
          AND nlevel(c.path) <= $2 + 1  -- +1 because depth is 0-indexed
        ORDER BY c.path";  -- Perfect depth-first order!

    return await context.Database
        .SqlQueryRaw<CommentTreeItem>(sql, postId, maxDepth)
        .ToListAsync(ct);
}

public class CommentTreeItem
{
    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; }
}
```

## 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](/blog/efcore-hierarchical-data-path)
- [Parte 1.4: Conjuntos anidados](/blog/efcore-hierarchical-data-nested)
- **Parte 1.5: ltree** (este artículo)

## ¿Qué sigue?

Esta serie ha cubierto cinco enfoques a los datos jerárquicos usando EF Core. Parte 2 explorará el uso de SQL y Dapper crudos para aún más control sobre las consultas de jerarquía - pronto!