Repository navigation
$not 的语义在 driver-sql 与 driver-memory / formula 之间分叉:NULL 行的去留相反,$not: {} 一个是 TRUE 一个是 FALSE #5146
Description
Activity
分诊(PM 循环第 1 轮,会话
session_01Pbu27iNUfQCHeuS551Rqo7):两个分叉拆开走:分叉二(
$not: {}→ driver-sql 应显式 FALSE)不等拍板 —— 布尔代数与 cloud#1073 已定的「子过滤器编译为空 = 布尔单位元」纪律共同决定答案,且它与 #5134(applyFilterCondition丢弃空$and/$or子分组)是同函数同家族缺陷。已在 #5134 留注并入其范围,一次派发一并修,避免两轮改同一个函数。分叉一(NULL 行的去留)挂
needs-user-decision上交维护者 —— 这是 RLS 规则在不同后端给出不同可见集合的语义定调,按两轴:- 项目长远合理性:
$not是 spec 声明的算子,declared = enforced 要求所有驱动同一答案。NULL-safe 改写(NOT (…) OR col IS NULL)让 2:1 的多数派语义成为唯一语义;反方向(内存驱动改三值逻辑)则把 SQL 的历史包袱扩散到全部实现,且 CEL!expr降解路径上的作者意图会系统性落空。代价:SQL 侧改写可能影响索引利用,需在实现时测。 - 防 AI 写元数据犯错:权限规则作者(人或 AI)写
!(stage == 'won')时几乎必然预期「stage 为空的行属于『不是 won』」;三值逻辑的静默排除正是「看着正常、可见集合错了」的形状。NULL-safe 是让写出来的规则在所有后端都按直觉兑现的方向。
建议:NULL-safe(正文倾向一致);拍板后连同跨驱动一致性 pin 测试一并立实现单。在此之前 cloud remote 跟随 SQL 家族的现状(cloud#1076 已记录)可维持。
Generated by Claude Code
- 项目长远合理性:
维护者拍板:分叉一取 NULL-safe
$not在所有驱动上采用 NULL-safe 语义 ——NOT (…) OR col IS NULL,即「该列没有值」视为「不满足被否定的条件」,与 driver-memory / formula(2:1 的多数派)一致。理由已记录:$not是 spec 声明的算子,declared = enforced 要求单一答案;权限规则作者写!(stage == 'won')时不会预期「stage 为空的行被排除」,三值逻辑的静默排除正是「看着正常、可见集合错了」的形状。needs-user-decision摘除。分叉二(
$not: {}→ FALSE)已随 #5134 / PR #5243 落地,本单不再重复。认领与范围切分
认领(PM 循环,会话
session_01Pbu27iNUfQCHeuS551Rqo7),engine 半边:- 分支:
claude/issue-5146-not-null-safe - worktree:
/home/user/objectstack-issue-5146 - 域:
domain:engine - 文件面:
packages/plugins/driver-sql/src/sql-driver.ts(applyFilterCondition的$not分支)+ driver-sql / driver-memory / formula 三包各自的 pin 测试 - ⛔ 不触碰
packages/spec/**
spec 半边另计(本车道硬禁区,转 spec 车道):
$not的 NULL 语义要写进FilterConditionSchema的契约 TSDoc(声明才算数),跨驱动一致性 case 进packages/spec/src/data/filter-logic-conformance.ts的FILTER_LOGIC_CASES—— 建议与 #5239 同批落(那一单正在扩同一张表,且要求与 driver-mongodb 同时落地)。engine 半边先落不会造成 declared ≠ enforced:它是让实现符合「同一算子同一答案」的既有要求,spec 半边补的是把该答案写明。下游连带(cloud 车道,不在本仓修):cloud#1076 的
RemoteTransport.buildWhereSQL当前跟随 SQL 家族发裸NOT (…)。本单落地后 framework 侧改为 NULL-safe,cloud remote 会成为新的少数派,需要同向跟进 —— 已记在此,由 cloud 车道排期。
Generated by Claude Code
- 分支:
- added 6 commits that reference this issue
on Aug 6, 2026 3 remaining items
- added 3 commits that reference this issue
on Aug 17, 2026 - added a commit that references this issue
on Aug 30, 2026 - added a commit that references this issue
on Sep 1, 2026 - added 5 commits that reference this issue
on Oct 7, 2026
修 cloud#1076(给 Turso remote 传输补
$not)时扫到,不在该 PR 范围内。cloud 那条按指示把远程模式钉到 SQL 家族(driver-sql)的行为上,但两族本身对同一条$not给出不同答案,这条记录该分叉。事实
packages/spec的FilterConditionSchema与$and/$or并列声明$not: FilterConditionSchema.optional(),三处实现:packages/plugins/driver-sql/src/sql-driver.tsapplyFilterCondition([driver-sql] 未知查询 operator(is/is_null)静默透传返整表,应报错;缺 $null 处理 #2704 加的分支)→ KnexwhereNot(cb)/orWhereNot(cb);packages/plugins/driver-memory/src/memory-matcher.tsmatch()→if (filter.$not) { if (match(record, filter.$not)) return false; };packages/formula/src/matches-filter.tsevalNode()→if (evalNode(record, val)) return false;。三者对良构、且 schema 明确允许的两种形状答案不一致。
分叉一:被比较列为 NULL 的行
以
{ $not: { stage: 'won' } }、某行stage IS NULL为例:select * from \deal` where (not (`stage` = 'won'))。SQL 三值逻辑下NULL = 'won'是 UNKNOWN,NOT UNKNOWN` 仍是 UNKNOWN → 该行不返回。match(record, { stage: 'won' })里undefined !== 'won'→ false → 取反 true → 该行返回。也就是同一条 RLS read scope(CEL
!expr经cel-to-filter.ts降解成{ $not: {...} }),落在 SQL 驱动上会把「该列为空」的行整批排除,落在内存驱动 / 公式求值上则会把它们全部包含进来。对权限规则来说,这是「同一条规则在不同后端给出不同可见集合」。分叉二:
$not: {}FilterConditionSchema是z.record(z.string(), z.unknown()).and(z.object({…optional})),{}合法,所以$not: {}是被声明允许的形状。knex('deal').where(qb => qb.whereNot(sub => {}))→select * from \deal``,即 TRUE / 全表。match(record, {})返回 true → 取反 → FALSE / 零行。matches-filter:evalNode(record, {})空对象循环不执行返回 true → 取反 → FALSE / 零行。按布尔代数
NOT TRUE ≡ FALSE,driver-memory / formula 是对的;driver-sql 的 TRUE 是 Knex「空分组不产出 SQL」的副作用,不像是有意的决定——而且它的方向是放宽(本该零行,实际全表),正是 #2704 / cloud#1073 那一族「静默 filter-bypass」。已经据此落地的东西(供交叉参考)
cloud#1076 的
RemoteTransport.buildWhereSQL:NOT (…),不做 NULL-safe 改写)——远程模式与本地 SqlDriver 是同一族,先保证这两者一致;$not: {}取 FALSE(1 = 0)——与 driver-memory / formula 一致,也与 cloud#1073 定下的「子过滤器编译为空 = 真值单位元」纪律自洽。所以现在的状态是:远程 Turso 在 NULL 上跟 SQL 家族,在
$not: {}上跟内存家族——因为这两族在这两点上各自只有一边是站得住的。这不是一个能长期维持的形状,需要在 framework 侧定一次。需要决定的
$not是否应当 NULL-safe(NOT (…) OR col IS NULL,即把「该列没有值」视为「不满足被否定的条件」,与 JS 家族一致),还是保持 SQL 三值逻辑、并把这条差异写进 spec 文档要求内存驱动改成三值?倾向前者——权限规则的作者写!(stage == 'won')时几乎不会预期「stage 为空的行被排除」,而且 SQL 家族是这里的少数派(2 : 1)。$not: {}:driver-sql 应改为显式 FALSE(在子分组编译为空时发方言的 FALSE 常量),与另外两家对齐。这个方向没有争议,只是要有人动 Knex 那段。两条都属于「declared = enforced」:
$not是 spec 声明的算子,声明了就该在所有驱动上给同一个答案。定完后建议在packages/formula或驱动共享的一致性测试里加一组跨驱动 pin,别只改实现。关联
cloud#1076(远程传输补
$not)、cloud#1073 / cloud#1074(布尔单位元)、#2704(driver-sql 加$not分支)、packages/formula/src/cel-to-filter.ts(CEL!expr的降解点)。