Skip to content

Commit b8a9598

Browse files
committed
test(driver-sql): LIKE-escape guard joins the live PG + MySQL dialect matrix, plus a literal-backslash case (#5589)
`SqlDriver.applyLike`'s own TSDoc grades an unescaped `%` a P0 filter bypass, but its regression guard hard-coded `client: 'better-sqlite3'` — so the P0 was verified on exactly one of the three shipped dialects, and on the end of the range with the least to say (SQLite honours no default escape character at all, which is why the explicit bound `ESCAPE` exists). The matrix and the CI job were already here: `live-dialect-matrix.testkit.ts` is the shared D-A3 cell list, and `Temporal Conformance (live PG + MySQL)` already runs this whole package against Postgres 16 and MySQL 8.0. The LIKE family was simply 0 of the 8 files reading `OS_TEST_POSTGRES_URL` / `OS_TEST_MYSQL_URL`. This wires it in — no new CI job. - the three existing cases (literal `%`, literal `_`, ordinary substring) now also run once per `DIALECT_CELLS` cell, over an issue-prefixed table; - a fourth case pins the escape character itself (literal `\`), which is where MySQL's "ESCAPE must evaluate as a constant at execution time" and its string-literal backslash doubling stop being inferences and get executed; - a fixture-integrity assertion first, so a backslash eaten on the way IN cannot be misread as an `applyLike` verdict; - the hard-coded SQLite guard is KEPT: it is the one that runs on every `pnpm test` with nothing to provision. The matrix extends it, not replaces it. An unprovisioned cell is a named skip and a red under `OS_EXPECT_LIVE_DIALECT_MATRIX=1`, via the testkit's `declareUnprovisionedCell`. Test-only; no runtime change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WyvqvKMG6asi9aXjKE6xtx
1 parent 7bf3d1c commit b8a9598

1 file changed

Lines changed: 203 additions & 1 deletion

File tree

packages/drivers/driver-sql/src/sql-driver-like-escape.test.ts

Lines changed: 203 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,84 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

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';
476
import { SqlDriver } from '../src/index.js';
77+
import {
78+
DIALECT_CELLS,
79+
declareUnprovisionedCell,
80+
type DialectCell,
81+
} from './live-dialect-matrix.testkit.js';
582

683
/**
784
* P0-3 regression: the `contains` / `$contains` operator must escape LIKE
@@ -49,3 +126,128 @@ describe('SqlDriver — contains escapes LIKE metacharacters (P0-3)', () => {
49126
expect(r.map((x: any) => x.id)).toEqual(['1']);
50127
});
51128
});
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

Comments
 (0)