Skip to content

fix(driver-sql): $not 取反前先把操作数编译成全域谓词,NULL 行不再被静默排除 (#5146) - #5296

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-5146-not-null-safe
Aug 4, 2026
Merged

fix(driver-sql): $not 取反前先把操作数编译成全域谓词,NULL 行不再被静默排除 (#5146)#5296
os-zhuang merged 1 commit into
mainfrom
claude/issue-5146-not-null-safe

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #5146

按维护者在 #5146 上的拍板落地 engine 半边:$not 在 driver-sql 上改为 NULL-safe,
driver-memory / formula(2:1 的多数派)给出同一个答案。

开工前的事实核对(issue 写于今晨,sql-driver.ts 当天被 PR #5243 大改)

worktree 基于 origin/main@88b9b2d5c(含 #5243)。逐条实测,issue 正文引用的三处实现
与实测 SQL 仍然成立:

issue 的说法 现场实测(#5243 之后) 结论
$not → 裸 not (…),NULL 行被排除 select id from deal where not (stage = 'won') → 只返回 stage='lost' 的行 成立
driver-memory / formula 返回 NULL 行 两者对 { $not: { stage: 'won' } } 都返回 stage 为 null / 缺失的行 成立
分叉二 $not: {} → TRUE 已修:where 1 = 0 → 零行 已由 #5243 落地,本单一行不动

归约与 NULL-safe 改写没有互相够不到的问题。 #5243reduceFilterNode 只对
空组合子做三值归约($not of TRUE → FALSE、$not of FALSE → TRUE),判定是结构性
的、与 NULL 无关;带真实谓词的 $not 一律归约成 'clause' 并进入编译分支,正是本次
改写生效的地方。改写发生在归约之后,且保证不改变任何节点的归约结论(每个加了守卫的
合取项仍带字段键,'clause' 还是 'clause'),所以 #5243 钉住的行为全部原样保留 ——
测试里对 { $not: {} }$not of {$or: []}、非 filter 节点操作数的 ADR-0112 拒收
都各留了一条 pin。

实现

改动只有一处编译分支:applyFilterCondition$not 在取反之前,先用新的纯函数
nullSafeNegationOperand 把操作数改写成全域(total)谓词 —— 永远 TRUE 或 FALSE,
不会是 UNKNOWN。

-- 之前
not (`stage` = 'won')
-- 现在
not ((`stage` is not null) and (`stage` = 'won'))

对 issue 给出的扁平形状,这与正文要求的 NOT (…) OR col IS NULL 完全等价。两点是刻意
的、也是我唯一在 issue 文字之外做的判断:

1. 守卫下推到每个叶子,而不是挂在 NOT 旁边。 二者只在扁平形状上等价。$not 内含
$or 时,顶层的 OR col IS NULL 会把 JS 家族排除的行重新放进来 —— 某列为 NULL、
$or 的另一个分支成立的行。下推之后 De Morgan 在两值叶子上成立,$and / $or /
嵌套 $not 全部无需特例(嵌套 $not 由它自己的分支负责,NOT 全域 本身即全域)。
测试里 $not: { $or: [{stage:'won'},{owner:'u1'}] } 就是这一条的 pin。

2. 守卫方向按算子逐个判定,不是一刀切。 { $not: { a: { $ne: 5 } } } 的语义是
「a 就是 5」,两个 JS 后端都把 NULL 行排除;无条件加 OR a IS NULL 会把这些行交回去 ——
正是 #2704 / #5134 那一族的静默放松。所以:

算子 缺值时 JS 家族的答案 编译形状
$eq / 隐式 = / $in / $gt $gte $lt $lte / $between / $contains / $startsWith / $endsWith / $regex false col IS NOT NULL AND (…)
$ne / $nin / $notContains true col IS NULL OR (…)
$null / $exists / $eq: null / $ne: null 本就是全域谓词 不加任何守卫

所有取值都是实测得到的(对 driver-memoryformula 跑了同一张表),不是推断。

行为变化(必读:这是可见集合的变化)

{ $not: { col: v } } 现在返回 col IS NULL 的行,以前不返回。

对 RLS 的意义:权限规则里的 CEL !exprpackages/formula/src/cel-to-filter.ts 降解
{ $not: {…} },所以同一条 read scope 在 SQL 数据源上的可见集合会变大 ——
变大的部分正好是「被否定的那一列没有值」的行。举例:read scope 写
!(stage == 'won'),一条 stage 为空的记录以前在 SQL 后端上看不见、在内存后端 /
写侧 check 上看得见;现在三者一致地看得见

需要留意的部署形态:如果某条 scope 事实上依赖了旧的「NULL 行被顺带排除」来隐藏数据,
本次之后它不再隐藏 —— 那种依赖本来就是不可移植的(换个驱动就失效),正确的写法是把
col IS NOT NULL 的意图显式写出来({ $and: [ { col: { $null: false } }, { $not: {…} } ] })。
changeset 里同样按「可观察行为变更」写明,不是内部重构。

$ne / $nin 反向的一侧没有被放松:{ $not: { a: { $ne: 5 } } } 仍然只返回
a = 5 的行。普通(非否定)比较的 SQL 逐字符不变,测试里有直接的 SQL 文本断言;
所以本次不会让任何非否定谓词失去索引利用,$not 路径上新增的 IS NOT NULL 守卫处在
一个原本就不可 sargable 的 NOT (…) 里面。

测试

三包各一组 pin(覆盖:NULL/缺失行在 $not 下的去留、嵌套 $and/$or/双重否定、
逐算子极性、以及非空行的既有行为不回归)。SQL 侧同时断言生成的 SQL 文本(沿用该目录
既有的 knex toString() 断言先例)。

  • packages/plugins/driver-sql/src/sql-driver-not-null-safe.test.ts(新增,25 条)
  • packages/plugins/driver-memory/src/memory-matcher-not-null-safe.test.ts(新增,17 条)
  • packages/formula/src/matches-filter-not-null-safe.test.ts(新增,17 条)
  • packages/plugins/driver-sql/src/sql-driver-null-operators.test.ts:唯一一条需要
    改期望
    的既有用例 —— 它原本把三值逻辑的结果(['3'])当成正确答案钉住,现在按新裁定
    改为 ['2','3','4'],并在注释里写明这就是行为变更本身。

两个 JS 后端彼此不一致的角落($notContains 对 null 值、$exists 对「键存在但值为
null」、driver-memory缺键 $nin)按实测分别钉住,没有假装一致 —— 这几处
不在 #5146 的裁定范围内,已另行记录(见下)。SQL 侧对 $notContains 跟随 formula,
理由:那正是本驱动今天已有的答案,不借这次改写顺手裁定一个没人裁定过的语义。

验证(均在共享锁下、NODE_OPTIONS=--max-old-space-size=4096):

  • turbo test --filter=@objectstack/driver-sql --filter=@objectstack/driver-memory --filter=@objectstack/formula --filter=@objectstack/driver-sqlite-wasm → 全绿(driver-sql 754 passed / 44 skipped;driver-sqlite-wasm 继承 SqlDriver,用真实 sql.js 引擎跑同一张 FILTER_LOGIC_CASES)
  • turbo typecheck(同三包)→ 全绿
  • eslint(改动的 5 个文件)→ 零告警
  • 连带面回归:objectql(1868)、plugin-security(731)、service-storage(283)、plugin-audit(108)→ 全绿

范围说明


Generated by Claude Code

SQL 是三值逻辑:`NULL = 'won'` 是 UNKNOWN,`NOT UNKNOWN` 仍是 UNKNOWN,而 `WHERE`
只保留 TRUE。于是 `applyFilterCondition` 发出的裸 `not (stage = 'won')` 把「该列没有
值」的行整批丢掉,而 `driver-memory` 的 `match()` 与 `formula` 的
`matchesFilterCondition` 用普通两值 JS 求值(`undefined !== 'won'` → 行匹配),把同
一批行**全部返回**。一个 spec 声明的算子,答案取决于跑它的是哪个驱动 —— 而权限规则
里的 CEL `!expr` 经 `cel-to-filter.ts` 正是降解成 `{ $not: {…} }`,所以同一条 read
scope 在不同后端准入的行集不同。#5146 判定以 JS 家族的答案为准(2:1 多数派),本次
把 SQL 侧对齐。

`$not` 的操作数在取反前经 `nullSafeNegationOperand` 改写,使每个叶子都编译成**全域
(total)**谓词 —— 永远 TRUE 或 FALSE,不会 UNKNOWN:

    -- 之前:not (`stage` = 'won')
    -- 现在:not ((`stage` is not null) and (`stage` = 'won'))

对 issue 给出的扁平形状,这与 `NOT (…) OR col IS NULL` 等价。守卫下推到**叶子**而非
挂在 `NOT` 旁边,是为了在操作数嵌套时仍然正确:`$not` 内含 `$or` 时,顶层的
`OR col IS NULL` 会把 JS 家族排除的行(某列为 NULL、但另一分支成立)重新放进来。

守卫方向按算子逐个判定,不是一刀切:`$ne` / `$nin` / `$notContains` 走
`col IS NULL OR (…)`,`$eq` / `$in` / `$gt` / `$contains` 一族走
`col IS NOT NULL AND (…)`,`$null` / `$exists` / `$eq: null` / `$ne: null` 本就是全域
谓词、一个字节不加。无条件加 `OR col IS NULL` 会把 `{$not: {a: {$ne: 5}}}`(语义是
「a 就是 5」)静默放松成也返回 NULL 行 —— 正是 #2704 / #5134 那一族缺陷。

只有 `$not` 路径被改写:普通比较的 SQL 逐字符不变,没有非否定谓词因此失去索引。
#5134 / PR #5243 的布尔单位元与 ADR-0112 形状拒收全部保持;`{ field: {} }`(#5240)
刻意不在此裁定,编译结果与之前完全一致。

`driver-memory` 与 `formula` 无需改动 —— 三家各补一组 pin 测试,把「值缺失行在
`$not` 下的去留」钉在一起,并把两个 JS 后端彼此不一致的角落(`$notContains`、
`$exists`、缺键 `$nin`)按实测钉住而不是假装一致。

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 4:06pm

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling size/l and removed 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/driver-sql.

9 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/drivers.mdx (via @objectstack/driver-sql)
  • content/docs/getting-started/glossary.mdx (via @objectstack/driver-sql)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/driver-sql)
  • content/docs/plugins/anatomy.mdx (via @objectstack/driver-sql)
  • content/docs/plugins/packages.mdx (via @objectstack/driver-sql)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/driver-sql)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/driver-sql)
  • content/docs/protocol/objectql/query-syntax.mdx (via @objectstack/driver-sql)
  • content/docs/releases/implementation-status.mdx (via @objectstack/driver-sql)

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.

Copy link
Copy Markdown
Contributor Author

范围外发现(已按 Prime Directive #10 单独立 issue,均未在本 PR 修)

实测过程中扫到三处邻接分叉,都不属于 #5146 的裁定范围,本 PR 一行未动:


Generated by Claude Code

@os-zhuang
os-zhuang marked this pull request as ready for review August 4, 2026 17:34
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 4, 2026
Merged via the queue into main with commit 5aae790 Aug 4, 2026
24 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-5146-not-null-safe branch August 4, 2026 17:45
os-zhuang pushed a commit that referenced this pull request Aug 4, 2026
同步 #5289(theme token 退役)、#5293(HttpServerConfig 退役)、#5296 等。

冲突 1 处:`packages/spec/src/migrations/registry.ts` —— step17 `rationale`
的纯追加碰撞(#5021#5015 各追加一段)。按「双方事实都保留」解决:
main 已落地的 theme 段落原样保留,本单段落改写开头衔接语跟在其后。

生成物一律按 os-regen 四步处理:先 `git checkout origin/main --` 取回
main 侧全部 os-regen 路径,再整体重新生成(gen:schema / gen:api-surface /
gen:spec-changes / gen:upgrade-guide / gen:docs / gen:skill-refs /
gen:skill-docs / gen:strictness-ledger),零文本合并、零手改数字。

merge-base gate 现在一次性为三个兄弟单的 5 处整 def 删除背书:
system/HttpServerConfig(9 行)、ui/Animation(2)、ui/EmbedConfig(7)、
ui/NotificationAction(3)、ui/ZIndex(8),全部走 #4650 路径 3。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ErbEDVAg1No9gdg1pgDAGB
os-zhuang pushed a commit that referenced this pull request Aug 4, 2026
…aseline (#5235)

`git merge origin/main` brought #5289 / #5293 / #5296 / #5300, three of which
change the authorable surface (theme token tombstones, the HttpServerConfig
removal, the NotificationAction / EmbedConfig removals). The anchor is written
from the merge base, so it moves with it: baseRev 88b9b2d26e1029, 8045 → 8016
keys, regenerated by `gen:schema` rather than text-merged — this file is on the
os-regen list precisely because a textual merge of it means nothing.

Verified line-for-line against `git show 26e1029:packages/spec/authorable-surface.json`,
and the siblings' entries survived in it: `ui/Theme:animation [RETIRED]` /
`ui/Theme:zIndex [RETIRED]` are carried, HttpServerConfig / NotificationAction /
EmbedConfig are gone from every generated witness at once.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

$not 的语义在 driver-sql 与 driver-memory / formula 之间分叉:NULL 行的去留相反,$not: {} 一个是 TRUE 一个是 FALSE

2 participants