Skip to content

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

Closed

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. added
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    on Aug 4, 2026
  2. 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

  3. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    on Aug 4, 2026
  4. self-assigned this
    on Aug 4, 2026
  5. 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

  6. 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

bugSomething isn't workingpm:dispatchedDispatched to a dev agent by /pm-dispatch

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions