Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .changeset/formula-exists-means-has-a-value.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
---
"@objectstack/formula": patch
---

fix(formula): `matchesFilterCondition` 的 `$exists` 改读「有值」,与 `$null` 成严格互补

**行为变更,影响 RLS 写侧 `check` 的判定。** `{ x: { $exists: true } }` 对
`{ x: null }` 以前答 `true`(键存在),现在答 `false`(没有值)。

`matchesFilterCondition` 是 RLS `check` 子句(insert/update 的 post-image)的求值器 ——
写路径上没有查询可以下推,只能逐记录判定。它此前把 `$exists` 读成「键是否存在」
(`actual !== undefined`),而 `driver-sql` 一直把同一个算子编译成 `IS NOT NULL`。
于是同一条规则里的 `$exists`,写侧放行的记录读侧看不见。

2026-08-06 裁定取「有值」,理由是另一种读法在最要紧的地方**无法兑现**:SQL 里列
**就是** schema,一行不可能「缺一个键」,所以 `driver-sql` 除了 `IS NOT NULL` 别无
可编译的东西。字段的存在性是 **schema** 的属性,不是**记录**的属性;spec 若声明
「键是否存在」,就是在承诺两个后端永远交付不了的语义。因此 `driver-sql` 的发射器
一字未动,移动的是本求值器。

对齐之后 `$exists` 与 `$null` 在每个后端上都是严格互补:
`$exists: true` ≡ `$null: false`,`$exists: false` ≡ `$null: true`。
「键缺失」与「值为 null」在这里是同一个事实 —— 这也正是 `getPath` 对两者本来就
返回同一个 `undefined` 的原因。

`$ne` / `$nin` / `$notContains` / `$null` 四个算子本来就是本次裁定的目标语义,
一字未改。
41 changes: 41 additions & 0 deletions .changeset/read-scope-null-safe-negative-operators.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
---
"@objectstack/service-analytics": patch
---

fix(service-analytics): read scope 的 `$ne` / `$nin` / `$notContains` 改为 NULL-safe,与写侧 `check` 对齐

**这是一次安全相关的行为变更,涉及分析查询的可见行集合。**
read scope 里的 `{ stage: { $ne: 'won' } }` 以前**不返回** `stage IS NULL` 的行,
现在**返回**它们。`$nin` / `$notContains` 同理。

`read-scope-sql.ts` 是 RLS / 租户 read scope 降解成 SQL 的唯一通道(ADR-0021 D-C)。
它此前把这三个算子编译成裸的 `col <> ?` / `col NOT IN (…)` / `col NOT LIKE ?`,
而 SQL 是三值逻辑:被比较列为 NULL 时谓词是 UNKNOWN,`WHERE` 只保留 TRUE,于是
「该列没有值」的行被整批丢掉。

**为什么必须与 `driver-sql` 同一个 PR 落地,而不是排到下一批。** 同一条 RLS 规则被
写一次、在**两侧**求值:读路径由本文件降解成 SQL,写路径由 `formula` 的
`matchesFilterCondition` 逐记录求值。`formula` 一直用两值 JS(`undefined !== 'won'`
为真)返回这些行。只对齐其中一侧,得到的不是「更小的修复」,而正是那个缺陷本身 ——
一条权限规则准入两个不同的行集,写侧允许的记录读侧看不见。

```sql
-- 之前
"t"."stage" <> ?
"t"."stage" NOT IN (?)
"t"."stage" NOT LIKE ? ESCAPE ?
-- 现在
("t"."stage" IS NULL OR "t"."stage" <> ?)
("t"."stage" IS NULL OR "t"."stage" NOT IN (?))
("t"."stage" IS NULL OR "t"."stage" NOT LIKE ? ESCAPE ?)
```

括号不是排版:`compileField` 用裸 ` AND ` 连接同一字段的多个算子,不加括号的
`col IS NULL OR …` 会比那个 AND 结合得更松,从而**静默放宽整条 scope**。

