Skip to content

Commit 424bbd4

Browse files
os-zhuangclaude
andauthored
test(driver-sql): measure what MySQL does with a conflict target it cannot honour (#8592) (#8624)
The MySQL cell of the unbacked-conflict-target matrix was guarded one way only -- `if (!cell.available) declareUnprovisionedCell(...)` with no else. With OS_TEST_MYSQL_URL absent it announced itself un-run; with it PRESENT, on the one CI job that attaches a live MySQL 8.0, it declared nothing and measured nothing. The declaration disappeared exactly when the capability to measure appeared, and OS_EXPECT_LIVE_DIALECT_MATRIX could not catch it because a cell that emits no suite is not a skip. Structural fix: `declareDialectCell(cell, matrix, measure)` in the shared testkit is TOTAL -- `measure` is required, so the one-way form no longer typechecks. The other six consumers were checked and are already two-way. Measured on live MySQL 8.0.46 (system mysql-server, mysqld --daemonize), through the same knex + mysql2 path upsert takes. Table: `email` named in conflictKeys with no unique index, `tax_id` carrying the only unique one: - it does NOT refuse -- the identical call is VALIDATION_ERROR/400 on SQLite and Postgres; - it MERGES on `tax_id`, the key the caller never named, across two different `email` values -- a wrong write with no error; - it REPLACES the merged row's primary key while doing so (`id` sits in the merge set), which the card's inference did not contain; - it does NOT merge on `email`, the key it was given -- duplicates. The new pins are characterizations of a defect and say so; #8621 is the card that moves MySQL's accept set and will turn them red. Claude-Session: https://claude.ai/code/session_01VoxQqG5FiUHZKCST7KDoZC Co-authored-by: Claude <noreply@anthropic.com>
1 parent c931e53 commit 424bbd4

2 files changed

Lines changed: 253 additions & 22 deletions

File tree

packages/drivers/driver-sql/src/live-dialect-matrix.testkit.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,50 @@ export function declareUnprovisionedCell(cell: DialectCell, matrix: string): voi
165165
});
166166
}
167167

168+
/**
169+
* Run a cell EITHER WAY — measured when it is provisioned, declared un-run when
170+
* it is not — with no third outcome available to the caller.
171+
*
172+
* ## The hole this closes, which is not the one `declareUnprovisionedCell` closes
173+
*
174+
* That guard makes an UNPROVISIONED cell visible. It says nothing about the
175+
* provisioned case, and a consumer that writes only half the pair —
176+
*
177+
* ```ts
178+
* if (!MYSQL_CELL.available) declareUnprovisionedCell(MYSQL_CELL, '…');
179+
* // ^ no else: when the URL IS set, nothing is declared and nothing is run
180+
* ```
181+
*
182+
* — inverts the whole design. Measured on this file's own MySQL cell (#8592):
183+
* under Test Core (`OS_TEST_MYSQL_URL` absent) it announced itself as un-run,
184+
* and under `Temporal Conformance (live PG + MySQL)` — the one job with a live
185+
* MySQL 8.0 attached — it declared nothing and measured nothing. **The
186+
* declaration disappeared exactly when the capability to measure appeared**, and
187+
* `OS_EXPECT_LIVE_DIALECT_MATRIX=1` could not catch it because a cell that
188+
* emits no suite at all is not a skip.
189+
*
190+
* So the fix is a TOTAL function rather than a louder warning: `measure` is a
191+
* required parameter, so the one-way form above does not typecheck. A consumer
192+
* can still hand-roll `if (!cell.available) … else …` (six of them do, inside a
193+
* `for … continue` loop, and those are two-way already) — what it can no longer
194+
* do is ask for the un-run declaration WITHOUT saying what running would mean.
195+
*
196+
* @param matrix which matrix this cell belongs to — names the suite and the
197+
* failure message, exactly as in {@link declareUnprovisionedCell}.
198+
* @param measure declares the suites for a cell that CAN run right now.
199+
*/
200+
export function declareDialectCell(
201+
cell: DialectCell,
202+
matrix: string,
203+
measure: (cell: DialectCell) => void,
204+
): void {
205+
if (!cell.available) {
206+
declareUnprovisionedCell(cell, matrix);
207+
return;
208+
}
209+
measure(cell);
210+
}
211+
168212
/** What a server reports about its own timezone. */
169213
export interface ServerZone {
170214
/** The dialect's own spelling: `Asia/Shanghai`, `+08:00`, `SYSTEM`, … */

packages/drivers/driver-sql/src/sql-driver-upsert-conflict-target-dialects.test.ts

Lines changed: 209 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -43,17 +43,18 @@
4343
* `ON DUPLICATE KEY UPDATE`, which takes **no conflict target**: the named
4444
* keys are dropped before the statement leaves the process, so the server is
4545
* never asked to find an index for them. That is checkable with no server at
46-
* all, and the compile pin below checks it. The LIVE MySQL cell is still
47-
* declared un-run rather than dropped, because "the condition cannot arise"
48-
* is a claim about knex's compiler that a real server should eventually be
49-
* held to.
46+
* all, and the compile pin below checks it.
5047
*
51-
* ⚠️ What MySQL does INSTEAD of refusing — merge on whichever unique key the
52-
* row collides with, or insert a second row — is a different defect with a
53-
* different fix, filed separately. This file does not assert it, because
54-
* nobody has watched a MySQL server do it: an assertion written from the
55-
* compiled SQL alone would be exactly the transcribed-from-memory evidence
56-
* this card exists to stop accepting.
48+
* ⚠️ What MySQL does INSTEAD of refusing was left un-asserted by #8567 —
49+
* correctly, since nobody had watched a MySQL server do it and an assertion
50+
* written from the compiled SQL alone would be exactly the inferred evidence
51+
* that card existed to stop accepting. **[#8592] has now observed it on a live
52+
* MySQL 8.0.46**, and the last section of this file pins what the server
53+
* actually did: it merges on a unique key the caller never named, rewrites the
54+
* merged row's primary key, and does not merge on the key it was given. Those
55+
* pins describe a defect and are marked as such — the fix that makes them go
56+
* red is #8621, deliberately a separate card because it moves MySQL's accept
57+
* set.
5758
*
5859
* # Reverse verification — direction predicted BEFORE it was run
5960
*
@@ -68,11 +69,16 @@
6869
* precisely what makes them controls. Measured, and it matched.
6970
*/
7071

71-
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
72+
import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'vitest';
7273
import knex from 'knex';
7374
import { StandardErrorCode } from '@objectstack/spec/api';
7475
import { SqlDriver } from '../src/index.js';
75-
import { DIALECT_CELLS, declareUnprovisionedCell, type DialectCell } from './live-dialect-matrix.testkit.js';
76+
import {
77+
DIALECT_CELLS,
78+
declareDialectCell,
79+
declareUnprovisionedCell,
80+
type DialectCell,
81+
} from './live-dialect-matrix.testkit.js';
7682

7783
/** The shape `mapDataError` / `sendError` read off a thrown driver error. */
7884
interface WireBearingError extends Error {
@@ -309,18 +315,199 @@ describe('[#8567] MySQL: `onConflict().merge()` compiles the conflict target awa
309315
});
310316
});
311317

