Uh oh!
There was an error while loading. Please reload this page.
fix(search): $search compiles to $icontains so textual matching actually folds case - #7906
Conversation
…lly folds case
`$search` was case-SENSITIVE on textual fields, contrary to three places that
all declared the opposite: the `search.cross-field-object-search` checklist item
title, `search-filter.ts`'s own docblock ("Matching: case-insensitive"), and the
`search-conformance` ledger row. Searching "Retail" returned "Acme Retail";
searching "retail" returned nothing. Declared != enforced, and the declaration
is what three places already chose.
The cause was operator CHOICE, not operator behaviour. `fieldClausesForTerm`
emitted `{field: {$contains: term}}`, and `$contains` is contractually
case-SENSITIVE (#4706 Q2 = A) -- `$icontains` is the case-insensitive one.
SQLite's `LIKE` folded ASCII incidentally and hid the mismatch until #6518's
`LIKE`->`GLOB` change removed that accident.
Nothing about either operator changed. `$contains` remains case-sensitive and
`$icontains` remains its ASCII-folding twin; only which one `$search` compiles
to moved, which is what keeps this orthogonal to however #4706 Q2 finally lands.
Fixed in BOTH producers of search clauses. `search-filter.ts` was the one the
card named; `searchAll` -- the global-search palette behind GET /api/v1/search
-- turned out to be a second producer with the same defect, building its
AND-of-OR from `$contains` under a comment asserting `$contains` was the
case-insensitive operator. `search.console-global-search`'s knownGaps had
already recorded that path as this issue's to fix.
Deliberately unchanged: the select label->value path (`optionValuesMatching`
folds in JS and emits an exact-value `$in`), and the `__search` companion
clause, which stays `$contains` because the column is lowercase by construction
and the term is lowercased before it is compared -- a case-sensitive operator
over two already-folded values is exact, not a case bug. The select RAW-VALUE
fallback DID move, since it compares against raw stored text like any textual
clause.
All 6.5 filter faces verified to implement `$icontains` by reading the
implementation rather than counting greps -- including the two thin ones, where
service-analytics' cube lowering maps `$icontains` to a cube op that both
strategy renderers honour. driver-memory / driver-mongodb are frozen (#5499),
untouched, and already answer it.
Test placement follows the gap the card named: the dogfood pin stayed green
through the whole defect because its only case assertion was a select LABEL,
which passes on a case-sensitive build. It now carries the ['name']-narrowed
lowercase-vs-capitalized assertion over the real HTTP API, so the pin and the
checklist's textual clause finally cover the same mechanism.
Reverse-verified by reverting each producer independently: 7 search-filter unit
cases + the new dogfood assertion go red (the latter with `expected [] to
include 'Acme Retail'`, the issue's exact symptom) while the 4 pre-existing
dogfood cases stay green, and 3 searchAll cases go red on the palette side.
The 16 downstream tests that pinned `$contains` are updated. Several were fake
matchers that folded BOTH sides while keyed on `$contains` -- they implemented
`$icontains` semantics under the case-sensitive operator's name, which is part
of why no unit test on this path ever noticed.
Fixes#7641
Co-authored-by: Claude <noreply@anthropic.com>…rch-case-insensitive
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 3 package(s): 18 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also reference the affected code. These are read-only:
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes#7641
Option (A) per the PM ruling:
$searchemits$icontainsfor textual fields, so behaviour matches the three declarations that already said case-insensitive.What was wrong
fieldClausesForTermemitted{field: {$contains: term}}, and$containsis contractually case-SENSITIVE (#4706 Q2 = A).$icontainsis the case-insensitive one. SQLite'sLIKEfolded ASCII incidentally and hid the mismatch until #6518'sLIKE→GLOBchange removed that accident.⛔ Neither operator's semantics changed here.
$containsremains case-sensitive;$icontainsremains its ASCII-folding twin. Only which one$searchcompiles to moved — which is what keeps this orthogonal to however #4706 Q2 finally lands.Premise verification
Confirmed at HEAD before the first edit, by symbol rather than by the card's line numbers (they had moved):
search-filter.tsfieldClausesForTermemitted$containson both textual and enum-fallback arms; the docblock declared "Matching: case-insensitive".Acme Retailcapitalized, so the lowercase term genuinely could not hit.__searchcompanion from every record body (#7642) #7868 (8b90d68) confirmed non-overlapping — it touchedsearch-companion.ts,engine.ts,core.ts,index.ts+ one test + a changeset, and notsearch-filter.ts.$inpath confirmed already case-folding in JS (optionValuesMatching) and left undisturbed.Compile-surface conclusions — all 6½ faces
The PM's pre-dispatch grep gave counts; these are verified by reading each implementation, since a count cannot distinguish a mention from an arm.
driver-sqlapplyFilterCondition(+driver-sqlite-wasm/ turso-local by inheritance)case '$icontains':arm atsql-driver.ts:8965, with its own comparand refusal (icontainsComparandError) and a documented ASCII-only foldRemoteTransportbuildWhereSQLcase '$icontains':atremote-transport.ts:1997+icontainsComparandtwin; listed in bothSUPPORTED_FILTER_OPERATORSand the staged setcompileScopedFilterToSql(RLS read)read-scope-sql.ts:924emitsasciiLowerSqlExpr(col) LIKE asciiLowerSqlExpr(pattern) ESCAPE …, folding both sides. Only 2 hits because the arm is compact, not because it is a mentionlowerAnalyticsWhere(cube)filter-normalizer.ts:423maps$icontains → 'icontains', and that cube op is honoured by both renderers:objectql-strategy.ts:57(fold: true) andnative-sql-strategy.ts:722/737/772. Not a dead nameformulamatchesFilterCondition(RLS write-sidecheck)matches-filter.ts:244case '$icontains':on the sharedasciiCaseInsensitiveContainsfoldhaving-filterapplyHaving/matchesHavinghaving-filter.ts:349real arm + comparand refusal at :294.FILTER_LOGIC_CASESdoes not drive the HAVING path — its absence from the conformance table would have made a miss here go greendriver-memory/driver-mongodb$icontains(memory-driver.ts:1024,memory-matcher.ts:238,mongodb-filter.tsgate at :292), so compiling to it does not break themNo face needed changing and no fork was found.
A second producer, found during the surface sweep
search.console-global-search'sknownGapsalready recorded that the palette path shares this defect and that #7641 owns it. Verified and fixed here:metadata-protocolsearchAll(behindGET /api/v1/search) built the same AND-of-OR from$contains, under a comment asserting "ObjectQL exposes case-insensitive substring matching via$contains" — a fourth false declaration. Now emits$icontains; the comment is corrected.Deliberately unchanged
$in—optionValuesMatchingfolds in JS and emits exact values. Untouched, and pinned by a test that it stays$in.__searchcompanion clause — stays$contains. The column is lowercase by construction and the term is lowercased before comparison, so a case-sensitive operator over two already-folded values is exact, not a case bug. A comment now says so explicitly, and a test pins the two clauses using different operators in one filter, so nobody "aligns" them later.$icontains— it compares against raw stored text like any textual clause, so leaving it would have kept the docblock's blanket "case-insensitive" partly false. Called out inopen_questionsas the one judgment call beyond the literal wording of the ruling.The three declarations
All reconciled with the behaviour (required by the card either way):
search.cross-field-object-search) — already said case-insensitive, so the title is deliberately unchanged. ItsknownGaps, the textual clause'sverifynote, bothnegativeentries and thesearch-filter.tssource citation all read as if the gap were still open; they now record it CLOSED. Revision 3 history entry added. Same treatment forsearch.console-global-search's thirdknownGap.search-filter.tsdocblock — "Matching: case-insensitive" kept; the$orof$containsline corrected, and a[#7641]paragraph records why$icontainsis the operator and that no operator semantics moved.search-conformanceledger row —"case-insensitive $contains"was self-contradictory under Q2 = A; now"case-insensitive via $icontains".Test placement and reverse-verification
The card named the gap exactly: the dogfood pin stayed green because its only case assertion is a select label. That assertion is added where it will keep catching it — the ledger's declared HTTP-level proof,
showcase-search.dogfood.test.ts, narrowed to['name']so the select label→value path cannot satisfy it. No new isolated file for the$searchcase-fold.Reverse-verified by reverting each producer independently:
search-filter.ts→$containssearch-filter.test.tsexpected [] to include 'Acme Retail', the issue's exact symptom. The 4 pre-existing cases stayed green, reproducing the blind spot the card describedsearchAll→$containsprotocol.search-case-fold.test.tssearchAllgot its own file because there is no shared conformance coverage for that producer to join; it is named for the contract, not for the fix.16 downstream tests pinned the old operator and are updated. Several were fake matchers that folded both sides while keyed on
$contains— they implemented$icontainssemantics under the case-sensitive operator's name, which is part of why no unit test on this path ever caught the defect. Each now carries a comment saying so.Gates
Run in this worktree against a built closure, post-merge with
origin/main(d91fad5):pnpm check:query-options-erasurepnpm check:type-check-debtpnpm check:platform-checklistpnpm check:durability-log-levelpnpm check:engine-double-contractnode scripts/check-engine-split-ratio.mjspnpm check:nul-byteseslint(11 changed files)Gate list derived with
node scripts/pm/dispatch-gates.mjs, not enumerated by hand.Suites: objectql 3316 ✓ · metadata-protocol 1069 ✓ · rest 1471 ✓ · spec 10002 ✓ · lint 1922 ✓ · client 282 ✓ · dogfood search pins 7 ✓ — all re-run after merging
origin/main.Generated by Claude Code