fix(service-analytics)!: 作者的 where 也 NULL-safe —— $not 下推守卫、{$not:{}} 为零行、{} 析取项吸收 $or (#5325) - #5335
Merged
os-zhuang merged 1 commit intoAug 4, 2026
Conversation
…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
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 1 package(s): 8 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
os-zhuang
marked this pull request as ready for review
August 4, 2026 23:44
os-zhuang
deleted the
claude/issue-5325-normalizer-not-null-safe-and-const
branch
August 4, 2026 23:49
This was referenced Aug 5, 2026
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 #5325
filter-normalizer.ts的buildNode是service-analytics里第二份同缺陷拷贝。第一份(read-scope-sql.ts的compileNode,RLS 读作用域)由 #5297 / PR #5326 修好并已合入;这一份编译的是 dashboard widget / dataset 作者自己写的where,两者是各自独立的函数,所以那一单落地后同样三条仍然在。现场核对(STALE-PREMISE)
issue 正文写于
26e1029f5+ #5297 的分支。worktree 基于origin/main,开工后又同步到c7406b0ec(含 #5319、#5329)。issue 引用的现场逐条仍然成立:buildNode的$not分支if (inner) children.push(…){$not:{}}因此整条消失$and/$or的.filter((n) => n !== null){}析取项被丢NOT (${inner})NormalizedFilterNode只有leaf | and | or | notFILTER_LOGIC_CASES不含 null case期间落地的 #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{ $not: { stage: 'won' } }22,3,42,3,4✅{ $not: { stage: { $in: ['won'] } } }22,3,42,3,4✅{ $not: {} }1,2,3,4{ $or: [{ stage: 'won' }, {}] }11,2,3,41,2,3,4✅{ $not: { $or: [{stage:'won'},{owner:'u1'}] } }22,42,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。四个消费者各自实现:native-sql-strategy.compileFilterNode1 = 01 = 1objectql-strategy.filterNodeToCondition{$not: {}}null(无约束)objectql-strategy.renderFilterNodeSql(回显)1 = 01 = 1filter-normalizer.collectFilterLeaves[][]1 = 0是仓里两侧已有的拼法(read-scope-sql的FALSE_CLAUSE、driver-sql 的applyFalseConstant,#5134),不绑值、每种方言都合法;引擎路径的{$not: {}}是 driver-sql / formula / driver-memory 参考匹配器早已钉住的零行写法,没有另造第二种。params 绑定错位隐患 —— 三个编译器逐个核查
结论:改前三个都不会发生;但本次新增的「TRUE 吸收 OR」规则会引入它,所以两个 SQL 编译器都按 #5297 的修法处理了。
null的分支都在 push 任何值之前就决定了 ——buildFilterClause/buildFilterClauseSql的空值判断全在函数开头,not的inner === null由归纳法保证内层没 push 过,and/or的parts.length === 0同理。被.filter(Boolean)丢掉的兄弟子句一定是零绑定的。{$or: [{stage:'won'}, {}]}里'won'已进params)。直接返回null会让它留在params里没有$n消费,把后面每个占位符错位到别人的值上。params.length,吸收时截断回去;native-sql-strategy连joins一起还原(丢弃的分支可能注册过 LEFT JOIN,留着会在 to-many 关系上放大行数)。不变量写进 TSDoc:返回null的调用必须让params与进入时逐字节相同,归纳法对每种节点成立。filterNodeToCondition不绑值(产出的是FilterCondition),无此形状 —— 但它同样有「丢分支」的 bug,已按同样的语义修。单独 pin:
binds NOTHING — the discarded branch takes its comparand with it、keeps the rest of the filter aligned when a later predicate follows(断言owner = 'u1'绑到$1而不是$2)、以及回显路径的同名两条。三个编译器各自的改动
native-sql-strategy.compileFilterNode—— 新增const分支;not的空内层从null改为1 = 0(NOT TRUE ≡ FALSE);组合子改为逐子节点循环 +paramBase/joinBase截断,or遇到null子节点整组返回null。objectql-strategy.filterNodeToCondition—— 新增const分支;not的空内层从null改为{$not: {}};or的.filter(Boolean)改为「有null分支即整组无约束」。objectql-strategy.renderFilterNodeSql—— 与 1 相同的三条,加paramBase截断。回显必须复现执行:一条跑成零行、却回显「没有 WHERE」的语句,会让来查「为什么这张图是空的」的人拿到一条返回全表的 SQL。与本文件算子集的差异(参照实现 vs 这里)
守卫表按
sql-driver.ts/read-scope-sql.ts抄,差异全部来自本文件自己的 emitter(「每个 guard 匹配自己的 emitter」是sql-driver.ts早就写明的不变量):$null/$exists=== true/=== false),fieldLeaves就是这么读的read-scope-sql按真值读。两者都编译成 null 谓词 → 都是 null-total → guard'none',不影响结果$betweengte+lte两个正向比较$in: []/$nin: []read-scope-sql的FALSE_CLAUSE/1 = 1对齐 → null-total → guard'none'$eq/$ne的null比较数read-scope-sql的value === null两条 armnull比较数stringifyForCube成'',{$eq: null}在这里是普通值比较。这本身是缺陷,本单不裁定,已单独记录为 #5332,并刻意不在测试里钉住它的行集$notContainsformula(NULL 满足)本次改动逼出来的两条(不是顺手扩范围)
{$in: []}/{$nin: []}改为布尔常量。 不改的话 NULL-safe 的$not会回归:{$not: {a: {$in: []}}}从「全部行」变成「只有 NULL 行」,因为被丢掉的合取项在否定里会翻转整条的答案(read-scope-sql的对应用例钉的是「全部行」)。顺带修掉一条独立的静默放宽:{stage: {$in: []}}此前画全表。{a: {}}改为拒收,按{ field: {} }(零个操作符的字段约束)在同仓有三个答案:driver-sql 组合子内 TRUE、顶层抛 INVALID_FILTER、formula/driver-memory FALSE #5240 已拍板、fix(driver-sql,driver-memory,formula)!:{ field: {} }四个后端一律拒收 —— 零个操作符的字段约束不再有三个答案 (#5240) #5327 已在 driver-sql / driver-memory / formula 落地的口径。它此前不产出任何 leaf,而「不产出」就是常量 TRUE —— 在新的吸收规则下{$or: [{a: {}}, {b: 2}]}会从b = 2放宽成全表。三个可能答案里必须选一个,跟随已有拍板而不是另造第三个。非对象的
$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 字符串:NativeSQLStrategy.execute);{$not:{}}/{}析取项 / 常量的组合与嵌套,含generateSql层的 WHERE 与params断言;$ne/$nin不被放宽这条单独钉住);filterNodeToCondition的产出经compileScopedFilterToSql执行取行,与 raw-SQL 路径逐条比对;双重加守卫幂等单独钉住;renderFilterNodeSql回显 SQL 的常量、吸收、守卫、占位符对齐;$and: []/$or: []在$not内外都仍然抛non-empty array;{ field: {} }(零个操作符的字段约束)在同仓有三个答案:driver-sql 组合子内 TRUE、顶层抛 INVALID_FILTER、formula/driver-memory FALSE #5240 拒收、非对象操作数拒收、未知算子仍然抛。反向验证(
git stash掉三个源文件后跑新用例):Tests 36 failed | 11 passed (47),五条实测行的失败原文: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.ts、objectql-strategy.ts、filter-normalizer.ts三个文件,即本 PR 覆盖的三个编译器 +collectFilterLeaves,无第四个消费者。与 #5297 的关系
同一组缺陷的第二份拷贝,同一套修法(常量拼法、守卫下推到叶子、每个子节点先编译进自己的 bind 缓冲/可回滚),但落点不同:#5297 修的是 RLS 读作用域(越权面),本单修的是作者的 widget filter(数值面)。两份实现现在对「一个 filter 是什么意思」给出同一个答案,
filter-normalizer.ts的 TSDoc 声明的「两条 SQL 产出路径不得漂移」因此成立。明确不在范围
$and: []/$or: []空组合子)—— 独立裁定,本单只加用例把它钉在抛错这一侧。packages/spec/**——FILTER_LOGIC_CASES补 null case 归 spec 车道([spec] FILTER_LOGIC_CASES 补空组合子的布尔单位元四条 —— 需与 driver-mongodb 的单位元归约同时落地 #5239)。时序建议:本 PR 早于 [spec] FILTER_LOGIC_CASES 补空组合子的布尔单位元四条 —— 需与 driver-mongodb 的单位元归约同时落地 #5239 落地,否则native-sql-filter-logic-conformance.test.ts会因本文件未修而单独变红。read-scope-sql.ts、packages/plugins/**、packages/formula/**—— 一行未改,只读作参照。content/docs/releases/**—— 一行未改;变更记录只走.changeset/*.md。范围外发现(已单独立 issue,unassigned)
{field: {$eq: null}}/{$ne: null}编译成col = ''/col != '',与同文件里{field: null}的IS NULL自相矛盾 #5332 ——{field: {$eq: null}}/{$ne: null}在本文件编译成col = ''/col != ''(stringifyForCube(null)→''),与同文件{field: null}的IS NULL自相矛盾,也与read-scope-sql/ driver-sql 分叉。本 PR 的守卫表因此刻意不含那两条 arm,并注明由该单裁定;测试也刻意不钉它的行集。/analytics/sql回显的 SQL 丢掉$startsWith/$endsWith谓词:回显比实际执行的查询更宽,无法复现结果 #5333 ——/analytics/sql回显的 SQL 丢掉$startsWith/$endsWith谓词(SCALAR_SQL_OPS缺这两个,落到return null),回显比实际执行更宽,无法复现结果。where为数组(FilterArray 糖)时被normalizeAnalyticsFilterTree静默丢弃,图表画全表 #5334 ——normalizeAnalyticsFilterTree对数组where(FilterArray 糖)return null静默丢弃 → 画全表。fix(objectql,driver-sql,driver-memory,driver-mongodb)!:FilterArray在 engine 门下沉,四驱动数组方言删除 (#5158 拍板 C 第 2 步) #5329([engine] driver-sql 编译 spec 未声明的「数组 where 方言」—— 与 Turso remote 的拒收分叉,需一次定调(接纳进 spec 或响亮弃用) #5158 拍板 C 第 2 步)刚把 FilterArray 在 engine 六入口下沉、四驱动改拒收,analytics 是漏掉的第五道门。🤖 Generated with Claude Code
https://claude.ai/code/session_01Pbu27iNUfQCHeuS551Rqo7
Generated by Claude Code