Skip to content

Commit a2443e3

Browse files
docs(spec): $icontains 实施状态改按实测重写 —— #5702 已落地,SQL 族三面应答 (#6947) (#6987)
`filter.zod.ts` 上 `StringOperatorSchema` 的「Implementation status」段自述过期条件 是「直到 #5702 落地」;#5702 已于 2026-08-08 关闭,该段现在断言的与已发布状态相反。 按 driver 逐个实测重写(不是抄 issue 上的点检表,也不是 grep case 分支 —— grep 看不见 继承编译器的那一面,会把 5 数成 2):三个应答、两个响亮拒收。 ⛔ 行为零变化:`FILTER_OPERATORS` 未动,`$icontains` 仍刻意不在词表内(fail-closed)。 词表的真实闸口写明为 #6520,不再是已落地的 #5702。 Claude-Session: https://claude.ai/code/session_018ffcE95NaMJcL9XJ9VDYgk Co-authored-by: Claude <noreply@anthropic.com>
1 parent c733ae8 commit a2443e3

4 files changed

Lines changed: 78 additions & 25 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
filter: `$icontains` 的实施状态改按实测重写(#6947)
6+
7+
`filter.zod.ts``StringOperatorSchema` 的状态段仍然写着「没有任何后端应答 `$icontains`,五个 driver 一律拒收,直到 #5702 落地」。#5702 已于 2026-08-08 关闭,该段自述的过期条件已经触发,文字与已发布的实现正好相反。
8+
9+
按 driver 逐个实测(同一条 `{ name: { $icontains: 'acme' } }` 打到同时含 `acme corp``ACME CORP` 的样本上,而不是 grep case 分支 —— grep 看不见继承编译器的那一面,会少数一个):**五个 driver 里三个应答**(`driver-sql`;`driver-sqlite-wasm` 通过继承 `SqlDriver`,在另一套 sql.js 引擎上;`driver-turso` 的 local 与 remote 两条传输都应答),**两个响亮拒收**(`driver-memory``driver-mongodb`,均为 `INVALID_FILTER` / 400)。据此改写状态段,并同步 `$icontains``.describe()`(它会渲染进 `content/docs/references/data/filter.mdx`,原文同样停留在「lowerings land with #5702」)。
10+
11+
⛔ 行为零变化:`FILTER_OPERATORS` 未动,`$icontains` 仍然刻意不在词表里 —— 该数组是运行时 allowlist,收进去会让内存 `match()`**不匹配**`true`。词表的真实闸口从此写明是 #6520(JS 求值面),不再是已经落地的 #5702

content/docs/references/data/filter.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ Type: `[FilterArray](#filterarray)[]`
147147
| **$notContains** | `string` | optional | |
148148
| **$startsWith** | `string` | optional | |
149149
| **$endsWith** | `string` | optional | |
150-
| **$icontains** | `string` | optional | Contains substring, ignoring case — but ONLY ASCII case (A-Z against a-z). Every other character compares literally, so "café" does NOT match "CAFÉ" and "москва" does not match "МОСКВА". The domain is ASCII because that is the one fold all five backends can deliver: SQLite (and therefore turso and sqlite-wasm) folds ASCII only, so a Unicode promise here would be a guarantee three of the five could not keep. The comparand is matched LITERALLY — "%", "_" and regex metacharacters are ordinary characters, not wildcards. Case-SENSITIVE containment is $contains. [#5701: declared by the protocol; the driver lowerings land with #5702.] |
150+
| **$icontains** | `string` | optional | Contains substring, ignoring case — but ONLY ASCII case (A-Z against a-z). Every other character compares literally, so "café" does NOT match "CAFÉ" and "москва" does not match "МОСКВА". The domain is ASCII because that is the one fold all five backends can deliver: SQLite (and therefore turso and sqlite-wasm) folds ASCII only, so a Unicode promise here would be a guarantee three of the five could not keep. The comparand is matched LITERALLY — "%", "_" and regex metacharacters are ordinary characters, not wildcards. Case-SENSITIVE containment is $contains. [#5701 declared it; #5702 lowered it on the SQL family (driver-sql, driver-sqlite-wasm, driver-turso on both transports). driver-memory and driver-mongodb still REFUSE it with INVALID_FILTER / 400, so a filter using it is not portable across backends yet — #6520.] |
151151

152152

153153
---

packages/spec/src/data/filter-operator-vocabulary.test.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,19 @@
1313
* gate and `service-analytics`' coverage test DERIVE from. An entry here is a
1414
* claim that backends implement the operator.
1515
*
16-
* `$icontains` is declared and not yet enforced (#5701 is the contract half of
17-
* the #4706 ruling; #5702 writes the lowerings). Measured on the branch that
16+
* `$icontains` is declared and still not enforced (#5701 is the contract half
17+
* of the #4706 ruling; #5702 wrote the lowerings for the SQL family only, and
18+
* #6520 is what the remaining JS faces wait on). Measured on the branch that
1819
* added it to `FILTER_OPERATORS` early: driver-memory's gate stopped refusing
1920
* it and `match({ name: 'zzz' }, { name: { $icontains: 'acme' } })` returned
2021
* `true` — the predicate silently dropped, every row matched. That is the
21-
* widening #3948 is about, so the staging is not a stylistic choice.
22+
* widening #3948 is about, so the staging is not a stylistic choice, and
23+
* #5702 landing did NOT clear it: the array is read by the two faces that
24+
* still refuse the operator, not by the three that answer it.
2225
*
2326
* The pin below is deliberately an EQUALITY, not a subset check, so it fails in
2427
* both directions: a second staged operator added without recording it fails
25-
* here, and so does clearing `$icontains` in #5702 — which is the point. The
28+
* here, and so does clearing `$icontains` in #6520 — which is the point. The
2629
* failure message is the instruction.
2730
*/
2831

@@ -50,8 +53,10 @@ describe('the declaration surface and the enforcement surface', () => {
5053
+ 'add it here plus a note on FILTER_OPERATORS saying which issue implements it — an '
5154
+ 'operator in FILTER_OPERATORS with no backend arm makes driver-memory accept it and '
5255
+ "silently DROP the predicate (measured, #5701). If you are CLEARING one because you "
53-
+ 'just implemented it (#5702): remove it from this list AND delete the staging paragraph '
54-
+ 'on FILTER_OPERATORS, which is now describing something that is no longer true.',
56+
+ 'just implemented it on EVERY face (for `$icontains` that is #6520 — #5702 did the SQL '
57+
+ 'family and correctly left the staging in place): remove it from this list AND delete '
58+
+ 'the staging paragraph on FILTER_OPERATORS, which is now describing something that is '
59+
+ 'no longer true.',
5560
).toEqual(['$icontains']);
5661
});
5762

packages/spec/src/data/filter.zod.ts

Lines changed: 55 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -294,23 +294,55 @@ export const RangeOperatorSchema = lazySchema(() => z.object({
294294
* `$contains`. An application whose users search non-ASCII text should not read
295295
* `$icontains` as "accent- and case-blind search" — it is not one.
296296
*
297-
* ### Implementation status — declared here, NOT yet answered by any backend
298-
*
299-
* This PR is the contract half of the #4706 ruling and deliberately ships no
300-
* runtime behaviour (#5701). No driver evaluates `$icontains` today; all five
301-
* refuse it, loudly, as an operator they do not implement — which is the
302-
* fail-closed direction and stays true until #5702 lands the lowerings. The
303-
* same issue carries the `$contains`-family alignment the ruling above
304-
* requires (SQLite/turso `LIKE` made case-exact, mongo's hardcoded `'i'`
305-
* removed). Until then the sentence above is the DECLARATION and #5702 is the
306-
* gap; `FILTER_TEXT_CASES` (`filter-text-conformance.ts`) is the standard that
307-
* measures the gap, and the driver-conformance ledger carries one DEBT row per
308-
* backend so the gap is counted rather than assumed.
297+
* ### Implementation status — answered by the SQL family, refused by the rest
298+
*
299+
* #5701 shipped this declaration deliberately ahead of every runtime, and
300+
* #5702 (closed 2026-08-08, retuned by #6518) landed the lowerings on the SQL
301+
* family. Measured per backend, by running `{ name: { $icontains: 'acme' } }`
302+
* against a fixture holding BOTH `acme corp` and `ACME CORP` — not by grepping
303+
* for a case arm, which is blind to the face that inherits its compiler and so
304+
* undercounts:
305+
*
306+
* | driver | `$icontains` | how it gets there |
307+
* |---|---|---|
308+
* | `driver-sql` | ANSWERS both rows | its own `case '$icontains'`, folding through the same emitter that carries the escaping |
309+
* | `driver-sqlite-wasm` | ANSWERS both rows | INHERITED — `SqliteWasmDriver extends SqlDriver`; this package carries no text case arm of its own, on a different ENGINE |
310+
* | `driver-turso` | ANSWERS both rows, on BOTH transports | local inherits `SqlDriver`; the remote transport compiles independently and has its own arm |
311+
* | `driver-memory` | REFUSES — `INVALID_FILTER` / 400 | no arm; its `SUPPORTED_FIELD_OPERATORS` derives from {@link FILTER_OPERATORS}, which deliberately omits it |
312+
* | `driver-mongodb` | REFUSES — `INVALID_FILTER` / 400 | no arm; falls to its translator's `default:` |
313+
*
314+
* The other JS evaluators sit on the refusing side too: objectql's `having`
315+
* face records the omission in its own source, and `formula`'s `matchesFilter`
316+
* has no `$icontains` arm.
317+
*
318+
* **So the sentence an author needs is no longer "no backend answers this".**
319+
* It is: `$icontains` is EXECUTABLE on the SQL family and refused — loudly,
320+
* fail-closed, never silently — everywhere else, so a filter that uses it is
321+
* not portable across backends today. An app whose tests run on the in-memory
322+
* double and whose production runs SQL gets two different answers from one
323+
* filter: that divergence, and the remaining implementations, are #6520.
324+
*
325+
* **The vocabulary gate below is still closed, and #5702 is no longer what it
326+
* waits for.** `$icontains` stays out of {@link FILTER_OPERATORS} on purpose —
327+
* that array is a runtime allowlist, and listing an operator the in-memory
328+
* `match()` cannot evaluate makes it answer `true` for a NON-match (measured;
329+
* see that array's docblock). It joins when the JS faces get arms, in #6520.
330+
*
331+
* The `$contains`-family alignment the ruling above requires is likewise part
332+
* done rather than pending: #6518 made that family case-EXACT across the SQL
333+
* dialects, while `driver-memory`'s query path and `driver-mongodb` still fold
334+
* the whole Unicode range — the two rows #6682 tracks.
335+
*
336+
* `FILTER_TEXT_CASES` (`filter-text-conformance.ts`) is the standard that
337+
* measures all of the above, and the driver-conformance ledger still carries a
338+
* DEBT row for each of the two backends left, so what is open stays counted
339+
* rather than assumed.
309340
*
310341
* @see FILTER_TEXT_CASES — the conformance standard for every operator here.
311342
* @see RETIRED_FILTER_OPERATORS — why `$regex` is not in this list.
312343
* @see https://github.com/objectstack-ai/objectstack/issues/4706 (the ruling)
313-
* @see https://github.com/objectstack-ai/objectstack/issues/5702 (the backends)
344+
* @see https://github.com/objectstack-ai/objectstack/issues/5702 (the SQL family — landed)
345+
* @see https://github.com/objectstack-ai/objectstack/issues/6520 (the JS faces — open)
314346
*/
315347
export const StringOperatorSchema = lazySchema(() => z.object({
316348
/** Contains substring, CASE-SENSITIVELY - SQL: LIKE %?% (case-exact) */
@@ -337,8 +369,11 @@ export const StringOperatorSchema = lazySchema(() => z.object({
337369
+ 'sqlite-wasm) folds ASCII only, so a Unicode promise here would be a '
338370
+ 'guarantee three of the five could not keep. The comparand is matched '
339371
+ 'LITERALLY — "%", "_" and regex metacharacters are ordinary characters, not '
340-
+ 'wildcards. Case-SENSITIVE containment is $contains. [#5701: declared by the '
341-
+ 'protocol; the driver lowerings land with #5702.]'
372+
+ 'wildcards. Case-SENSITIVE containment is $contains. [#5701 declared it; #5702 '
373+
+ 'lowered it on the SQL family (driver-sql, driver-sqlite-wasm, driver-turso on '
374+
+ 'both transports). driver-memory and driver-mongodb still REFUSE it with '
375+
+ 'INVALID_FILTER / 400, so a filter using it is not portable across backends yet '
376+
+ '— #6520.]'
342377
),
343378
}));
344379

@@ -1194,10 +1229,12 @@ export const FilterArraySchema: z.ZodType<FilterArray, FilterArray> = z.lazy(()
11941229
* reader (verified — `NormalizedFilterSchema` is their only consumer, and
11951230
* nothing parses a filter through it at runtime), so declaring there is inert.
11961231
* Adding it HERE would flip driver-memory from a loud refusal to the silent
1197-
* widening measured above, before a single backend can answer the operator.
1232+
* widening measured above, on a face that still cannot answer the operator.
11981233
*
1199-
* **`$icontains` joins this array in the PR that implements it (#5702), not
1200-
* before.** `filter-operator-vocabulary.test.ts` pins the difference between
1234+
* **`$icontains` joins this array in the PR that gives the JS faces an arm
1235+
* (#6520), not before** — #5702 implemented the SQL family and correctly did
1236+
* NOT add it here, because the array is read by the faces that still refuse.
1237+
* `filter-operator-vocabulary.test.ts` pins the difference between
12011238
* the two surfaces at exactly `{ $icontains }`, so this staging cannot silently
12021239
* grow a second member, and clearing it is what makes that pin fail.
12031240
*

0 commit comments

Comments
 (0)