|
30 | 30 | * - **postgres → byte-identical text.** `translate()` is correct there today, |
31 | 31 | * so the correct diff is no diff: the emitted SQL and params are asserted |
32 | 32 | * verbatim against the pre-#15780 bytes, for all three compilers. |
33 | | - * - **`unknown` (no hook wired) → byte-identical text, ALSO `translate()`.** |
34 | | - * The residue keeps the shape it has always had. It is not an endorsement: |
35 | | - * it is what still runs on the dialects nothing here models (mssql, oracle, |
36 | | - * which do have `translate()`), and falling back to `LOWER()` for it would |
37 | | - * silently restore the Unicode fold #4706 Q1 = A took away. Note this |
38 | | - * DIVERGES from `driver-sql`'s `unknown` arm, which folds with `LOWER()` — |
39 | | - * each side keeps its own pre-existing residue, and neither claims the |
40 | | - * other's. |
| 33 | + * - **[#16028] `unknown` (no hook wired) → the portable `REPLACE` chain, |
| 34 | + * EXECUTED.** #15780 left this arm on `translate()` and called it |
| 35 | + * unbroken, which held only for the dialects the arm was PICTURED as |
| 36 | + * (mssql, oracle, which have `translate()`). `unknown` is not a dialect: |
| 37 | + * `normalizeSqlDialect` routes EVERYTHING it cannot name here, SQLite |
| 38 | + * included, so the residue arm carried the whole defect forward for the |
| 39 | + * four embedder compositions #16028 lists — among them a public |
| 40 | + * `AnalyticsService` constructed with its OPTIONAL `sqlDialect` left out. |
| 41 | + * The arm now folds with one nested `REPLACE` per ASCII letter, which every |
| 42 | + * SQL dialect parses, and the rows it answers on this engine are asserted |
| 43 | + * below from the same shared table the `sqlite` arm answers. |
| 44 | + * |
| 45 | + * ⛔ NOT `LOWER()`, which is what `driver-sql`'s `unknown` arm folds with: |
| 46 | + * `LOWER()` follows the collation, so adopting it would trade this parse |
| 47 | + * failure for SILENTLY wrong rows on PostgreSQL — the Unicode fold #4706 |
| 48 | + * Q1 = A rules out. ⚠️ And measuring `LOWER()` HERE proves nothing about |
| 49 | + * that, because SQLite's `lower()` is ASCII-only and answers the shared |
| 50 | + * table correctly: the trap is a green SQLite reading standing in for a |
| 51 | + * PostgreSQL one, so the `REPLACE` chain is pinned as ASCII-only BY |
| 52 | + * CONSTRUCTION (over every ASCII code point, below) rather than by a fold |
| 53 | + * that happens to behave on the one engine in this container. |
41 | 54 | * - **mysql → NOT MEASURED.** The nested-`REPLACE`-over-`CAST(… AS BINARY)` |
42 | 55 | * arm is asserted as TEXT only. No MySQL server is provisionable in this |
43 | 56 | * container — the same declared skip `driver-sql`'s #6518 suite and this |
@@ -119,40 +132,118 @@ const ctxFor = (dialect?: string): DatasetScopedStrategyContext => |
119 | 132 | const TRANSLATE_FOLD = |
120 | 133 | "translate(name, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz')"; |
121 | 134 |
|
| 135 | +/** |
| 136 | + * [#16028] The `unknown` arm's fold, rebuilt HERE from the ruled domain rather |
| 137 | + * than imported from the implementation — a second construction of the expected |
| 138 | + * text, so a change to the emitter's loop does not quietly re-bless itself. |
| 139 | + * |
| 140 | + * The endpoints are pinned verbatim beside every use (`REPLACE(name, 'A', 'a')` |
| 141 | + * innermost, `'Z', 'z')` outermost), which is also what discriminates this arm |
| 142 | + * from MySQL's: that one's innermost operand is `CAST(name AS BINARY)`. |
| 143 | + */ |
| 144 | +const ASCII_UP = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; |
| 145 | +const ASCII_LO = 'abcdefghijklmnopqrstuvwxyz'; |
| 146 | +const replaceFold = (expr: string): string => { |
| 147 | + let out = expr; |
| 148 | + for (let i = 0; i < ASCII_UP.length; i++) out = `REPLACE(${out}, '${ASCII_UP[i]}', '${ASCII_LO[i]}')`; |
| 149 | + return out; |
| 150 | +}; |
| 151 | + |
122 | 152 | describe('[#15780] the compiled TEXT, per dialect — all three compilers', () => { |
123 | 153 | const nativeSql = async (where: unknown, dialect?: string) => |
124 | 154 | new NativeSQLStrategy().generateSql(query(where), ctxFor(dialect)); |
125 | 155 | const echoSql = async (where: unknown, dialect?: string) => |
126 | 156 | new ObjectQLStrategy().generateSql(query(where), ctxFor(dialect)); |
127 | 157 |
|
128 | | - it('postgres and a host that wired NO hook keep the pre-#15780 bytes exactly', async () => { |
129 | | - // The non-regression half, for the two dialects that were already correct. |
130 | | - // Asserted verbatim, not by shape: `translate()` is right on Postgres, and |
131 | | - // the `unknown` residue must keep the only fold that still runs there. |
132 | | - for (const dialect of [undefined, 'postgres'] as const) { |
| 158 | + it('postgres keeps the pre-#15780 bytes exactly — the arm that was always right', async () => { |
| 159 | + // The non-regression half. Asserted verbatim, not by shape: `translate()` |
| 160 | + // is correct on Postgres, so the correct diff there is no diff — through |
| 161 | + // #15780 and through #16028 alike. |
| 162 | + const out = await nativeSql({ name: { $icontains: 'acme' } }, 'postgres'); |
| 163 | + expect(out.sql).toContain( |
| 164 | + `WHERE ${TRANSLATE_FOLD} LIKE translate($1, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz') ESCAPE $2`, |
| 165 | + ); |
| 166 | + expect(out.params).toEqual(['%acme%', '\\']); |
| 167 | + |
| 168 | + const echo = await echoSql({ name: { $icontains: 'acme' } }, 'postgres'); |
| 169 | + expect(echo.sql).toContain( |
| 170 | + `${TRANSLATE_FOLD} LIKE translate($1, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz') ESCAPE $2`, |
| 171 | + ); |
| 172 | + expect(echo.params).toEqual(['%acme%', '\\']); |
| 173 | + |
| 174 | + expect( |
| 175 | + compileScopedFilterToSql({ name: { $icontains: 'acme' } } as FilterCondition, 't', { dialect: 'postgres' }), |
| 176 | + ).toEqual({ |
| 177 | + sql: |
| 178 | + `translate("t"."name", 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz') LIKE ` + |
| 179 | + `translate(?, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz') ESCAPE ?`, |
| 180 | + params: ['%acme%', '\\'], |
| 181 | + }); |
| 182 | + }); |
| 183 | + |
| 184 | + it('[#16028] a host that wired NO hook folds with the PORTABLE chain, on all three compilers', async () => { |
| 185 | + // The moved cells, stated as the pin rather than left to a regenerate: the |
| 186 | + // `unknown` arm's emitted text CHANGED, from `translate()` — which SQLite |
| 187 | + // and MySQL/MariaDB cannot parse — to a nested `REPLACE` per ASCII letter, |
| 188 | + // which every SQL dialect can. The rows this text answers are executed |
| 189 | + // further down; here it is the bytes and the bindings. |
| 190 | + for (const dialect of [undefined, 'oracle', 'libsql'] as const) { |
| 191 | + // All three normalize to `unknown`: an unset hook, and two spellings |
| 192 | + // `normalizeSqlDialect` does not model. One arm, reached three ways. |
133 | 193 | const out = await nativeSql({ name: { $icontains: 'acme' } }, dialect); |
134 | 194 | expect(out.sql, String(dialect)).toContain( |
135 | | - `WHERE ${TRANSLATE_FOLD} LIKE translate($1, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz') ESCAPE $2`, |
| 195 | + `WHERE ${replaceFold('name')} LIKE ${replaceFold('$1')} ESCAPE $2`, |
136 | 196 | ); |
| 197 | + expect(out.sql, String(dialect)).not.toMatch(/translate\(/); |
| 198 | + // The chain's endpoints, verbatim — and the innermost operand is the bare |
| 199 | + // column, which is what tells this arm apart from MySQL's `CAST(… AS |
| 200 | + // BINARY)` one. |
| 201 | + expect(out.sql, String(dialect)).toContain("REPLACE(name, 'A', 'a')"); |
| 202 | + expect(out.sql, String(dialect)).toContain("'Z', 'z')"); |
| 203 | + expect(out.sql, String(dialect)).not.toMatch(/CAST\(/); |
137 | 204 | expect(out.params, String(dialect)).toEqual(['%acme%', '\\']); |
138 | 205 |
|
139 | 206 | const echo = await echoSql({ name: { $icontains: 'acme' } }, dialect); |
140 | 207 | expect(echo.sql, `echo ${String(dialect)}`).toContain( |
141 | | - `${TRANSLATE_FOLD} LIKE translate($1, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz') ESCAPE $2`, |
| 208 | + `${replaceFold('name')} LIKE ${replaceFold('$1')} ESCAPE $2`, |
142 | 209 | ); |
| 210 | + expect(echo.sql, `echo ${String(dialect)}`).not.toMatch(/translate\(/); |
143 | 211 | expect(echo.params, `echo ${String(dialect)}`).toEqual(['%acme%', '\\']); |
144 | 212 | } |
145 | 213 |
|
| 214 | + // The read scope — the compiler where an unevaluable statement is an |
| 215 | + // ADR-0021 policy that cannot be applied at all. |
146 | 216 | const noHook = compileScopedFilterToSql({ name: { $icontains: 'acme' } } as FilterCondition, 't'); |
147 | 217 | expect(noHook).toEqual({ |
148 | | - sql: |
149 | | - `translate("t"."name", 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz') LIKE ` + |
150 | | - `translate(?, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz') ESCAPE ?`, |
| 218 | + sql: `${replaceFold('"t"."name"')} LIKE ${replaceFold('?')} ESCAPE ?`, |
151 | 219 | params: ['%acme%', '\\'], |
152 | 220 | }); |
| 221 | + expect(noHook.sql).not.toMatch(/translate\(/); |
153 | 222 | expect( |
154 | | - compileScopedFilterToSql({ name: { $icontains: 'acme' } } as FilterCondition, 't', { dialect: 'postgres' }), |
| 223 | + compileScopedFilterToSql({ name: { $icontains: 'acme' } } as FilterCondition, 't', { dialect: 'nonesuch' }), |
155 | 224 | ).toEqual(noHook); |
| 225 | + |
| 226 | + // ⛔ And the arm did NOT become Postgres's: the two are now different text, |
| 227 | + // which is the whole point of splitting them. |
| 228 | + const pg = await nativeSql({ name: { $icontains: 'acme' } }, 'postgres'); |
| 229 | + const unknown = await nativeSql({ name: { $icontains: 'acme' } }, undefined); |
| 230 | + expect(unknown.sql).not.toBe(pg.sql); |
| 231 | + expect(unknown.params).toEqual(pg.params); |
| 232 | + }); |
| 233 | + |
| 234 | + it('[#16028] the case-EXACT four are byte-identical on `unknown` — only the FOLD moved', async () => { |
| 235 | + // The blast-radius pin. `fold` is the only thing #16028 changed on this |
| 236 | + // arm, so every case-exact operator must still emit the plain `LIKE` it |
| 237 | + // emitted before — no `REPLACE` anywhere near them. |
| 238 | + for (const op of ['$contains', '$notContains', '$startsWith', '$endsWith'] as const) { |
| 239 | + const out = await nativeSql({ name: { [op]: 'acme' } }, undefined); |
| 240 | + expect(out.sql, op).not.toMatch(/REPLACE\(|translate\(|lower\(/); |
| 241 | + expect(out.sql, op).toMatch(/name (NOT )?LIKE \$1 ESCAPE \$2/); |
| 242 | + } |
| 243 | + expect(compileScopedFilterToSql({ name: { $contains: 'acme' } } as FilterCondition, 't')).toEqual({ |
| 244 | + sql: '"t"."name" LIKE ? ESCAPE ?', |
| 245 | + params: ['%acme%', '\\'], |
| 246 | + }); |
156 | 247 | }); |
157 | 248 |
|
158 | 249 | it('sqlite compiles lower() over GLOB — one bound value, no ESCAPE clause', async () => { |
@@ -274,16 +365,81 @@ describe('[#15780] the three compilers, EXECUTED on a real SQLite engine', () => |
274 | 365 | ]); |
275 | 366 | }); |
276 | 367 |
|
277 | | - it('the defect, still reachable through a host that answers no dialect — so these pins discriminate', async () => { |
278 | | - // Before-red, stated as the engine's own refusal rather than argued: the |
279 | | - // `unknown` residue still emits `translate()`, and this engine cannot parse |
280 | | - // it. This is the control that keeps every green assertion above honest. |
| 368 | + it('[#16028] the PRE-fix bytes are still refused by this engine — the control the greens rest on', () => { |
| 369 | + // Before-red, kept as a control rather than deleted with the defect. This |
| 370 | + // is the statement the `unknown` arm emitted until #16028, rebuilt here and |
| 371 | + // handed to the engine: it does not parse. Every green below is therefore a |
| 372 | + // measurement of the CHANGE, not of an engine that would have accepted |
| 373 | + // anything. |
| 374 | + const preFix = |
| 375 | + `SELECT "id" FROM "rows" WHERE ${TRANSLATE_FOLD} LIKE ` + |
| 376 | + `translate(?, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz') ESCAPE ?`; |
| 377 | + expect(() => run(preFix, ['%acme%', '\\'])).toThrow(/no such function: translate/); |
| 378 | + }); |
| 379 | + |
| 380 | + it('[#16028] a host that answers NO dialect now compiles a statement this engine RUNS', async () => { |
| 381 | + // The card's headline, executed: with no `sqlDialect` hook wired — the |
| 382 | + // shape a directly-constructed public `AnalyticsService` has when its |
| 383 | + // OPTIONAL field is omitted — the compiled `where` used to be refused by |
| 384 | + // the engine. It now parses, and it answers the shared table's rows. |
281 | 385 | const { sql, params } = await new NativeSQLStrategy().generateSql( |
282 | 386 | query({ name: { $icontains: 'acme' } }), |
283 | 387 | unawareCtx, |
284 | 388 | ); |
285 | | - expect(sql).toContain('translate('); |
286 | | - expect(() => run(sql, params)).toThrow(/no such function: translate/); |
| 389 | + expect(sql).not.toMatch(/translate\(/); |
| 390 | + expect(run(sql, params)).toEqual(['1', '2']); |
| 391 | + }); |
| 392 | + |
| 393 | + it('[#16028] the `unknown` arm answers the shared $icontains table — all three compilers', async () => { |
| 394 | + // The same rows the `sqlite` arm is required to answer, required of the |
| 395 | + // residue arm. Executed on sql.js 1.14.1, the engine `driver-sqlite-wasm` |
| 396 | + // runs — which is what an `unknown` dialect that is really SQLite IS. |
| 397 | + for (const c of NAME_ICONTAINS) { |
| 398 | + expect(await executedIds(c.filter, unawareCtx), c.name).toEqual([...c.expected]); |
| 399 | + |
| 400 | + const scoped = { |
| 401 | + ...unawareCtx, |
| 402 | + getReadScope: (object: string) => (object === 'rows' ? (c.filter as FilterCondition) : null), |
| 403 | + } as DatasetScopedStrategyContext; |
| 404 | + const viaScope = await new NativeSQLStrategy().generateSql(query(undefined), scoped); |
| 405 | + expect(viaScope.sql, `read scope: ${c.name}`).not.toMatch(/translate\(/); |
| 406 | + expect(run(viaScope.sql, viaScope.params), `read scope: ${c.name}`).toEqual([...c.expected]); |
| 407 | + |
| 408 | + const echo = await new ObjectQLStrategy().generateSql(query(c.filter), unawareCtx); |
| 409 | + expect(echo.sql, `echo: ${c.name}`).not.toMatch(/translate\(/); |
| 410 | + expect(run(echo.sql, echo.params), `echo: ${c.name}`).toEqual([...c.expected]); |
| 411 | + } |
| 412 | + }); |
| 413 | + |
| 414 | + it('[#16028] the portable chain folds ASCII and NOTHING else — every code point, executed', () => { |
| 415 | + // The property the arm rests on, measured instead of argued, because the |
| 416 | + // one thing that must never happen here is a fold that reaches past `A`-`Z` |
| 417 | + // (#4706 Q1 = A). Two halves: |
| 418 | + // |
| 419 | + // 1. the chain equals the simultaneous `A`-`Z` map — i.e. applying the 26 |
| 420 | + // REPLACEs IN SEQUENCE cannot cascade, because every step writes a |
| 421 | + // lower-case letter and every later step matches an upper-case one; |
| 422 | + // 2. it touches nothing else — no accented letter, and none of the LIKE |
| 423 | + // metacharacters the escaping depends on staying literal. |
| 424 | + const asciiLower = (v: string) => |
| 425 | + v.replace(/[A-Z]/g, (ch) => ASCII_LO[ASCII_UP.indexOf(ch)]); |
| 426 | + const probes = [ |
| 427 | + ...Array.from({ length: 128 }, (_, i) => String.fromCharCode(i)).filter((ch) => ch !== '\0'), |
| 428 | + 'CAFÉ', 'café', 'ÀÉÎÕÜ', 'ÄÖÜ', 'ǍǏǑ', 'ΑΒΓ', 'АБВ', 'İIı', |
| 429 | + 'ACME Corp', '100% match', 'a_b', 'A\\B', 'ZzAa', |
| 430 | + ]; |
| 431 | + for (const probe of probes) { |
| 432 | + const literal = probe.replace(/'/g, "''"); |
| 433 | + const got = db.exec(`SELECT ${replaceFold(`'${literal}'`)}`)[0].values[0][0]; |
| 434 | + expect(got, JSON.stringify(probe)).toBe(asciiLower(probe)); |
| 435 | + } |
| 436 | + // ⚠️ And the control that says why `LOWER()` may not be adopted here even |
| 437 | + // though it passes on THIS engine: SQLite's `lower()` is ASCII-only, so it |
| 438 | + // agrees with the chain on every probe above. It is PostgreSQL's, which is |
| 439 | + // collation-aware, that would fold `É` — and no PostgreSQL server is |
| 440 | + // provisionable in this container. So this cell is why the arm is chosen by |
| 441 | + // CONSTRUCTION and not by what happens to pass locally. |
| 442 | + expect(db.exec(`SELECT lower('CAFÉ')`)[0].values[0][0]).toBe('cafÉ'); |
287 | 443 | }); |
288 | 444 |
|
289 | 445 | it('NativeSQLStrategy answers the shared table\'s $icontains rows', async () => { |
|
0 commit comments