Skip to content

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

Description

@os-zhuang

修 cloud#1076(给 Turso remote 传输补 $not)时扫到,不在该 PR 范围内。cloud 那条按指示把远程模式钉到 SQL 家族(driver-sql)的行为上,但两族本身对同一条 $not 给出不同答案,这条记录该分叉。

事实

packages/spec 的 FilterConditionSchema 与 $and / $or 并列声明 $not: FilterConditionSchema.optional(),三处实现:

三者对良构、且 schema 明确允许的两种形状答案不一致。

分叉一:被比较列为 NULL 的行

以 { $not: { stage: 'won' } }、某行 stage IS NULL 为例:

  • driver-sql:Knex 实测 select * from \deal` where (not (`stage` = 'won'))。SQL 三值逻辑下 NULL = 'won' 是 UNKNOWN,NOT UNKNOWN` 仍是 UNKNOWN → 该行不返回。
  • driver-memory / formula: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: {} 是被声明允许的形状。

  • driver-sql:Knex 对空分组直接丢弃整条子句。实测 knex('deal').where(qb => qb.whereNot(sub => {})) → select * from \deal``,即 TRUE / 全表。
  • driver-memory:match(record, {}) 返回 true → 取反 → FALSE / 零行。
  • formula 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:

  • NULL 语义跟随 driver-sql(发 NOT (…),不做 NULL-safe 改写)——远程模式与本地 SqlDriver 是同一族,先保证这两者一致;
  • $not: {} 取 FALSE(1 = 0)——与 driver-memory / formula 一致,也与 cloud#1073 定下的「子过滤器编译为空 = 真值单位元」纪律自洽。

所以现在的状态是:远程 Turso 在 NULL 上跟 SQL 家族,在 $not: {} 上跟内存家族——因为这两族在这两点上各自只有一边是站得住的。这不是一个能长期维持的形状,需要在 framework 侧定一次。

需要决定的

  1. NULL:$not 是否应当 NULL-safe(NOT (…) OR col IS NULL,即把「该列没有值」视为「不满足被否定的条件」,与 JS 家族一致),还是保持 SQL 三值逻辑、并把这条差异写进 spec 文档要求内存驱动改成三值?倾向前者——权限规则的作者写 !(stage == 'won') 时几乎不会预期「stage 为空的行被排除」,而且 SQL 家族是这里的少数派(2 : 1)。
  2. $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 的降解点)。

Activity

  1. os-zhuang commented on Aug 4, 2026

    @os-zhuang
    ContributorAuthor

    分诊(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

  2. self-assigned this
    on Aug 4, 2026
  3. os-zhuang commented on Aug 4, 2026

    @os-zhuang
    ContributorAuthor

    维护者拍板:分叉一取 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

  4. 3 remaining items

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions