|
19 | 19 | * (3) multiple children of one parent, (4) self-referential, (5) the #226 residual |
20 | 20 | * gap (rebuild parent AND drop the child's FK in one migration), (6) a multi-table |
21 | 21 | * A↔B cycle → refuse (emitter-level), (7) a no-referenced-rebuild → byte-identical to |
22 | | - * the pre-#241 path. |
| 22 | + * the pre-#241 path, (8) a MIXED migration — cascade + unrelated create-table/add-column |
| 23 | + * in the same batch, proving `renderD1`'s splice with a NON-empty `rest` (every other |
| 24 | + * scenario has every change on an affected table, so `rest.up` is always empty there). |
23 | 25 | */ |
24 | 26 | import { test, expect, describe, beforeEach, afterEach } from "bun:test"; |
25 | 27 | import { mkdtempSync, rmSync } from "node:fs"; |
@@ -496,4 +498,89 @@ describe("#241 D1 FK-cascade — real-engine gate (libSQL, one transaction = rem |
496 | 498 | expect(String((await queryRows("SELECT level FROM logs"))[0]!.level)).toBe("info"); |
497 | 499 | expect(await reDiffChanges(expected2)).toEqual([]); |
498 | 500 | }); |
| 501 | + |
| 502 | + // Scenario 8 — mixed migration: cascade + unrelated native changes → non-empty `rest` --- |
| 503 | + test("mixed migration: cascade (referenced parent) PLUS unrelated create-table/add-column flow through `rest`, re-diff EMPTY", async () => { |
| 504 | + const parentV = (withEnum: boolean): unknown => |
| 505 | + entity("Parent", [ |
| 506 | + { "field.long": { name: "id" } }, |
| 507 | + { "field.string": { name: "name", "@required": true } }, |
| 508 | + ...(withEnum ? [ENUM_KIND] : []), |
| 509 | + ID_PK, |
| 510 | + ]); |
| 511 | + const child = entity("Note", [ |
| 512 | + { "field.long": { name: "id" } }, |
| 513 | + { "field.long": { name: "parentId", "@required": true } }, |
| 514 | + ID_PK, |
| 515 | + { "identity.reference": { name: "ref_parent", "@fields": ["parentId"], "@references": "Parent" } }, |
| 516 | + ]); |
| 517 | + // Unrelated to the Parent/Note cascade set — no FK to or from either. Exists in |
| 518 | + // BOTH versions; v2 adds a column, exercising `rest`'s add-column path. |
| 519 | + const otherV = (withNote: boolean): unknown => |
| 520 | + entity("Other", [ |
| 521 | + { "field.long": { name: "id" } }, |
| 522 | + { "field.string": { name: "tag", "@required": true } }, |
| 523 | + ...(withNote ? [{ "field.string": { name: "note" } }] : []), |
| 524 | + ID_PK, |
| 525 | + ]); |
| 526 | + // Brand-new in v2 — exercises `rest`'s create-table path. |
| 527 | + const widget = entity("Widget", [ |
| 528 | + { "field.long": { name: "id" } }, |
| 529 | + { "field.string": { name: "label", "@required": true } }, |
| 530 | + ID_PK, |
| 531 | + ]); |
| 532 | + const v1 = rootMeta([parentV(false), child, otherV(false)]); |
| 533 | + const v2 = rootMeta([parentV(true), child, otherV(true), widget]); |
| 534 | + |
| 535 | + await applyV1(v1); |
| 536 | + await execEach([ |
| 537 | + "INSERT INTO parents (id, name) VALUES (1, 'root')", |
| 538 | + "INSERT INTO notes (id, parent_id) VALUES (1, 1)", |
| 539 | + "INSERT INTO others (id, tag) VALUES (1, 'x')", |
| 540 | + ]); |
| 541 | + |
| 542 | + const { res, em, changes, expected2 } = await migrateV2(v2); |
| 543 | + expect(res.ok).toBe(true); |
| 544 | + |
| 545 | + // The construction under test: an FK-referenced-parent rebuild (cascade) PLUS |
| 546 | + // unrelated native changes on tables outside the affected set — proves |
| 547 | + // `nonAffected`/`rest` is non-empty for this migration. |
| 548 | + expect(changes.some((c) => c.kind === "add-check" && c.table === "parents")).toBe(true); |
| 549 | + expect(changes.some((c) => c.kind === "create-table" && c.table.name === "widgets")).toBe(true); |
| 550 | + expect( |
| 551 | + changes.some((c) => c.kind === "add-column" && c.table === "others" && c.column.name === "note"), |
| 552 | + ).toBe(true); |
| 553 | + |
| 554 | + // The emitted `up` contains BOTH the cascade block (temp `__f_` tables for the |
| 555 | + // referenced parent + its referrer) AND the spliced-in `rest` (the unrelated |
| 556 | + // create-table and add-column) — proving the splice really ran with a |
| 557 | + // non-empty `rest`, not the trivially-empty case every other scenario exercises. |
| 558 | + expect(em.up).toContain('CREATE TABLE "__f_parents"'); |
| 559 | + expect(em.up).toContain('CREATE TABLE "__f_notes"'); |
| 560 | + expect(em.up).toContain('CREATE TABLE "widgets"'); |
| 561 | + expect(em.up).toContain('ALTER TABLE "others" ADD COLUMN "note"'); |
| 562 | + |
| 563 | + // Seeded row data intact — across the cascaded tables AND the `rest`-altered table. |
| 564 | + const parents = await queryRows("SELECT id, name, kind FROM parents ORDER BY id"); |
| 565 | + expect(parents.length).toBe(1); |
| 566 | + expect(String(parents[0]!.name)).toBe("root"); |
| 567 | + expect(parents[0]!.kind).toBeNull(); |
| 568 | + const notes = await queryRows("SELECT id, parent_id FROM notes ORDER BY id"); |
| 569 | + expect(notes.length).toBe(1); |
| 570 | + expect(Number(notes[0]!.parent_id)).toBe(1); |
| 571 | + const others = await queryRows("SELECT id, tag, note FROM others ORDER BY id"); |
| 572 | + expect(others.length).toBe(1); |
| 573 | + expect(String(others[0]!.tag)).toBe("x"); |
| 574 | + expect(others[0]!.note).toBeNull(); |
| 575 | + |
| 576 | + // The brand-new native table is live (created AFTER the cascade, per the splice order). |
| 577 | + await execEach(["INSERT INTO widgets (id, label) VALUES (1, 'w1')"]); |
| 578 | + expect((await queryRows("SELECT label FROM widgets"))[0]!.label).toBe("w1"); |
| 579 | + |
| 580 | + // The rebuilt FK is LIVE, not silently dropped. |
| 581 | + expect(await insertIsRejected("INSERT INTO notes (id, parent_id) VALUES (2, 999)")).toBe(true); |
| 582 | + |
| 583 | + // Convergence — the whole point: a mixed migration re-diffs EMPTY too. |
| 584 | + expect(await reDiffChanges(expected2)).toEqual([]); |
| 585 | + }); |
499 | 586 | }); |
0 commit comments