|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#21163] An autonumber prefix carrying a `LIKE` metacharacter seeds its |
| 5 | + * counter from the data table's MAX — cold, and on the #5495 re-seed — on every |
| 6 | + * dialect, SQLite included. |
| 7 | + * |
| 8 | + * # The defect |
| 9 | + * |
| 10 | + * `scanMaxNumericTail` is the one read of "the highest counter already stored |
| 11 | + * under this prefix". It anchors the scan with `escapeLikePrefix(prefix) + '%'`, |
| 12 | + * which escapes `\`, `%` and `_` with a backslash — and then compiled the |
| 13 | + * predicate through Knex's `where(col, 'like', …)`, which declares NO `ESCAPE` |
| 14 | + * clause on any dialect. Postgres and MySQL read a backslash as the default |
| 15 | + * `LIKE` escape, so they were right by default. SQLite has no escape character |
| 16 | + * unless one is declared, so `SO\_%` asked for the literal three characters |
| 17 | + * `S`,`O`,`\` followed by any one character — and matched nothing. Measured on |
| 18 | + * better-sqlite3 at `cb45469e` (this file's fixtures, before the fix): |
| 19 | + * |
| 20 | + * | format | stored | cold create issued | re-seed after rows 2..30 land | |
| 21 | + * |:---|:---|:---|:---| |
| 22 | + * | `SO_{0000}` | `SO_0007` | `SO_0001` | refused: UNIQUE constraint failed | |
| 23 | + * | `SO%{0000}` | `SO%0007` | `SO%0001` | refused: UNIQUE constraint failed | |
| 24 | + * | `SO\{0000}` | `SO\0007` | `SO\0001` | refused: UNIQUE constraint failed | |
| 25 | + * | `{region}-{0000}`, region `north_east` | `north_east-0007` | `north_east-0001` | — | |
| 26 | + * | `SO-{0000}` (control) | `SO-0007` | `SO-0008` | `SO-0031` | |
| 27 | + * |
| 28 | + * The `{region}` row is why this is not an exotic-format problem: the prefix is |
| 29 | + * RENDERED, so a `_` arrives from data as readily as from the format — and |
| 30 | + * snake_case is this platform's spelling for machine names. |
| 31 | + * |
| 32 | + * Cold, the counter starts under numbers already stored, so a later issue |
| 33 | + * collides with them. On the re-seed path the scan answers 0, the forward-only |
| 34 | + * re-seed cannot move the counter, and every retry collides again until the |
| 35 | + * retry budget is spent: the #5495 storm, back for that format. |
| 36 | + * |
| 37 | + * # What is asserted |
| 38 | + * |
| 39 | + * The issued value — the caller-visible answer — on a cold bootstrap over a |
| 40 | + * stored row, and on the create that follows a bypass write landing ABOVE a |
| 41 | + * warm counter. Per dialect cell: SQLite always runs; live Postgres and MySQL |
| 42 | + * run where provisioned (the `Temporal Conformance (live PG + MySQL)` job runs |
| 43 | + * this whole package against both) and are declared un-run otherwise. On those |
| 44 | + * two the backslash was already the default escape, so their cells pin that |
| 45 | + * DECLARING it changed nothing there — they passed before the fix and must |
| 46 | + * pass after it. |
| 47 | + * |
| 48 | + * The plain prefix is the control on every cell: it has nothing to escape, so |
| 49 | + * it was right before and after, and a fix that broke the scan wholesale would |
| 50 | + * redden it too. |
| 51 | + */ |
| 52 | + |
| 53 | +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; |
| 54 | +import { SqlDriver } from '../src/index.js'; |
| 55 | +import { DIALECT_CELLS, declareDialectCell, type DialectCell } from './live-dialect-matrix.testkit.js'; |
| 56 | + |
| 57 | +const TABLE = 'os21163_like_escape'; |
| 58 | +const SEQUENCES_TABLE = '_objectstack_sequences'; |
| 59 | + |
| 60 | +interface PrefixCase { |
| 61 | + /** Suite label. */ |
| 62 | + label: string; |
| 63 | + /** The declared format. */ |
| 64 | + format: string; |
| 65 | + /** The stored value for counter `n` under this format. */ |
| 66 | + render: (n: number) => string; |
| 67 | + /** True when the rendered prefix carries a character `escapeLikePrefix` escapes. */ |
| 68 | + escaped: boolean; |
| 69 | +} |
| 70 | + |
| 71 | +const pad4 = (n: number) => String(n).padStart(4, '0'); |
| 72 | + |
| 73 | +const STATIC_PREFIX_CASES: readonly PrefixCase[] = [ |
| 74 | + { label: '`_` in the prefix', format: 'SO_{0000}', render: (n) => `SO_${pad4(n)}`, escaped: true }, |
| 75 | + { label: '`%` in the prefix', format: 'SO%{0000}', render: (n) => `SO%${pad4(n)}`, escaped: true }, |
| 76 | + { label: '`\\` in the prefix', format: 'SO\\{0000}', render: (n) => `SO\\${pad4(n)}`, escaped: true }, |
| 77 | + { label: 'a plain prefix (control)', format: 'SO-{0000}', render: (n) => `SO-${pad4(n)}`, escaped: false }, |
| 78 | +]; |
| 79 | + |
| 80 | +const objectWith = (format: string) => |
| 81 | + ({ |
| 82 | + name: TABLE, |
| 83 | + fields: { |
| 84 | + region: { type: 'string' }, |
| 85 | + so_no: { type: 'autonumber', format, unique: true }, |
| 86 | + title: { type: 'string' }, |
| 87 | + }, |
| 88 | + }) as any; |
| 89 | + |
| 90 | +function suite(cell: DialectCell) { |
| 91 | + describe(`sql-driver — autonumber prefix LIKE escape (${cell.label}) [#21163]`, () => { |
| 92 | + let driver: SqlDriver; |
| 93 | + |
| 94 | + const knex = () => (driver as any).knex; |
| 95 | + |
| 96 | + /** Drop the data table and this object's counter rows, so every test starts COLD. */ |
| 97 | + const reset = async () => { |
| 98 | + await knex().schema.dropTableIfExists(TABLE); |
| 99 | + await knex()(SEQUENCES_TABLE) |
| 100 | + .where('object', TABLE) |
| 101 | + .del() |
| 102 | + .catch(() => {}); |
| 103 | + }; |
| 104 | + |
| 105 | + /** Land rows by a path that bypasses `fillAutoNumberFields`, as a seed replay does. */ |
| 106 | + const bypassInsert = async (values: string[], region: string | null = null) => { |
| 107 | + await knex()(TABLE).insert( |
| 108 | + values.map((v) => ({ id: `bypass-${v}`, region, so_no: v, title: 'seed replay' })), |
| 109 | + ); |
| 110 | + }; |
| 111 | + |
| 112 | + beforeEach(async () => { |
| 113 | + driver = new SqlDriver(cell.config()); |
| 114 | + await reset(); |
| 115 | + }); |
| 116 | + |
| 117 | + afterEach(async () => { |
| 118 | + await reset(); |
| 119 | + await driver.disconnect(); |
| 120 | + }); |
| 121 | + |
| 122 | + for (const c of STATIC_PREFIX_CASES) { |
| 123 | + const role = c.escaped ? 'red before the fix on SQLite' : 'control'; |
| 124 | + |
| 125 | + it(`${c.label}: a COLD bootstrap seeds from the stored MAX (${role})`, async () => { |
| 126 | + await driver.initObjects([objectWith(c.format)]); |
| 127 | + await bypassInsert([c.render(7)]); |
| 128 | + |
| 129 | + const created = await driver.create(TABLE, { title: 'first issued' }); |
| 130 | + expect(created.so_no).toBe(c.render(8)); |
| 131 | + }); |
| 132 | + |
| 133 | + it(`${c.label}: the #5495 re-seed moves a warm counter past rows a bypass write landed (${role})`, async () => { |
| 134 | + await driver.initObjects([objectWith(c.format)]); |
| 135 | + |
| 136 | + // Warm the counter on an EMPTY table, so this test does not depend on |
| 137 | + // the cold scan above: there is nothing for the bootstrap to find. |
| 138 | + expect((await driver.create(TABLE, { title: 'warm' })).so_no).toBe(c.render(1)); |
| 139 | + |
| 140 | + // Rows 2..30 land above the counter. Nothing tells the sequence. |
| 141 | + await bypassInsert(Array.from({ length: 29 }, (_, i) => c.render(i + 2))); |
| 142 | + |
| 143 | + const created = await driver.create(TABLE, { title: 'after the seeds' }); |
| 144 | + expect(created.so_no).toBe(c.render(31)); |
| 145 | + }); |
| 146 | + } |
| 147 | + |
| 148 | + it('a `_` that arrives from DATA through `{field}` interpolation seeds from the stored MAX (red before the fix on SQLite)', async () => { |
| 149 | + await driver.initObjects([objectWith('{region}-{0000}')]); |
| 150 | + await bypassInsert(['north_east-0007'], 'north_east'); |
| 151 | + |
| 152 | + const created = await driver.create(TABLE, { region: 'north_east', title: 'first issued' }); |
| 153 | + expect(created.so_no).toBe('north_east-0008'); |
| 154 | + }); |
| 155 | + }); |
| 156 | +} |
| 157 | + |
| 158 | +for (const cell of DIALECT_CELLS) { |
| 159 | + declareDialectCell(cell, 'autonumber prefix LIKE escape (#21163)', suite); |
| 160 | +} |
0 commit comments