Skip to content

REST 读路径:searchFields / groupBy / aggregations 指向不存在的字段时被静默降级(#4226 收口后剩下的三条轴) #4254

Closed

Description

@os-zhuang

TL;DR

#4226(PR #4240)把 sort / select / expand 三条轴收口成了「要么生效,要么抛错」,加上 filter 轴的 #4134 / #4164 / #4181 / #4121,读路径上四条点名字段的轴已经收满。

同一台机器在另外三条上还在漏气,而且失败方向和已修的那些一模一样:

── searchFields ──────────────────────────────────────
searchFields=title           -> 200  命中 1 行        ✅
searchFields=no_such_field   -> 200  命中 2 行        ❌ 要收窄,结果放宽了
searchFields=title,no_such   -> 200  用 [title]       ⚠️ 未知的那半静默丢弃

── groupBy ───────────────────────────────────────────
groupBy=[status]             -> [{status:'open',n:2},{status:'done',n:1}]  ✅
groupBy=[no_such_field]      -> [{no_such_field:null, n:3}]                ❌ 3 组塌成 1 组

── aggregations ──────────────────────────────────────
sum(amount)                  -> 真实合计                                   ✅
sum(no_such_field)           -> [{status:'open',s:0},{status:'done',s:0}]  ❌ 求和得 0

实测

真 ObjectQL 引擎 + 真 registry,3 行(open×2 / done×1),走引擎的 in-memory 聚合回退路径(driver-memory / driver-rest / 部分 SQL driver 走的就是这条)。

1. searchFields —— 与 select 完全同构,且端到端可见

根因在 packages/objectql/src/search-filter.ts:111-116:

if (requested && requested.length > 0) {
  const allowSet = new Set(allowed);
  const validated = requested.filter((f) => allowSet.has(f));   // ← 部分未知:静默丢弃
  if (validated.length > 0) return validated;
}
return allowed;                                                  // ← 全未知:退回全集

这就是 #4226 在 select 上修掉的那两段逻辑的逐行同构:先丢弃未知项,再在结果为空时退回全集。

resolveSearchFields 直接测:

allowed default          : ["title","notes","status"]
requested=["title"]      : ["title"]
requested=["no_such"]    : ["title","notes","status"]   ← 要一列,给了全部
requested=["title","no_such"] : ["title"]                ← 未知的那半无声无息

端到端(search=alpha,title:'Alpha' 在 t1,notes:'alpha in notes' 在 t2):

?search=alpha&searchFields=title           -> ids ["t1"]
?search=alpha&searchFields=no_such_field   -> ids ["t1","t2"]    多返回一行

这一条比 select 那条更值得修,因为它改变结果集(select 只改列)。searchFields 是 ADR-0061 的 override,唯一用途就是收窄搜索范围,失败方向却是扩大 —— 同一个反直觉方向。

而且它落在刚修好的那个 bug 的隔壁。 searchFields 目前在框架内的唯一调用方是 GET /data/:object/export(rest-server.ts:4757-4763,export-honors-search-term 刚合入)。那个 changeset 自己写的动机是:

exporting after a search downloaded the unsearched superset — more rows than the screen showed, in a file that looks authoritative, with nothing indicating the difference.

searchFields=<拼错> 现在做的正是这件事:导出一个比请求更宽的集合,文件看起来同样权威。同一条路由,同一个失败方向,刚被堵上的洞的旁边。

2. groupBy —— N 组塌成 1 组,结果看起来完全正常

groupBy / aggregations 是 QUERY_AST_KEYS 的成员,所以不会掉进隐式 filter 桶,但也没有任何字段校验:findData 原样转给 engine.aggregate()(protocol.ts:3746-3752)。

in-memory 路径上 projectGroupValue(row, 'no_such_field') 对每一行都得 undefined,于是所有行进同一个桶:

groupBy=[no_such_field] -> [{ no_such_field: null, n: 3 }]

n: 3 是真实的行数,结构也完全合法 —— 和「这个对象的该列真的只有一个取值」一模一样。一个图表拿到这个会画出一根柱子,没有任何东西提示分组没生效。

3. aggregations —— sum 得 0,与真实的 0 无法区分

sum(no_such_field) -> [{status:'open', s:0}, {status:'done', s:0}]

财务报表里的「本季度营收 0」和「字段名拼错了」返回同一个东西。avg / min / max 同理。

4. 顺带:两个后端可能给出相反答案

上面是 in-memory 回退路径的实测。SQL driver 走的是原生 aggregate,GROUP BY no_such_field 会被数据库拒绝 —— 具体是抛出还是被吞成 [] 未实测(SqlDriver 对 no such column 有识别与重试逻辑)。如果两条路径给出不同答案,那就是 #4226 里点名的「两条路由对同一份输入给出相反答案」在聚合轴上的重演,值得一并实测确认。

建议

按 #3948 原则(An unapplied filter must not look like a satisfied one)在同一个 ingress 收口,复用 #4226 落地的 resolveQueryFields(protocol.ts,已经是四条轴共用的一次解析):

关联

Activity

  1. self-assigned this
    on Jul 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions