Skip to content

Commit 361c7fa

Browse files
os-warrenclaude
andcommitted
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
1 parent f7db8f4 commit 361c7fa

8 files changed

Lines changed: 644 additions & 138 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@objectstack/service-analytics": patch
3+
---
4+
5+
Analytics `$icontains` no longer compiles a `translate()` call on SQLite and MySQL, where that function does not exist and the statement failed to parse.
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 **every** dialect. `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 (a host that wires no dialect hook) — `translate()`, **unchanged**. These arms were never broken, so the emitted SQL and its bound parameters are byte-identical to before.
13+
- **MySQL** — the nested-`REPLACE` fold over `CAST(… AS BINARY)`, matching what `driver-sql` emits for the same operator. 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+
`$icontains` and the case-sensitive `$contains` family remain two separate constructs on every dialect — collapsing them would give `$contains` back the case fold #4706 Q2 = A took away from it. A host that answers no dialect keeps exactly the behaviour it had.

packages/services/service-analytics/src/__tests__/icontains-dialect-sql.test.ts

Lines changed: 345 additions & 0 deletions
Large diffs are not rendered by default.

packages/services/service-analytics/src/__tests__/text-operator-case-exactness.test.ts

Lines changed: 62 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -50,18 +50,40 @@
5050
* — on the same engine, and requires the same row sets from both. A third
5151
* hand-copy of the table anywhere is the thing to refuse.
5252
*
53-
* ## What is deliberately NOT changed
53+
* ## What #15684 deliberately did not change, and what #15780 then did
5454
*
55-
* `$icontains` (#6520) keeps its own construct on every dialect: it folds BOTH
56-
* sides through `asciiLowerSqlExpr`, and the assertions below pin that the
57-
* emitted text is untouched by the dialect. That fold is `translate()`, which
58-
* SQLite does not have (measured: `no such function: translate` on sql.js
59-
* 1.14.1) — so those statements are pinned as TEXT and are NOT executed here.
60-
* That is a separate defect, filed as #15780, and this suite's `$icontains`
61-
* assertions are the control that must stay unchanged while it is open.
62-
* Escaping (#5567) is likewise unchanged for every `LIKE` arm; the GLOB arm
63-
* brings its OWN escaped character class (`*`, `?`, `[`), which is why the
64-
* second fixture below exists.
55+
* As written, this suite pinned `$icontains` (#6520) as a CONTROL: it kept its
56+
* own construct on every dialect, folding both sides through
57+
* `asciiLowerSqlExpr`, and the assertion below pinned that its emitted text was
58+
* untouched by the dialect. The property that control protected is the one that
59+
* still matters: **the two text families must not collapse onto one path**, or
60+
* `$contains` gets back the fold #4706 Q2 = A took away from it. The pin was
61+
* "the fold arm still emits `translate()` on every dialect" only because, while
62+
* #15684's scope was the case-exact four, dialect-invariance was a cheap
63+
* PROXY for family-separation — the two happened to coincide.
64+
*
65+
* They stopped coinciding. That same `translate()` is a PostgreSQL/Oracle
66+
* function SQLite does not have (measured: `no such function: translate` on
67+
* sql.js 1.14.1), so `$icontains` did not merely go unpinned on SQLite — it
68+
* failed to PARSE there. That was #15780, and closing it moved `$icontains`
69+
* onto this same per-dialect table with a `fold` flag, which makes its emitted
70+
* text dialect-DEPENDENT by design. The old assertion could then only be read
71+
* two ways: as a defect it must go red for, or as a statement of the property,
72+
* which it no longer is.
73+
*
74+
* ⛔ It was therefore neither deleted nor loosened — it was re-aimed at the
75+
* property itself, which is now pinned DIRECTLY and more tightly than the proxy
76+
* ever did (`$icontains and the case-EXACT family stay two constructs…`
77+
* below): on each dialect, `$icontains` and `$contains` must compile to
78+
* DIFFERENT text, and `$contains` must carry no fold. That discriminates
79+
* against the collapse in both directions, where dialect-invariance only
80+
* discriminated against one. The row sets that make it more than a text
81+
* comparison are executed in `icontains-dialect-sql.test.ts`, which owns the
82+
* `$icontains` half of the family from here on.
83+
*
84+
* Escaping (#5567) is unchanged for every `LIKE` arm; the GLOB arm brings its
85+
* OWN escaped character class (`*`, `?`, `[`), which is why the second fixture
86+
* below exists.
6587
*/
6688

6789
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
@@ -234,22 +256,37 @@ describe('[#15684] the compiled TEXT, per dialect', () => {
234256
.toEqual({ sql: 'CAST("t"."name" AS BINARY) LIKE CAST(? AS BINARY) ESCAPE ?', params: ['%acme%', '\\'] });
235257
});
236258

237-
it('$icontains is untouched by the dialect — the fold arm still emits translate() on both sides', async () => {
238-
// The control that must stay green. #6520's construct is case-INSENSITIVE
239-
// by ruling, so it never wants the case-exact table; if a future edit routes
240-
// it through `text-match-sql.ts`, the `$contains` family gets back the fold
241-
// #4706 Q2 = A took away from it and this line reds first.
259+
it('$icontains and the case-EXACT family stay two constructs on EVERY dialect', async () => {
260+
// [#15684 → #15780] The control, re-aimed. This assertion used to read "the
261+
// fold arm still emits translate() on every dialect", which was a PROXY for
262+
// the property below and stopped being one when #15780 gave `$icontains` a
263+
// per-dialect fold of its own. See this file's header for the full reading.
264+
//
265+
// The property is family SEPARATION: `$icontains` folds (#4706 Q1 = A) and
266+
// `$contains` must not (#4706 Q2 = A). A collapse in EITHER direction reds
267+
// here — the fold leaking onto `$contains`, or `$contains`' bare construct
268+
// being handed to `$icontains`.
242269
for (const dialect of [undefined, 'sqlite', 'postgres', 'mysql'] as const) {
243-
const out = await nativeSql({ name: { $icontains: 'acme' } }, dialect);
244-
expect(out.sql, String(dialect)).toContain(
245-
"WHERE translate(name, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz') LIKE translate($1,",
246-
);
247-
expect(out.sql, String(dialect)).toContain('ESCAPE $2');
248-
expect(out.params, String(dialect)).toEqual(['%acme%', '\\']);
270+
const icontains = await nativeSql({ name: { $icontains: 'acme' } }, dialect);
271+
const contains = await nativeSql({ name: { $contains: 'acme' } }, dialect);
272+
expect(icontains.sql, String(dialect)).not.toBe(contains.sql);
273+
// `$contains` carries NO fold, in any of the three spellings a fold has.
274+
expect(contains.sql, String(dialect)).not.toMatch(/translate\(|lower\(|REPLACE\(/);
275+
// …and `$icontains` carries exactly one of them, per dialect.
276+
const FOLD_PER_DIALECT: Record<string, RegExp> = {
277+
undefined: /translate\(name, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'/,
278+
postgres: /translate\(name, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'/,
279+
sqlite: /lower\(name\) GLOB lower\(\$1\)/,
280+
mysql: /REPLACE\(CAST\(name AS BINARY\), 'A', 'a'\)/,
281+
};
282+
expect(icontains.sql, String(dialect)).toMatch(FOLD_PER_DIALECT[String(dialect)]);
249283
}
250-
expect(
251-
compileScopedFilterToSql({ name: { $icontains: 'acme' } } as FilterCondition, 't', { dialect: 'sqlite' }).params,
252-
).toEqual(['%acme%', '\\']);
284+
// The read scope, the compiler where the collapse would be an ADR-0021
285+
// over-reach rather than a wrong chart, holds the same separation.
286+
const filterOf = (op: '$contains' | '$icontains') =>
287+
compileScopedFilterToSql({ name: { [op]: 'acme' } } as FilterCondition, 't', { dialect: 'sqlite' });
288+
expect(filterOf('$icontains').sql).not.toBe(filterOf('$contains').sql);
289+
expect(filterOf('$contains').sql).not.toMatch(/lower\(/);
253290
});
254291
});
255292

packages/services/service-analytics/src/like-pattern.ts

Lines changed: 41 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -108,16 +108,18 @@
108108
*
109109
* - {@link likePattern} / {@link escapeLikePattern} build the LIKE PATTERN,
110110
* and are used by the `LIKE` arms — Postgres, MySQL, and the `unknown`
111-
* residue — plus `$icontains` on every dialect.
111+
* residue — for BOTH text families.
112112
* - Which KEYWORD those patterns hang off, and whether a GLOB pattern with a
113113
* different escaped character class is built instead, is
114114
* `text-match-sql.ts`'s answer. ⛔ Do not re-derive it here.
115115
*
116-
* `$icontains` IS implemented here since #6520, and it is a separate construct
117-
* rather than a flag on the family above: it folds ASCII case on BOTH sides via
118-
* {@link asciiLowerSqlExpr}, while the `$contains` family stays case-EXACT. The
119-
* two must not be collapsed — a shared "case-insensitive" path would give the
120-
* `$contains` family the fold the ruling took away from it.
116+
* [#15780] `$icontains` (#6520) goes through that same table, and its fold is a
117+
* per-dialect construct too — {@link asciiLowerSqlExpr} is only the Postgres /
118+
* `unknown` arm of it. What the two families share is the TABLE and the
119+
* escaping; what separates them is one `fold` flag set on the `$icontains` row
120+
* alone. ⛔ The two must never be collapsed into one case-insensitive path —
121+
* that would give the `$contains` family back the fold #4706 Q2 = A took away
122+
* from it, which is the failure `text-operator-case-exactness.test.ts` guards.
121123
*
122124
* ## `String(value)` is safe here because nothing unrenderable reaches it (#5234)
123125
*
@@ -177,9 +179,17 @@ export function likePattern(shape: LikeShape, value: unknown): string {
177179
return shape === 'starts' ? `${escaped}%` : shape === 'ends' ? `%${escaped}` : `%${escaped}%`;
178180
}
179181

180-
/** `A`..`Z` and the `a`..`z` they fold onto — the #4706 Q1 = A domain, as data. */
181-
const ASCII_UPPER_LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
182-
const ASCII_LOWER_LETTERS = 'abcdefghijklmnopqrstuvwxyz';
182+
/**
183+
* `A`..`Z` and the `a`..`z` they fold onto — the #4706 Q1 = A domain, as data.
184+
*
185+
* [#15780] EXPORTED, because the fold is now chosen per dialect and two of the
186+
* three arms are built from this domain rather than from `translate()`:
187+
* `text-match-sql.ts`'s MySQL arm nests one `REPLACE` per letter. The domain
188+
* itself stays here, in one copy — a second 26-character literal anywhere is
189+
* how the Postgres arm and the MySQL arm start folding different alphabets.
190+
*/
191+
export const ASCII_UPPER_LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
192+
export const ASCII_LOWER_LETTERS = 'abcdefghijklmnopqrstuvwxyz';
183193

184194
/**
185195
* [#6520] Wrap a SQL expression in `$icontains`' ASCII-ONLY case fold.
@@ -199,24 +209,28 @@ const ASCII_LOWER_LETTERS = 'abcdefghijklmnopqrstuvwxyz';
199209
* alone, so it is the fold the ruling names rather than the one the database
200210
* happens to offer.
201211
*
202-
* ## The dialect this assumes, stated so it can go red rather than stale
203-
*
204-
* Postgres. `translate()` is Postgres/Oracle; SQLite has no such function — and
205-
* [#15684] MEASURED that these compilers' statements do reach SQLite, so the
206-
* warning this paragraph used to write in the conditional is now a live defect,
207-
* filed as #15780: on sql.js 1.14.1, `SELECT translate('ABC','ABC','abc')`
208-
* answers `no such function: translate`, so an `$icontains` in an analytics
209-
* `where` or in an RLS read scope over a SQLite datasource does not merely
210-
* over-match — it fails to parse.
211-
*
212-
* ⛔ Do NOT close that by falling back to `LOWER()`, which would silently
213-
* restore the Unicode fold this function exists to avoid. The remedy is one
214-
* more arm on `text-match-sql.ts`'s per-dialect table, whose shapes
215-
* `driver-sql`'s `textMatchPredicate` already carries: `lower(col) GLOB
216-
* lower(?)` on SQLite (measured ASCII-only there — `lower('CAFÉ')` is
217-
* `cafÉ`), nested `REPLACE` over `CAST(… AS BINARY)` on MySQL. #15684
218-
* deliberately did not build it: its scope was the case-EXACT four, and its
219-
* suite pins THIS expression as the control that must stay unchanged.
212+
* ## The dialect this arm is FOR — no longer the dialect it assumes
213+
*
214+
* Postgres, and the `unknown` residue. `translate()` is Postgres/Oracle and
215+
* SQLite has no such function, so while this expression was emitted on EVERY
216+
* dialect it did not merely over-match on SQLite — it failed to PARSE
217+
* (`no such function: translate` on sql.js 1.14.1). That was #15780, and it is
218+
* closed: this function is now ONE arm of `text-match-sql.ts`'s per-dialect
219+
* table ({@link textMatchPredicateSql}, `fold: true`), reached on `postgres`
220+
* and on `unknown`, while SQLite gets `lower(col) GLOB lower(?)` and MySQL the
221+
* nested-`REPLACE` binary fold.
222+
*
223+
* ⛔ Do NOT "simplify" this to `LOWER()`, on any arm. Postgres' `LOWER()` is
224+
* locale-aware and would silently restore the Unicode fold #4706 Q1 = A rules
225+
* out; SQLite's `lower()` is ASCII-only, which is why the SQLite arm may use it
226+
* and this one may not. That asymmetry is the whole reason the fold is chosen
227+
* per dialect rather than written once.
228+
*
229+
* ⛔ Nor is `unknown` free to adopt `driver-sql`'s residue: that face folds
230+
* `unknown` with `LOWER()` because `LOWER()` is the shape it emitted before
231+
* #6518. This face's pre-existing shape is `translate()`, so `translate()` is
232+
* what its residue keeps. Each side keeps its own, and neither claims the
233+
* other's — an `unknown` dialect is by definition one nothing here measured.
220234
*
221235
* The caller must apply it to BOTH sides of the comparison. Folding only the
222236
* comparand compares a folded needle against a raw column and matches just the

packages/services/service-analytics/src/read-scope-sql.ts

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import type { FilterCondition } from '@objectstack/spec/data';
44
import type { RegisteredErrorCode } from '@objectstack/spec/api';
5-
import { likePattern, LIKE_ESCAPE_CHAR, asciiLowerSqlExpr, type LikeShape } from './like-pattern.js';
5+
import { type LikeShape } from './like-pattern.js';
66
import { textMatchPredicateSql, normalizeSqlDialect } from './text-match-sql.js';
77
import { textOperatorPolarity } from './non-text-column.js';
88
import {
@@ -800,13 +800,15 @@ function textMatch(
800800
negate: boolean,
801801
params: unknown[],
802802
opts: ReadScopeCompileOptions,
803+
fold = false,
803804
): string {
804805
return textMatchPredicateSql({
805806
dialect: normalizeSqlDialect(opts.dialect),
806807
column: col,
807808
shape,
808809
value: val,
809810
negate,
811+
fold,
810812
bind: (v) => bind(params, v),
811813
});
812814
}
@@ -1331,19 +1333,25 @@ function compileOperator(
13311333
* the rows already lower-case — and on a read scope that is a row set the
13321334
* policy author never wrote, in the narrowing direction here but in the
13331335
* WIDENING direction under a `$not`.
1336+
*
1337+
* [#15780] …and WHICH fold is the DIALECT's answer, exactly as the keyword
1338+
* is for the case-exact arms. This line used to spell its own binds and
1339+
* emit `translate()` unconditionally, on the reasoning that a
1340+
* case-INSENSITIVE operator never wants the per-dialect case-EXACT
1341+
* construct. The first half of that was right and the second half hid the
1342+
* defect: `translate()` is Postgres/Oracle, so on a SQLite datasource this
1343+
* read scope compiled to a statement the engine could not PARSE — an RLS
1344+
* policy that cannot be evaluated at all. It goes through
1345+
* {@link textMatch} now with `fold` set, which keeps `translate()` on
1346+
* Postgres and the `unknown` residue, emits `lower(col) GLOB lower(?)` on
1347+
* SQLite and the nested-`REPLACE` binary fold on MySQL. The `ESCAPE`
1348+
* binding is still never folded — the construct table owns that, and the
1349+
* SQLite arm has no `ESCAPE` clause to bind at all.
13341350
*/
1335-
case '$icontains': {
1351+
case '$icontains':
13361352
assertRenderableText(op, field, val);
1337-
const gated = textOverNonTextColumn(op, field, opts);
1338-
if (gated) return gated;
1339-
// The two binds are spelled out rather than taken from {@link textMatch},
1340-
// because only the PATTERN placeholder is folded, the `ESCAPE` one must
1341-
// not be, and this operator is case-INSENSITIVE by ruling so it never
1342-
// wants the per-dialect case-exact construct. Left-to-right, so the
1343-
// values land in `params` in placeholder order.
1344-
const patternRef = asciiLowerSqlExpr(bind(params, likePattern('contains', val)));
1345-
return `${asciiLowerSqlExpr(col)} LIKE ${patternRef} ESCAPE ${bind(params, LIKE_ESCAPE_CHAR)}`;
1346-
}
1353+
return textOverNonTextColumn(op, field, opts)
1354+
?? textMatch(col, 'contains', val, false, params, opts, true);
13471355
// [#5298] NULL-safe: `NOT LIKE` is UNKNOWN for a NULL column, and "does not
13481356
// contain" is true of a value that is not there.
13491357
case '$notContains':

0 commit comments

Comments
 (0)