Skip to content

Commit 3264516

Browse files
os-zhuangclaude
andauthored
fix(driver-sql,service-analytics)!: 两类无意义比较对象不再编译成「静默空谓词」—— $in/$nin 的对象成员与 LIKE 族的对象比较值一律拒收 (#5234) (#6296)
* fix(driver-sql,service-analytics)!: refuse the two comparand shapes that compiled to a silent nonsense predicate (#5234) An `$in`/`$nin` list member that cannot be bound, and a LIKE-family comparand that `String()` cannot render, both compiled to valid SQL that answered with a predicate the caller never wrote: - `{status: {$in: ['a', {foo: 1}]}}` answered as if the second member had never been written; `{status: {$nin: [{foo: 1}]}}` excluded NOTHING, so the exclusion the caller wrote silently did not happen. - `{name: {$contains: {}}}` bound `LIKE '%[object Object]%'` and MATCHED a row whose text really was `[object Object]`; `$notContains` excluded it. #5041 (PR #5223) recorded both as "Deliberately NOT extended" on the grounds that they were fail-closed. Measured, neither premise held: the `$nin` / `$notContains` direction is wider, not narrower, and the answers were wrong rather than empty. The guard lands at each package's own chokepoint rather than at the three `String()` emitters, so one `$contains` still means one thing on every face: `assertCompilableComparand` in driver-sql, `fieldLeaves` for the analytics `where` door (the only leaf producer, so it covers all three consumers of the tree), and `compileOperator` for the read-scope lowering. The fence is an allow-list, copied from driver-turso's RemoteTransport, which has refused these same two shapes since cloud#1004 / #1058 — local and remote SQLite answered the same query differently until now. Primitives are deliberately untouched: `{$contains: 5}` and `{$contains: null}` agree across every backend today and #5526 pinned the latter. Arrays are refused because they already forked inside service-analytics (`%al,be%` at the read scope, `%al%` at the `where` door). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WyvqvKMG6asi9aXjKE6xtx * test(driver-sql): 跟进 #6210 的 DriverQuery 收窄,并把反向验证计数重测到 d367f03 - `find()` 调用去掉 `object` 键:#6210 把驱动查询参数收窄为 `DriverQuery`, 对象名只由第一个实参给出。没有用 `as any` 绕过——拒收类测试一旦用越契约的 输入构造查询,就不再是在验证调用方真能发出的形状。 - 反向验证计数重测:LIKE 臂停用 → 8 failed / 40 passed;成员臂停用 → 4 failed / 44 passed(此前记的 32 / 36 是首写时对 40 个测试测的)。失败数 两次都不变,是承重的那一半;通过数随邻测增删而漂移,已在注释里说明。 - 补记 analytics 侧同法实测:两个门回退到 main 版本 → 17 failed / 41 passed, 而 `like-metacharacter-escape.test.ts` 在该回退下保持绿——它锁的是两包谓词 之间的一致,拒收文件锁的是门确实调用了谓词,两者都需要。 Refs #5234 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WyvqvKMG6asi9aXjKE6xtx --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 6517448 commit 3264516

11 files changed

Lines changed: 1201 additions & 22 deletions
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
---
2+
"@objectstack/driver-sql": minor
3+
"@objectstack/service-analytics": minor
4+
---
5+
6+
fix(driver-sql,service-analytics)!: 两类无意义比较对象不再编译成「静默空谓词」——`$in`/`$nin` 的对象成员与 LIKE 族的对象比较值一律拒收 (#5234)
7+
8+
两个形状此前都**编译通过、执行、并给出一个作者没写过的答案**,而且没有任何东西记录这件事:
9+
10+
| filter | 改前 | 改后 |
11+
|---|---|---|
12+
| `{status: {$in: ['a', {foo: 1}]}}` | 该成员绑不上任何行,查询答得**就像第二个成员从没被写过** | `INVALID_FILTER` / 400,点名 `index 1` |
13+
| `{status: {$nin: [{foo: 1}]}}` | `NOT IN ('[object Object]')` —— **一行都没排除**,作者写下的排除悄悄没发生 | 同上 |
14+
| `{name: {$contains: {}}}` | `LIKE '%[object Object]%'` —— 对一行文本恰好是 `[object Object]` 的记录,**真的命中了** | `INVALID_FILTER` / 400,点名 `StringOperatorSchema` |
15+
| `{name: {$notContains: {}}}` | 反过来:为一个没人记录的理由**排除了一条真实记录** | 同上 |
16+
17+
#5041(PR #5223)在 `assertCompilableComparand` 的头注释里把这两个形状写为 "Deliberately NOT
18+
extended",理由是它们 fail-closed(只收窄结果集)、比 #5041 实测的裸 `TypeError` 低一级。**实测下来这
19+
两条理由都不成立**:`$nin` / `$notContains` 方向是**放宽**(该排除的没排除,在 read-scope 下即 #5347 /
20+
#5324 判过的 over-reach);而 `$contains: {}` 给的从来不是「零行」,是**错行**
21+
22+
## 三份实现一起动,否则修完仍是方言
23+
24+
同一个 `String()` 宽容在本仓有多份;只收紧 `driver-sql` 会变成「哪个面接的就是哪个答案」——
25+
#5146 / #5332 / #5567 各花一轮消掉的那类分叉。守卫因此落在**每个包自己的收口点**,而不是三个发射器:
26+
27+
- **`driver-sql`** —— `assertCompilableComparand`,#5041 已有的那一个门。
28+
- **`service-analytics``where`** —— `filter-normalizer.ts``fieldLeaves`。它是本包**唯一**
29+
leaf 生产者,所以一处拒收同时覆盖三个消费方:`NativeSQLStrategy`(真正执行的语句)、
30+
`ObjectQLStrategy.generateSql`(`/analytics/sql` 回显)与 `ObjectQLStrategy.convertFilter`(引擎路径)。
31+
这个顺序是关键而非顺手:`convertFilter`**生产者**,在那里 `String()` 会把对象洗成一个类型完全正确
32+
`'[object Object]'` 字符串交给驱动,下游再严格的驱动也永远看不到它该严格的那个形状。
33+
- **`service-analytics` 的 read-scope 门** —— `read-scope-sql.ts``compileOperator`,它编译的
34+
`FilterCondition` 不经过上面那个门。
35+
36+
`like-pattern.ts``applyLike` 里的 `String(value)` **原样保留**:它们不再是缺陷所在,因为门前已经没有
37+
渲染不出来的值能到达。两包的谓词由 `like-metacharacter-escape.test.ts` 逐值互锁——正是该文件已经用来锁
38+
转义表达式的同一套办法。
39+
40+
## 围栏是 allow-list,而且每一条都是实测后决定的
41+
42+
`driver-turso` `RemoteTransport` 的形状(cloud#1004 / #1058):deny-list 会把下一个被发明出来的值形状
43+
悄悄放进来,这正是那个 bug 熬过第一次修复的原因。顺带说明,**turso 自 #1058 起就已经拒收这两个形状**,
44+
所以本地 SQLite 与远程 SQLite 此前对同一条查询给的是不同答案;本次改动把它们收敛到一起。
45+
46+
留在围栏内的(逐条实测,不是假设):
47+
48+
- **数字 / 布尔 / `null`**:`{$contains: 5}``%5%``{$contains: null}``%null%``driver-sql`
49+
`driver-memory` 与 analytics 两个面上**今天答案一致**,#5526 还专门把 `null` 这条钉住了。拒收它们是在
50+
**破坏**一致,不是建立一致——所以只拒**对象**
51+
- **`Date`**:turso 的 allow-list 把它作为唯一的对象转换保留,拒收会重新叉开本地与远程。
52+
- **binary**:`$in` 成员照收(`isBindableComparand` 与写路径 `formatInput` 同一套分类),LIKE 拒收——它
53+
绑得上但渲染不出作者想要的东西。这就是两个谓词而不是一个带 flag 的原因。
54+
- **`undefined`**:不可授权(JSON 没有 `undefined`),analytics 门按 #5526 / #5332 归一为 `null` 而非拒收;
55+
`driver-sql` 拒收它会**造出**一个分歧而不是消除一个,故照旧。
56+
57+
被拒的**数组**是本次唯一一个「拒收即消分叉」的形状:`{name: {$contains: ['al','be']}}``read-scope-sql`
58+
(与 `driver-sql`)绑 `%al,be%`,在 analytics 的 `where` 门却绑 `%al%`(它读 `values[0]`,后面的成员被
59+
静默丢弃)。同一个包对同一条 filter 有两个答案,两个门现在都拒。
60+
61+
## 作者需要知道的迁移
62+
63+
这两个形状本来就没有能用的读法——`filter.zod.ts``StringOperatorSchema` 早就把 LIKE 族比较数声明为
64+
`z.string()`,本次只是让声明变成强制(Prime Directive #12,declared = enforced)。改后它们答 400 而不是
65+
一个错答案;把比较数换成字面值即可。`{$eq: {…}}` **不在本次范围**,仍按 `toSqlBindValue` 绑 JSON(#5526
66+
钉住的行为)。

packages/drivers/driver-sql/src/sql-driver-out-of-contract-filter-input.test.ts

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -304,13 +304,42 @@ describe('[#5347/#5348] SqlDriver refuses out-of-contract filter input', () => {
304304
expect(crossField.message).toContain('Cross-field comparison');
305305
});
306306

307-
it('the regex family keeps its non-string comparands (explicitly out of scope)', async () => {
308-
// #5347 measured this family as AGREEING across backends and fail-closed,
309-
// and #5041 left it out of the comparand guard on the same evidence. It
310-
// is not tightened here, and this pins that it was not tightened by
311-
// accident.
307+
it('the LIKE family keeps its non-string PRIMITIVE comparands', async () => {
308+
// This assertion used to read "the regex family keeps its non-string
309+
// comparands (explicitly out of scope)", carrying:
310+
//
311+
// > #5347 measured this family as AGREEING across backends and
312+
// > fail-closed, and #5041 left it out of the comparand guard on the
313+
// > same evidence. It is not tightened here, and this pins that it was
314+
// > not tightened by accident.
315+
//
316+
// **#5234 supersedes the OBJECT half of that** (Prime Directive #13 — the
317+
// reversal is quoted rather than deleted so the next reader can find it
318+
// from the sentence they remember). Both premises held only for
319+
// primitives:
320+
//
321+
// - "agreeing across backends" — `{$startsWith: {}}` did agree, on the
322+
// WRONG answer. `String({})` is `'[object Object]'`, and against a row
323+
// storing that literal text the pattern MATCHED. This fixture has no
324+
// such row, so the old `toEqual([])` passed because nothing was there
325+
// to match, not because the compiled predicate was right.
326+
// - "fail-closed" — `$notContains` and `$nin` invert it: the exclusion
327+
// the caller wrote silently did not happen.
328+
//
329+
// The primitive half is UNCHANGED and still pinned here, deliberately:
330+
// `{$contains: 1}` → `%1%` is what this driver, `driver-memory` and both
331+
// `service-analytics` faces all give, and #5526 kept it on purpose.
312332
expect(await ids({ stage: { $contains: 1 } })).toEqual([]);
313-
expect(await ids({ stage: { $startsWith: {} } })).toEqual([]);
333+
expect(await ids({ stage: { $contains: null } })).toEqual([]);
334+
expect(await ids({ stage: { $startsWith: true } })).toEqual([]);
335+
336+
// The object half now refuses, in this driver's own envelope. Replaced
337+
// rather than re-spelled: an assertion that keeps passing because nothing
338+
// is produced pins nothing at all.
339+
const err = await refusalOf({ stage: { $startsWith: {} } });
340+
expect(err.code).toBe('INVALID_FILTER');
341+
expect(err.status).toBe(400);
342+
expect(err.message).toContain('$startsWith');
314343
});
315344
});
316345
});

0 commit comments

Comments
 (0)