…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
Fixes #5334
按维护者裁定取 lower(下沉),与 #5329 的 engine 六入口逐条同构。改动只在
packages/services/service-analytics/src/strategies/filter-normalizer.ts的normalizeAnalyticsFilterTree一处 + 新测试 + changeset。现场核对(worktree 基于
c89d18c16,晚于要求的1792384e8)issue 正文引用的两行仍然逐字成立:
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/specisFilterAST([['stage','=','won']])true→{stage:'won'}isFilterAST([{stage:'won'}])false,parseFilterAST返回undefinedisFilterAST([['stage','=','won'],'or',['stage','=','lost']])(中缀)false,parseFilterAST返回undefinedisFilterAST(['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),元素必须还是数组;isFilterAST为false,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 门逐条对照
lowerWhereFilterArray)normalizeAnalyticsFilterTree)[]where键null(= 无约束)。同义:两边都不发谓词、不报错isFilterAST为真parseFilterAST→ 交给驱动parseFilterAST→ 交给buildNode[{...}]、含词表外算子、含['and']、含[42])code === 'INVALID_FILTER'、status === 400isFilterAST真但parseFilterAST落空信封取的是四驱动
filterArrayReachedDriverError的那一套(INVALID_FILTER/ 400),没有另造第三套。engine 门本身抛的是裸Error(无 code/status)—— 派发说明要求对齐的是驱动侧信封,这里按驱动侧落。下沉产物 × #5335 新逻辑的交互(逐条核过)
parseFilterAST的值域是封闭的,核完的结论是没有意外交互,理由不是「跑了没炸」而是值域本身:$not不会出现。AST 词表(AST_OPERATOR_MAP)没有取反算子,组只有and/or,所以下沉产物里永远没有$not—— fix(service-analytics)!: 作者的where也 NULL-safe ——$not下推守卫、{$not:{}}为零行、{}析取项吸收$or(#5325) #5335 的nullSafeNegationOperand/notOf/ 「NOT TRUE ≡ FALSE」这条路不会被数组写法触发。对象写法的$not一行未动。{}与{field:{}}不会出现。convertComparison必产出恰好一个键;组要么返回子条件、要么undefined。所以 service-analytics 的第二个 SQL 编译器filter-normalizer.buildNode仍带着 #5297 的三条分叉:$not非 NULL-safe、{$not:{}}不加 WHERE、$or的{}析取项被丢 #5325 的「TRUE 吸收$or」与{ field: {} }(零个操作符的字段约束)在同仓有三个答案:driver-sql 组合子内 TRUE、顶层抛 INVALID_FILTER、formula/driver-memory FALSE #5240 的空字段约束拒收都不会被下沉产物撞到。$and/$or数组不会出现。parseFilterAST对 0 个子条件返回undefined,1 个直接返回该子条件,所以组至少两支且全是对象 ——buildNode的空组合器拒收不会被误触。{$in: []}会出现(['stage','in',[]]),正好落在 fix(service-analytics)!: 作者的where也 NULL-safe ——$not下推守卫、{$not:{}}为零行、{}析取项吸收$or(#5325) #5335 新加的布尔常量{kind:'const', value:false}上:编译成1 = 0,取 0 行。已单独钉住(见测试表)。若这次下沉落在 service-analytics 的第二个 SQL 编译器filter-normalizer.buildNode仍带着 #5297 的三条分叉:$not非 NULL-safe、{$not:{}}不加 WHERE、$or的{}析取项被丢 #5325 之前的树上,它会一条子句都不发、画全表。$算子只有$eq $ne $gt $gte $lt $lte $in $nin $contains $notContains $startsWith $endsWith $between $null,全部在 analytics 的词表内(MONGO_TO_CUBE_OP+$between+$null)。下沉不会造出 analytics 编译不了的算子。$between(['amount','between',[15,35]])照旧下沉成两个界,继承$lte的整日规则。两个 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 个用例。generateSql产出WHERE+params === ['won'],.execute取到d1,d2[]→ 无WHERE、params为空、取全表;engine 路filter为undefined[{stage:'won'}]、中缀、词表外算子、['and']、[42]——code === 'INVALID_FILTER'、status === 400、措辞含 “UNFILTERED”getReadScope: () => ({owner:'u1'})下重跑,两种写法取到的行逐条相同且等于 scope 后的期望 —— 证明下沉发生在 scope 注入之前、没有绕过它executeAggregate都没发['stage','in',[]]→ 布尔常量 FALSE → 0 行命令与输出:
反向验证(把实现 stash 掉,新用例必须失败)
git stash push -- .../filter-normalizer.ts后跑同一个测试文件:53 failed | 3 passed (56)。原文节选:第一条正是 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 一行未改)
filter-normalizer.ts既存的四处拒收是裸Error(无 ADR-0112 信封),而/analytics/dataset/query的 REST 面用 message 正则嗅探、丢弃code/status,一律答500 ANALYTICS_QUERY_FAILED。本 PR 新落的INVALID_FILTER/ 400 在 service 侧是对的,过了那道面同样会被降级成 500。一个缺陷的两半,未打标,待分诊。inferCube仍把数组where当「不是筛选」跳过 —— #5334 之后这个!Array.isArray守卫已经过时 #5353(finding)——inferCube仍用!Array.isArray(query.where)把数组当非筛选跳过;analytics 是 #5158 拍板 C 漏掉的第五道门:where为数组(FilterArray 糖)时被normalizeAnalyticsFilterTree静默丢弃,图表画全表 #5334 之后已过时。今天无可观察后果(resolveFieldSql回落裸列名,取到的行一致),记为观察类。{field: {$eq: null}}/{$ne: null}编译成col = ''/col != '',与同文件里{field: null}的IS NULL自相矛盾 #5332({$eq: null}编译成col = '')、/analytics/sql回显的 SQL 丢掉$startsWith/$endsWith谓词:回显比实际执行的查询更宽,无法复现结果 #5333(回显丢$startsWith/$endsWith)—— 同文件的独立单,本 PR 未碰;nullValueSatisfiesOperator的注释里已有指向 分析查询的{field: {$eq: null}}/{$ne: null}编译成col = ''/col != '',与同文件里{field: null}的IS NULL自相矛盾 #5332 的既存说明,保持原样。packages/spec/**、packages/objectql/**、packages/plugins/**—— 只读作参照,一行未改。构建曾顺带重写packages/spec/authorable-surface.base.json(生成物,baseRev跟到当前 HEAD),已git checkout还原,不在本 PR 的 diff 里。风险
where的 producer。Generated by Claude Code