|
43 | 43 | * `ON DUPLICATE KEY UPDATE`, which takes **no conflict target**: the named |
44 | 44 | * keys are dropped before the statement leaves the process, so the server is |
45 | 45 | * 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. |
50 | 47 | * |
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. |
57 | 58 | * |
58 | 59 | * # Reverse verification — direction predicted BEFORE it was run |
59 | 60 | * |
|
68 | 69 | * precisely what makes them controls. Measured, and it matched. |
69 | 70 | */ |
70 | 71 |
|
71 | | -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; |
| 72 | +import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'vitest'; |
72 | 73 | import knex from 'knex'; |
73 | 74 | import { StandardErrorCode } from '@objectstack/spec/api'; |
74 | 75 | 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'; |
76 | 82 |
|
77 | 83 | /** The shape `mapDataError` / `sendError` read off a thrown driver error. */ |
78 | 84 | interface WireBearingError extends Error { |
@@ -309,18 +315,199 @@ describe('[#8567] MySQL: `onConflict().merge()` compiles the conflict target awa |
309 | 315 | }); |
310 | 316 | }); |
311 | 317 |
|
| 318 | +// ───────────────────────────────────────────────────────────────────────── |
| 319 | +// [#8592] MySQL — what happens INSTEAD of the refusal, now observed |
| 320 | +// ───────────────────────────────────────────────────────────────────────── |
| 321 | + |
312 | 322 | /** |
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 |
314 | 376 | * |
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`). |
322 | 383 | */ |
323 | 384 | 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 | + }); |
326 | 513 | } |
0 commit comments