318+
// ─────────────────────────────────────────────────────────────────────────
319+
// [#8592] MySQL — what happens INSTEAD of the refusal, now observed
320+
// ─────────────────────────────────────────────────────────────────────────
321+
312322
/**
313-
* The live MySQL cell: declared un-run, never quietly dropped.
323+
* ⚠️⚠️ **These pins record a DEFECT, not a contract.** Every assertion below is
324+
* a characterization of what MySQL 8.0 does today, written down so it stops
325+
* being an inference. Do not read any of them as behaviour worth keeping: when
326+
* the pre-flight refusal lands (#8621 — deliberately NOT this card, it moves
327+
* MySQL's accept set and is a `minor` with its own argument), these tests go red
328+
* and must be REWRITTEN to the refusal, not relaxed to keep them green.
329+
*
330+
* # How this was measured
331+
*
332+
* #8567 left the MySQL half as an inference from compiled SQL — "merges on
333+
* whichever unique key the row happens to collide with" — and said so, because
334+
* inferred dialect behaviour is not evidence. This card observed it instead, on
335+
* a real server raised in the dev container: system MySQL 8.0.46 (Ubuntu noble
336+
* `mysql-server`), `mysqld --daemonize`, `default_time_zone='+08:00'`, driven
337+
* through the same knex + `mysql2` path `upsert` takes. CI's
338+
* `Temporal Conformance (live PG + MySQL)` job runs this same cell against
339+
* `mysql:8.0`.
340+
*
341+
* The table: `email` is the column the CALLER names in `conflictKeys` and has no
342+
* unique index; `tax_id` carries the only unique index. Verified DDL:
343+
*
344+
* ```
345+
* CREATE TABLE `os8592_mismatched` (
346+
* `id` varchar(255) NOT NULL, … `email` varchar(255) DEFAULT NULL,
347+
* `tax_id` varchar(255) DEFAULT NULL, …
348+
* PRIMARY KEY (`id`),
349+
* UNIQUE KEY `uniq_os8592_mismatched_tax_id` (`tax_id`)
350+
* )
351+
* ```
352+
*
353+
* # What the server did — three facts, all worse than "does not refuse"
354+
*
355+
* ```
356+
* seed upsert({email:'a@b.com', tax_id:'T-1', title:'first'}, ['email'])
357+
* -> RESOLVED. rows=[{id:'VBjOQwQp3uTtewte', email:'a@b.com', tax_id:'T-1'}]
358+
* B upsert({email:'other@b.com', tax_id:'T-1', title:'second'}, ['email'])
359+
* -> RESOLVED. rows=[{id:'RnSaXzGO69OKkP_D', email:'other@b.com', tax_id:'T-1'}]
360+
* ONE row. Merged on `tax_id` — which the caller never named — across two
361+
* DIFFERENT `email` values. And the surviving row's PRIMARY KEY changed.
362+
* D seed then upsert({email:'a@b.com', tax_id:'T-2'}, ['email'])
363+
* -> RESOLVED. TWO rows, both `email='a@b.com'`: the merge the caller asked
364+
* for did not happen either.
365+
* ```
366+
*
367+
* The identical first call is refused on SQLite and Postgres with
368+
* `VALIDATION_ERROR` / 400 (the sweep above). So MySQL fails in both directions
369+
* at once: it merges where the other two refuse, and it does not merge on the key
370+
* it was told to merge on. The card's inference was right about the wrong-key
371+
* merge and did not contain the primary-key rewrite, which is the sharpest edge —
372+
* the row's identity is silently replaced, so anything holding the old `id`
373+
* dangles with no error anywhere.
374+
*
375+
* # Reverse verification — direction predicted BEFORE running it
314376
*
315-
* The compile pin above proves the refusal cannot arise on MySQL. It does NOT
316-
* prove what happens instead, and that question needs a server this container
317-
* has none of (`mysqld` and `mariadbd` are both absent; only a PHP client
318-
* library is installed, and the docker daemon is unreachable). Reporting the
319-
* cell keeps that gap addressable by anyone who has one, instead of leaving a
320-
* dialect silently uncovered — which is the vacuous-green shape
321-
* `live-dialect-matrix.testkit.ts` exists to prevent.
377+
* Predicted: deleting the `measure` argument from `declareDialectCell` below
378+
* cannot reproduce the original one-way gap, because `measure` is a REQUIRED
379+
* parameter — the failure is a TypeScript error at the call site rather than a
380+
* silently absent suite. That is the point of making the testkit helper total:
381+
* the hole #8592 found is no longer expressible. Measured; it matched (tsc:
382+
* `Expected 3 arguments, but got 2`).
322383
*/
323384
const MYSQL_CELL = DIALECT_CELLS.find((c) => c.id === 'mysql')!;
324-
if (!MYSQL_CELL.available) {
325-
declareUnprovisionedCell(MYSQL_CELL, 'unbacked conflict-target refusal (behaviour never observed)');
385+
386+
/** The named conflict target is `email`; the only unique index is on `tax_id`. */
387+
const MISMATCHED = {
388+
name: 'os8592_mismatched',
389+
fields: {
390+
email: { type: 'string' },
391+
tax_id: { type: 'string', unique: true },
392+
title: { type: 'string' },
393+
},
394+
} as any;
395+
396+
declareDialectCell(
397+
MYSQL_CELL,
398+
'unbacked conflict-target refusal (MySQL merges on the wrong key instead)',
399+
declareMysqlObservedBehaviour,
400+
);
401+
402+
function declareMysqlObservedBehaviour(cell: DialectCell): void {
403+
describe(`[#8592] SqlDriver.upsert — what MySQL does instead of refusing (${cell.label})`, () => {
404+
let driver: SqlDriver;
405+
let knexInstance: any;
406+
407+
const rows = async (): Promise<any[]> => {
408+
const found = await driver.find(MISMATCHED.name, {});
409+
return [...found].sort((a: any, b: any) => String(a.tax_id).localeCompare(String(b.tax_id)));
410+
};
411+
412+
beforeAll(async () => {
413+
driver = new SqlDriver(cell.config());
414+
knexInstance = (driver as any).knex;
415+
await knexInstance.schema.dropTableIfExists(MISMATCHED.name);
416+
await driver.initObjects([MISMATCHED]);
417+
});
418+
419+
afterAll(async () => {
420+
await knexInstance?.schema.dropTableIfExists(MISMATCHED.name).catch(() => {});
421+
await driver?.disconnect?.();
422+
});
423+
424+
// The live cells share one database with every other suite in this package,
425+
// so each case starts from an empty table rather than from its neighbour.
426+
beforeEach(async () => {
427+
await knexInstance(MISMATCHED.name).delete();
428+
});
429+
430+
it('does NOT refuse the conflict target that SQLite and Postgres refuse', async () => {
431+
const err = await captureError(() =>
432+
driver.upsert(MISMATCHED.name, { email: 'a@b.com', tax_id: 'T-1', title: 'first' }, ['email']),
433+
);
434+
435+
// Not a bare `resolves` — the sweep above proves this exact call is a
436+
// `VALIDATION_ERROR`/400 on the other two dialects, and THAT asymmetry is
437+
// the finding. If this ever starts throwing, the pre-flight refusal has
438+
// landed and this whole suite is what needs rewriting.
439+
expect(
440+
err,
441+
'MySQL accepted an unbacked conflict target here when this was measured — a change ' +
442+
'means the accept set moved (#8621), and these characterization pins are now stale',
443+
).toBeNull();
444+
});
445+
446+
it('MERGES on a unique key the caller never named — a wrong write, with no error', async () => {
447+
await driver.upsert(MISMATCHED.name, { email: 'a@b.com', tax_id: 'T-1', title: 'first' }, ['email']);
448+
const seeded = await rows();
449+
expect(seeded).toHaveLength(1);
450+
451+
// Same `tax_id` (the unique index), DIFFERENT `email` (the named target).
452+
// A merge keyed on `email` cannot match; a merge keyed on `tax_id` does.
453+
const err = await captureError(() =>
454+
driver.upsert(MISMATCHED.name, { email: 'other@b.com', tax_id: 'T-1', title: 'second' }, ['email']),
455+
);
456+
expect(err).toBeNull();
457+
458+
const after = await rows();
459+
expect(
460+
after,
461+
'two rows would mean MySQL treated these as distinct; one means it merged them on `tax_id`',
462+
).toHaveLength(1);
463+
expect(after[0].email).toBe('other@b.com');
464+
expect(after[0].title).toBe('second');
465+
});
466+
467+
it('REPLACES the surviving row’s primary key while merging on that wrong key', async () => {
468+
await driver.upsert(MISMATCHED.name, { email: 'a@b.com', tax_id: 'T-1', title: 'first' }, ['email']);
469+
const seededId = (await rows())[0].id;
470+
471+
await driver.upsert(MISMATCHED.name, { email: 'other@b.com', tax_id: 'T-1', title: 'second' }, ['email']);
472+
const mergedId = (await rows())[0].id;
473+
474+
// `id` sits in the merge set, so `on duplicate key update … id = values(id)`
475+
// overwrites the stored row's identity with the fresh nanoid minted for the
476+
// insert that lost. Every external reference to `seededId` now dangles, and
477+
// nothing anywhere reported an error.
478+
expect(
479+
mergedId,
480+
'the merged row kept its original id — the primary-key rewrite measured in #8592 is gone, ' +
481+
'which is good news that this pin must be rewritten to describe',
482+
).not.toBe(seededId);
483+
});
484+
485+
it('does NOT merge on the key it WAS told to merge on — duplicates on `email`', async () => {
486+
await driver.upsert(MISMATCHED.name, { email: 'a@b.com', tax_id: 'T-1', title: 'first' }, ['email']);
487+
// Same `email` (the named merge key), different `tax_id` (nothing unique
488+
// collides). The caller asked for a merge on `email`; it does not happen.
489+
await driver.upsert(MISMATCHED.name, { email: 'a@b.com', tax_id: 'T-2', title: 'second' }, ['email']);
490+
491+
const after = await rows();
492+
expect(after).toHaveLength(2);
493+
expect(after.map((r: any) => r.email)).toEqual(['a@b.com', 'a@b.com']);
494+
expect(after.map((r: any) => r.title)).toEqual(['first', 'second']);
495+
});
496+
497+
/**
498+
* The control, and the reason the three pins above are readable as a defect
499+
* rather than as a broken cell: the primary-key merge path — the one whose
500+
* target MySQL's `ON DUPLICATE KEY UPDATE` really does honour — still works
501+
* on this same driver and this same table.
502+
*/
503+
it('still merges correctly on the primary key — no conflictKeys, one row', async () => {
504+
await driver.upsert(MISMATCHED.name, { id: 'os8592_fixed', email: 'id@b.com', tax_id: 'T-7', title: 'first' });
505+
await driver.upsert(MISMATCHED.name, { id: 'os8592_fixed', email: 'id@b.com', tax_id: 'T-7', title: 'second' });
506+
507+
const after = await rows();
508+
expect(after).toHaveLength(1);
509+
expect(after[0].id).toBe('os8592_fixed');
510+
expect(after[0].title).toBe('second');
511+
});
512+
});
326513
}

0 commit comments

Comments
 (0)