层级查询为什么不能只靠 parent_id

0 阅读1分钟

上一篇讲了 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$idteam$hierarchy$caption 这类层级视角字段。

但真正执行筛选时,只有两类情况会使用闭包表:

  • 字段名里明确出现 $hierarchy$
  • 普通维度字段上使用了 childrenOfdescendantsOfselfAndDescendantsOfancestorsOfselfAndAncestorsOf 这类层级操作符。

普通的 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,也不用关心 parentKeychildKeydistance 这些执行细节。

它只需要表达业务查询意图。

层级汇总和后代明细也不是一回事

还有一个细节:查后代范围,不代表一定要按后代展示。

业务可能有两种不同需求。

第一种是“把总部及所有下级汇总成一行”。

这更像是在层级视角下看总部这个节点。

可以用类似这样的字段表达:

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

如果需要限制深度,就应该改用 childrenOfdescendantsOfselfAndDescendantsOf 这类操作符,并显式传 maxDepth

DSL 做的是把边界说清楚

层级查询不是为了让 DSL 变复杂。

它恰恰是在减少业务接口里的隐含复杂度。

在语义层里,父子维度至少要把这些事情说清楚:

  • 默认精确匹配,不自动展开;
  • 什么时候查直接子节点;
  • 什么时候查所有后代;
  • 是否包含自身;
  • 是否向上查祖先;
  • 是否限制深度;
  • 输出是汇总到层级节点,还是展示后代明细。

这些能力一旦进入 DSL,后端就不用在每个列表接口、统计接口、导出接口里重复设计一套层级参数。

前端、后端、人和 LLM 看到同一段查询,也能判断它到底是在查一层、查多层、向上查,还是汇总到某个层级节点。

这就是 parent_id 和层级查询语义之间的差别。

parent_id 是数据结构的一部分。

而层级查询需要表达的是业务希望怎么沿着这棵树取数。

小结

动态 SQL 阶段,层级查询通常会散在各个接口里。

今天这个接口加 includeChildren,明天那个接口加 recursive,后天报表又要求按上级节点汇总。

写着写着,后端又回到了不断加参数、改 SQL、解释口径的状态。

到了语义层阶段,父子维不应该只是一个普通外键。

它应该把“自身”“直接下级”“所有下级”“所有上级”“包含自身”“限制深度”“层级汇总”“后代明细”这些查询意图变成可校验的 DSL。

这样层级查询才不会每次重新发明一套接口参数。

下一步,再继续看另一类复杂情况:当一个查询模型里出现多层对象、嵌套关系和展开路径时,DSL 又要怎么继续往前演化。