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

@os-zhuang
os-zhuang marked this pull request as ready for review August 5, 2026 00:37
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 5, 2026
Merged via the queue into main with commit b857356 Aug 5, 2026
24 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-5334-analytics-lower-filter-array branch August 5, 2026 00:48
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