|
2 | 2 |
|
3 | 3 | /** |
4 | 4 | * [#5702] `$icontains` on the SQL family, and the retirement of `$regex` / |
5 | | - * `$options` — the DRIVER half of the #4706 ruling (its contract half is #5701). |
| 5 | + * `$options` — the DRIVER half of the #4706 ruling (its contract half is #5701) |
| 6 | + * — plus [#6518] the case-sensitivity half #5702 could not deliver. |
6 | 7 | * |
7 | | - * ## What this pins, and why it is not the shared text case-set |
| 8 | + * ## This cell now answers the WHOLE shared case-set |
8 | 9 | * |
9 | | - * `@objectstack/spec/data` carries a canonical text case-set whose rows this |
10 | | - * file reuses (`FILTER_TEXT_ROWS` — the same nine, so a verdict here is |
11 | | - * comparable to one anywhere else). It deliberately does NOT import that |
12 | | - * case-set's CASES export, because `scripts/check-driver-conformance.mjs` |
13 | | - * judges a cell covered by that import and this driver does not yet answer the |
14 | | - * whole table: five of its cases require the `$contains` family to be |
15 | | - * case-SENSITIVE (#4706 Q2 = A), which SQLite's `LIKE` is not, and which cannot |
16 | | - * be fixed in this driver alone — `read-scope-sql` and `service-analytics` |
17 | | - * compile the same predicate for RLS and for the analytics face, so a |
18 | | - * driver-only change would give ONE permission rule two row sets (#3948). That |
19 | | - * work is filed separately and the driver's DEBT row stays open for it. |
| 10 | + * `FILTER_TEXT_CASES` is imported and executed below, which is what |
| 11 | + * `scripts/check-driver-conformance.mjs` reads as coverage. #5702 deliberately |
| 12 | + * imported only `FILTER_TEXT_ROWS` and left the CASES export alone, because |
| 13 | + * five of its cases require the `$contains` family to be case-SENSITIVE (#4706 |
| 14 | + * Q2 = A) and SQLite's `LIKE` folds ASCII — an import then would have flipped |
| 15 | + * the cell to "covered" while five cases went unanswered, which is a gate |
| 16 | + * reporting success over a standard nobody runs. |
20 | 17 | * |
21 | | - * Importing the case-set here would flip the cell to "covered" while five of |
22 | | - * its cases were unanswered — a gate reporting success over a standard nobody |
23 | | - * runs, which is exactly the failure the gate exists to prevent. |
| 18 | + * #6518 is what makes the import honest: `textMatchPredicate` emits `GLOB` on |
| 19 | + * the SQLite dialects (case-exact, carrying GLOB's own `[*]` / `[?]` / `[[]` |
| 20 | + * escapes because SQLite's grammar gives it no `ESCAPE` clause), keeps `LIKE` |
| 21 | + * on Postgres (already case-exact) with the `$icontains` fold moved off the |
| 22 | + * Unicode-folding `LOWER()` onto an ASCII-only `translate()`, and compares over |
| 23 | + * `CAST(… AS BINARY)` on MySQL. The per-dialect reasoning and the measurements |
| 24 | + * behind each cell live on that function. |
24 | 25 | * |
25 | 26 | * ## The reverse verification, direction decided BEFORE it was run |
26 | 27 | * |
27 | 28 | * - **Refusal face** — predicted RED, measured RED. Restoring the deleted |
28 | 29 | * `case '$regex':` fallthrough makes every assertion below that reads `code` |
29 | 30 | * / `status` fail, because the filter compiles again and nothing throws. |
30 | | - * - **`$icontains` face on SQLite** — predicted red, and the prediction needed |
31 | | - * a correction that is recorded here rather than smoothed over: deleting the |
32 | | - * `case '$icontains':` arm turns these cases red LOUDLY (the operator falls to |
33 | | - * `default:` and is refused), but deleting only the `LOWER()` fold does NOT, |
34 | | - * for any comparand. SQLite's `LIKE` folds ASCII by itself, so on this |
35 | | - * dialect the fold is unobservable in rows. The compiled-SQL case at the end |
36 | | - * is what pins it, and it is the only thing here that can. |
| 31 | + * - **`$icontains` face on SQLite** — #5702 recorded that deleting only the |
| 32 | + * fold changed NO row here, because `LIKE` folded ASCII by itself, so the |
| 33 | + * compiled-SQL case was the only witness it could offer. #6518 retires that |
| 34 | + * caveat, and the retirement was predicted before it was run: under `GLOB` |
| 35 | + * the fold is load-bearing in ROWS. Measured — dropping the column-side |
| 36 | + * `lower()` turns `$icontains: 'acme'` and `$icontains: 'ACME'` alike from |
| 37 | + * `['1','2']` into `['2']`, and `$icontains: 'CAFÉ'` from `['3']` into `[]`. |
| 38 | + * - **`$contains` case-sensitivity** — predicted RED on exactly the five case |
| 39 | + * rows before the change, measured RED: reverting the sqlite arm of |
| 40 | + * `textMatchPredicate` to `LIKE` fails those five case rows and NO others in |
| 41 | + * the table (10 reds in this file: the five, plus the five blocks below that |
| 42 | + * name the construct directly). |
37 | 43 | */ |
38 | 44 |
|
39 | 45 | import type { Knex } from 'knex'; |
40 | 46 | import { describe, it, expect, beforeAll, afterAll } from 'vitest'; |
41 | 47 | import type { DriverOptions, FilterCondition } from '@objectstack/spec/data'; |
42 | | -import { FILTER_TEXT_ROWS } from '@objectstack/spec/data'; |
| 48 | +import { FILTER_TEXT_CASES, FILTER_TEXT_ROWS } from '@objectstack/spec/data'; |
43 | 49 | import { SqlDriver } from './sql-driver.js'; |
44 | 50 |
|
45 | 51 | /** The error a refused filter produced — never a bare `toThrow()` (see below). */ |
@@ -206,26 +212,105 @@ describe('[#5702] SqlDriver — $icontains, and the retired $regex/$options', () |
206 | 212 | await expect(driver.find('txt', { where: { name: { $regex: 'zzz' } } }, BYPASS)).rejects.toThrow(); |
207 | 213 | }); |
208 | 214 |
|
209 | | - // ── The fold, where it is actually observable on SQLite ──────────────────── |
| 215 | + // ── [#6518] The case-SENSITIVE family (#4706 Q2 = A) ─────────────────────── |
210 | 216 |
|
211 | | - it('compiles LOWER() on both operands, and $contains on neither', async () => { |
212 | | - // On SQLite this is the ONLY witness to the fold: `LIKE` already folds ASCII |
213 | | - // here, so `$contains` and `$icontains` select identical rows for every |
214 | | - // comparand and a dropped `LOWER()` changes no answer. It changes the SQL, |
215 | | - // and it changes the answer on Postgres (whose LIKE is case-exact), so the |
216 | | - // statement is what has to be pinned. |
| 217 | + it('$contains is case-SENSITIVE in both directions', async () => { |
| 218 | + // The defect this closes: on `origin/main` both lines answered ['1','2'], |
| 219 | + // because SQLite's `LIKE` folds ASCII whatever the contract says. Over- |
| 220 | + // matching, not a near miss — the caller asked for one row and got two. |
| 221 | + expect(await ids({ name: { $contains: 'acme' } })).toEqual(['2']); |
| 222 | + expect(await ids({ name: { $contains: 'ACME' } })).toEqual(['1']); |
| 223 | + }); |
| 224 | + |
| 225 | + it('the case rule holds under negation too', async () => { |
| 226 | + // Row 1 is EXCLUDED from the negation only if the positive form excluded |
| 227 | + // it. A folding backend drops row 1 here and looks "stricter", which is the |
| 228 | + // reading that hides the defect. |
| 229 | + expect(await ids({ name: { $notContains: 'acme' } })) |
| 230 | + .toEqual(['1', '3', '4', '5', '6', '7', '8', '9']); |
| 231 | + }); |
| 232 | + |
| 233 | + it('$startsWith and $endsWith are case-SENSITIVE', async () => { |
| 234 | + expect(await ids({ name: { $startsWith: 'ACME' } })).toEqual(['1']); |
| 235 | + expect(await ids({ name: { $endsWith: 'corp' } })).toEqual(['2']); |
| 236 | + }); |
| 237 | + |
| 238 | + it('$contains and $icontains no longer answer identically on SQLite', async () => { |
| 239 | + // #5702 measured that they DID, for every comparand, and recorded it as the |
| 240 | + // reason `$icontains` was unobservable on this dialect. That is the fact |
| 241 | + // #6518 changes, so it is asserted rather than left as a comment: the two |
| 242 | + // operators are now distinguishable in rows on the dialect where they were |
| 243 | + // not. |
| 244 | + expect(await ids({ name: { $contains: 'acme' } })).not |
| 245 | + .toEqual(await ids({ name: { $icontains: 'acme' } })); |
| 246 | + }); |
| 247 | + |
| 248 | + // ── [#6518] GLOB's metacharacters are escaped, exactly as LIKE's were ────── |
| 249 | + |
| 250 | + for (const [label, comparand] of [ |
| 251 | + ['*', 'a*b'], |
| 252 | + ['?', 'a?b'], |
| 253 | + ['[', 'a[b'], |
| 254 | + ] as const) { |
| 255 | + it(`treats "${label}" as a literal character, not a GLOB metacharacter`, async () => { |
| 256 | + // The new operator brings a NEW escaped character class, and an |
| 257 | + // unescaped `*` is the same filter bypass an unescaped `%` is under LIKE: |
| 258 | + // measured on this fixture, the unescaped pattern `*a*b*` returns rows |
| 259 | + // 7, 8 and 9 where the escaped one returns none of them. No fixture row |
| 260 | + // holds these characters, so the observable claim is that the pattern |
| 261 | + // stays literal and selects nothing rather than expanding. |
| 262 | + expect(await ids({ name: { $contains: comparand } })).toEqual([]); |
| 263 | + expect(await ids({ name: { $icontains: comparand } })).toEqual([]); |
| 264 | + }); |
| 265 | + } |
| 266 | + |
| 267 | + it('compiles the case-exact SQLite construct, on both operators', async () => { |
217 | 268 | // Identifier quoting is the dialect's (knex renders backticks on the sqlite |
218 | | - // clients), so the assertion is on the SHAPE, not on one dialect's quotes. |
219 | | - const unquote = (sql: string) => sql.replace(/[`"\[\]]/g, ''); |
| 269 | + // clients), so the assertion is on the SHAPE. `[` / `]` are NOT stripped |
| 270 | + // here the way #5702's version stripped them: under GLOB they are the |
| 271 | + // escape mechanism, so erasing them would erase what is being pinned. |
| 272 | + const unquote = (sql: string) => sql.replace(/[`"]/g, ''); |
220 | 273 |
|
221 | 274 | const icontainsSql = unquote(driver.compileWhere({ name: { $icontains: 'acme' } })); |
222 | | - expect(icontainsSql).toContain('LOWER(name) LIKE LOWER('); |
223 | | - expect(icontainsSql).toContain('ESCAPE'); |
224 | | - // The escaped pattern still travels as the comparand, wildcards and all. |
225 | | - expect(icontainsSql).toContain('%acme%'); |
| 275 | + expect(icontainsSql).toContain('lower(name) GLOB lower('); |
| 276 | + expect(icontainsSql).toContain('*acme*'); |
| 277 | + // GLOB has no ESCAPE clause in SQLite's grammar; emitting one is a syntax |
| 278 | + // error, so its absence is part of the construct rather than a detail. |
| 279 | + expect(icontainsSql).not.toContain('ESCAPE'); |
226 | 280 |
|
227 | 281 | const containsSql = unquote(driver.compileWhere({ name: { $contains: 'acme' } })); |
228 | | - expect(containsSql).toContain('name LIKE'); |
229 | | - expect(containsSql).not.toContain('LOWER'); |
| 282 | + expect(containsSql).toContain('name GLOB'); |
| 283 | + expect(containsSql).not.toContain('lower'); |
| 284 | + expect(containsSql).not.toContain('LIKE'); |
| 285 | + }); |
| 286 | + |
| 287 | + // ── The shared standard, executed ────────────────────────────────────────── |
| 288 | + |
| 289 | + /** |
| 290 | + * [#6518] Every case in `FILTER_TEXT_CASES`, on the live SQLite cell. |
| 291 | + * |
| 292 | + * The invariant this enforces is the case-set's own: a backend answers with |
| 293 | + * the SAME ROW SET, or REFUSES with `INVALID_FILTER` — never a third, quieter |
| 294 | + * answer. So the rejection cases assert `code` AND `status` (ADR-0112) plus |
| 295 | + * the prescription the message must carry, and never a bare `toThrow()`. |
| 296 | + * |
| 297 | + * The blocks above are not redundant with this loop: they name the SQLite |
| 298 | + * construct and its escaped class, which the shared table deliberately does |
| 299 | + * not know about — it encodes the contract, not any dialect's spelling. |
| 300 | + */ |
| 301 | + describe('FILTER_TEXT_CASES — the shared standard', () => { |
| 302 | + for (const testCase of FILTER_TEXT_CASES) { |
| 303 | + it(testCase.name, async () => { |
| 304 | + if (testCase.expectRejection) { |
| 305 | + const err = await refusalOf(testCase.filter); |
| 306 | + expect(err.code).toBe(testCase.code); |
| 307 | + expect(err.status).toBe(400); |
| 308 | + for (const mention of testCase.mustMention) expect(err.message).toContain(mention); |
| 309 | + return; |
| 310 | + } |
| 311 | + expect(await ids(testCase.filter), testCase.note ?? testCase.name) |
| 312 | + .toEqual([...testCase.expected]); |
| 313 | + }); |
| 314 | + } |
230 | 315 | }); |
231 | 316 | }); |
0 commit comments