fix(analytics): $ne / $nin / $notContains 在 Cube 面保留无值行 (#5298 第二批) (#5977) - #6004
Merged
Merged
Conversation
…he Cube face (#5977) Second batch of the #5298 ruling; PR #5962 landed the first on driver-sql, read-scope-sql and formula. Measured before the change on this package's own fixture, for `{stage: {$ne: 'won'}}` with rows 3-4 carrying a NULL `stage`: the raw-SQL strategy answered `2`, the display-SQL echo answered `2`, and the ObjectQL engine condition answered `2,3,4`. Three compilers of ONE normalized tree, two answers — and the engine column was right only because driver-sql guards for itself since #5962, not because the analytics layer did. The echo was the worst of the three: it described a NARROWER query than the one that ran. `fieldLeaves` now emits the guard as tree STRUCTURE — an `or` of the `notSet` leaf with the comparison — rather than as a SQL trick inside one strategy, so all three compilers produce one predicate and none of them needs to know the rule. Same trade the #5146 `$not` rewrite in this file already took, including its cost: the engine path guards twice, which is idempotent. Which operators are guarded is NOT a new list. It is the existing polarity pair `nullValueSatisfiesOperator` / `operatorIsNullTotal` — the guard applies exactly on their `allowNull` verdict. Those arms read the COMPARAND, so `$ne: null` stays `IS NOT NULL` and an empty `$nin` stays the TRUE constant; a hard-coded list of three operator names would have been wrong as well as duplicated. Positive comparisons compile byte-identically, and `{$not: {stage: {$ne: 'v'}}}` still means "stage is v" rather than widening. Fixture triage, each re-judged individually rather than batch re-spelled: - `filter-operator-coverage.test.ts` — three cases gain the `d_null` row. - `objectql-daterange.test.ts` — engine-filter shape; its subject (both operands survive the merge) is unchanged and still visible. - `objectql-contains-canonical-operator.test.ts` — its stand-in engine read only `filter.stage`, which the guarded tree no longer has at top level, so it was handed an EMPTY operator object and passed every row. Replaced with a tree walk; the operator-key assertion got the same treatment. - `filter-normalizer-not-null-safe.test.ts` — the line that recorded this divergence as "real and out of #5146's scope" is now the ruling's answer. FILTER_LOGIC_CASES is unchanged: `$ne` / `$not` enrol in #5903's PR, the last blocker. The spec table's measured matrix drops the Cube row it no longer describes. Fixes #5977 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WyvqvKMG6asi9aXjKE6xtx
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 2 package(s): 112 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
This was referenced Aug 6, 2026
os-zhuang
marked this pull request as ready for review
August 6, 2026 14:37
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #5977
#5298 裁决的第二批(第一批 PR #5962 已 MERGED:driver-sql 五出口 + read-scope-sql + formula)。方向按「四项确认落档」④:非否定路径
$ne/$nin/$notContains包含 NULL 行。一、先实测(派发词第 1 条):前提成立,但发射点与 issue 正文的指认不同
用真实消费端驱动(
NativeSQLStrategy端到端跑 sql.js;ObjectQL 路径在executeAggregate缝上捕获引擎 filter 并真实执行),fixture 为本包既有的filter-normalizer-not-null-safe.test.ts那张表(行 3/4 的stage为 NULL)。{stage: {$ne: 'won'}}改动前实测:NativeSQLStrategy原生 SQL22,3,4ObjectQLStrategy回显 SQL(/analytics/sql)22,3,4ObjectQLStrategy→ 引擎 condition2,3,42,3,4改前 WHERE 逐字为
stage != $1/stage NOT IN ($1)/stage NOT LIKE $1 ESCAPE $2—— 三条在 SQL 三值逻辑下对 NULL 列都是 UNKNOWN,WHERE于是丢掉正是 JS 家族会返回的行。与裁决方向分叉,实锤,所以本单不是零改动单。但正文对落点的指认需要修正(rule 6:issue body is a lead, not a spec):正文说的
filter-normalizer.ts:267-275是MONGO_TO_CUBE_OP这张算子改名表,它本身不决定 NULL 行为;真正的发射器是native-sql-strategy.ts的buildFilterClause与objectql-strategy.ts的buildFilterClauseSql。成本清单 §1.C 把 Cube 面记成一个发射点,实际是两个。第三列也值得单独读:引擎那列改前就是对的,但不是因为 analytics 层做对了,而是因为 #5962 让
driver-sql自己加了守卫。也就是说,一条 widget filter 画出哪些行,取决于下游哪个编译器先接住这枚叶子。回显那列最糟——它描述的语句比真正跑的语句更窄,正是 #5333 反过来的失败模式。二、改法:守卫做成树结构,而不是某一个 strategy 里的 SQL 技巧
fieldLeaves现在把叶子发成or(notSet, 比较)。这样这棵树的三个编译器产出同一个谓词,没有一个需要知道这条规则;也因此没有触碰 ⛔ 的objectql-strategy.ts,回显路径是白拿的。这正是本文件里 #5146
$not改写已经采用的同一笔交易(header 里写着「the rewrite lives HERE rather than in native-sql-strategy on purpose」),包括它的代价:引擎路径会守卫两次,而这是幂等的(c IS NULL OR (c IS NULL OR c <> v))。哪些算子被守卫不是新列表——复用既有极性对
nullValueSatisfiesOperator/operatorIsNullTotal,守卫恰好落在它们的allowNull判定上(即nullGuardForFieldSpec对单算子的问法)。这两个 arm 读的是比较数而非算子名,所以:$ne: null仍编译成set(IS NOT NULL),是 total,绝不能被拓宽 —— 硬编码三个算子名字在这里会出错;$nin仍是 TRUE 常量;$eq/$in/$contains/ 序关系族)逐字节不变;{$not: {stage: {$ne: 'won'}}}仍然是「stage 就是 won」,没有被拓宽(极性per算子,这条有专门用例)。三、反向验证:方向如预判为 RED(非计数门反转)
本改动是直白的谓词变更(不是 PR #5046 那种喂给计数的 alias 肢),所以预判「拆掉守卫 → 新钉子转红」,实测吻合:把守卫条件短路掉后 15 条转红,含新增 7 条 #5298 钉子全红,恢复后全绿。
派发词第 4 条预判的「native-sql harness 的 N1 格转绿」不适用:N1 至今未入
FILTER_LOGIC_CASES(见下),所以 harness 里没有 N1 这一格可转;等效证据是上面那张实测表 + 新增钉子。如实记录而非套模板。四、fixture 三分诊(逐条判,不批量改写)
4 个文件 10 条红,逐条判定:
filter-operator-coverage.test.ts三格加d_null行;objectql-daterange.test.ts引擎 filter 形状(它的主题「两个操作数都活下来、后者不覆盖前者」不变且仍可见)。objectql-contains-canonical-operator.test.ts的替身引擎只读filter.stage,而加守卫后条件树顶层已没有stage—— 它于是拿到一个空算子对象,循环体一次不执行,全表通过。这条断言当时是「因为什么都没产生而绿」,正是要整体替换的那一类:改成走树的求值器;算子键断言同样从Object.keys(filter.stage)换成树遍历。filter-normalizer-not-null-safe.test.ts里那行把此分叉记作「real and out of$not的语义在 driver-sql 与 driver-memory / formula 之间分叉:NULL 行的去留相反,$not: {}一个是 TRUE 一个是 FALSE #5146's scope」的注释,现在正是裁决的答案。消费半径按规则的调用面扫,不是按改动包扫:
rest(依赖 service-analytics)全绿 854;cli/verify/qa-dogfood无相关 fixture。五、N1 入表判定(派发词第 3 条):留给 #5903
按 #5298 PM 裁定的「后落地者入表」规则,收尾时实测
git log origin/main与 issue 状态:#5903 仍 open、未 MERGED(git log origin/main --grep=5903空),本 PR 是两者中的先落地者,故FILTER_LOGIC_CASES一行未加,N1 如实留给 #5903。但 spec 表文档里那张实测 blocker 矩阵必须订正:它写着 Cube 面
$ne答['2'],本 PR 之后这句话就成了假话。已把该行摘掉,并写明 Cube 面已对齐、driver-tursoremote 成为$ne与$not两行共同的最后一个 blocker,于是两行随 #5903 一起入表。六、边界与验证
⛔ 全部遵守:未动
like-pattern.ts/strategies/objectql-strategy.ts(#5234 在飞)、未动任何 driver 包、未动 driver-memory/mongodb、未动content/docs/releases/。pnpm --filter @objectstack/service-analytics test→ 59 files / 1105 passed(基线 1097,新增 8 条)pnpm --filter @objectstack/spec test→ 323 files / 8288 passedpnpm --filter @objectstack/rest test→ 62 files / 854 passedtypecheck绿;check-nul-bytes绿;changeset 自带origin/main(ffd51fd7a)rebase 后复跑objectql-contains-canonical-operator.test.ts相邻,后落地者 rebase。Generated by Claude Code