fix(driver-sql): $not 取反前先把操作数编译成全域谓词,NULL 行不再被静默排除 (#5146) - #5296
Merged
Conversation
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
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 1 package(s): 9 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
This was referenced Aug 4, 2026
read-scope-sql 的
$not 有两处与 SQL 驱动分叉:非 NULL-safe(#5146 后的最后一个异类),且 { $not: {} } 编译成空 → RLS 整表放行
#5297
Closed
Contributor
Author
范围外发现(已按 Prime Directive #10 单独立 issue,均未在本 PR 修)实测过程中扫到三处邻接分叉,都不属于 #5146 的裁定范围,本 PR 一行未动:
Generated by Claude Code |
os-zhuang
marked this pull request as ready for review
August 4, 2026 17:34
This was referenced Aug 4, 2026
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
This was referenced Aug 4, 2026
Merged
Open
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 88b9b2d → 26e1029, 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
This was referenced Aug 4, 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 #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 仍然成立:
$not→ 裸not (…),NULL 行被排除select id from deal where not (stage = 'won')→ 只返回stage='lost'的行driver-memory/formula返回 NULL 行{ $not: { stage: 'won' } }都返回 stage 为 null / 缺失的行$not: {}→ TRUEwhere 1 = 0→ 零行归约与 NULL-safe 改写没有互相够不到的问题。 #5243 的
reduceFilterNode只对空组合子做三值归约(
$notof TRUE → FALSE、$notof FALSE → TRUE),判定是结构性的、与 NULL 无关;带真实谓词的
$not一律归约成'clause'并进入编译分支,正是本次改写生效的地方。改写发生在归约之后,且保证不改变任何节点的归约结论(每个加了守卫的
合取项仍带字段键,
'clause'还是'clause'),所以 #5243 钉住的行为全部原样保留 ——测试里对
{ $not: {} }、$notof{$or: []}、非 filter 节点操作数的 ADR-0112 拒收都各留了一条 pin。
实现
改动只有一处编译分支:
applyFilterCondition的$not在取反之前,先用新的纯函数nullSafeNegationOperand把操作数改写成全域(total)谓词 —— 永远 TRUE 或 FALSE,不会是 UNKNOWN。
对 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 那一族的静默放松。所以:
$eq/ 隐式=/$in/$gt$gte$lt$lte/$between/$contains/$startsWith/$endsWith/$regexcol IS NOT NULL AND (…)$ne/$nin/$notContainscol IS NULL OR (…)$null/$exists/$eq: null/$ne: null所有取值都是实测得到的(对
driver-memory与formula跑了同一张表),不是推断。行为变化(必读:这是可见集合的变化)
{ $not: { col: v } }现在返回col IS NULL的行,以前不返回。对 RLS 的意义:权限规则里的 CEL
!expr经packages/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)→ 全绿范围说明
$not: {}→ FALSE)不在本单 —— 已随 SqlDriver.applyFilterCondition 丢弃编译成空的 $and/$or 子过滤器,而不是套用布尔单位元 —— 与同仓 matchesFilterCondition / driver-memory 相反 #5134 / PR fix(driver-sql): 空$and/$or/$not按布尔单位元编译,$or: []不再返回全表 (#5134) #5243 落地,本 PR 未改动相关代码,只补了 pin。
FilterConditionSchema的契约 TSDoc 与FILTER_LOGIC_CASES的跨驱动 conformance case 归 spec 车道(建议随 [spec] FILTER_LOGIC_CASES 补空组合子的布尔单位元四条 —— 需与 driver-mongodb 的单位元归约同时落地 #5239 与 driver-mongodb 同批落)。本 PR
未触碰
packages/spec/。engine 半边先落不会造成 declared ≠ enforced:它让实现满足「同一算子同一答案」这条既有要求,spec 半边补的是把该答案写明。
packages/plugins/driver-mongodb/未触碰([spec] FILTER_LOGIC_CASES 补空组合子的布尔单位元四条 —— 需与 driver-mongodb 的单位元归约同时落地 #5239 要求与 spec 那张表同时落地)。RemoteTransport.buildWhereSQL当前跟随 SQL 家族发裸
NOT (…)。本 PR 合入后 framework 侧变为 NULL-safe,cloud remote会成为新的少数派,需要同向跟进。
Generated by Claude Code