|
| 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. |
0 commit comments