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.

@os-zhuang
os-zhuang marked this pull request as ready for review August 4, 2026 23:44
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 4, 2026
Merged via the queue into main with commit 1792384 Aug 4, 2026
24 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-5325-normalizer-not-null-safe-and-const branch August 4, 2026 23:49
os-zhuang pushed a commit that referenced this pull request Aug 5, 2026
本 PR 的 spec 半边是契约文档,机械合并会把 base 时代的论断带上 main;
逐条对当前 origin/main 实测后校订:

- FilterConditionSchema 的 NULL-safe $not 合规段:read-scope-sql 已由
  #5326 对齐(#5297 关闭)、filter-normalizer 已由 #5335 对齐(#5325
  关闭),七个面全部一致 —— 「尚未合规、指向 #5297」改写为已闭合的事实。
- 「Deliberately NOT declared here」:空组合子单位元由「两立场对峙、
  上交 #5322」改为「#5322 已拍板取单位元,实施在 #5365(排在本 PR 之后
  合入);main 上两个 analytics 编译器今天仍拒收,故本 PR 仍不在此声明,
  声明随 #5365 翻正」;{ field: {} } 由「无后端设闸」改为「#5327 已闸
  四家,driver-mongodb 是唯一还在作答的后端(#5376)」。
- filter-logic-conformance.ts 族 2/3 状态行同步重测:族 2 的后端阻塞
  已清零,唯余 fixture 工作;族 3 的四家闸门已落,阻塞改为表形扩展 +
  mongodb(#5376)。族 1 段落一字未动 —— 由 #5365 在其同步轮删除,
  已约定分工。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ErbEDVAg1No9gdg1pgDAGB
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