fix(spec): stop folding like/ilike onto $contains at the wire (#7536) - #7593
Conversation
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
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PhHptz16p1kRmbmuzEZkgd
#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
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 6 package(s): 114 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 8 release-owned page(s) also reference the affected code. These are read-only:
|
…ble (#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
Closes #7536
The defect
The wire lowering carried
'like': '$contains'inAST_OPERATOR_MAP(
packages/spec/src/data/filter.zod.ts), so everylikepredicate arrivingover HTTP was rewritten into a substring search before any driver saw it.
$containsLIKE-escapes its comparand and wraps it in%…%, which breaks alikein both directions at once. Measured in QA run #7463:["name","like","%Industries"]200, 0 rows — the%bound as a literal percent signIndustries["name","like","Industries"]$containscontrol["name","ilike","…"]400—ilikehad no lowering at all, soisFilterAST()refused the whole filterThe second row is the tell:
likeand$containsproducing the same bytesmeans
likewas never reaching the driver as a pattern.The file already documented the contract being violated.
canonicalAstOperator,thirty lines below the map entry, carried a hand-written exemption for
like/ilikewhose comment read: "they are NOT substring matches at thedriver … Folding them onto
containswould silently wrap the value in%…%andchange what the query means." That exemption only ever shaped its own output;
the lowering the wire path takes had none. Consequence named on the card:
driver-sql's
like/ilikehandling has been unreachable from the wire since#5158 — and it turned out to be worse than unreachable, see below.
What changed
Two new declared operators,
$like/$ilike. The comparand IS thepattern:
%any sequence,_exactly one character, backslash escapes either,matched against the WHOLE value — so a wildcard-free pattern is an exact
comparison.
$likeis case-SENSITIVE (#4706 Q2 = A, the contract$containsalready answers);
$ilikefolds ASCII only (Q1 = A).like/ilikelower tothem,
ilikeentering the AST vocabulary for the first time.canonicalAstOperator's exemption is retired — the generic round-tripanswers both spellings by construction now, so the special case is gone along
with the reason it existed.
The pattern language is defined once, in the spec, and shared by every face:
hasDanglingLikeEscape,likePatternToRegexSource,matchesLikePattern,likePatternToGlobPattern. Six faces translating one pattern languageseparately is the #3948 shape reached through translation instead of vocabulary.
driver-sql gained an emitter arm that did not exist. The card said the bind
arm was unreachable; measured, there was no arm to reach — the array-format
emitter went away with the array dialect in #5158, leaving only the two infix
spellings in
SCALAR_COMPARAND_OPERATORS, a comparand gate for an operatornothing could emit. So a lowering-only fix would have turned a silent wrong
answer into a 400 and the card's repro table still would not pass.
Blast-radius sweep — one row per execution path
driver-sqlLIKE(Postgres/MySQL),GLOB(SQLite)sql-driver-like-pattern.test.ts, 19 cases against real SQLitedriver-sqlite-wasmSqlDriver's compilerdriver-tursolocal + remoteturso-local-remote-like-parity.test.ts, 15 cases, each asserting the two transports agree BEFORE asserting the answerdriver-memoryquery path + reference matchermemory-like-pattern.test.ts, each case asserted on both faces@objectstack/formulamatches-filter-like.test.tsdriver-mongodbINVALID_FILTER/400 via itsdefault:armobjectqlhavingINVALID_FILTER/400 (#7047 already fixed the bareError)service-analyticsnormalizerINVALID_FILTER/400service-analyticsread-scope-sqlREAD_SCOPE_COMPILE_FAILED/500 — deliberate for RLS; recorded, not changed here$like/$ilikeare deliberately not inFILTER_OPERATORS: that array isthe runtime allowlist several faces derive acceptance from, and adding a name
there before every face has an arm turns a loud refusal into a silently DROPPED
predicate — the widening measured in #5701, ruled on in #3948.
driver-memorywidens its own
SUPPORTED_FIELD_OPERATORSby hand instead, the precedentdriver-turso's remote transport set for$icontainsin #5702.Two faces got arms rather than refusals for reasons that do not generalise:
formulabecause a declared operator hitting its silentfalsewould denyevery write while the read scope's SQL matched rows (the #6993 defect), and
driver-memorybecause it is the in-memory double — an app whose tests runthere and whose production runs SQL must not meet a 400 for a filter that works.
The latter is also the one driver holding the
VALID_AST_OPERATORSexpressibility invariant (#3948), which a refusal would have broken; that
invariant's suite is what caught it.
Why SQLite gets GLOB.
$likeis case-exact and SQLite'sLIKEfolds ASCIIunconditionally (
PRAGMA case_sensitive_likeis connection-global) — #6518'sfinding and the operator it landed on. GLOB speaks a different pattern language,
so the pattern is TRANSLATED, not escaped, including GLOB's own metacharacters
which are ordinary to LIKE: an unescaped
*in a GLOB pattern is the samefilter bypass an unescaped
%is under LIKE (#5567).Refused rather than given a meaning: a pattern ending in a lone unpaired
backslash. No reading survives every backend (Postgres rejects it outright, GLOB
has no escape character), so it is refused at the door on every face by one
shared test.
Wire-compat findings
No shipped app metadata, seed, fixture or docs example authors a
likepredicate —
VIEW_FILTER_OPERATORSdoes not even admit the spelling, so nostored view can carry one. The only in-repo producer was
@objectstack/client's query builder, and it was broken twice over:.contains(),.startsWith()and.endsWith()built aliketuple by gluingwildcards onto the caller's value. While the wire folded
likeonto$contains, the glued%was escaped back into a literal, so.contains('name','Corp')searched for the text%Corp%and matched only rowscontaining percent signs. And once
likereaches the driver as a real pattern,the glue becomes the other bug — a
%or_inside the caller's own valuewould silently become a wildcard. They now emit
contains/starts_with/ends_with, whose comparand is text..like()is unchanged and finally works;.ilike()is new.Three test/doc sites used
$likeas their "an operator the dialect does nothave" exemplar; those were retargeted at
$sounds_likewith the reason recordedin place, and the historical account of cloud#1030 left intact.
Reverse verification — predicted in writing, then measured
'like': '$contains'canonicalAstOperatorgoing red, which confirms the exemption and the map entry were always one fact$containswraplike ≠ containsinequality, which I predicted would also go red and wanted to: it is load-bearing in both directions$likearmsINVALID_FILTER, not wrong rowsLIKEnotGLOBon SQLiteACME INDUSTRIESrow is reachable by several patterns once LIKE folds case, so the fold shows up in more row sets than the case-labelled oneexpected undefined to be 'INVALID_FILTER'— the gate supplies the envelope, the translator only throwsOne further prediction miss worth recording: I expected the wire
ilikecase togo red under probe 1, and it stayed green.
parseFilterAST's lenient$${op}fallback mints
$ilikeeven with no map entry — it isisFilterAST(theprotocol door) that would have refused it. Two functions, two answers, which is
exactly the split #3948 is about.
Gates
pnpm --filter @objectstack/spec check:generated— all 13 artifacts up to datenode scripts/check-driver-conformance.mjs— OK, ledger unchanged (36 covered, 4 DEBT)api-surface/,export-origins/andcontent/docs/references/**moved and are committedMerged
origin/main(never rebased) after #7574 landed; the incoming diff isdisjoint from every file here.
Not touched
No
content/docs/releases/, nodocs/adr/**. #5222 ($fieldcross-fieldpush-down) is not implemented here.
Generated by Claude Code