fix(drivers): text-operator case folding is the contract's answer, not the dialect's (#6518) - #6706
Merged
Merged
Conversation
`$contains` 族与 `$icontains` 的大小写答案此前取决于底层数据库的 `LIKE` 语义,两个方向都是**过匹配**——返回了 filter 排除的行,在 ADR-0021 的 RLS 读作用域上是越权而非「宽松过滤」(#3948): | | `$contains` 族(契约要求大小写敏感,#4706 Q2=A) | `$icontains`(只折叠 ASCII,#4706 Q1=A) | |:--|:--|:--| | SQLite / turso / sqlite-wasm | ❌ `LIKE` 自带 ASCII 折叠 | ✅ `lower()` 只折 ASCII | | Postgres | ✅ `LIKE` 恰好大小写精确 | ❌ `LOWER()` 折全 Unicode | | MySQL | ❌ 随 collation | ❌ `LOWER()` 折全 Unicode | 横着读:**每个方言恰好在另一个方言犯错的那一半上是对的**——这就是两半都 无法从单一后端上被发现的原因。 下译改为按方言选构造,仍然只有一个发射点(转义与折叠是同一条代码路径, 未转义的通配符是 P0 —— #5567): - **SQLite 族 → `GLOB`**。`LIKE` 的 ASCII 折叠无法按语句关闭 (`PRAGMA case_sensitive_like` 是连接级全局开关),实测 `CAST(col AS BLOB) LIKE ?` 一行都不匹配。`GLOB` 大小写精确,并自带转义类 (`*` / `?` / `[` 写作自闭合字符类,因为 SQLite 语法不给 `GLOB` 任何 `ESCAPE` 子句)。 - **Postgres → `LIKE` 不变**,只把折叠从 `LOWER()` 换成显式 `translate()`; live PG 16 实测 `LOWER('CAFÉ')` 就是 `'café'`。 - **MySQL → `CAST(… AS BINARY)` 上的 `LIKE`**,比较按字节,collation 不再 参与;折叠同样按字节做,因 UTF-8 自同步故恰为 ASCII-only。 - 其余 client 保留改动前的 `LIKE`/`LOWER()` 形状,并记为残留而非静默。 turso 的 remote transport 自带一份编译器,同批移动;两个 transport 由新的 `turso-local-remote-text-parity.test.ts` 用共享 `FILTER_TEXT_CASES` 钉在同 一组行上。driver-sql / -sqlite-wasm / -turso 三格的 `FILTER_TEXT_CASES` DEBT 随之清偿;driver-memory / driver-mongodb 属 #5499 冻结族,如实留 DEBT 并改指新填的后继单 #6682。 Fixes #6518 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AYvVzTaCWDCC32e9Le9zXn
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 3 package(s): 17 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
… is case-exact on sqlite (#6518) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AYvVzTaCWDCC32e9Le9zXn
This was referenced Aug 8, 2026
os-zhuang
marked this pull request as ready for review
August 8, 2026 13:48
This was referenced Aug 8, 2026
This was referenced Aug 8, 2026
test(driver-turso): drop the dead
as never on date-bucket-parity's create options gate (#6394)
#6757
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #6518
$contains/$notContains/$startsWith/$endsWithare case-SENSITIVE by contract (#4706 Q2 = A), and$icontainsfolds ASCII only (#4706 Q1 = A). Neither held: case sensitivity was whateverLIKEhappened to mean on the dialect underneath. Both directions over-matched — rows the filter excludes came back — which on an ADR-0021 RLS read scope is over-reach, not a loose filter (#3948).$containsfamily — case-SENSITIVE$icontains— ASCII-only foldLIKEfolds ASCIIlower()is ASCII-onlyLIKEis case-exactLOWER()folds all of UnicodeLOWER()folds all of UnicodeRead across: each dialect was already right on the half another one got wrong. That is why neither half was findable from one backend, and why the acceptance asked for a live PG/MySQL cell.
Premise re-verified against post-#6549
origin/main#6549(82397b6) merged the morning this was dispatched and rewroteapplyLike/pushLike(adding thefoldaxis) and the five DEBT rows. Re-measured ond13f627: the premise still holds.applyLikestill emitted oneLIKE … ESCAPE ?for every dialect andLOWER()for the fold; the five case rows ofFILTER_TEXT_CASESwere still unanswered and still the sole reason the five DEBT rows survived.premise_still_valid: true.What is emitted now
One emitter (
textMatchPredicate), construct chosen by dialect, so escaping and folding stay a single code path — an unescaped wildcard is a filter bypass, P0 (#5567):GLOB.PRAGMA case_sensitive_likeis connection-global (one query would redefine every other query on the connection), so the fold cannot be switched off per statement. Of the operand-level tricks,CAST(col AS BLOB) LIKE ?was measured to match nothing at all — SQLite'sLIKEis false for a BLOB operand — so the operator has to change.GLOBis case-exact and brings its own escaped class:*,?,[→[*],[?],[[], because SQLite's grammar givesGLOBnoESCAPEclause.$icontainskeepslower()on both operands.LIKE, unchanged (already case-exact). Only the fold moved, fromLOWER()to an explicittranslate()over the 26 ASCII letters — the mapping is visible in the emitted SQL, so its ASCII-only-ness is structural rather than a property of the server's locale.LIKEoverCAST(… AS BINARY), byte-wise so no collation decides the case; the fold is 26REPLACEs over that same binary rendering. Byte-wise ASCII lowering is the ruled fold because UTF-8 is self-synchronising — a byte in0x41..0x5Acan only ever be a real ASCIIA..Z. The rejected alternatives are written out on the helper (LOWER()over-folds;LOWER()on a binary string is documented ineffective;CONVERT(… USING ascii)collidescaféwithcafÉ, strictly worse).$contains在 SQLite 过折叠、$icontains在 PG/MySQL 过折叠 #6518 shape — the only form that still runs there — recorded as residue in the ledger rather than left to be discovered.Per-face verdict table
Every face from the dispatch inventory. "Not mentioned" would read as "missed", so all of them are here.
applyLike→textMatchPredicateFILTER_TEXT_CASESgreen on sqlite and on live PG.RemoteTransport.pushLikeGLOB+ glob escapes. New parity suite pins it to the local face on every case.GLOBis supplied by the SQLite build, not the application, so "it inherits" is not taken on faith.read-scope-sql?that both consumers rewrite to$N, with"double quoted"identifiers — Postgres-shaped, and on PostgresLIKEis the ruled semantics (measured live:%acme%→ row 2 only,%ACME%→ row 1 only). Assumption now written down inlike-pattern.tsand pinned by new cases, so it fails rather than rots.filter-normalizer+ sharedlike-pattern.ts(+native-sql-strategy,objectql-strategy)$Nplaceholders and noILIKE/LOWER(.matchesFilterConditionString.prototype.includes/startsWith/endsWithcompare exactly. Nothing changed — which is exactly why it is pinned: this is the JS baseline the drivers were brought to, and the next mistake here is a "helpful"toLowerCase()that no assertion would have caught.having-filtercheck-driver-conformancescopes itself topackages/drivers/*), so a fold added here would go red nowhere.memory-driver.tsRegExp-'i';memory-matcher.tsincludes; analytics face)mongodb-filter.tshard-coded$options: 'i')$icontainson faces 3/4, per the dispatch's (d): verified fail-closed, unchanged. The package has zero$icontainsreferences;filter-normalizer.ts'sfieldLeavesthrowsinvalidFilterErrorandread-scope-sql.ts'scompileOperatorthrows from itsdefault:arm. Both are now asserted, so "unimplemented" cannot quietly become "dropped" — a dropped predicate widens. Adding the operator there stays #6520's programme.Reverse verification — direction predicted BEFORE each run
Three experiments, each reverting one arm, each predicted to fail a different set of cells. That the sets are disjoint is the claim that this issue really is two independent defects rather than one seen twice.
GLOB→LIKElower()(sqlite)$icontains: 'acme'and'ACME'both['1','2']→['2'];$icontains: 'CAFÉ'['3']→[]translate()→LOWER()$icontains: 'café'answered['3','4']where the case-set demands['4']; the'CAFÉ'mirror answered['3','4']where it demands['3']The
CAST(col AS BLOB) LIKE ?candidate was also measured before being written off, on better-sqlite3 3.53.4 against the nine-row fixture: every shape returned[].GLOB's escape class was measured the same way — unescaped*a*b*returned 6 rows where*a[*]b*returns 1.Which live cells executed, and which did not
LOWER('CAFÉ')genuinely is'café', so the over-fold was reproduced before the fix and is absent after. All 17 cases + the fixture-control green (OS_TEST_POSTGRES_URLset).mysqld, no working Docker daemon). It is a declared skip by name via the testkit'sdeclareUnprovisionedCell, never a faked green, andOS_EXPECT_LIVE_DIALECT_MATRIX=1turns that skip into a failure for a runner that believes it provisioned one. CI'sTemporal Conformance (live PG + MySQL)job already sets both URLs, so the mysql cell executes there. Its arm rests meanwhile on documented behaviour plus a compiled-SQL pin — knex builds a MySQL statement without a server, so the shape (twoCAST(… AS BINARY), 52REPLACEs under the fold, noLOWER() is asserted in-process.Pin sweep
Swept the whole repo in one pass; every flipped pin asserts the new substance, none was merely deleted, and every refusal assertion is verbatim.
driver-sql/sql-driver-icontains-and-retired-operators.test.tsLOWER(…) LIKE LOWER(→lower(…) GLOB lower(; now importsFILTER_TEXT_CASES; added case-sensitivity, negation, glob-escape blocksdriver-sql/sql-driver-text-case-conformance.test.tsDIALECT_CELLS(#5589 conventions) + the per-dialect compiled shapedriver-sqlite-wasm/sqlite-wasm-icontains-…test.tsFILTER_TEXT_CASES; added case-sensitivity + glob-escape blocks on sql.jsdriver-turso/remote-transport-text-predicates.test.tsLIKE … ESCAPEshape →GLOB, plus a new block asserting*/?/[are escaped and%is notdriver-turso/turso-local-remote-text-parity.test.tsdriver-turso/remote-transport-{comparand,null-comparand,undefined-comparand}-refusal.test.ts%…%→*…*,LIKE→GLOB; each keeps the claim it was makingformula/matches-filter.test.ts,objectql/having-filter.test.tsservice-analytics/__tests__/like-metacharacter-escape.test.ts$icontainsfail-closed at both doorsscripts/check-driver-conformance.mjsSearched for and found no other fixture, REST-layer test or doc example asserting a folded row set.
DEBT re-grade
The two frozen rows could not point at #6518 (it closes here), and #6520 covers only requirement 1 (
$icontains). Requirement 2 for those two packages had no successor, so one was filed unassigned, search-first-deduped: #6682. Both rows now say explicitly that both successors must land before the row can go, because coverage is judged by importing the whole case-set — a half-answered cell must not import it.Changeset level — graded, with reasoning
minoron the three driver packages. Not a downgrade to dodge the ADR-0087 gate: no declared surface moves.$containsstill exists, takes the same comparand, andfilter.zod.tsis untouched — the case-sensitivity delivered here was already published as the contract by #5701 one release earlier in this same open v17 major, and the drivers were the half that had not caught up. Prime Directive #12 in the direction it points: declared = enforced. Walking the gate's four dispositions honestly, none applies (registered: nothing registered;unpublished: the packages are published;already-registered: no id;no-migration-prescription: refused, since the body does carry migration guidance) — which is the gate saying this is not an ADR-0087-class change. Same grade its sibling #5702/#6549 took for the same operator family in the same rc cycle. The gate confirms:this PR adds no declared-breaking changeset.The behaviour change is stated prominently in the changeset body regardless: result sets only ever get narrower, and anyone relying on
$containsto ignore case should write$icontains.Gates — enumerated from
.github/workflows/lint.yml, run one by oneAlso green, each run individually:
check:slot-lookup,check:verify-stand-in,check:doc-authoring,check:docs-audit-scope,check:role-word,check:quick-reference-counts,check:adr-anchors,check:org-identifier,check:authz-resolver,check:service-providers,check:route-envelope,check:error-code-casing,check:wildcard-fallthrough,check:meta-type-normalized,check:init-service-contract,check:durability-log-level,check:startup-registry-verdict,check:objectui-changeset,check:release-notes,check:release-body,check:node-version,check:workflow-status-functions,check:shard-attestation,check:published-files,check:resume-authority-declared,check:merge-driver,check:spec-parsed-alias,check:stall-guard,check:type-check-coverage,check:i18n,check:i18n-coverage,check:app-nav-i18n,check:skill-frame-sync,check:skill-compatibility,check:prerelease-pins, and the spec-scoped set (check:generated --reconcile-only,check:skill-docs,check:spec-changes,check:upgrade-guide,check:authorable-surface,check:docs,check:skill-refs,check:react-blocks,check:api-surface,check:exported-any,check:dual-source-exports,check:skill-examples), pluscheck:doc-formula-expressions, examples typecheck and the downstream-contract typecheck.One red, and it is not in
lint.yml:check:objectui-pin-freshreports the objectui pin as stale. It lives inrelease.yml/objectui-pin-freshness.yml(the Version-Packages/release path), is a function of repo state versus objectui'smainrather than of this diff, and refreshing it would be exactly the pin-bump rider this change must not carry. Left untouched and reported.Constraints honoured
packages/specsource: zero changes — the case-set already encoded the ruled contract, so no spec-side change turned out to be necessary.content/docs/releases/: untouched.as any/as never/@ts-ignore. The oneas unknown asadded is the sanctioned fix(drivers): 聚合函数拒收带上 ADR-0112 信封,并把两类条件分开措辞 (#5907) #6204 named-member spelling, and it replaces rather than adds erosion:asLibsqlClient()in the turso testkit names theClienttarget type once, where the pre-existing suites each wroteclient: stub as never— a cast to the bottom type, which would keep compiling even ifclientwere re-typed to something the stub cannot model. Existing call sites can migrate to it; nothing forces them to here. The new matrix suite reaches knex through the publicgetKnex()rather than the(driver as any).knexits sibling matrices use.assertEngineDeleteDispatchsite was needed; every new/rewritten rejection case assertscodeandstatus(ADR-0112), never a baretoThrow.Successor repricing (必答项)
count_distinctin the SQL family@objectstack/typespredicateunique-violation.ts.options?: anynarrowingremote-transport.tsgains no newany, and the new parity suite exercises both transports through the typed driver surface on 22 cases — narrowing work there now has a row-level regression net it did not have. Not a blocker either way.GroupByNode.aliasOpen questions (adjudicated here, per the dispatch — not blocking)
translate()vslower(x COLLATE "C")on Postgres. Both measured ASCII-only on live PG 16 (C.utf8 and ICU databases). Chosetranslate(): its ASCII-only-ness is structural — the 26-character mapping is visible in the emitted SQL — whereCOLLATE "C"is a property of a collation definition a reader has to go look up. Long-term-project axis, and it also makes the MySQL arm read as the same idea rather than an unrelated trick.REPLACEs are verbose (~52 per$icontainspredicate). Accepted: the alternatives are wrong rather than merely uglier (spelled out on the helper), the predicate was a full scan either way, and correctness that a reviewer can verify by reading beats brevity that needs a live server to trust.'unknown'dialect keeps the old shape.GLOBis a syntax error andCAST(… AS BINARY)means something else outside the three modelled clients, so the old form is the only one that runs. Recorded as residue in the ledger and pinned in the matrix rather than left silent.like-pattern.tssays in as many words that the file is wrong the day these compilers emit for SQLite or MySQL, and the pin fails if the emitted shape stops being Postgres-shaped.Out-of-scope findings
$containsfamily still folds case — the last two backends left on the wrong side of #4706 Q2 = A #6682 (filed unassigned,findingposture, search-first-deduped): driver-memory and driver-mongodb still fold case on their query paths, and driver-memory's two faces disagree with each other. [裁决] driver-memory / driver-mongodb 投入冻结 —— 维护者 2026-08-05 口径(跨单锚点) #5499-frozen, so out of scope here by ruling; filed so the surviving DEBT rows point at a live successor instead of a closed issue.Generated by Claude Code