Skip to content

fix(search): $search compiles to $icontains so textual matching actually folds case - #7906

Merged
huangyiirene merged 2 commits into
mainfrom
claude/issue-7641-search-case-insensitive
Aug 12, 2026
Merged

fix(search): $search compiles to $icontains so textual matching actually folds case#7906
huangyiirene merged 2 commits into
mainfrom
claude/issue-7641-search-case-insensitive

Conversation

@huangyiirene

Copy link
Copy Markdown
Collaborator

Fixes#7641

Option (A) per the PM ruling: $search emits $icontains for textual fields, so behaviour matches the three declarations that already said case-insensitive.

What was wrong

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 LIKEGLOB change removed that accident.

Neither operator's semantics changed here.$contains remains case-sensitive; $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.

Premise verification

Confirmed at HEAD before the first edit, by symbol rather than by the card's line numbers (they had moved):

  • search-filter.tsfieldClausesForTerm emitted $contains on both textual and enum-fallback arms; the docblock declared "Matching: case-insensitive".
  • The showcase seed stores Acme Retail capitalized, so the lowercase term genuinely could not hit.
  • fix(objectql): strip the hidden __search companion from every record body (#7642) #7868 (8b90d68) confirmed non-overlapping — it touched search-companion.ts, engine.ts, core.ts, index.ts + one test + a changeset, and notsearch-filter.ts.
  • Enum/select $in path 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.

#surfaceconclusionevidence
1driver-sqlapplyFilterCondition (+ driver-sqlite-wasm / turso-local by inheritance)already compliantreal case '$icontains': arm at sql-driver.ts:8965, with its own comparand refusal (icontainsComparandError) and a documented ASCII-only fold
2turso RemoteTransportbuildWhereSQLalready compliantindependent compiler, own case '$icontains': at remote-transport.ts:1997 + icontainsComparand twin; listed in both SUPPORTED_FILTER_OPERATORS and the staged set
3service-analytics compileScopedFilterToSql (RLS read)already compliant — thin count was realread-scope-sql.ts:924 emits asciiLowerSqlExpr(col) LIKE asciiLowerSqlExpr(pattern) ESCAPE …, folding both sides. Only 2 hits because the arm is compact, not because it is a mention
4service-analytics lowerAnalyticsWhere (cube)already compliant — thinnest count, chased downstreamfilter-normalizer.ts:423 maps $icontains → 'icontains', and that cube op is honoured by both renderers: objectql-strategy.ts:57 (fold: true) and native-sql-strategy.ts:722/737/772. Not a dead name
5formulamatchesFilterCondition (RLS write-side check)already compliantmatches-filter.ts:244case '$icontains': on the shared asciiCaseInsensitiveContains fold
½objectql having-filterapplyHaving / matchesHavingalready complianthaving-filter.ts:349 real arm + comparand refusal at :294. ⚠️ Declared explicitly becauseFILTER_LOGIC_CASES does not drive the HAVING path — its absence from the conformance table would have made a miss here go green
driver-memory / driver-mongodbout of scope — frozen (#5499), not flippeduntouched. Verified they do answer $icontains (memory-driver.ts:1024, memory-matcher.ts:238, mongodb-filter.ts gate at :292), so compiling to it does not break them

No face needed changing and no fork was found.

A second producer, found during the surface sweep

search.console-global-search's knownGaps already recorded that the palette path shares this defect and that #7641 owns it. Verified and fixed here:

metadata-protocolsearchAll (behind GET /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

  • Select label→value $inoptionValuesMatching folds in JS and emits exact values. Untouched, and pinned by a test that it stays $in.
  • __search companion 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.
  • The select RAW-VALUE fallback DID move to $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 in open_questions as 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):

  1. Checklist item title (search.cross-field-object-search) — already said case-insensitive, so the title is deliberately unchanged. Its knownGaps, the textual clause's verify note, both negative entries and the search-filter.ts source citation all read as if the gap were still open; they now record it CLOSED. Revision 3 history entry added. Same treatment for search.console-global-search's third knownGap.
  2. search-filter.ts docblock — "Matching: case-insensitive" kept; the $or of $contains line corrected, and a [#7641] paragraph records why $icontains is the operator and that no operator semantics moved.
  3. search-conformance ledger 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 $search case-fold.

Reverse-verified by reverting each producer independently:

revertred
search-filter.ts$contains7 unit cases in search-filter.test.ts
same revert, dogfood pin1 — the new assertion, failing expected [] to include 'Acme Retail', the issue's exact symptom. The 4 pre-existing cases stayed green, reproducing the blind spot the card described
searchAll$contains3 cases in the new protocol.search-case-fold.test.ts

searchAll got 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 $icontains semantics 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):

gateresult
pnpm check:query-options-erasureexit 0 — baseline unchanged, no files added
pnpm check:type-check-debtexit 0 — no entry above its recorded number (built closure first, as required)
pnpm check:platform-checklistexit 0 — 15 areas, 182 items
pnpm check:durability-log-levelexit 0
pnpm check:engine-double-contractexit 0
node scripts/check-engine-split-ratio.mjsexit 0
pnpm check:nul-bytesexit 0
eslint (11 changed files)exit 0

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

…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>
@vercel

vercelBot commented Aug 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 12, 2026 3:42am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/metadata-protocol, @objectstack/objectql, @objectstack/dogfood.

18 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/concepts/metadata-lifecycle.mdx(via @objectstack/metadata-protocol, @objectstack/objectql)
  • content/docs/data-modeling/formulas.mdx(via packages/objectql)
  • content/docs/deployment/migration-from-objectql.mdx(via @objectstack/objectql)
  • content/docs/deployment/vercel.mdx(via @objectstack/objectql)
  • content/docs/kernel/contracts/data-engine.mdx(via @objectstack/objectql)
  • content/docs/kernel/runtime-services/examples.mdx(via packages/objectql)
  • content/docs/kernel/services-checklist.mdx(via @objectstack/metadata-protocol, @objectstack/objectql)
  • content/docs/kernel/services.mdx(via @objectstack/objectql)
  • content/docs/permissions/authentication.mdx(via @objectstack/objectql)
  • content/docs/permissions/authorization.mdx(via packages/qa/dogfood)
  • content/docs/permissions/delegated-administration.mdx(via packages/qa/dogfood)
  • content/docs/permissions/system-context.mdx(via packages/objectql)
  • content/docs/plugins/index.mdx(via @objectstack/objectql)
  • content/docs/plugins/packages.mdx(via @objectstack/objectql)
  • content/docs/protocol/kernel/http-protocol.mdx(via @objectstack/metadata-protocol)
  • content/docs/protocol/kernel/index.mdx(via @objectstack/objectql)
  • content/docs/protocol/objectql/query-syntax.mdx(via packages/objectql)
  • content/docs/protocol/objectql/state-machine.mdx(via @objectstack/objectql)

2 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx(via @objectstack/objectql)
  • content/docs/releases/v9.mdx(via @objectstack/metadata-protocol)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 12, 2026
@huangyiirene
huangyiirene marked this pull request as ready for review August 12, 2026 04:12
@huangyiirene
huangyiirene added this pull request to the merge queueAug 12, 2026
Merged via the queue into main with commit 4e74c18Aug 12, 2026
27 checks passed
@huangyiirene
huangyiirene deleted the claude/issue-7641-search-case-insensitive branch August 12, 2026 04:24
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

$search is case-sensitive on textual fields, contrary to three declarations that say case-insensitive

2 participants

@huangyiirene@claude