上一篇讲了 timeWindow。
它解决的是时间维度上的口径问题:同比、环比、累计、滚动窗口,不能每个接口各写一套日期 SQL。
继续往下走,还会遇到另一类很常见的查询:层级。
比如:
- 查某个部门及其所有下级的销售额;
- 查某个商品分类下面所有子分类的库存;
- 查某个大区下面所有省、市、门店的订单;
- 查某个节点的直接下级;
- 查某个员工所在组织往上的完整路径。
这些需求听起来也很普通。
很多系统里,表上确实也会有一个 parent_id。
于是很容易产生一个想法:既然有 parent_id,那层级查询是不是多传一个父节点 ID 就行了?
实际做起来,一般不会这么简单。
parent_id 只能表达一层关系
parent_id 表达的是一条边。
它能告诉你:A 的父节点是谁,或者哪些节点的父节点是 A。
但业务问的通常不是“一条边”,而是一段范围。
比如“查技术中心下面的所有团队销售额”,这里至少有几种可能:
- 只查技术中心自己;
- 查技术中心的直接下级;
- 查技术中心下面所有层级的团队;
- 查技术中心自己和所有下级;
- 按技术中心汇总成一行;
- 按每个下级团队分别展示明细。
这些需求看起来都和同一个节点有关,但查询语义不一样。
如果只给接口传一个 parentId,后端就必须再补很多隐含约定:
- 要不要包含自身;
- 只查一层还是查所有层;
- 最多查几层;
- 是向下查后代,还是向上查祖先;
- 是把结果汇总到父节点,还是按子节点拆开显示。
这些约定如果写在某个接口的注释里,过不了多久就会变成另一种动态 SQL 维护问题。
所以当前 Java 实现里,父子维不会只靠业务表上的 parent_id 硬查。
它会为父子维配置一张闭包表,也就是 closure table。
这张表记录的不是单层父子关系,而是“某个祖先节点”和“某个后代节点”之间的路径关系,并带一个 distance。
可以简单理解为:
ancestor_id descendant_id distance
T001 T001 0
T001 T002 1
T001 T003 2
distance = 0 表示自身。
distance = 1 表示直接子节点。
distance > 1 表示更深层的后代。
文章不展开闭包表怎么维护。这里真正想说的是:只有底层有了这类层级关系表,DSL 里的“所有下级”“包含自身”“限制深度”才有稳定的执行基础。
同一个节点,有多种查询意图
拿组织树举例。
假设 T001 是“总部”。
如果查询条件是:
team$id = T001
它应该表示只查总部这个节点本身。
但如果业务说“查总部下面所有团队”,这个条件就不够了。
这时至少要区分几类操作:
childrenOf(T001) 直接下级,默认 distance = 1
descendantsOf(T001) 所有下级,不含自身,distance > 0
selfAndDescendantsOf(T001) 自身和所有下级,包含 distance = 0
ancestorsOf(T001) 所有上级,不含自身
selfAndAncestorsOf(T001) 自身和所有上级
这些不是 SQL 写法上的差异,而是业务意图上的差异。
当前实现里,这些操作符会被识别为父子维层级操作符,然后改写为闭包表条件。
向下查时,事实表外键会关联到闭包表的后代节点,再用闭包表里的祖先节点匹配传入值。
向上查时方向相反,用闭包表里的后代节点匹配传入值,再查它的祖先路径。
如果传了 maxDepth,查询实现会把它转成 distance 条件。比如 descendantsOf + maxDepth: 2 可以理解为只取 2 层以内的后代。
如果 DSL 里没有这些操作符,调用方只能继续发明参数名:
includeChildren=true
includeSelf=true
recursive=true
parentDepth=3
queryDirection=DOWN
再往后,不同接口会有不同叫法。
有的叫 withChildren,有的叫 includeSubOrg,有的叫 recursiveQuery,有的干脆写死在 SQL 里。
这就是查询能力没有收敛到语义层时最常见的问题。
默认视角不要有魔法
层级查询还有一个容易踩的点:普通维度和层级维度不能混在一起。
如果用户写:
{
"columns": ["team$caption", "sum(salesAmount) as totalSales"],
"groupBy": ["team$caption"],
"slice": [
{ "field": "team$id", "op": "=", "value": "T001" }
]
}
这个查询应该只匹配 T001。
它不应该因为 team 恰好是父子维度,就偷偷把所有下级也查出来。
默认视角要和普通维度一样,精确、可预期。
只有当调用方明确使用层级语义时,查询逻辑才应该进入层级查询。
这个点和当前实现是一致的。
当前 Java 实现在注册查询模型时,会为父子维额外注册 team$hierarchy$id、team$hierarchy$caption 这类层级视角字段。
但真正执行筛选时,只有两类情况会使用闭包表:
- 字段名里明确出现
$hierarchy$; - 普通维度字段上使用了
childrenOf、descendantsOf、selfAndDescendantsOf、ancestorsOf、selfAndAncestorsOf这类层级操作符。
普通的 team$id = T001 仍然按普通维度精确匹配。
比如:
{
"columns": ["team$caption", "sum(salesAmount) as totalSales"],
"groupBy": ["team$caption"],
"slice": [
{
"field": "team$id",
"op": "selfAndDescendantsOf",
"value": "T001",
"maxDepth": 3
}
]
}
这里表达得就清楚很多:
- 查询字段仍然是
team$id; - 操作符说明这是“自身和所有下级”;
maxDepth说明最多展开 3 层;- 输出仍然按
team$caption分组,所以返回的是各团队明细。
这里底层会走闭包表,但调用方不用自己写 closure JOIN,也不用关心 parentKey、childKey、distance 这些执行细节。
它只需要表达业务查询意图。
层级汇总和后代明细也不是一回事
还有一个细节:查后代范围,不代表一定要按后代展示。
业务可能有两种不同需求。
第一种是“把总部及所有下级汇总成一行”。
这更像是在层级视角下看总部这个节点。
可以用类似这样的字段表达:
{
"columns": ["team$hierarchy$caption", "sum(salesAmount) as totalSales"],
"groupBy": ["team$hierarchy$caption"],
"slice": [
{ "field": "team$hierarchy$id", "op": "=", "value": "T001" }
]
}
这类写法使用的是 $hierarchy$ 视角。
在当前实现里,team$hierarchy$id = T001 会走闭包表,按“祖先节点是 T001”的范围取数,也就是 T001 自身及其后代。
这里不需要再写 selfAndDescendantsOf,因为 $hierarchy$ 视角本身已经表达了这个范围。
第二种是“范围还是总部下面,但结果要看到每个团队”。
这时筛选可以使用层级视角,展示仍然用普通维度:
{
"columns": ["team$caption", "sum(salesAmount) as totalSales"],
"groupBy": ["team$caption"],
"slice": [
{ "field": "team$hierarchy$id", "op": "=", "value": "T001" }
]
}
这两个查询的筛选范围接近,但输出结构不同。
一个是汇总到父节点。
一个是展开后代明细。
如果都靠 parent_id 参数和接口约定来表达,调用方很难一眼看出结果会是什么样。
还有一个边界也要注意:maxDepth 是层级操作符的参数。
如果写的是 team$hierarchy$id = T001 这种层级视角精确匹配,它表达的是完整的自身及后代范围,不读取 maxDepth。
如果需要限制深度,就应该改用 childrenOf、descendantsOf、selfAndDescendantsOf 这类操作符,并显式传 maxDepth。
DSL 做的是把边界说清楚
层级查询不是为了让 DSL 变复杂。
它恰恰是在减少业务接口里的隐含复杂度。
在语义层里,父子维度至少要把这些事情说清楚:
- 默认精确匹配,不自动展开;
- 什么时候查直接子节点;
- 什么时候查所有后代;
- 是否包含自身;
- 是否向上查祖先;
- 是否限制深度;
- 输出是汇总到层级节点,还是展示后代明细。
这些能力一旦进入 DSL,后端就不用在每个列表接口、统计接口、导出接口里重复设计一套层级参数。
前端、后端、人和 LLM 看到同一段查询,也能判断它到底是在查一层、查多层、向上查,还是汇总到某个层级节点。
这就是 parent_id 和层级查询语义之间的差别。
parent_id 是数据结构的一部分。
而层级查询需要表达的是业务希望怎么沿着这棵树取数。
小结
动态 SQL 阶段,层级查询通常会散在各个接口里。
今天这个接口加 includeChildren,明天那个接口加 recursive,后天报表又要求按上级节点汇总。
写着写着,后端又回到了不断加参数、改 SQL、解释口径的状态。
到了语义层阶段,父子维不应该只是一个普通外键。
它应该把“自身”“直接下级”“所有下级”“所有上级”“包含自身”“限制深度”“层级汇总”“后代明细”这些查询意图变成可校验的 DSL。
这样层级查询才不会每次重新发明一套接口参数。
下一步,再继续看另一类复杂情况:当一个查询模型里出现多层对象、嵌套关系和展开路径时,DSL 又要怎么继续往前演化。