Skip to content

Commit 1792384

Browse files
os-zhuangclaude
andauthored
fix(service-analytics)!: 作者的 where 也 NULL-safe —— $not 下推守卫、{$not:{}} 为零行、{} 析取项吸收 $or (#5325) (#5335)
`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 Claude-Session: https://claude.ai/code/session_01Pbu27iNUfQCHeuS551Rqo7 Co-authored-by: Claude <noreply@anthropic.com>
1 parent c7406b0 commit 1792384

5 files changed

Lines changed: 1117 additions & 33 deletions

File tree

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
---
2+
"@objectstack/service-analytics": minor
3+
---
4+
5+
fix(service-analytics)!: 分析查询的 `where` —— `$not` 变 NULL-safe、`{$not:{}}` 变零行、`$or``{}` 析取项不再被丢 (#5325)
6+
7+
`filter-normalizer.ts``buildNode` 是这个包里**第二份**同缺陷拷贝:第一份
8+
(`read-scope-sql.ts``compileNode`,RLS 读作用域)已由 #5297 修好,而这一份编译的是
9+
**作者自己写的 `where`** —— dashboard widget / dataset 的筛选器。两者是各自独立的函数,
10+
所以那一单合入后这三条仍然在。以 `driver-sql` 同一份 fixture 实测(4 行,行 3、4 的
11+
`stage` 为 NULL,行 3 的 `amount` 为 NULL,行 4 的 `owner` 为 NULL):
12+
13+
| widget 的 `where` | 改前取到的行 | 改后(= driver-memory / formula / #5296 后的 driver-sql) |
14+
|---|---|---|
15+
| `{ $not: { stage: 'won' } }` | `2` | `2,3,4` |
16+
| `{ $not: { stage: { $in: ['won'] } } }` | `2` | `2,3,4` |
17+
| `{ $not: {} }` | **全表** | **零行** |
18+
| `{ $or: [{ stage: 'won' }, {}] }` | `1` | 全表 |
19+
| `{ $not: { $or: [{stage:'won'},{owner:'u1'}] } }` | `2` | `2,4` |
20+
21+
**这是可观察的行为变更,不是内部重构 —— 已有的图表数值会变:**
22+
23+
- **`{$not: {}}` 的 widget 此前画的是整个数据集,现在是零行。** `buildNode({})` 返回
24+
`null`(= 无约束 = TRUE),`$not` 分支的 `if (inner)` 因此为假,整条 `$not` 消失,
25+
WHERE 一个字都不发 —— 一条意思是「什么都不显示」的筛选器显示了全部。`NOT TRUE ≡ FALSE`,
26+
现在它编译成 `1 = 0`
27+
- **`$not` 下 NULL 行的去留变了,所以图上的数字会变。** SQL 是三值逻辑而 `WHERE` 只保留
28+
TRUE,裸 `NOT (stage = ?)``stage` 为 NULL 的行全部丢掉;`driver-memory``formula`
29+
和(#5296 之后的)`driver-sql` 都把它们算进来。同一条 widget filter,在分析查询和普通
30+
`find()` 上给出不同的行集,取决于哪个后端接住它。#5146 已拍板 JS 家族的答案为准,本次
31+
按同一口径把守卫**下推到叶子**(`{col: {$null: false}}` / `{$or: [{col:{$null:true}}, …]}`,
32+
极性逐算子决定)。**受影响的图表数值会上升**(负向筛选现在包含空值行)。
33+
- **`$or` 里的 `{}` 析取项不再被丢。** TRUE 是 AND 的单位元但**吸收** OR,所以
34+
`{$or: [{stage:'won'}, {}]}` 整条为 TRUE;此前它被 `.filter(n => n !== null)` 丢掉,
35+
查询被静默**收紧**成剩余分支。
36+
- **空集合是布尔常量,不再是「没有谓词」。** `{stage: {$in: []}}` 此前编译成空子句
37+
→ 无约束 → 画全表,现在是零行(`1 = 0`);`{$nin: []}` 不排除任何行。
38+
- **两处新的响亮拒收(此前静默放宽):** `$not` / `$or` / `$and`**非对象**操作数
39+
(`{$not: null}` 曾整条消失 → 等于不筛),以及**零个操作符的字段约束** `{a: {}}`
40+
—— 后者按 #5240 的拍板拒收,与 driver-sql / driver-memory / formula 一致;不这么做的话,
41+
「TRUE 吸收 OR」会把 `{$or: [{a: {}}, {b: 2}]}``b = 2` 放宽成全表。
42+
43+
实现落在 normalizer 而不是某个 strategy:守卫在这一层是**结构**(多一个 `$null` 合取项),
44+
`filterNodeToCondition` 交给 ObjectQL 引擎后在**任何驱动上都成立**,包括本身不 NULL-safe
45+
的那些;只加在 raw-SQL 那条路径,等于说「分析查询的 `$not` 是什么意思取决于哪个驱动接住它」。
46+
代价是引擎路径会**双重加守卫**,已实测幂等(`NOT (c IS NOT NULL AND (c IS NOT NULL AND c = v))`
47+
与单层等价),只是 SQL 多一层冗余谓词。
48+
49+
`NormalizedFilterNode` 因此新增布尔常量 kind —— 该联合此前只有 `leaf | and | or | not`,
50+
没有 FALSE 的表示法,这正是 `{$not:{}}` 只能编译成「什么都不发」的根本原因。三个编译器
51+
(`native-sql-strategy.compileFilterNode``objectql-strategy.filterNodeToCondition`
52+
回显给浏览器的 `renderFilterNodeSql`)各自实现它;引擎路径用的是 `{$not: {}}`,即
53+
driver-sql / formula / driver-memory 参考匹配器早已钉住的零行写法(#5134),没有另造第二种。
54+
55+
`$and: []` / `$or: []` 的空组合子**不在本次范围**,仍然 fail-closed 抛错(独立裁定见 #5322),
56+
并已加用例钉在抛错这一侧。

0 commit comments

Comments
 (0)