Skip to content

Commit 49ca5e1

Browse files
Claudeclaude
andcommitted
fix(driver-sql): 空 $and/$or/$not 按布尔单位元编译,$or: [] 不再返回全表 (#5134)
applyFilterCondition 把每个组合子都编译成一个 knex 分组回调,而 knex 对 「一个子句都没加进去的分组」不产出 SQL。于是「这个组是空的」和「这个组 已被满足」编译成了同一条查询。丢弃子句不等于套用单位元,而两个单位元的 方向相反:空 $and 是 TRUE、空 $or 是 FALSE、$not 空子分组是 FALSE。旧代码 对三者一律给全表;$and 恰好正确只是因为「丢掉」在 AND 侧碰巧等价于 TRUE。 放松的两格是安全相关的:$or: [] 最常见的来源正是「本该有条件、但循环一个 析取项都没填进去」的 RLS read scope,当成全表意味着本该看不到任何行的人 拿到了整表。formula 的 matchesFilterCondition 与 driver-memory 三条本来 就都对,driver-sql 是唯一的例外。 配套的形状拒收是同一处修复不可分的一半:套用单位元的前提是「编译成空」 只剩一个成因。$or: [null] / ['x'] / [[…]] / [new Date()] 以前同样无痕消失, 不先拦掉就上单位元会把它们从「被静默忽略」升级成「匹配所有行」,比原 bug 更坏。$and/$or 的元素与 $not 的操作数现在必须是 plain object 的 filter 节点,否则按 ADR-0112 响亮拒收(INVALID_FILTER / 400,报错指明位置)。 原型判定是关键的一半:Date/RegExp/class 实例都满足 typeof === 'object' 却枚举为空,被接受就会被读成 TRUE。 判定是结构性的(编译前先把整棵树归约成 true/false/clause 三值),而不是 「编译完再问 knex 有没有产出」—— 原缺陷本身就是后者那种观察,而观察分不清 「因为本来就是空」和「因为有东西没编译出来」。结构判定没有这个盲区,并且 保证编译器打开的每个分组都至少收到一条子句。 非空的 $and/$or/$not 编译方式完全未变。{ field: {} } 刻意不裁决(归约把带 字段键的节点一律判为 clause),该分叉另记 #5240;一致性表扩条另记 #5239。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pbu27iNUfQCHeuS551Rqo7
1 parent d9971d3 commit 49ca5e1

3 files changed

Lines changed: 518 additions & 3 deletions

File tree

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
"@objectstack/driver-sql": patch
3+
---
4+
5+
fix(driver-sql): 空 `$and`/`$or`/`$not` 按布尔单位元编译 —— `$or: []` 不再返回全表
6+
7+
**这是一处查询行为变更,且直接关系到 RLS。** `{ $or: [] }` 以前返回**整张表**,
8+
现在返回**零行**。如果你的代码依赖了旧行为,它依赖的是一个 filter 旁路。
9+
10+
`applyFilterCondition` 把每个组合子都编译成一个 knex 分组回调,而 knex 对「一个子句
11+
都没加进去的分组」不产出任何 SQL。于是「这个组是空的」和「这个组已被满足」编译成了
12+
同一条查询。**丢弃子句不等于套用单位元**,而两个单位元的方向是相反的:
13+
14+
| 写法 | 布尔代数 | 旧编译 | 错的方向 |
15+
|---|---|---|---|
16+
| `{ $and: [] }` | TRUE → 全部行 | 全表 | 碰巧正确 |
17+
| `{ $or: [] }` | FALSE → **零行** | 全表 | **静默放松** |
18+
| `{ $or: [{a}, {}] }` | `{}` 是 TRUE 析取项 → 全部行 | `(a = ?)` | 静默收紧 |
19+
| `{ $not: {} }` | `NOT TRUE ≡ FALSE`**零行** | 全表 | **静默放松** |
20+
21+
`$and: []` 恰好正确的理由不是代码理解了单位元,而是「丢掉」在 AND 侧碰巧等价于
22+
TRUE —— 同一段代码在 OR 与 NOT 侧就必然错。放松的那两格是安全相关的:`$or: []`
23+
最常见的来源正是「本该有条件、但循环一个析取项都没填进去」的 RLS read scope,
24+
把它当成全表意味着**本该看不到任何行的人拿到了整表**
25+
26+
同仓另外两个后端(`formula``matchesFilterCondition``driver-memory`)三条
27+
本来就都是对的,`driver-sql` 是唯一的例外;现在四个答案统一。
28+
29+
**配套的形状拒收(否则修复会变得更糟)。** 套用单位元的前提是「编译成空」只剩一个
30+
成因。在此之前 `$or: [null]``$or: ['x']``$or: [[…]]``$or: [new Date()]`
31+
同样会无痕消失;不先拦掉它们就上单位元,会把它们从「被静默忽略」**升级成「匹配所有
32+
行」**,比原 bug 更坏。因此 `$and`/`$or` 的元素与 `$not` 的操作数现在必须是
33+
**plain object** 的 filter 节点,否则按 ADR-0112 响亮拒收
34+
(`INVALID_FILTER` / 400,报错指明出错位置,如 `filter.$or[1]`)。原型检查是关键
35+
的一半:`Date`/`RegExp`/class 实例都满足 `typeof x === 'object'` 却枚举为空,
36+
若被接受就会被读成 TRUE。同理 `$and: 'x'` 这类非数组操作数也不再被当成一个名为
37+
`$and` 的字段列。
38+
39+
判定是**结构性**的(编译前先归约整棵树),而不是「编译完再问 knex 有没有产出」——
40+
原缺陷本身就是后者那种观察,而观察分不清「因为本来就是空」和「因为有东西没编译
41+
出来」。结构判定没有这个盲区,并且保证编译器打开的每个分组都至少收到一条子句,
42+
knex 再没有机会静默丢弃一个组。
43+
44+
非空的 `$and`/`$or`/`$not` 编译方式完全未变。
Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#5134] An empty `$and` / `$or` / `$not` group compiles to its BOOLEAN
5+
* IDENTITY, never to "nothing".
6+
*
7+
* `applyFilterCondition` used to build every combinator as a Knex group callback
8+
* and let the callback add nothing when the group was empty. Knex emits no SQL
9+
* for a group that received no clause, so "the group is empty" and "the group is
10+
* satisfied" became the same query. Dropping a clause is not the same as
11+
* applying an identity, and the two identities point in OPPOSITE directions:
12+
*
13+
* | filter | boolean algebra | old compile | direction of the error |
14+
* |-----------------|------------------------|-------------|------------------------|
15+
* | `{$and: []}` | TRUE → every row | every row | accidentally right |
16+
* | `{$or: []}` | FALSE → **zero rows** | every row | silently WIDENED |
17+
* | `{$or:[{a},{}]}`| `{}` is a TRUE disjunct → every row | `(a = ?)` | silently narrowed |
18+
* | `{$not: {}}` | NOT TRUE ≡ FALSE → zero rows | every row | silently WIDENED |
19+
*
20+
* `$and: []` was right for the wrong reason — "drop it" happens to equal TRUE on
21+
* the AND side, so the same line is necessarily wrong on the OR side. The
22+
* widening direction is the security-relevant one: `$or: []` is what an RLS read
23+
* scope compiles to when the loop that should have filled its disjuncts produced
24+
* nothing, and answering that with the WHOLE TABLE hands a user every row the
25+
* scope existed to hide. `matchesFilterCondition` (formula) and `driver-memory`
26+
* already answer all three correctly; this driver was the outlier.
27+
*
28+
* # Why the shape rejection below is part of the same fix
29+
*
30+
* Identity reduction is only safe once "this group compiled to empty" has
31+
* EXACTLY ONE cause. Before it, `$or: [null]`, `$or: ['x']`, `$or: [[…]]` and
32+
* `$or: [new Date()]` also vanished without a trace. Applying the identity
33+
* without rejecting those first would have PROMOTED every one of them from
34+
* "silently ignored" to "matches all rows" — strictly worse than the bug. So
35+
* non-node elements are refused loudly (ADR-0112 `INVALID_FILTER`, the envelope
36+
* every sibling filter refusal in this driver speaks) BEFORE any identity is
37+
* applied. Same discipline as cloud#1073, which fixed the identical defect in
38+
* Turso's `RemoteTransport.buildWhereSQL`.
39+
*
40+
* The conformance table (`FILTER_LOGIC_CASES` in `@objectstack/spec/data`) is
41+
* where these cases ultimately belong so all four backends are held to them at
42+
* once — filed as #5239, because driver-mongodb needs its own identity reduction
43+
* to pass them (it passes an empty `$and`/`$or` straight to MongoDB, which
44+
* ERRORS) and the two must land together.
45+
*
46+
* One neighbouring shape is deliberately NOT ruled on here: `{ field: {} }`, a
47+
* field constrained by zero operators, which this driver compiles to no SQL
48+
* inside a combinator while `matchesFilter` and `driver-memory` both answer
49+
* FALSE and this driver's own top-level path refuses it. Three answers to one
50+
* filter — filed as #5240. The reduction classifies any node carrying a field
51+
* key as `'clause'`, so that shape compiles exactly as it did before this fix.
52+
*/
53+
54+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
55+
import { SqlDriver } from '../src/index.js';
56+
import type { FilterCondition } from '@objectstack/spec/data';
57+
58+
const FIXTURE = [
59+
{ id: '1', stage: 'won', owner: 'u1', amount: 10 },
60+
{ id: '2', stage: 'lost', owner: 'u2', amount: 20 },
61+
{ id: '3', stage: 'open', owner: 'u1', amount: 30 },
62+
];
63+
64+
const ALL = ['1', '2', '3'];
65+
66+
/** The shape `mapDataError` / `sendError` read off a thrown driver error. */
67+
interface WireBearingError extends Error {
68+
code?: string;
69+
status?: number;
70+
}
71+
72+
describe('[#5134] SqlDriver compiles empty $and/$or/$not to their boolean identity', () => {
73+
let driver: SqlDriver;
74+
let knex: any;
75+
76+
beforeEach(async () => {
77+
driver = new SqlDriver({
78+
client: 'better-sqlite3',
79+
connection: { filename: ':memory:' },
80+
useNullAsDefault: true,
81+
});
82+
knex = (driver as any).knex;
83+
await knex.schema.createTable('deal', (t: any) => {
84+
t.string('id').primary();
85+
t.string('stage');
86+
t.string('owner');
87+
t.float('amount');
88+
});
89+
await knex('deal').insert(FIXTURE);
90+
});
91+
92+
afterEach(async () => {
93+
await knex.destroy();
94+
});
95+
96+
// The cast is deliberate: several `where`s below are shapes the schema permits
97+
// but no sane author writes, fed in to prove the compiler answers them the way
98+
// boolean algebra says rather than by accident of what Knex renders.
99+
const ids = async (where: unknown): Promise<string[]> => {
100+
const rows = await driver.find('deal', {
101+
object: 'deal',
102+
fields: ['id'],
103+
where: where as FilterCondition,
104+
});
105+
return rows.map((r: any) => String(r.id)).sort();
106+
};
107+
108+
const refusalOf = async (where: unknown): Promise<WireBearingError> => {
109+
try {
110+
await ids(where);
111+
} catch (e) {
112+
return e as WireBearingError;
113+
}
114+
throw new Error('expected the driver to refuse this filter, but it resolved');
115+
};
116+
117+
// ── The three identities, in one batch ────────────────────────────────────
118+
119+
describe('the identity batch', () => {
120+
it('empty $and is TRUE — every row (deliberate, not an accident of dropping)', async () => {
121+
expect(await ids({ $and: [] })).toEqual(ALL);
122+
});
123+
124+
it('empty $or is FALSE — ZERO rows, not the whole table', async () => {
125+
expect(await ids({ $or: [] })).toEqual([]);
126+
});
127+
128+
it('empty $not is FALSE — NOT TRUE ≡ FALSE, so zero rows', async () => {
129+
expect(await ids({ $not: {} })).toEqual([]);
130+
});
131+
});
132+
133+
// ── The regression these identities exist to prevent ──────────────────────
134+
135+
it('an RLS read scope whose disjunct list came out empty hides every row', async () => {
136+
// The exact production shape: a scope builder looped over zero grants and
137+
// handed the driver `{$or: []}`. Answering it with the full table is the
138+
// filter bypass #5134 reports.
139+
expect(await ids({ $or: [] })).not.toEqual(ALL);
140+
expect(await ids({ $or: [] })).toHaveLength(0);
141+
});
142+
143+
it('a scope that AND-s a real predicate with an empty $or still hides every row', async () => {
144+
expect(await ids({ owner: 'u1', $or: [] })).toEqual([]);
145+
});
146+
147+
// ── `{}` is a TRUE operand wherever it appears ────────────────────────────
148+
149+
it('an empty branch makes the whole $or TRUE (it is a TRUE disjunct)', async () => {
150+
expect(await ids({ $or: [{ stage: 'won' }, {}] })).toEqual(ALL);
151+
});
152+
153+
it('an empty branch inside $and is the AND identity — siblings still apply', async () => {
154+
expect(await ids({ $and: [{ stage: 'won' }, {}] })).toEqual(['1']);
155+
});
156+
157+
it('an empty $or branch is dropped as the OR identity, siblings survive', async () => {
158+
expect(await ids({ $or: [{ stage: 'won' }, { $or: [] }] })).toEqual(['1']);
159+
});
160+
161+
// ── The identities compose through nesting ────────────────────────────────
162+
163+
it('a FALSE branch makes the enclosing $and FALSE', async () => {
164+
expect(await ids({ $and: [{ stage: 'won' }, { $or: [] }] })).toEqual([]);
165+
});
166+
167+
it('$not of a FALSE group is TRUE', async () => {
168+
expect(await ids({ $not: { $or: [] } })).toEqual(ALL);
169+
});
170+
171+
it('$not of a TRUE group is FALSE', async () => {
172+
expect(await ids({ $not: { $and: [] } })).toEqual([]);
173+
});
174+
175+
it('a nested empty $not still collapses to FALSE under $and', async () => {
176+
expect(await ids({ $and: [{ stage: 'won' }, { $not: {} }] })).toEqual([]);
177+
});
178+
179+
it('an empty $not as a $or branch is dropped, not promoted', async () => {
180+
expect(await ids({ $or: [{ stage: 'won' }, { $not: {} }] })).toEqual(['1']);
181+
});
182+
183+
// ── Shape rejection: an empty compile must have exactly ONE cause ─────────
184+
185+
describe('non-filter-node operands are refused loudly, never reduced', () => {
186+
const cases: Array<[string, unknown, string]> = [
187+
['null element', { $or: [null] }, 'filter.$or[0]'],
188+
['string element', { $or: ['x'] }, 'filter.$or[0]'],
189+
['array element', { $or: [[{ stage: 'won' }]] }, 'filter.$or[0]'],
190+
['Date element', { $or: [new Date()] }, 'filter.$or[0]'],
191+
['number element in $and', { $and: [42] }, 'filter.$and[0]'],
192+
['non-node deeper in the list', { $or: [{ stage: 'won' }, null] }, 'filter.$or[1]'],
193+
['nested under a good branch', { $and: [{ $or: [null] }] }, 'filter.$and[0].$or[0]'],
194+
['$not operand is an array', { $not: [] }, 'filter.$not'],
195+
['$not operand is null', { $not: null }, 'filter.$not'],
196+
['$not operand is a string', { $not: 'x' }, 'filter.$not'],
197+
['$or is not an array at all', { $or: 'x' }, 'filter.$or'],
198+
['$and is not an array at all', { $and: { stage: 'won' } }, 'filter.$and'],
199+
];
200+
201+
for (const [name, where, position] of cases) {
202+
it(`${name} → 400 INVALID_FILTER naming ${position}`, async () => {
203+
const err = await refusalOf(where);
204+
expect(err.code).toBe('INVALID_FILTER');
205+
expect(err.status).toBe(400);
206+
expect(err.message).toContain(position);
207+
// #3867 — driver-internal wording never reaches the wire.
208+
expect(err.message).not.toContain('[sql-driver]');
209+
});
210+
}
211+
212+
it('garbage is NOT upgraded to match-all by the identity reduction', async () => {
213+
// The regression the rejection exists to prevent: before identity
214+
// reduction `{$or:[null]}` silently returned every row via the dropped
215+
// group; a naive identity would have made it match-all *on purpose*.
216+
await expect(ids({ $or: [null] })).rejects.toThrow();
217+
await expect(ids({ $or: [new Date()] })).rejects.toThrow();
218+
});
219+
220+
it('a class instance is not a filter node either', async () => {
221+
// `Object.entries(new Foo())` can be empty, which would reduce to TRUE and
222+
// hand back the whole table. Prototype identity is what separates a filter
223+
// node from an arbitrary object.
224+
class NotAFilter {
225+
stage = 'won';
226+
}
227+
const err = await refusalOf({ $or: [new NotAFilter()] });
228+
expect(err.code).toBe('INVALID_FILTER');
229+
});
230+
});
231+
232+
// ── Nothing that worked before changes ────────────────────────────────────
233+
234+
describe('existing compilation is untouched', () => {
235+
it('a plain $or still ORs its branches', async () => {
236+
expect(await ids({ $or: [{ stage: 'won' }, { stage: 'lost' }] })).toEqual(['1', '2']);
237+
});
238+
239+
it('a $or branch still ANDs its own keys (#3774)', async () => {
240+
expect(await ids({ $or: [{ stage: 'won', owner: 'u1' }, { stage: 'nope' }] })).toEqual(['1']);
241+
});
242+
243+
it('a non-empty $not still negates', async () => {
244+
expect(await ids({ $not: { stage: 'won' } })).toEqual(['2', '3']);
245+
});
246+
247+
it('$not still ANDs with its sibling keys', async () => {
248+
expect(await ids({ $not: { stage: 'won' }, owner: 'u1' })).toEqual(['3']);
249+
});
250+
251+
it('a nested $and still intersects', async () => {
252+
expect(await ids({ $and: [{ owner: 'u1' }, { stage: 'open' }] })).toEqual(['3']);
253+
});
254+
255+
it('an absent filter is not a failed filter', async () => {
256+
expect(await ids({})).toEqual(ALL);
257+
expect(await ids(undefined)).toEqual(ALL);
258+
});
259+
260+
it('operators inside a branch still compile', async () => {
261+
expect(await ids({ $or: [{ amount: { $gte: 25 } }, { stage: 'lost' }] })).toEqual(['2', '3']);
262+
});
263+
});
264+
});

0 commit comments

Comments
 (0)