与 `driver-sql` 一样统一用 OR 展开而非方言等价物(`NOT LIKE` 没有对应形式;SQLite
写法依赖本仓不锁定的引擎版本;实测执行计划相同)。正向比较逐字符不变,
`$ne: null` 仍是 `IS NOT NULL`(空值谓词,不是比较)。

`$not` 路径的逐叶守卫(#5146 / #5326)按原样保留,两条路径读同一张极性表。
`filter-normalizer`(Cube 面)不在本次范围内,归本裁决第二批。
34 changes: 34 additions & 0 deletions .changeset/spec-filter-logic-conformance-null-column.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
---
"@objectstack/spec": patch
---

feat(spec): `FILTER_LOGIC_ROWS` 新增可空列 `d`,`FILTER_LOGIC_CASES` 收入 `$null` 用例

跨后端 filter conformance 表此前**刻意不含**任何空值处理,自陈理由是「三值 SQL 引擎
与两值 JS 匹配器无法被同一个答案约束」。#5146(`$not`)与 #5298
(`$ne`/`$nin`/`$notContains`)两次裁定取消了这个前提:「该列没有值」现在有唯一的
跨后端答案,于是它和其他语义一样属于这张标准表。

**给第三方驱动作者的迁移要点。** `FilterLogicRow` 新增 `d: string | null`
(第 1-2 行有值,第 3-4 行为 NULL)。用这张表校验自建后端时:

- DDL / schema 声明里必须把 `d` 声明为**可空**。`NOT NULL` 列,或把 `null` 替换成
`''` 的 seed,会让新用例因为**错误的原因**变绿 —— 这两条用例要测的恰恰就是
「一行没有值」时发生什么,fixture 里没有这样的行就什么都没测到。
- 新增两条用例:`{d: {$null: true}}` → `['3','4']`,`{d: {$null: false}}` → `['1','2']`。
互补的两条一起入表,是为了把 `$null` 钉成对整表的**划分**,而不是其中一半 ——
这样一个 `NOT NULL` 的 fixture 列会响亮地失败,而不是安静地全绿。

单独开一列而不是把 `a` / `b` 挖空:`(a, b)` 的 2x2 真值表是「一对谓词被错误 OR 起来
必然多出 id」的依据,在它上面开洞会为了空值用例削弱每一条组合子用例。

同批订正了模块文档的两处事实错误:独立实现的计数由「五个」改为**七个**(补上
`driver-turso` 的 `RemoteTransport.buildWhereSQL` 与 `service-analytics` 的
`filter-normalizer` —— 两者都是手写发射器,#5298 实测它们对空值族的答案与其余五个
不同),以及原先「#5146 一族 every surface answers the same way」的说法(它漏掉了
turso remote,该分叉由 #5903 跟踪)。

`$ne` / `$not` 两条对应用例**尚未入表**:实测 `driver-turso` remote(#5903)与
`filter-normalizer`(本裁决第二批)还答不出来,而一条已知会红的用例不能强制任何裁决,
只会把别的车道的未完成工作变成这张表的失败。它们随各自的修复 PR 入表 —— 模块文档的
「RULED but not yet enrolled」小节里写好了实测矩阵与两个 blocker。
70 changes: 70 additions & 0 deletions .changeset/sql-driver-null-safe-negative-operators.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
---
"@objectstack/driver-sql": patch
---

fix(driver-sql): `$ne` / `$nin` / `$notContains` 改为 NULL-safe;`$exists` 的非布尔比较值改为拒收

**这是一处可观察的查询行为变更,且直接关系到 RLS 的可见集合。**
`{ stage: { $ne: 'won' } }` 以前**不返回** `stage IS NULL` 的行,现在**返回**它们。
`$nin` 与 `$notContains` 同理。

### 变更一:三个否定算子在 `$not` 之外也 NULL-safe(#5298)

#5146 已经把 `$not` 判定为 NULL-safe(PR #5296),但**只改了 `$not` 内部**;算子自身
携带否定的三个 —— `$ne` / `$nin` / `$notContains` —— 逐字符未变。于是留下一个使用者
可见的裂缝:`{ $not: { stage: 'won' } }` 三家一致,`{ stage: { $ne: 'won' } }` 仍然
分叉。

成因与 #5146 同源:SQL 是三值逻辑,`NULL <> 'won'` 是 UNKNOWN 而不是 TRUE,`WHERE`
只保留 TRUE;`driver-memory` 与 `formula` 的 `matchesFilterCondition` 用两值 JS 求值
(`undefined !== 'won'` 直接为真),把这些行**都返回**。2026-08-06 裁定取「包含无值行」
方向(与 #5146 同向),本次把 SQL 侧对齐过去。

```sql
-- 之前
`stage` <> 'won'
`stage` not in ('won')
`stage` NOT LIKE '%won%' ESCAPE '\'
-- 现在
(`stage` is null or `stage` <> 'won')
(`stage` is null or `stage` not in ('won'))
(`stage` is null or `stage` NOT LIKE '%won%' ESCAPE '\')
```

**统一用 OR 展开,不走方言等价物**(`IS DISTINCT FROM` / `IS NOT` / `<=>`),三条理由:
`NOT LIKE` 根本没有对应形式,走方言就必然要维护两种形状;SQLite 的写法依赖本仓并不
锁定的引擎版本(sql.js 与 libSQL 各自演进);实测 `EXPLAIN QUERY PLAN` 两种写法计划
完全相同 —— `<>` / `NOT IN` / `NOT LIKE` 改动前**本来就是全表扫描**,没有索引可失去,
也没有索引可赢回。

**正向比较一个字节都没动。** `{ a: 1 }` 仍然是 `a = 1`,`$in` 仍然是 `in (…)`,
`$gt` / `$contains` 一族同理,所以绝大多数普通查询的 SQL 形状不变。
`$ne: null` 也不变 —— 它是空值**谓词**(`IS NOT NULL`)而不是比较,「有任何值」对
一个没有值的行本来就是假。

**`$not` 路径不受影响。** `nullSafeNegationOperand` 的逐叶守卫按原样保留:它必须能在
操作数任意嵌套时通过 De Morgan 组合,这与叶子发射器自身是否全域是两个独立的正确性
来源,把它们耦合起来会让其中一个的回退静默破坏另一个。

### 变更二:`$exists` 的非布尔比较值改为拒收(#5369,套用 #5347 裁定 A)

`FieldOperatorsSchema` 声明 `$exists: z.boolean()`,而从 `where` 到驱动之间没有任何
环节按它校验,所以非布尔值真的会到达发射器。到达之后各后端分叉方向相反:本驱动的
`opValue === false` 恒等判断把「除 false 以外的一切」读成 `IS NOT NULL`,`=== true`
的写法则把「除 true 以外的一切」读成 `IS NULL`。注意字符串 `"false"` 是**真值**,
所以它落在与作者本意**相反**的一侧 —— JSON 往返或 AI 生成的 scope 很容易产出它。

现在与 `$null` 的闸门并排,在 `reduceFilterKey` 的校验遍历里拒收,`INVALID_FILTER` /
400,信封与措辞同款。`{ $exists: true }` / `{ $exists: false }` 行为一字未变。

**发射器与极性表刻意不动。** 闸门落地后只有两个布尔值能到达它们,`opValue === false`
与 `value === false` 已经是穷尽的二选一。#5369 正文建议的「收紧为 `value === true`」
方向写反了:极性表回答的是「NULL 列是否**满足**该算子」,而 NULL 列恰恰在调用方要求
`$exists: false` 时满足它 —— `$null: true` 与 `$exists: false` 是同一个问题,两条
分支正确地互为镜像,而不是互为副本。

### 相关

`driver-memory` / `driver-mongodb` 的对应半边按 #5499 冻结,本次零改动、既有一致性
断言全绿;`driver-turso` 的 remote transport 是独立编译器,归 #5903;
`service-analytics` 的 `filter-normalizer`(Cube 面)归本裁决第二批。
Original file line numberDiff line numberDiff line change
Expand Up@@ -108,7 +108,7 @@ const CONFORMANCE_CUBE: Cube = {
sql: TABLE,
measures: { count: { name: 'count', label: 'Rows', type: 'count', sql: 'id' } },
dimensions: Object.fromEntries(
(['id', 'a', 'b', 'c', 'owner', 'status', 'parent_object', 'parent_id'] as const).map((f) => [
(['id', 'a', 'b', 'c', 'd', 'owner', 'status', 'parent_object', 'parent_id'] as const).map((f) => [
f,
{ name: f, label: f, type: 'string' as const, sql: f },
]),
Expand All@@ -132,6 +132,10 @@ describe('[#5324] InMemoryDriver.find — filter logic conformance (the LIVE que
a: { type: 'text', name: 'a' },
b: { type: 'text', name: 'b' },
c: { type: 'text', name: 'c' },
// [#5298] Nullable by construction — rows 3-4 seed `d: null`. This driver
// stores what it is given, so no `nullable` flag exists to set; what
// matters is that the seed keeps the null instead of substituting ''.
d: { type: 'text', name: 'd' },
owner: { type: 'text', name: 'owner' },
status: { type: 'text', name: 'status' },
parent_object: { type: 'text', name: 'parent_object' },
Expand DownExpand Up@@ -188,7 +192,7 @@ describe('[#5345] MemoryAnalyticsService — the same table, through the THIRD f
await driver.connect();
await driver.syncSchema(TABLE, {
fields: Object.fromEntries(
(['id', 'a', 'b', 'c', 'owner', 'status', 'parent_object', 'parent_id'] as const).map((f) => [
(['id', 'a', 'b', 'c', 'd', 'owner', 'status', 'parent_object', 'parent_id'] as const).map((f) => [
f,
{ type: 'text', name: f },
]),
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -49,6 +49,8 @@ describe.skipIf(!sharedMongod)('driver-mongodb — filter logic conformance', ()
a: { type: 'string' },
b: { type: 'string' },
c: { type: 'string' },
// [#5298] The NULL-bearing column; rows 3-4 seed it as `null`.
d: { type: 'string' },
owner: { type: 'string' },
status: { type: 'string' },
parent_object: { type: 'string' },
Expand Down
61 changes: 46 additions & 15 deletions packages/drivers/driver-sql/src/sql-driver-not-null-safe.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,14 +29,25 @@
* row 3) and lets the direction of the guard follow each operator's own answer
* for a missing value (`$ne` / `$nin` are NOT widened).
*
* Nothing outside a `$not` changes: an ordinary comparison compiles to the same
* SQL it always did, so no plain predicate loses an index to this.
* A POSITIVE comparison is unchanged: it compiles to the same SQL it always did,
* so no plain predicate loses an index to this.
*
* # [#5298] The sequel, and why this file now pins BOTH directions
*
* #5146 deliberately stopped at `$not` and this suite pinned that boundary:
* two assertions asserted that a bare `$ne` still dropped the NULL rows, so a
* rewrite leaking past its scope would go red. The 2026-08-06 ruling on #5298
* took the same direction for the operators that carry their own negation
* (`$ne` / `$nin` / `$notContains`), so those two assertions FLIPPED — see the
* block at the bottom of this file, which says so at the point of the flip.
*
* These expectations are duplicated by hand in the two JS backends'
* `*-not-null-safe.test.ts`. They belong in `FILTER_LOGIC_CASES`
* (`@objectstack/spec/data`) so every backend is held to them at once — that
* table is being extended under #5239 / the spec lane of #5146, which lands
* with driver-mongodb; until then these three files are the pin.
* `*-not-null-safe.test.ts`. `FILTER_LOGIC_CASES` (`@objectstack/spec/data`)
* grew a nullable column in #5298 and now carries the `$null` partition for
* every backend at once; the `$ne` and `$not` rows join it once `driver-turso`'s
* remote transport answers them the same way (#5903 — it is an independent
* filter compiler that inherits none of this). Until then these files are the
* pin for the SQL family.
*/

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
Expand DownExpand Up@@ -220,23 +231,43 @@ describe('[#5146] SqlDriver compiles $not NULL-safely', () => {

// ── Nothing outside `$not` moves ───────────────────────────────────────────

describe('only the $not path is rewritten', () => {
it('a plain comparison compiles to exactly the SQL it always did', () => {
describe('POSITIVE comparisons are still compiled exactly as before', () => {
it('a positive comparison compiles to exactly the SQL it always did', () => {
expect(sqlFor({ stage: 'won' })).toBe("select `id` from `deal` where `stage` = 'won'");
expect(sqlFor({ stage: { $ne: 'won' } })).toBe("select `id` from `deal` where `stage` <> 'won'");
expect(sqlFor({ amount: { $gt: 15 } })).toBe('select `id` from `deal` where `amount` > 15');
expect(sqlFor({ stage: { $in: ['won'] } })).toBe("select `id` from `deal` where `stage` in ('won')");
});

it('a plain comparison returns the rows it always did', async () => {
expect(await ids({ stage: 'won' })).toEqual(['1']);
// Still SQL semantics outside a negation: `<> 'won'` drops the NULL rows.
// That divergence from the JS backends is real but out of #5146's scope —
// it is filed separately rather than smuggled in here.
expect(await ids({ stage: { $ne: 'won' } })).toEqual(['2']);
/**
* FLIPPED PIN (#5298). Both assertions in this block used to pin the
* OPPOSITE answer, on purpose: when #5146 was implemented the non-negated
* `$ne` was explicitly out of its scope, so the suite pinned "`<> 'won'`
* still drops the NULL rows" to prove the rewrite had not leaked past the
* `$not` it was scoped to. The 2026-08-06 ruling on #5298 took the other
* direction for `$ne` / `$nin` / `$notContains`, so the pin flips with it —
* this is the reverse-verification anchor doing its job, not a regression.
*
* The `$not` half of the suite above is untouched and still green, which is
* what says the two rulings compose rather than one overwriting the other.
*/
it('$ne / $nin / $notContains are NULL-safe outside a $not too (#5298)', async () => {
expect(sqlFor({ stage: { $ne: 'won' } })).toBe(
"select `id` from `deal` where (`stage` is null or `stage` <> 'won')",
);
expect(await ids({ stage: { $ne: 'won' } })).toEqual(['2', '3', '4']);
expect(await ids({ stage: { $nin: ['won'] } })).toEqual(['2', '3', '4']);
expect(await ids({ stage: { $notContains: 'wo' } })).toEqual(['2', '3', '4']);
expect(await ids({})).toEqual(ALL);
});

it('a positive comparison returns the rows it always did', async () => {
expect(await ids({ stage: 'won' })).toEqual(['1']);
expect(await ids({ stage: { $in: ['won'] } })).toEqual(['1']);
// `$ne: null` is a null PREDICATE, not a comparison — "has any value" is
// still false for a row that has none. Unchanged by #5298.
expect(await ids({ stage: { $ne: null } })).toEqual(['1', '2']);
});

it('a $or / $and of plain comparisons is unchanged', () => {
expect(sqlFor({ $or: [{ stage: 'won' }, { owner: 'u2' }] })).toBe(
"select `id` from `deal` where ((`stage` = 'won') or (`owner` = 'u2'))",
Expand Down
3 changes: 3 additions & 0 deletions packages/drivers/driver-sql/src/sql-driver-or-filter.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -86,6 +86,9 @@ function declareFilterLogicSweep(cell: DialectCell): void {
t.string('a');
t.string('b');
t.string('c');
// [#5298] Nullable (knex's default) — rows 3-4 of the fixture have no `d`,
// and the null cases measure nothing against a NOT NULL column.
t.string('d');
t.string('owner');
t.string('status');
t.string('parent_object');
Expand Down
Loading
Loading