|
1 | 1 | // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. |
2 | 2 |
|
3 | | -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; |
| 3 | +/** |
| 4 | + * LIKE-metacharacter escaping for the `$contains` family — the guard on |
| 5 | + * `SqlDriver.applyLike`, whose own TSDoc calls an unescaped `%` a filter bypass |
| 6 | + * and grades it P0. |
| 7 | + * |
| 8 | + * Two layers, and they are not the same assertion: |
| 9 | + * |
| 10 | + * 1. **The P0-3 regression** (below, hard-coded `better-sqlite3`) — the one |
| 11 | + * that must run on every `pnpm test`, on any machine, with no server to |
| 12 | + * provision. It is the default-path guard and is deliberately NOT replaced |
| 13 | + * by the matrix. |
| 14 | + * 2. **The DRIVER axis** (#5589, ADR-0053 D-A3) — the same predicates re-run |
| 15 | + * once per cell of `DIALECT_CELLS`, plus the literal-backslash case, on |
| 16 | + * whatever live engines the runner provisioned. |
| 17 | + * |
| 18 | + * # Why the second layer exists (#5589) |
| 19 | + * |
| 20 | + * `applyLike` binds an explicit `ESCAPE ?` precisely BECAUSE the three shipped |
| 21 | + * dialects disagree about what a backslash means inside a LIKE pattern: |
| 22 | + * Postgres defaults to backslash, MySQL assumes `\` unless `NO_BACKSLASH_ESCAPES` |
| 23 | + * is set, and SQLite honours NO default escape character at all. Layer 1 covered |
| 24 | + * exactly one of those three — and it was the end of the range with the *least* |
| 25 | + * to say, since on SQLite nothing is an escape character until the clause says |
| 26 | + * so. A dialect-specific miscompile of a P0 filter bypass had nothing to fail. |
| 27 | + * |
| 28 | + * The infrastructure to fix that was already here and already wired into CI: the |
| 29 | + * `Temporal Conformance (live PG + MySQL)` job runs this whole package against |
| 30 | + * Postgres 16 and MySQL 8.0 with `OS_TEST_POSTGRES_URL` / `OS_TEST_MYSQL_URL` |
| 31 | + * set, and `live-dialect-matrix.testkit.ts` is the shared cell list every other |
| 32 | + * matrix consumer iterates. Layer 1 simply never joined — the LIKE family was |
| 33 | + * 0 of the 8 files reading those URLs. No new CI job is needed here either. |
| 34 | + * |
| 35 | + * A cell nobody provisioned is a named skip, and a red under |
| 36 | + * `OS_EXPECT_LIVE_DIALECT_MATRIX=1` (`declareUnprovisionedCell`): a matrix that |
| 37 | + * silently found zero live cells must not report OK (#4646). |
| 38 | + * |
| 39 | + * # What the live cells prove that SQLite cannot |
| 40 | + * |
| 41 | + * Stated precisely, because the temptation is to overclaim — and #5589 assumed |
| 42 | + * the opposite, that the backslash case could not go before-red/after-green on |
| 43 | + * SQLite at all. Measured: it can. Deleting the `\\` limb of the escaped |
| 44 | + * character class turns the case red on the SQLite cell alone (the pattern |
| 45 | + * degenerates to `%\%`, whose trailing wildcard the backslash consumes, so the |
| 46 | + * answer is `[]` rather than `['bsl']`), while the other three stay green. The |
| 47 | + * bound `ESCAPE '\'` puts every dialect under one rule, which is the whole |
| 48 | + * design, so the ARITHMETIC is decidable in-process. What only a live cell can |
| 49 | + * decide is whether the *transport* preserves that rule: |
| 50 | + * |
| 51 | + * - **MySQL, the `ESCAPE` argument.** The manual requires it to "evaluate as a |
| 52 | + * constant at execution time". `applyLike` binds a placeholder. Through |
| 53 | + * knex + mysql2's default `query()` path it is interpolated client-side into |
| 54 | + * a literal and the constraint holds — but that was an INFERENCE from the |
| 55 | + * driver's code path, never an execution. The mysql cell executes it. |
| 56 | + * - **MySQL, the backslash's second life.** MySQL applies C escape syntax |
| 57 | + * inside string literals, so the one backslash this code binds has to survive |
| 58 | + * mysql2's own escaping and the server's lexer before LIKE ever sees it. |
| 59 | + * Only a real server round-trip can show the count came out right. |
| 60 | + * - **Postgres.** `standard_conforming_strings`, and the fact that a bound |
| 61 | + * parameter is not a string literal at all, are likewise claims about a |
| 62 | + * wire protocol rather than about this file's arithmetic. |
| 63 | + * |
| 64 | + * So the literal-backslash case is the interesting cell of the four, and its |
| 65 | + * value is concentrated on `live mysql` / `live postgres`. That is a statement |
| 66 | + * about which cells carry the new information, NOT a claim that the SQLite cell |
| 67 | + * is decorative — it pins the `\` limb of the escaped character class exactly as |
| 68 | + * hard as the others do. |
| 69 | + * |
| 70 | + * (Nothing here is temporal, so the D-B3 server-timezone axis does not apply — |
| 71 | + * requiring a non-UTC server would only manufacture reds that say nothing about |
| 72 | + * LIKE. Same call as `sql-driver-or-filter.test.ts` and the pagination matrix.) |
| 73 | + */ |
| 74 | + |
| 75 | +import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll } from 'vitest'; |
4 | 76 | import { SqlDriver } from '../src/index.js'; |
| 77 | +import { |
| 78 | + DIALECT_CELLS, |
| 79 | + declareUnprovisionedCell, |
| 80 | + type DialectCell, |
| 81 | +} from './live-dialect-matrix.testkit.js'; |
5 | 82 |
|
6 | 83 | /** |
7 | 84 | * P0-3 regression: the `contains` / `$contains` operator must escape LIKE |
@@ -49,3 +126,128 @@ describe('SqlDriver — contains escapes LIKE metacharacters (P0-3)', () => { |
49 | 126 | expect(r.map((x: any) => x.id)).toEqual(['1']); |
50 | 127 | }); |
51 | 128 | }); |
| 129 | + |
| 130 | +// ── The driver axis (#5589, ADR-0053 D-A3) ────────────────────────────────── |
| 131 | + |
| 132 | +/** |
| 133 | + * Issue-prefixed table name: the live cells share one database with every other |
| 134 | + * suite in this package (and with each other's runs), so the bare `docs` layer 1 |
| 135 | + * can afford on an in-memory SQLite would be a collision waiting to be read as a |
| 136 | + * filter-bypass regression. |
| 137 | + */ |
| 138 | +const LIKE_TABLE = 'os5589_like_escape'; |
| 139 | + |
| 140 | +/** |
| 141 | + * The fixture rows, one per metacharacter under test plus one carrying none. |
| 142 | + * |
| 143 | + * Each row is the ONLY row that its case's expected answer names, so a widening |
| 144 | + * miscompile (which is the failure mode: every LIKE defect here matches MORE |
| 145 | + * rows, never fewer) cannot be mistaken for a pass. |
| 146 | + */ |
| 147 | +const LIKE_ROWS = [ |
| 148 | + { id: 'pct', title: '50% off sale' }, // literal % |
| 149 | + { id: 'plain', title: 'plain title' }, // no metacharacter |
| 150 | + { id: 'us', title: 'a_b underscore' }, // literal _ |
| 151 | + { id: 'bsl', title: 'C:\\logs' }, // literal backslash — one, not two |
| 152 | +] as const; |
| 153 | + |
| 154 | +interface LikeEscapeCase { |
| 155 | + readonly name: string; |
| 156 | + /** The comparand a user typed, as a LITERAL. */ |
| 157 | + readonly value: string; |
| 158 | + readonly expected: readonly string[]; |
| 159 | + readonly note: string; |
| 160 | +} |
| 161 | + |
| 162 | +const LIKE_ESCAPE_CASES: readonly LikeEscapeCase[] = [ |
| 163 | + { |
| 164 | + name: 'a "%" value matches only rows containing a literal %, not every row', |
| 165 | + value: '%', |
| 166 | + expected: ['pct'], |
| 167 | + note: 'an unescaped % expands to %%% and matches every row — the P0 filter bypass', |
| 168 | + }, |
| 169 | + { |
| 170 | + name: 'a "_" value matches only rows containing a literal _, not any single char', |
| 171 | + value: '_', |
| 172 | + expected: ['us'], |
| 173 | + note: 'an unescaped _ is LIKE\'s single-character wildcard, so %_% matches every non-empty row', |
| 174 | + }, |
| 175 | + { |
| 176 | + name: 'an ordinary substring still matches normally', |
| 177 | + value: 'sale', |
| 178 | + expected: ['pct'], |
| 179 | + note: 'the escaping must not break the ordinary case it wraps', |
| 180 | + }, |
| 181 | + { |
| 182 | + name: 'a "\\" value matches only rows containing a literal backslash', |
| 183 | + value: '\\', |
| 184 | + expected: ['bsl'], |
| 185 | + note: |
| 186 | + 'the escape character itself, escaped: the pattern reaching the server must carry TWO ' + |
| 187 | + 'backslashes and the bound ESCAPE argument exactly ONE. Drop the `\\\\` limb of the escaped ' + |
| 188 | + 'character class and the pattern degenerates to `%\\%` — whose trailing wildcard is eaten ' + |
| 189 | + 'by the backslash, leaving "anything, then a literal % at end of string", which answers ' + |
| 190 | + 'NO row here (measured on the sqlite cell: []). On MySQL this is also the case that ' + |
| 191 | + 'executes, rather than infers, that the bound ESCAPE placeholder satisfies "constant at ' + |
| 192 | + 'execution time" and that one backslash survives client-side interpolation plus the ' + |
| 193 | + 'server lexer', |
| 194 | + }, |
| 195 | +]; |
| 196 | + |
| 197 | +for (const cell of DIALECT_CELLS) { |
| 198 | + if (!cell.available) { |
| 199 | + declareUnprovisionedCell(cell, 'LIKE-metacharacter escape'); |
| 200 | + continue; |
| 201 | + } |
| 202 | + declareLikeEscapeSweep(cell); |
| 203 | +} |
| 204 | + |
| 205 | +function declareLikeEscapeSweep(cell: DialectCell): void { |
| 206 | + describe(`SqlDriver LIKE-metacharacter escape (${cell.label})`, () => { |
| 207 | + let driver: SqlDriver; |
| 208 | + let knexInstance: any; |
| 209 | + |
| 210 | + beforeAll(async () => { |
| 211 | + driver = new SqlDriver(cell.config()); |
| 212 | + knexInstance = (driver as any).knex; |
| 213 | + |
| 214 | + // Live cells reuse one database, so the sweep starts from a dropped table. |
| 215 | + await knexInstance.schema.dropTableIfExists(LIKE_TABLE); |
| 216 | + await knexInstance.schema.createTable(LIKE_TABLE, (t: any) => { |
| 217 | + t.string('id').primary(); |
| 218 | + t.string('title'); |
| 219 | + }); |
| 220 | + await knexInstance(LIKE_TABLE).insert([...LIKE_ROWS]); |
| 221 | + }); |
| 222 | + |
| 223 | + afterAll(async () => { |
| 224 | + await knexInstance?.schema.dropTableIfExists(LIKE_TABLE).catch(() => {}); |
| 225 | + await driver?.disconnect?.(); |
| 226 | + }); |
| 227 | + |
| 228 | + /** |
| 229 | + * The fixture must have landed as typed before any verdict below means |
| 230 | + * anything. A backslash that mysql2 or the server lexer ate on the way IN |
| 231 | + * would make the `\` case fail for a reason that has nothing to do with |
| 232 | + * `applyLike` — and, worse, a doubled one would make it PASS for the wrong |
| 233 | + * reason on a build that had stopped escaping. |
| 234 | + */ |
| 235 | + it('stored the fixture backslash as exactly one character', async () => { |
| 236 | + const rows = await knexInstance(LIKE_TABLE).where({ id: 'bsl' }).select('title'); |
| 237 | + expect(String(rows[0]?.title)).toBe('C:\\logs'); |
| 238 | + }); |
| 239 | + |
| 240 | + for (const c of LIKE_ESCAPE_CASES) { |
| 241 | + it(c.name, async () => { |
| 242 | + const rows = await driver.find(LIKE_TABLE, { |
| 243 | + object: LIKE_TABLE, |
| 244 | + where: { title: { $contains: c.value } }, |
| 245 | + }); |
| 246 | + const got = rows |
| 247 | + .map((r: any) => String(r.id)) |
| 248 | + .sort((x: string, y: string) => x.localeCompare(y)); |
| 249 | + expect(got, c.note).toEqual([...c.expected]); |
| 250 | + }); |
| 251 | + } |
| 252 | + }); |
| 253 | +} |
0 commit comments