Skip to content

fix(service-analytics): where 为 FilterArray 时下沉而不是静默丢弃(第五道门,#5334) - #5355

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-5334-analytics-lower-filter-array
Aug 5, 2026
Merged

fix(service-analytics): where 为 FilterArray 时下沉而不是静默丢弃(第五道门,#5334)#5355
os-zhuang merged 1 commit into
mainfrom
claude/issue-5334-analytics-lower-filter-array

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #5334

按维护者裁定取 lower(下沉),与 #5329 的 engine 六入口逐条同构。改动只在 packages/services/service-analytics/src/strategies/filter-normalizer.tsnormalizeAnalyticsFilterTree 一处 + 新测试 + changeset。

现场核对(worktree 基于 c89d18c16,晚于要求的 1792384e8)

issue 正文引用的两行仍然逐字成立:

const where = (query as { where?: unknown }).where;
if (!where || typeof where !== 'object' || Array.isArray(where)) return null;

Array.isArray(where)return null,整条 where 消失。#5325 / PR #5335 的大改(守卫下推、{kind:'const'}、三个编译器的 paramBase/joinBase 截断、{$in:[]}{a:{}} 的新答案)都落在这一行之下buildNode 一侧,没有动这个入口,所以两条引用与现场无出入。

其余核过的事实(用 built spec 实测,不是读代码推断):

事实 实测
parseFilterAST 住在 packages/spec/src/data/filter.zod.ts,service-analytics 已依赖 @objectstack/spec 是,零新依赖(裁定第 1 条成立)
isFilterAST([['stage','=','won']]) true{stage:'won'}
isFilterAST([{stage:'won'}]) false,parseFilterAST 返回 undefined
isFilterAST([['stage','=','won'],'or',['stage','=','lost']])(中缀) false,parseFilterAST 返回 undefined
isFilterAST(['stage','sounds_like','won']) false,但 parseFilterAST 会给出 {stage:{$sounds_like:'won'}} —— 所以必须先 isFilterAST 把门,否则拼错的算子会被兜底成一个没人执行的条件

⚠️ 一处与派发说明的出入,请复核

派发说明把 issue 正文那条实测(where: [{ stage: 'won' }])列为验收:「改后必须产出正确的 WHERE 并绑值」。这条字面用例落在拒收分支,不是下沉分支,原因如上表:[{stage:'won'}] 不是 FilterArray —— FilterArraySchema 的列表分支是 z.array(FilterArraySchema).min(1),元素必须还是数组;isFilterASTfalse,parseFilterAST 无从下沉。engine.find('deal', {where: [{stage:'won'}]})#5329 起给的也正是拒收。

按裁定的三种到达([] / isFilterAST 真 / 其余非空数组 → 响亮拒收),它只能落在第三格。要让它「产出正确的 WHERE」,就得为「FilterCondition 对象的数组 = 隐式 AND」新造一种谱系里没有的方言 —— 那正是 #5158 拍板 C 在消灭的东西,也会立刻与 engine 门分叉。

所以本 PR:同一个意图的可下沉写法 [['stage','=','won']] 钉住了 issue 要的 WHERE + 绑值,而 issue 正文那条字面量钉成拒收(INVALID_FILTER / 400)。两者都有用例。若维护者要的是另一种读法,说一声,我改。

三种到达,与 #5329 engine 门逐条对照

到达 engine 门(lowerWhereFilterArray) 本 PR(normalizeAnalyticsFilterTree)
[] 删掉 where 返回 null(= 无约束)。同义:两边都不发谓词、不报错
isFilterAST 为真 parseFilterAST → 交给驱动 parseFilterAST → 交给 buildNode
其余非空数组(含中缀、含 [{...}]、含词表外算子、含 ['and']、含 [42]) 抛错,措辞含 “is not a filter” / “Infix joins … NOT one of the shapes” 同措辞,外加 ADR-0112 信封:code === 'INVALID_FILTER'status === 400
isFilterAST 真但 parseFilterAST 落空 「构造上不可达」的响亮兜底 同,不静默

信封取的是四驱动 filterArrayReachedDriverError 的那一套(INVALID_FILTER / 400),没有另造第三套。engine 门本身抛的是裸 Error(无 code/status)—— 派发说明要求对齐的是驱动侧信封,这里按驱动侧落。

下沉产物 × #5335 新逻辑的交互(逐条核过)

parseFilterAST 的值域是封闭的,核完的结论是没有意外交互,理由不是「跑了没炸」而是值域本身:

两个 strategy 都覆盖

下沉发生在 normalizeAnalyticsFilterTree,两条路都受益,但各自有用例钉住:

  • NativeSQLStrategy —— 断言打在 generateSql(SQL + params)和 .execute(在 sql.js 上真取行)。
  • ObjectQLStrategy —— 断言打在交给引擎的 FilterCondition 上(数组写法产出的条件与对象写法深相等),以及经一个 stand-in 引擎取到的行;外加 generateSql 回显 SQL 这条路(它也编译同一棵树,原先数组 where 从执行里消失时,回显里也一起消失了)。

测试

新文件 packages/services/service-analytics/src/__tests__/filter-array-lowering.test.ts,56 个用例。

覆盖 结果
issue 验收 generateSql 产出 WHERE + params === ['won'],.execute 取到 d1,d2 pass
到达 1 [] → 无 WHEREparams 为空、取全表;engine 路 filterundefined pass
到达 2 13 组「对象写法 ⇄ 数组写法」等价表,每组都带显式期望行(只比两种写法会让「双双画全表」也通过) pass
到达 3 5 种不可下沉数组:[{stage:'won'}]、中缀、词表外算子、['and'][42] —— code === 'INVALID_FILTER'status === 400、措辞含 “UNFILTERED” pass
授权面等价性 同一批等价表在 getReadScope: () => ({owner:'u1'}) 下重跑,两种写法取到的行逐条相同且等于 scope 后的期望 —— 证明下沉发生在 scope 注入之前、没有绕过它 pass
拒收不执行 被拒的筛选一条 SQL 都没到驱动;engine 路一次 executeAggregate 都没发 pass
入参不被改写 调用方自己的数组原样保留 pass
#5335 交叉 ['stage','in',[]] → 布尔常量 FALSE → 0 行 pass

命令与输出:

pnpm --filter @objectstack/service-analytics exec vitest run --maxWorkers=2 src/__tests__/filter-array-lowering.test.ts
 Test Files  1 passed (1)
      Tests  56 passed (56)

pnpm --filter @objectstack/service-analytics exec vitest run --maxWorkers=2      # 连带面全量
 Test Files  45 passed (45)
      Tests  695 passed (695)

tsc --noEmit -p packages/services/service-analytics/tsconfig.json
 # 改前 / 改后逐字节相同的 13 行既存报错(analytics-service.test.ts、
 # measure-source-field-gate.test.ts、objectql-timedimension-projection.test.ts),
 # 本次改动新增 0 条。该包没有 typecheck script,故直接 tsc 并与 stash 基线对比。

eslint --no-inline-config <两个改动文件>
 # 干净退出;实现侧无 `as any`。

反向验证(把实现 stash 掉,新用例必须失败)

git stash push -- .../filter-normalizer.ts 后跑同一个测试文件:53 failed | 3 passed (56)。原文节选:

FAIL  ... > emits a bound WHERE for the lowerable spelling of #5334's filter
AssertionError: expected 'SELECT id AS "id", COUNT(*) AS "total…' to match /WHERE/
- Expected:  /WHERE/
+ Received:  "SELECT id AS \"id\", COUNT(*) AS \"total\" FROM \"deal\" GROUP BY id"

FAIL  ... > lowers: equality — the issue's own filter, in its lowerable spelling
AssertionError: the FilterArray spelling: expected [ 'd1', 'd2', 'd3', 'd4' ] to deeply equal [ 'd1', 'd2' ]

FAIL  ... > lowers under the read scope: prefix AND group
AssertionError: array spelling, scoped: expected [ 'd1', 'd3', 'd4' ] to deeply equal [ 'd1' ]

FAIL  ... > refuses: the INFIX join form
Error: expected the filter to be refused, but the query ran

第一条正是 issue 正文那句「生成无 WHERE 的 SQL、params 为空」的机器复现。仍然通过的 3 条是 [] 的两条(它本来就该无过滤)和「入参不被改写」—— 语义未变,符合预期。

本地跳过 / CI-only 盲区

grep -rn "skipIf|describe.skip|it.skip|test.skip|todo(|.only("service-analytics/src 全包:0 命中。本包没有条件跳过的用例,上面的 695 就是本地实际执行数,没有「本地跳过、CI 才跑」的盲区。新用例用 sql.js(纯 WASM),与邻居 native-sql-filter-logic-conformance 同一理由,不依赖 native ABI。

变更记录

.changeset/analytics-where-filter-array-lowered.md(@objectstack/service-analytics: patch),写明这是可观察的行为变更:此前数组 where 被静默丢弃、图表画全表;现在正确筛选,或以 INVALID_FILTER / 400 响亮拒收。未碰 content/docs/releases/

范围外(已单独立单,本 PR 一行未改)

风险


Generated by Claude Code

…ng it (#5334)

`normalizeAnalyticsFilterTree` answered every ARRAY `where` with `return null`:
the whole filter disappeared with no error and no trace, and the chart was drawn
over the entire dataset — #3650 / #4128's silent-widening class, reached through
the array spelling.

`FilterArray` is INPUT-ONLY authoring sugar (#5285) and #5158's ruling C lowers
it at every door through the single `parseFilterAST` sink; #5329 did that for
ObjectQL's six entry points and deleted the four drivers' array dialects.
Analytics is the fifth door — it compiles `where` itself, to SQL
(NativeSQLStrategy) or to a FilterCondition for the engine (ObjectQLStrategy) —
so nothing upstream lowers for it. It now gives the same three answers:

- `[]`                       → no filter (unchanged);
- a well-formed FilterArray  → lowered through parseFilterAST, then compiled by
                               buildNode, so both spellings select the same rows;
- any other non-empty array  → refused with INVALID_FILTER / 400 (ADR-0112, the
                               envelope filterArrayReachedDriverError uses).

`isFilterAST` gates before `parseFilterAST` so the operator vocabulary is
checked before the lenient `$${op}` fallback can turn a misspelling into a
`$sounds_like` condition nothing executes.

Both strategies read this normalizer, so both paths are covered, and both are
pinned as ROW RESULTS on sql.js / a stand-in engine rather than as SQL strings —
a dropped predicate leaves the statement valid, which is why the defect survived
every string assertion in this package.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pbu27iNUfQCHeuS551Rqo7
@vercel

vercel Bot commented Aug 5, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 5, 2026 12:25am

Request Review

@github-actions github-actions Bot added the size/l label Aug 5, 2026
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-analytics.

8 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/api/data-api.mdx (via @objectstack/service-analytics)
  • content/docs/api/index.mdx (via @objectstack/service-analytics)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/service-analytics)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/service-analytics)
  • content/docs/plugins/packages.mdx (via @objectstack/service-analytics)
  • content/docs/releases/implementation-status.mdx (via @objectstack/service-analytics)
  • content/docs/releases/v17.mdx (via @objectstack/service-analytics)
  • content/docs/releases/v9.mdx (via @objectstack/service-analytics)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

PM 复核(会话 session_01Pbu27iNUfQCHeuS551Rqo7):你提的那处出入,你是对的,采纳

关于 where: [{ stage: 'won' }] 落在拒收分支

我的派发说明在这一点上是错的。 我把 issue 正文的复现值原样抄成了验收标准(「改后必须产出正确的 WHERE 并绑值」),没有先核它是不是一个合法的 FilterArray。你核了,它不是 —— FilterArraySchema 的列表分支是 z.array(FilterArraySchema).min(1),元素必须还是数组,所以 isFilterAST([{stage:'won'}])false,parseFilterAST 无从下沉。

按裁定的三种到达,它只能落在第三格。要让它「产出正确的 WHERE」,就得为「FilterCondition 对象的数组 = 隐式 AND」新造一种谱系里不存在的方言 —— 那正是 #5158 拍板 C 在消灭的东西,而且会立刻与 engine 门分叉(engine.find('deal', {where: [{stage:'won'}]})#5329 起给的就是拒收)。

你的处理是对的:可下沉的写法 [['stage','=','won']] 钉住 WHERE + 绑值,issue 正文那条字面量钉成拒收,两者都有用例。issue 正文那条复现本身没错 —— 它当时演示的是「任何数组都被静默丢弃」,这一点成立;只是那个具体的值恰好不可下沉。

另一处值得记的核查

isFilterAST 必须把门,不能直接 parseFilterAST —— 你实测出 ['stage','sounds_like','won']isFilterASTfalse,但 parseFilterAST 会兜底成 {stage:{$sounds_like:'won'}},即把作者拼错的算子变成一个没人执行的条件。这与 #5329 在 engine 门得出的同一结论一致(那边的原话是「后者对拼错操作符有 $${op} 宽松兜底」),两道门因此真正同构,而不是看起来同构。

下沉产物 × #5335 的交互:结论可采信,因为它论的是值域而不是「跑了没炸」

你给的五条($not 不会出现 —— AST 词表无取反算子;{}/{field:{}} 不会出现 —— convertComparison 必产出恰好一个键;空 $and/$or 不会出现 —— 0 个子条件返回 undefined、1 个直接返回该子条件;{$in: []} 出现且正好落在 #5335 新加的布尔常量上;算子词表是 analytics 词表的子集)是封闭值域的论证,不是抽样。这正是我要的那种核查。其中 {$in: []} 那条尤其值得留着:若这次下沉落在 #5325 之前的树上,它会一条子句都不发、画全表 —— 两单的顺序不是巧合,是必要条件。

授权面等价性测试

「同一批等价表在 getReadScope 下重跑,两种写法取到的行逐条相同且等于 scope 后的期望」—— 这条同时证明了下沉发生在 scope 注入之前、没有绕过它。比我要求的等价性又多守了一层。

关于 #5352

你立的这单是对的,而且它让本 PR 的结论要打个折扣要写清楚:service 层拒收正确,但过了 /analytics/dataset/query 那道面会被降级成 500 ANALYTICS_QUERY_FAILED(该面用 message 正则嗅探、丢弃 code/status)。所以「作者拿到一个说明哪里写错了的 400」这件事,要等 #5352 才真正成立。已记入分诊队列,本 PR 不因此阻塞 —— 拒收本身在正确的位置,面的问题归面。

CI 绿即转 ready 入队。


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

analytics 是 #5158 拍板 C 漏掉的第五道门:where 为数组(FilterArray 糖)时被 normalizeAnalyticsFilterTree 静默丢弃,图表画全表

2 participants