Skip to content

Commit dcad825

Browse files
os-warrenclaude
andauthored
fix(service-analytics): compile the $icontains ASCII fold per dialect — translate() is not a SQLite function (#16020)
* fix(service-analytics): compile the $icontains ASCII fold per dialect — translate() is not a SQLite function All three of this package's SQL compilers spelled the #6520 fold as `translate(col, 'ABC…', 'abc…')` on EVERY dialect. `translate()` is PostgreSQL/Oracle; SQLite has none, so on a SQLite datasource an analytics `where` carrying `$icontains` — and an ADR-0021 D-C read scope carrying it — compiled a statement the engine refuses to parse. Measured on sql.js 1.14.1 (SQLite 3.49.1, the engine driver-sqlite-wasm runs): `SELECT translate('ABC','ABC','abc')` answers `no such function: translate`. `$icontains` now goes through `text-match-sql.ts`'s per-dialect construct table with one `fold` flag, set on that operator alone: - sqlite → `lower(col) GLOB lower(?)`, ASCII-only there (`lower('CAFÉ')` is `cafÉ`), which is the #4706 Q1 = A boundary rather than an approximation of it. - postgres → `translate()`, byte-identical to before. Never broken. - unknown → `translate()`, byte-identical to before. The residue keeps the shape it had; note this diverges from driver-sql, whose unknown arm folds with LOWER(), and neither face claims the other's. - mysql → the nested-REPLACE fold over CAST(… AS BINARY), matching driver-sql. TEXT ONLY — no MySQL server is provisionable here. The `sql` keyword field on `ObjectQLStrategy`'s LIKE_SQL_OPS lost its last reader in this move and is removed: a dead field named `sql` beside a compiler invites exactly the misreading this defect was. #15684's `$icontains` control asserted "the fold arm still emits translate() on every dialect". That was a PROXY for the property it protected — the two text families must not collapse onto one path — and this change makes the fold dialect-DEPENDENT by design, so the proxy no longer states the property. It is re-aimed rather than deleted or loosened: `$icontains` and `$contains` must now compile to DIFFERENT text on each dialect, and `$contains` must carry no fold in any of its three spellings. That discriminates against the collapse in both directions where dialect-invariance discriminated against one. Fixes #15780 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y * docs(service-analytics): correct the $icontains changeset — state the unknown-is-SQLite carve-out and name the measured set Round 2 of the Clause-② contract review on PR #16020. Text only: no file under packages/ moves, and the review found no code defect. The changeset said the `unknown` arm was "never broken". That is false for an `unknown` that is really SQLite — the review drove four off-repo constructions that land there, and for each of them `translate()` still reaches the engine and still fails to parse. The carve-out is now stated here word for word with the PR body, and tracked as #16028. "byte-identical" and "every dialect" were unqualified here while the PR body qualified them; both now name the same measured sets (the six verbatim in-suite cells widened to the review's 2,721-cell construction with 0 deltas, and the 510-cell family-separation set). The MySQL arm is byte-equal to driver-sql's `textMatchPredicate` on 60/60 cells but was executed nowhere — text-only on both faces, said plainly rather than left under "measured". The deliberate divergence from driver-sql's `unknown` arm is now recorded in the changeset too. No card-relation trailer here on purpose: this branch squashes, and the PR body declares the relation once. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y * docs(service-analytics): retract the "more tightly than the proxy" clause from the case-exactness suite header The round-1 contract review measured that clause false: FOLD_PER_DIALECT inspects the COLUMN side only, so a one-sided column fold on the postgres / unknown arm leaves this file green (exit 0, 14 passed) where the pre-fix `LIKE translate($1,` pin caught it. The PR body and the changeset were corrected in round 2; this source header was not, and a PR body cannot reach someone who opens this suite six months from now. The header now carries the two halves the PR body already states: tighter on family separation (the four collapse mutations the review drove all went red here), looser on both-sides folding, plus the cross-reference that icontains-dialect-sql.test.ts holds the verbatim postgres / unknown byte pin for that second case. Concluded as not net-weakened across the two files, never as a strict tightening. Comment-only, proven two ways with firing controls: every added and removed diff line is a comment line (28 added, 8 removed, 0 non-comment; four real code lines fed to the same filter classify as CODE), and the file transpiled with removeComments is byte-identical to its 711db06 blob (sha256/16 941867ad0f3e45a7, 14888 bytes on both sides, while injecting one code line in memory moves that hash). The other seven files this PR touches are untouched: git hash-object equals the 711db06 blob for each. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 1157e7b commit dcad825

8 files changed

Lines changed: 668 additions & 138 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
"@objectstack/service-analytics": patch
3+
---
4+
5+
Analytics `$icontains` no longer compiles a `translate()` call on the `sqlite` and `mysql` dialects. On **SQLite** that function does not exist and the statement failed to parse — measured on the engine, not inferred. On **MySQL** the same construct was emitted and its arm is repaired the same way, but nothing was ever executed there: the MySQL arm is asserted as emitted TEXT only, on this face and on `driver-sql`'s alike, so no MySQL parse failure is claimed as measured.
6+
7+
`$icontains` folds ASCII case on both sides of the comparison (#4706 Q1 = A). All three of this package's SQL compilers — the query's own `where` (`NativeSQLStrategy.buildFilterClause`), the ADR-0021 D-C read scope (`compileScopedFilterToSql`) and the `ObjectQLStrategy` echo of that statement — spelled that fold as `translate(col, 'ABC…', 'abc…')` on all four dialect values a compiler can see: `sqlite`, `mysql`, `postgres` and `unknown`, onto which `normalizeSqlDialect` maps everything else, an unset hook and `'oracle'` included. `translate()` is PostgreSQL/Oracle; SQLite has none. Measured on sql.js 1.14.1 (SQLite 3.49.1, the engine `driver-sqlite-wasm` runs), `SELECT translate('ABC','ABC','abc')` answers `no such function: translate` — so this was not a filter that returned the wrong rows, it was a statement the engine refused. On a SQLite datasource, an analytics `where` carrying `$icontains` and an **RLS read scope** carrying it were both unusable.
8+
9+
The fold is now chosen per dialect, on the same construct table the case-exact text family already used, reached through one `fold` flag:
10+
11+
- **SQLite**`lower(col) GLOB lower(?)`. SQLite's `lower()` is ASCII-only (measured: `lower('CAFÉ')` is `cafÉ`), so this is the ruled fold rather than an approximation of it, and it runs.
12+
- **PostgreSQL** and the `unknown` residue — `translate()`, byte-for-byte what those two arms emitted before. Measured set for that word: this package's own suite pins six cells verbatim — `{NativeSQLStrategy, ObjectQLStrategy echo, compileScopedFilterToSql} × {dialect unset, 'postgres'}` for `{name: {$icontains: 'acme'}}`, full emitted SQL and the exact bound params — and the round-1 contract review widened it to **2,721 cells** (2,720 = `{undefined, 'postgres', 'unknown', 'oracle'} × 5 compiler paths × 8 filter shapes × 17 comparands`, plus the bare `{dialect: undefined}` cell), emitted at the merge-base blobs (all five hash-verified) and again at this head: **0 changed cells, 0 error cells**. Outside that set nothing is claimed — no PostgreSQL server was contacted, and on `sqlite` and `mysql` the bytes deliberately changed (340 of 680 cells each, all inside the four `$icontains` shapes).
13+
- **MySQL** — the nested-`REPLACE` fold over `CAST(… AS BINARY)`, matching what `driver-sql` emits for the same operator; the review measured the two faces byte-equal on 60 of 60 MySQL cells. Asserted as text only — no MySQL server is provisionable in the container that wrote this, so that cell is a declared skip, not a claimed pass.
14+
15+
⚠️ Carve-out, stated because it is the surviving half of the defect and not an aside: an `unknown` dialect that is really SQLite is **not** fixed by this change. The residue is reached by four constructions the round-1 contract review drove rather than reasoned — a `SqlDriver` given a **class** client or an unrecognised spelling (`'libsql'`), a host hook answering knex's own `'sqlite3'`, a directly-constructed public `AnalyticsService` with the optional `sqlDialect` omitted, and a `data` service without `getDriverForObject`. For each of them `translate()` still reaches the engine and still fails to parse, on the `where` path, the read scope and the echo alike. No in-repo SQLite driver lands there — `SqliteWasmDriver` and `TursoDriver` both answer `"sqlite"`, measured — so this is an embedder-composition population, not a shipped-driver one. Tracked as #16028.
16+
17+
`$icontains` and the case-sensitive `$contains` family remain two separate constructs on every dialect the compilers accept — collapsing them would give `$contains` back the case fold #4706 Q2 = A took away from it. Measured set for that word: 510 cells (six dialect names — the four values above plus `'oracle'` and an unset hook, which both normalize to `unknown` — × 5 compiler paths × 17 comparands), 0 of them identical between the two families and no `$contains` cell carrying a fold.
18+
19+
⚠️ One deliberate divergence from `driver-sql`, recorded here rather than only in this package's source: `driver-sql`'s own `unknown` arm folds with `LOWER()`, this one keeps `translate()`. Each face keeps the residue it already had, and adopting `LOWER()` here would silently restore on PostgreSQL the Unicode fold #4706 Q1 = A rules out. The pointer exists on this side only; `driver-sql` carries no cross-reference back.

0 commit comments

Comments
 (0)