Skip to content

Commit d063a96

Browse files
os-zhuangclaude
andauthored
fix(spec): stop folding like/ilike onto $contains at the wire (#7536) (#7593)
* fix(spec): stop folding like/ilike onto $contains at the wire (#7536) The wire lowering carried 'like': '$contains' in AST_OPERATOR_MAP, so every like predicate arriving over HTTP was rewritten into a substring search before any driver saw it. $contains LIKE-escapes its comparand and wraps it in %…%, which broke like in both directions: a caller's wildcards bound as literals (['name','like','%Industries'] returned 0 rows) and a wildcard-free pattern became a substring match byte-identical to the $contains control. canonicalAstOperator already documented the contract being violated, thirty lines below the map entry, in a hand-written exemption for like/ilike. That exemption only shaped its own output; the lowering the wire takes had none. - spec: new $like/$ilike operators, the pattern language defined once (hasDanglingLikeEscape, likePatternToRegexSource, matchesLikePattern, likePatternToGlobPattern), like/ilike lowered to them, the exemption retired. - driver-sql: the emitter arm the wire could not reach since #5158 — LIKE on Postgres/MySQL, GLOB on SQLite (case-exactness, #6518), pattern translated rather than escaped. - driver-turso: the same on the remote transport, sharing the spec translation. - driver-memory: both faces, so the in-memory double does not 400 for a filter production answers; the stale infix like==contains arm is gone. - formula: arms, so a write-side check agrees with the read-side SQL. - client: contains/startsWith/endsWith stop gluing wildcards into a like tuple. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PhHptz16p1kRmbmuzEZkgd * test: retarget $like unknown-operator exemplars, docs (#7536) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PhHptz16p1kRmbmuzEZkgd * chore(spec): regenerate api-surface, export-origins and reference docs (#7536) The four new pattern-language exports and the two new declared operators. Also corrects the $like describe() and the FILTER_OPERATORS staging table, which both said the JS faces refuse — driver-memory and formula answer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PhHptz16p1kRmbmuzEZkgd * docs(objectql): document $like/$ilike in the query-syntax operator table (#7536) Adds the two operators to the reference table and a short section on why $like is not a spelling of $contains — text vs pattern, substring vs whole value — plus the per-backend coverage split. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PhHptz16p1kRmbmuzEZkgd --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f1544e2 commit d063a96

27 files changed

Lines changed: 2079 additions & 45 deletions

‎.changeset/like-wire-lowering.md‎

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/driver-sql": minor
4+
"@objectstack/driver-turso": minor
5+
"@objectstack/driver-memory": minor
6+
"@objectstack/formula": minor
7+
"@objectstack/client": patch
8+
---
9+
10+
fix(spec,drivers,formula,client): `like`/`ilike` stop being folded onto `$contains` at the wire (#7536)
11+
12+
A `like` predicate that arrived over HTTP was rewritten into a substring search
13+
before any driver saw it, because `AST_OPERATOR_MAP` (`data/filter.zod.ts`)
14+
carried `'like': '$contains'`. `$contains` LIKE-escapes its comparand and wraps
15+
it in `%…%`, which breaks a `like` in **both** directions at once. Measured in
16+
QA run #7463 against showcase on SQLite:
17+
18+
| filter | before | now |
19+
|---|---|---|
20+
| `["name","like","%Industries"]` | `200`, **0 rows** — the `%` bound as a literal percent sign | the rows ENDING WITH `Industries` |
21+
| `["name","like","Industries"]` | a substring match, **byte-identical to the `$contains` control** | an EXACT match |
22+
| `["name","ilike","…"]` | `400` — `ilike` had no lowering at all, so `isFilterAST()` refused the whole filter | the case-insensitive twin |
23+
24+
The second row is the tell: `like` and `$contains` producing the same bytes
25+
means `like` was not reaching the driver as a pattern at all.
26+
27+
The file already documented the contract being violated. `canonicalAstOperator`,
28+
thirty lines below the map entry, carried a hand-written exemption for
29+
`like`/`ilike` whose comment read: *"they are NOT substring matches at the
30+
driver: driver-sql passes them to SQL verbatim, so the caller binds the
31+
wildcards. Folding them onto `contains` would silently wrap the value in `%…%`
32+
and change what the query means."* That exemption only ever shaped its own
33+
output; the lowering the wire path takes had none. A consequence worth naming:
34+
driver-sql's `like`/`ilike` handling has been unreachable from the wire since
35+
#5158.
36+
37+
## What changed
38+
39+
**New operators `$like` / `$ilike`** on `StringOperatorSchema` and
40+
`FieldOperatorsSchema`. The comparand IS the pattern: `%` matches any sequence,
41+
`_` matches exactly one character, a backslash escapes either, and the pattern
42+
must cover the WHOLE value — so a pattern with no wildcards is an exact
43+
comparison, not a substring search. `$like` is case-SENSITIVE (the #4706 Q2 = A
44+
contract its `$contains` sibling answers); `$ilike` folds ASCII case and nothing
45+
else (Q1 = A), so `café` does not match `CAFÉ`.
46+
47+
`AST_OPERATOR_MAP` now lowers `like` → `$like` and `ilike` → `$ilike`. `ilike`
48+
enters the AST vocabulary for the first time — it previously had no entry, so
49+
`isFilterAST()` refused it. `canonicalAstOperator`'s hand-written exemption is
50+
retired: the generic round-trip answers `like`/`ilike` by construction now, so
51+
the special case is gone along with the reason it existed.
52+
53+
The pattern language is defined **once**, in the spec, and shared by every face
54+
that needs it — `hasDanglingLikeEscape`, `likePatternToRegexSource`,
55+
`matchesLikePattern` and `likePatternToGlobPattern`. Six faces implementing one
56+
pattern language separately is the `#3948` shape reached through translation
57+
instead of vocabulary.
58+
59+
**Which backends answer, and which refuse.** `$like`/`$ilike` are deliberately
60+
NOT in `FILTER_OPERATORS`, the runtime allowlist several packages derive
61+
acceptance from — adding a name there before every face has an arm turns a loud
62+
refusal into a silently DROPPED predicate, which is the widening measured in
63+
#5701 and ruled on in #3948.
64+
65+
| face | `$like` / `$ilike` |
66+
|---|---|
67+
| `driver-sql` (and `driver-sqlite-wasm`, which inherits its compiler) | **answers** — `LIKE` on Postgres/MySQL, `GLOB` on SQLite |
68+
| `driver-turso`, both transports | **answers** — the remote transport compiles independently, holds to the local one by a parity suite |
69+
| `driver-memory`, both faces | **answers** — the in-memory double must not 400 for a filter that works in production |
70+
| `@objectstack/formula` (`matchesFilterCondition`) | **answers** — so a write-side RLS `check` agrees with the read-side SQL |
71+
| `driver-mongodb`, objectql `having`, `service-analytics` | **refuse**, loudly, in the ADR-0112 `INVALID_FILTER` envelope |
72+
73+
The refusals are the point rather than a gap: #7536 exists because a `like` was
74+
silently given `$contains`' meaning, and a face that quietly answers a different
75+
question is worse than one that refuses. Clearing the remainder means arms on
76+
those faces in one PR — the #6520 direction.
77+
78+
**Why SQLite gets `GLOB`.** `$like` is case-exact and SQLite's `LIKE` folds
79+
ASCII unconditionally, which cannot be switched off per statement
80+
(`PRAGMA case_sensitive_like` is connection-global). That is #6518's finding,
81+
and the operator it landed on. Because GLOB speaks a different pattern language
82+
(`*`/`?`, and `%`/`_` are ordinary characters), the pattern is TRANSLATED rather
83+
than escaped — including GLOB's own metacharacters, which are ordinary to LIKE:
84+
an unescaped `*` in a GLOB pattern is the same filter bypass an unescaped `%` is
85+
under LIKE (#5567).
86+
87+
**Refused rather than given a meaning:** a pattern ending in a lone unpaired
88+
backslash. No reading survives every backend — Postgres rejects such a pattern
89+
outright, GLOB has no escape character at all — so it is refused at the door on
90+
every face, by one shared test.
91+
92+
## ⚠️ Behaviour changes
93+
94+
1. **`like` now means `LIKE`.** If you were relying on `like` behaving as a
95+
substring search — the defect — write `contains` instead. A wildcard-free
96+
`like` is now an exact match.
97+
2. **`like`/`ilike` on `driver-mongodb`, objectql `having` and analytics now
98+
return `400 INVALID_FILTER`** where a (wrong) substring answer came back
99+
before. Write `$contains`/`$icontains` on those backends. `driver-memory` is
100+
deliberately NOT in that list — it implements the operators, because an
101+
application whose tests run on the in-memory double and whose production runs
102+
SQL must not meet a 400 in test for a filter that works in production.
103+
3. **`@objectstack/client`'s `.contains()`, `.startsWith()` and `.endsWith()`
104+
emit different operators.** They used to build a `like` tuple by gluing
105+
wildcards onto the caller's value (`[field, 'like', '%' + value + '%']`),
106+
which was wrong twice over: the wire folded `like` onto `$contains`, which
107+
escaped the glued `%` back into a literal, so `.contains('name','Corp')`
108+
searched for the text `%Corp%` and matched only rows containing percent
109+
signs. And once `like` reaches the driver as a real pattern, the glue becomes
110+
the *other* bug — a `%` or `_` inside the caller's own value would silently
111+
become a wildcard. They now emit `contains` / `starts_with` / `ends_with`,
112+
whose comparand is text. `.like()` is unchanged and finally works; `.ilike()`
113+
is new.
114+
115+
Note the case semantics this corrects on paper too: `.contains()`'s docblock
116+
claimed "case-insensitive", but the `$contains` family is case-SENSITIVE by
117+
contract (#4706 Q2 = A). Use `.ilike()` for a case-insensitive pattern.

‎content/docs/api/client-sdk.mdx‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -473,14 +473,22 @@ const filter = createFilter()
473473
.greaterThan('revenue', 10000) // field > value
474474
.lessThanOrEqual('age', 100) // field <= value
475475
.in('category', ['A', 'B', 'C']) // field IN (...)
476-
.like('name', '%Corp%') // field LIKE pattern
477-
.contains('name', 'Corp') // LIKE %Corp%
478-
.startsWith('name', 'Acme') // LIKE Acme%
476+
.like('name', '%Corp%') // pattern match — the wildcards are YOURS
477+
.ilike('name', '%corp%') // same, ignoring ASCII case
478+
.contains('name', 'Corp') // literal substring — no wildcards involved
479+
.startsWith('name', 'Acme') // literal prefix
479480
.isNull('deleted_at') // field IS NULL
480481
.between('created_at', '2024-01', '2024-12')
481482
.build();
482483
```
483484

485+
`like` / `ilike` take a **pattern**: `%` matches any sequence, `_` matches
486+
exactly one character, and a backslash escapes either. The pattern is matched
487+
against the whole value, so `like('name', 'Corp')` is an exact comparison — for
488+
a substring search use `contains`, whose argument is plain text and needs no
489+
escaping. `like` / `ilike` are executed by the SQL backends; the others refuse
490+
them with `invalid_filter`.
491+
484492
---
485493

486494
## Query Options

‎content/docs/deployment/troubleshooting.mdx‎

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,8 @@ The custom error map provides "Did you mean?" suggestions for common typos.
125125
```
126126
$eq, $ne, $gt, $gte, $lt, $lte,
127127
$in, $nin, $between,
128-
$contains, $notContains, $startsWith, $endsWith,
128+
$contains, $notContains, $startsWith, $endsWith, $icontains,
129+
$like, $ilike,
129130
$null, $exists,
130131
$and, $or, $not
131132
```
@@ -135,10 +136,27 @@ Common mistakes:
135136
|:---|:---|
136137
| `$equal` | `$eq` |
137138
| `$notEqual` | `$ne` |
138-
| `$like` | `$contains` |
139139
| `$greaterThan` | `$gt` |
140140
| `$isNull` | `$null` |
141141
| `$notIn` | `$nin` |
142+
| `$regex` | `$icontains` |
143+
144+
`$like` is **not** a misspelling of `$contains` — the two mean different things,
145+
and picking the wrong one gives a silently wrong answer rather than an error:
146+
147+
- `$contains` takes **text**, matched literally as a substring. A `%` or `_` in
148+
the comparand is an ordinary character.
149+
- `$like` takes a **pattern**, matched against the whole value, with the
150+
wildcards you write: `%` is any sequence, `_` is exactly one character, and a
151+
backslash escapes either. A pattern with no wildcards is an exact comparison,
152+
not a substring search.
153+
154+
`$ilike` is `$like`'s case-insensitive twin, and `$icontains` is `$contains`'s.
155+
Both fold ASCII case only, so `café` does not match `CAFÉ`.
156+
157+
`$like` / `$ilike` are executed by the SQL family (`driver-sql`,
158+
`driver-sqlite-wasm`, `driver-turso`). The other backends refuse them with this
159+
same `invalid_filter` error — write `$contains` there.
142160

143161
---
144162

‎content/docs/protocol/objectql/query-syntax.mdx‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,10 +254,44 @@ const query: QueryAST = {
254254
| `$notContains` | String does not contain, **case-sensitive** | `{ name: { $notContains: 'test' } }` |
255255
| `$startsWith` | String starts with, **case-sensitive** | `{ email: { $startsWith: 'admin' } }` |
256256
| `$endsWith` | String ends with, **case-sensitive** | `{ domain: { $endsWith: '.com' } }` |
257+
| `$like` | `LIKE` pattern — **you write the wildcards**, **case-sensitive** | `{ name: { $like: '%Industries' } }` |
258+
| `$ilike` | Same pattern language, **ignoring ASCII case** | `{ name: { $ilike: '%industries' } }` |
257259
| `$between` | Range (inclusive) | `{ close_date: { $between: ['2024-01-01', '2024-12-31'] } }` |
258260
| `$null` | Null check | `{ manager_id: { $null: true } }` / `{ phone: { $null: false } }` |
259261
| `$exists` | Field exists (NoSQL) | `{ metadata: { $exists: true } }` |
260262

263+
### `$like` is not a spelling of `$contains`
264+
265+
The two take different things and picking the wrong one is a silently wrong
266+
answer rather than an error:
267+
268+
- **`$contains` takes TEXT.** It is matched literally as a substring, and `%`,
269+
`_` and regex metacharacters in your comparand are ordinary characters —
270+
escaped on your behalf.
271+
- **`$like` takes a PATTERN.** `%` matches any sequence, `_` matches exactly one
272+
character, and a backslash escapes either. The pattern is matched against the
273+
**whole value**, so a pattern with no wildcards is an *exact comparison*, not
274+
a substring search.
275+
276+
```ts
277+
{ name: { $contains: 'Industries' } } // Acme Industries, Industries Ltd, Industries
278+
{ name: { $like: 'Industries' } } // Industries — exact
279+
{ name: { $like: '%Industries' } } // Acme Industries, Industries — ends with
280+
```
281+
282+
A pattern ending in a lone unpaired backslash is refused (`INVALID_FILTER`): no
283+
reading of it survives every backend, so it is rejected rather than guessed.
284+
285+
<Callout type="warn">
286+
**Backend coverage.** `$like` / `$ilike` are executed by the SQL family
287+
(`driver-sql`, `driver-sqlite-wasm`, `driver-turso` on both transports),
288+
`driver-memory`, and the in-memory `matchesFilter` evaluator.
289+
`driver-mongodb`, ObjectQL `having` and the analytics compilers **refuse**
290+
them with `INVALID_FILTER` rather than approximating them — use `$contains` /
291+
`$icontains` there. That split is deliberate: a backend that quietly answered
292+
a *different* question is the defect these operators exist to end (#7536).
293+
</Callout>
294+
261295
### Case Sensitivity
262296

263297
The string operators compare **case-sensitively**. `$icontains` is the one that does

‎content/docs/references/data/filter.mdx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,8 @@ Type: `[FilterArray](#filterarray)[]`
148148
| **$startsWith** | `string` | optional | |
149149
| **$endsWith** | `string` | optional | |
150150
| **$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); #6520 lowered it on every JS evaluation face, so it is portable across every backend the platform ships.] |
151+
| **$like** | `string` | optional | Whole-string pattern match with CALLER-bound wildcards: "%" matches any sequence (including empty), "_" matches exactly one character, and a backslash escapes the character after it ("\\%", "\\_", "\\\\") so it matches literally. The pattern must cover the WHOLE value — a pattern with no wildcards is an exact comparison, NOT a substring search; write $contains for containment. A pattern ending in a lone unpaired backslash is refused (INVALID_FILTER). Comparison is case-SENSITIVE, same contract as $contains (#4706 Q2 = A); $ilike is the case-insensitive twin. [#7536. Answered by the SQL family (driver-sql, driver-sqlite-wasm, driver-turso on both transports), by driver-memory and by @objectstack/formula. driver-mongodb, objectql `having` and service-analytics REFUSE it in the INVALID_FILTER envelope rather than approximating it — see FILTER_OPERATORS for why it is staged out of that allowlist.] |
152+
| **$ilike** | `string` | optional | Whole-string pattern match like $like — "%" / "_" wildcards bound by the caller, backslash escapes — but ignoring ASCII case (A-Z against a-z) and ONLY ASCII case: "café" does NOT match "CAFÉ", the same #4706 Q1 = A boundary $icontains declares, because SQLite's fold is ASCII-only and three of the five backends are SQLite underneath. [#7536; staged with $like — see FILTER_OPERATORS.] |
151153

152154

153155
---

‎packages/client/src/client.test.ts‎

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1015,25 +1015,58 @@ describe('FilterBuilder enhancements', () => {
10151015
expect(f[2]).toEqual(['age', '<=', 65]);
10161016
});
10171017

1018-
it('should add contains filter', () => {
1018+
// [#7536] These three used to build a `like` tuple by gluing wildcards onto
1019+
// the caller's value (`['name', 'like', '%alice%']`), which was wrong in two
1020+
// directions at once. While the wire folded `like` onto `$contains` — which
1021+
// LIKE-ESCAPES its comparand — the glued `%` came back as a literal percent
1022+
// sign, so `.contains('name','alice')` searched for the text `%alice%` and
1023+
// matched only rows containing percent signs. And once `like` reaches the
1024+
// driver as a real pattern, the glue becomes the OTHER bug: a `%` or `_`
1025+
// inside the caller's own value would silently become a wildcard. Naming the
1026+
// operator that means what the method says removes both.
1027+
it('should add contains filter as the literal-text operator', () => {
10191028
const f = createFilter<{ name: string }>()
10201029
.contains('name', 'alice')
10211030
.build();
1022-
expect(f).toEqual(['name', 'like', '%alice%']);
1031+
expect(f).toEqual(['name', 'contains', 'alice']);
1032+
});
1033+
1034+
it('should not let the caller\'s own wildcards leak into a contains filter', () => {
1035+
// The regression this shape prevents: `50%` is TEXT here, not a pattern.
1036+
const f = createFilter<{ name: string }>()
1037+
.contains('name', '50%')
1038+
.build();
1039+
expect(f).toEqual(['name', 'contains', '50%']);
10231040
});
10241041

10251042
it('should add startsWith filter', () => {
10261043
const f = createFilter<{ name: string }>()
10271044
.startsWith('name', 'A')
10281045
.build();
1029-
expect(f).toEqual(['name', 'like', 'A%']);
1046+
expect(f).toEqual(['name', 'starts_with', 'A']);
10301047
});
10311048

10321049
it('should add endsWith filter', () => {
10331050
const f = createFilter<{ email: string }>()
10341051
.endsWith('email', '.com')
10351052
.build();
1036-
expect(f).toEqual(['email', 'like', '%.com']);
1053+
expect(f).toEqual(['email', 'ends_with', '.com']);
1054+
});
1055+
1056+
it('should pass a like() pattern through UNCHANGED — the wildcards are the caller\'s', () => {
1057+
// The one method that always meant "pattern", and the one the wire
1058+
// lowering broke: `%Industries` matched nothing before #7536.
1059+
const f = createFilter<{ name: string }>()
1060+
.like('name', '%Industries')
1061+
.build();
1062+
expect(f).toEqual(['name', 'like', '%Industries']);
1063+
});
1064+
1065+
it('should add ilike filter for a case-insensitive pattern', () => {
1066+
const f = createFilter<{ name: string }>()
1067+
.ilike('name', '%industries')
1068+
.build();
1069+
expect(f).toEqual(['name', 'ilike', '%industries']);
10371070
});
10381071

10391072
it('should add exists filter', () => {

0 commit comments

Comments
 (0)