Skip to content

fix(service-analytics)!: 作者的 where 也 NULL-safe —— $not 下推守卫、{$not:{}} 为零行、{} 析取项吸收 $or (#5325) - #5335

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-5325-normalizer-not-null-safe-and-const
Aug 4, 2026
Merged

fix(service-analytics)!: 作者的 where 也 NULL-safe —— $not 下推守卫、{$not:{}} 为零行、{} 析取项吸收 $or (#5325)#5335
os-zhuang merged 1 commit into
mainfrom
claude/issue-5325-normalizer-not-null-safe-and-const

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #5325

filter-normalizer.tsbuildNodeservice-analytics第二份同缺陷拷贝。第一份(read-scope-sql.tscompileNode,RLS 读作用域)由 #5297 / PR #5326 修好并已合入;这一份编译的是 dashboard widget / dataset 作者自己写的 where,两者是各自独立的函数,所以那一单落地后同样三条仍然在。

现场核对(STALE-PREMISE)

issue 正文写于 26e1029f5 + #5297 的分支。worktree 基于 origin/main,开工后又同步到 c7406b0ec(含 #5319#5329)。issue 引用的现场逐条仍然成立:

issue 引用的现场 核对结果
buildNode$not 分支 if (inner) children.push(…) ✅ 原样,{$not:{}} 因此整条消失
$and/$or.filter((n) => n !== null) ✅ 原样,{} 析取项被丢
三处 NOT (${inner}) ✅ 三个编译器各一处,位置与正文一致
NormalizedFilterNode 只有 leaf | and | or | not ✅ 无 FALSE 表示法
FILTER_LOGIC_CASES 不含 null case ✅ 该表 TSDoc 明写「Nothing here exercises null handling」,所以现有门禁看不见这三条

期间落地的 #5329(#5158 拍板 C 第 2 步)只动 engine 入口与四个驱动,不与本单冲突 —— 但暴露了 analytics 是它漏掉的第五道门,已单独记录(见范围外清单)。

实测(sql.js,fixture 与 driver-sql 的 sql-driver-not-null-safe.test.ts 逐行相同)

行 3、4 的 stage 为 NULL,行 3 的 amount 为 NULL,行 4 的 owner 为 NULL。断言打到 NativeSQLStrategy.generateSql / .execute 这一层(#5297 的教训:缺陷常在编译器与调用方的接缝处)。

where 改前实测 改后实测 应为(JS 家族 / #5296 后的 driver-sql)
{ $not: { stage: 'won' } } 2 2,3,4 2,3,4
{ $not: { stage: { $in: ['won'] } } } 2 2,3,4 2,3,4
{ $not: {} } 1,2,3,4 零行 零行 ✅
{ $or: [{ stage: 'won' }, {}] } 1 1,2,3,4 1,2,3,4
{ $not: { $or: [{stage:'won'},{owner:'u1'}] } } 2 2,4 2,4

「改前」一列是把三个源文件 git stash 掉后由同一批用例实测出来的,与 issue 正文的「实测行」逐条吻合。

两条裁定的实现

裁定 1 —— 守卫加在 buildNode(normalizer)

nullSafeNegationOperand 重写 $not操作数(仍是一个 FilterCondition),把守卫下推到叶子,极性逐算子决定;buildNode 再照常编译它,没有新的分支。

因此守卫是结构而不是 SQL 技巧:{$not: {stage: 'won'}} 的操作数变成 {$and: [{stage: {$null: false}}, {stage: 'won'}]},这份结构经 filterNodeToCondition 交给 ObjectQL 引擎后在任何驱动上都成立

双重加守卫已用执行验证幂等,不只是推演:测试里 compileScopedFilterToSql(同包另一个 FilterCondition 消费者,#5297 已是 NULL-safe)充当引擎,对本路径已经守卫过的条件再守卫一次,执行后取到的行与 raw-SQL 路径逐行相同(2,3,4),SQL 里出现两层 IS NOT NULL

裁定 2 —— NormalizedFilterNode 加布尔常量 kind

| { kind: 'const'; value: boolean }。TRUE 保留既有拼法(null = 无约束 = AND 单位元),FALSE 需要一个节点因为它必须活着进 WHERE。四个消费者各自实现:

消费者 FALSE TRUE
native-sql-strategy.compileFilterNode 1 = 0 1 = 1
objectql-strategy.filterNodeToCondition {$not: {}} null(无约束)
objectql-strategy.renderFilterNodeSql(回显) 1 = 0 1 = 1
filter-normalizer.collectFilterLeaves [] []

1 = 0 是仓里两侧已有的拼法(read-scope-sqlFALSE_CLAUSE、driver-sql 的 applyFalseConstant,#5134),不绑值、每种方言都合法;引擎路径的 {$not: {}} 是 driver-sql / formula / driver-memory 参考匹配器早已钉住的零行写法,没有另造第二种

params 绑定错位隐患 —— 三个编译器逐个核查

结论:改前三个都不会发生;但本次新增的「TRUE 吸收 OR」规则会引入它,所以两个 SQL 编译器都按 #5297 的修法处理了。

  • 改前为什么不会:每个返回 null 的分支都在 push 任何值之前就决定了 —— buildFilterClause / buildFilterClauseSql 的空值判断全在函数开头,notinner === null 由归纳法保证内层没 push 过,and/orparts.length === 0 同理。被 .filter(Boolean) 丢掉的兄弟子句一定是零绑定的。
  • 本次为什么会:吸收规则要丢弃的是一个已经编译、已经绑定的兄弟分支({$or: [{stage:'won'}, {}]}'won' 已进 params)。直接返回 null 会让它留在 params 里没有 $n 消费,把后面每个占位符错位到别人的值上。
  • 修法:两个 SQL 编译器进入组合子时记下 params.length,吸收时截断回去;native-sql-strategyjoins 一起还原(丢弃的分支可能注册过 LEFT JOIN,留着会在 to-many 关系上放大行数)。不变量写进 TSDoc:返回 null 的调用必须让 params 与进入时逐字节相同,归纳法对每种节点成立。
  • filterNodeToCondition 不绑值(产出的是 FilterCondition),无此形状 —— 但它同样有「丢分支」的 bug,已按同样的语义修。

单独 pin:binds NOTHING — the discarded branch takes its comparand with itkeeps the rest of the filter aligned when a later predicate follows(断言 owner = 'u1' 绑到 $1 而不是 $2)、以及回显路径的同名两条。

三个编译器各自的改动

  1. native-sql-strategy.compileFilterNode —— 新增 const 分支;not 的空内层从 null 改为 1 = 0(NOT TRUE ≡ FALSE);组合子改为逐子节点循环 + paramBase / joinBase 截断,or 遇到 null 子节点整组返回 null
  2. objectql-strategy.filterNodeToCondition —— 新增 const 分支;not 的空内层从 null 改为 {$not: {}};or.filter(Boolean) 改为「有 null 分支即整组无约束」。
  3. objectql-strategy.renderFilterNodeSql —— 与 1 相同的三条,加 paramBase 截断。回显必须复现执行:一条跑成零行、却回显「没有 WHERE」的语句,会让来查「为什么这张图是空的」的人拿到一条返回全表的 SQL。

与本文件算子集的差异(参照实现 vs 这里)

守卫表按 sql-driver.ts / read-scope-sql.ts 抄,差异全部来自本文件自己的 emitter(「每个 guard 匹配自己的 emitter」是 sql-driver.ts 早就写明的不变量):

算子 本文件的 emitter 与参照实现的差异
$null / $exists 恒等读(=== true / === false),fieldLeaves 就是这么读的 read-scope-sql 按真值读。两者都编译成 null 谓词 → 都是 null-total → guard 'none',不影响结果
$between lower 成 gte + lte 两个正向比较 driver-sql 的表里没有这个算子;两个正向比较取同一个默认极性
$in: [] / $nin: [] 布尔常量(本次新增) read-scope-sqlFALSE_CLAUSE / 1 = 1 对齐 → null-total → guard 'none'
$eq / $nenull 比较数 没有 read-scope-sqlvalue === null 两条 arm 因为本文件把 null 比较数 stringifyForCube'',{$eq: null} 在这里是普通值比较。这本身是缺陷,本单不裁定,已单独记录为 #5332,并刻意不在测试里钉住它的行集
$notContains 跟随 formula(NULL 满足) 与 driver-sql / read-scope-sql 一致,不对已归档的分歧投票

本次改动逼出来的两条(不是顺手扩范围)

非对象的 $not 操作数 / $and·$or 分支元素同样改为拒收:{$not: null} 此前整条消失(等于不筛),而在吸收规则下把它读成 TRUE 会放宽到全表 —— 垃圾输入的两种读法都不正当,read-scope-sql 拒收同样的形状。

测试

新文件 packages/services/service-analytics/src/__tests__/filter-normalizer-not-null-safe.test.ts,48 条,全部在 sql.js 上取行,不只断言 SQL 字符串:

Test Files  44 passed (44)
      Tests  639 passed (639)          # 全包回归,新增 48 条

反向验证(git stash 掉三个源文件后跑新用例):Tests 36 failed | 11 passed (47),五条实测行的失败原文:

× `{$not: {stage: "won"}}` returns the NULL-stage rows — was `2`
    AssertionError: expected [ '2' ] to deeply equal [ '2', '3', '4' ]
× `{$not: {stage: {$in: ["won"]}}}` returns them too — was `2`
    AssertionError: expected [ '2' ] to deeply equal [ '2', '3', '4' ]
× `{$not: {}}` matches NO row — was the whole dataset
    AssertionError: expected [ '1', '2', '3', '4' ] to deeply equal []
× `{$or: [{stage: "won"}, {}]}` matches EVERY row — was `1`
    AssertionError: expected [ '1' ] to deeply equal [ '1', '2', '3', '4' ]
× `{$not: {$or: […]}}` excludes a NULL row whose OTHER branch matches — was `2`
    AssertionError: expected [ '2' ] to deeply equal [ '2', '4' ]

tsc --noEmit:7 条 error,与改动前 git stash 后的基线逐条相同(全部在既有测试文件里,与本次无关)。eslint packages/services/service-analytics/src --no-inline-config:无输出。turbo run build --filter=@objectstack/service-analytics:成功。

本地跳过/CI 才跑的盲区:无。 grep -rn "skipIf\|describe.skip\|it.skip\|test.skip\|todo("service-analytics/src 下零命中,44 个测试文件全部在本地真实执行。消费面静态清点:grep -rn "NormalizedFilterNode" 全仓只命中 native-sql-strategy.tsobjectql-strategy.tsfilter-normalizer.ts 三个文件,即本 PR 覆盖的三个编译器 + collectFilterLeaves,无第四个消费者。

#5297 的关系

同一组缺陷的第二份拷贝,同一套修法(常量拼法、守卫下推到叶子、每个子节点先编译进自己的 bind 缓冲/可回滚),但落点不同:#5297 修的是 RLS 读作用域(越权面),本单修的是作者的 widget filter(数值面)。两份实现现在对「一个 filter 是什么意思」给出同一个答案,filter-normalizer.ts 的 TSDoc 声明的「两条 SQL 产出路径不得漂移」因此成立。

明确不在范围

范围外发现(已单独立 issue,unassigned)


🤖 Generated with Claude Code

https://claude.ai/code/session_01Pbu27iNUfQCHeuS551Rqo7


Generated by Claude Code

…t:{}}` 为零行、`{}` 析取项吸收 `$or` (#5325)

`filter-normalizer.ts` 的 `buildNode` 是这个包里第二份同缺陷拷贝。第一份
(`read-scope-sql.ts` 的 `compileNode`,RLS 读作用域)由 #5297 修好;这一份编译的是
dashboard widget / dataset 作者自己写的 `where`,是各自独立的函数,所以那一单合入后
同样三条仍然在。以 driver-sql `sql-driver-not-null-safe.test.ts` 逐行相同的 fixture
在 sql.js 上实测(行 3、4 的 stage 为 NULL,行 3 的 amount 为 NULL,行 4 的 owner 为 NULL):

| `where`                                        | 改前 | 改后 |
|---|---|---|
| `{ $not: { stage: 'won' } }`                   | `2`  | `2,3,4` |
| `{ $not: { stage: { $in: ['won'] } } }`        | `2`  | `2,3,4` |
| `{ $not: {} }`                                 | 全表 | 零行    |
| `{ $or: [{ stage: 'won' }, {}] }`              | `1`  | 全表    |
| `{ $not: { $or: [{stage:'won'},{owner:'u1'}] } }` | `2` | `2,4` |

守卫加在 normalizer 而不是 `native-sql-strategy`:在这一层它是结构(`$and` 里多一个
`{col: {$null: false}}`),经 `filterNodeToCondition` 交给引擎后在任何驱动上都成立,
包括本身不 NULL-safe 的那些。只加在 raw-SQL 那条路径等于说「分析查询的 `$not` 是什么
意思取决于哪个驱动接住它」,正是 #5146 花一整轮消灭掉的东西。引擎路径因此会双重加
守卫,已实测幂等:`NOT (c IS NOT NULL AND (c IS NOT NULL AND c = v))` 与单层等价,
代价只是一层冗余谓词。

`NormalizedFilterNode` 新增布尔常量 kind。该联合此前只有 `leaf | and | or | not`,
没有 FALSE 的表示法 —— 这正是 `{$not:{}}` 只能编译成「什么都不发」的根本原因。三个
编译器各自实现它:`native-sql-strategy.compileFilterNode`(`1 = 0` / `1 = 1`,与
`read-scope-sql` 和 driver-sql 的 `applyFalseConstant` 同一拼法)、
`objectql-strategy.filterNodeToCondition`(`{$not: {}}`,driver-sql / formula /
driver-memory 参考匹配器早已钉住的零行写法,#5134)、`renderFilterNodeSql`(回显给
浏览器的展示 SQL,它同样必须复现执行)。`collectFilterLeaves` 对常量返回空数组 ——
常量约束的是行,不是列,不参与跨对象信封检查。

params 绑定错位隐患(#5297 的现场教训)逐个核对过:改前三个编译器都不会发生,因为
每个返回 `null` 的分支都在 push 任何值之前就决定了。但本次新增的「TRUE 吸收 OR」
规则会丢弃已经编译(并已绑定)的兄弟分支,于是引入该隐患;两个 SQL 编译器因此都记下
进入组合子时的 `params.length`,吸收时截断回去(`native-sql-strategy` 连 joins 一起
还原),不变量写进 TSDoc:返回 `null` 的调用必须让 `params` 与进入时逐字节相同。
`filterNodeToCondition` 不绑值,无此形状。

一并收进来的两条,都是本次改动逼出来的,不是顺手扩范围:

- 空集合 `{$in: []}` / `{$nin: []}` 此前编译成空子句(= 无约束 = 画全表),现在是布尔
  常量。不这么改,NULL-safe 的 `$not` 会把 `{$not: {a: {$in: []}}}` 从「全部行」变成
  「只有 NULL 行」—— 被丢掉的合取项在否定里会翻转整条的答案。`read-scope-sql` 早就
  按常量处理(`FALSE_CLAUSE` / `1 = 1`)。
- 零个操作符的字段约束 `{a: {}}` 改为拒收,按 #5240 已拍板的口径(driver-sql /
  driver-memory / formula 三个后端在 #5327 已经这么做,analytics 是第四道门)。它此前
  不产出任何 leaf,而「不产出」就是常量 TRUE —— 在新的吸收规则下
  `{$or: [{a: {}}, {b: 2}]}` 会从 `b = 2` 放宽成全表。三个答案里必须选一个,跟随已有
  拍板而不是另造第三个。

非对象的 `$not` 操作数 / `$and` `$or` 分支元素同样改为拒收:此前 `{$not: null}` 整条
消失(等于不筛),而在吸收规则下把它读成 TRUE 会放宽到全表 —— 两种读法都不是垃圾输入
的正当解释,`read-scope-sql` 拒收同样的形状。

`$and: []` / `$or: []` 的空组合子不在本单范围(独立裁定 #5322),仍然 fail-closed 抛错,
并加了用例把它钉在抛错这一侧,免得这次改写顺手把它变成布尔单位元。

反向验证:把三个源文件 stash 掉后,新用例 48 条里 36 条失败,五条实测行逐条复现 issue
正文的「实测行」那一列。

Fixes #5325

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

vercel Bot commented Aug 4, 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 4, 2026 11:39pm

Request Review

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

github-actions Bot commented Aug 4, 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.

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/xl tests tooling

Projects

None yet

2 participants