带有 EF 核心的种子路径
被绑定的路径将完整的祖先存储为分隔字符串@- @% /1/3/7/ “-”使祖先可以立即读取,而没有任何附加词“.” 完美地为面包屑的一代和人类创造了“-” 可读调试“MS K3” 虽然移动子树意味着更新每一个后代的'路径字符串@.}
系列导航
- 部分=1:}概览 介绍和比较
- 部分=1.1: 相邻列表
- 缩略语
- 集成路径 @(_这篇文章#)}
- {1.4:} 内嵌套件部件
- 部份 @1.5:_ltree
什么是定点路径@ ? @
进化路径模式 @ (} 也称为 @ I"_ Path 编号@" in Joe Celko'树和等级) 将每個節點的完整祖先都儲存為定義字串 @- 像是檔案路徑或郵寄地址@. 而不是直接在行中儲存 MS K13
把它想象成存储完整的 URL, 而不是仅仅保存页面名称@.路径 /blog/posts/2024/my-article 告诉您在等级体系中的确切位置 @,# 没有需要的查询@.}
关键洞察力: 我们're 交易存储冗余的询问复杂度@.BARBAR
概念视觉化
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
路径是自定义@ - @ 描述_ MS K1} @
- 阅读祖先的书 解析
/1/3/4/{\fn华文楷体\fs16\1cHE0E0E0}祖先是 [1, 3, 4] - 寻找后代@: @% 查询
WHERE path LIKE '/1/3/%'→所有在节点下 <3>\fn方正黑体_GBK\fs18\b1\bord1\shad1\3cH2F2F3F}<MSK# - 计算深度@: @% 计数分隔符除以 1
- 寻找兄弟姐妹 : 查询
WHERE path LIKE '/1/3/_/'{\fn华文楷体\fs16\1cHE0E0E0}
定义实体
该实体添加一个单路径列@: @%
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 核心配置
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
}
}
数据库
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"
业务业务
插入新注释
插入时需要从父路径@' @s path@ :}构建路径
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;
}
}
获得即时儿童
使用父子关系@-_ child relations @ (}我们保留了父子之意Comment*):
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);
}
获取所有祖先
在这里实现的路径光亮@ - @ 分析路径\ ,} 不需要数据库查询\ MS K2
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();
}
获取所有后继者
路径前缀使用类似@ : @
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);
}
获取具有深度的后代
我们可以从路径中计算深度@: @%
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; }
}
删除子树
简单且路径匹配@: @%
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);
}
移动子树
这是实现路径“-”的昂贵操作, 我们必须更新所有子孙路径“ :” 。
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;
}
}
查询流视觉化
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>
性能特点
| “ | ”操作“ | ”复杂度“MS K2”数据库查询 | “笔记” | ||
|---|---|---|---|---|---|
| @ | 插入# | O(1) | 2 _ | 加入#MS K6 更新路径| | |
| @ | 收留孩子 @O} | O(1) @ @ | 1 @ I | 使用父母CommentId index@ | # |
| @ | # 获取祖先的先祖 # | @OQ(d)} @MS K4 2 @ @ | # 取回路径 @+# 收集祖先的前辈 @MS卡8# | ||
| 获取子孙 | O(1)* | MS K1 I | 获取路径+ 像查询 | ||
| @ | _移動子樹 | _O(_SMS K3} | }MSSK5}{ | }更新後裔路径 | |
| @ | 删去子树 { | } O(1)* +Q 批量删除 @ | Q |
*路径列上有正确的索引
指数的考虑
对于 PostgreSQL,}考虑使用时路径索引是关键 text_pattern_ops“, ” , 它使得有效的前缀能够像查询一样在非“ -C locales:}
-- 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);
通过迁移添加此选项@: @
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
"CREATE INDEX ix_comments_path_pattern ON comments (path text_pattern_ops)");
}
路徑格式
不同的分隔符有交易 -offs*:*}
| 格式@ | 示例 @ | Pros _ | cons | |
|---|---|---|---|---|
/1/3/7/ @ |
@ 此文章@ MS K1 @ clear , @ URL @ MPK3} 类似 @ OMK4 @ 简单解析 @ MCK5 @ 使用更多空间 @ MASK6 @ | |||
1.3.7 |
_PostgreSQL ltree 风格 @ | _Contractime\ ,}与 ltre { | }(时间和小数冲突) | }合作 |
1,3,7 数据中的 M |
逗号可引起问题 @ | @ | ||
001.003.007 “ |
固定”“- width @ | }排序 *,} 一致的“MS K4” 限制范围=%, 废弃空间= | } |
缩略 /id/ 推荐使用有前导和末端斜线的格式,因为 @: @%
- 像样的图案工作正确 @ ( @ @
/1/%匹配符合/1/但无/10/) - 容易分割和解析
- 可读取可调试的 Human@- @%
Pros和 Cons
-=YTET -伊甸园字幕组=- 翻译: |------|------| @|_Astestors 通过剖析 <(>No choice")""{|}#移动子树需要更新所有后代的 | > “|”-通过简单查询“|”-路径长度限制树底深度@| \ |\ 深度计算来自路径@|\ 字符串操作有高空 @|} 用于调试@| @ like charch can be speak without proper index *{|} 这样的查询可以慢一些。 “|对面包屑一代有好处 ”“| 路径必须和父体Commentd @|}同步保持同步。 “|”单列添加“|” Can'“t 使用标准 B%-“ tree 用于匹配 @|”的后缀
何时使用定点路径
选择 profiled 路径时 :\
- 面包屑是一项共同要求
- 与后代相比,对祖先的询问更频繁
- 树的深度是相近的 @(}你赢了#' 200)}t have 100+♪ 水平树_
- 移动子树是稀有的
- 您想要用于调试的 human - 可读化等级数据
** :\ \ 时避免定点路径**
- 您经常移动子树 : @ ( @ 上传所有路径是昂贵的@ MS K1 @
- 树可以非常深的@(path 字符串变得不灵通 @)#
- 您需要高效的后缀来匹配 @ ( @ 查找以图案结尾的所有树\ )} @
- 您对树叶更满意吗? (PostgreSQL- 具体但更优化的@)
与树的比较
如果您在 PostgreSQL ,中考虑 部份 @1.5:_ltree 取代.ltree本质上是一个数据库@-notive,优化实现路径,使用 @:}
- 高效查询的GST指数支持
- 运营商 @(
@>,<@,~# ,#等等 @.# - 路径跟踪处理功能
- 匹配通配符的模式模式
换成 PostgreSQL lock -\in}.\请注意 Npgsql 提供商现在支持 LINQ 翻译 树的果实, LTree 类型@, @}虽然循环 CTE仍然需要原始 SQL.}
系列导航
- 部分=1:}概览
- 部分=1.1: 相邻列表
- 缩略语
- 集成路径 @(_这篇文章#)}
- {1.4:} 内嵌套件部件
- 部份 @1.5:_ltree