Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions src/db/migration-column-extraction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ function stripSqlComments(text: string): string {
export type SchemaEvent =
| { type: "define_column"; table: string; column: string }
| { type: "drop_table"; table: string }
| { type: "rename_table"; from: string; to: string }
| { type: "remove_column"; table: string; column: string };

/** Extract the schema-affecting events a single SQL statement produces. Statements that don't affect table
Expand All @@ -163,6 +164,13 @@ export function extractSchemaEvents(rawStatement: string): SchemaEvent[] {
const dropTableMatch = /^\s*DROP\s+TABLE\s+(?:IF\s+EXISTS\s+)?(\w+)/i.exec(statement);
if (dropTableMatch) return [{ type: "drop_table", table: dropTableMatch[1]!.toLowerCase() }];

// `ALTER TABLE <old> RENAME TO <new>` — a whole-table rebuild: every column tracked under <old> must move to
// <new> so a collision detector does not go blind on the renamed table (#9647). Checked BEFORE the column
// rename below: `RENAME\s+TO` requires TO immediately after RENAME, which the column form (RENAME [COLUMN]
// <a> TO <b>, an identifier between RENAME and TO) never has, so the two can't be confused either direction.
const renameTableMatch = /\bALTER\s+TABLE\s+(\w+)\s+RENAME\s+TO\s+(\w+)/i.exec(statement);
if (renameTableMatch) return [{ type: "rename_table", from: renameTableMatch[1]!.toLowerCase(), to: renameTableMatch[2]!.toLowerCase() }];

const renameColumnMatch = /\bALTER\s+TABLE\s+(\w+)\s+RENAME\s+(?:COLUMN\s+)?(\w+)\s+TO\s+(\w+)/i.exec(statement);
if (renameColumnMatch) {
const table = renameColumnMatch[1]!.toLowerCase();
Expand Down Expand Up @@ -221,6 +229,17 @@ export function detectColumnCollisions(orderedFileContents: ReadonlyArray<readon
}
continue;
}
if (event.type === "rename_table") {
// Re-key every column tracked under the old name to the new one (#9647): a later CREATE/ALTER against
// the renamed table must still collide with a column it already carries, and must NOT collide with the
// now-vacated old name. Rebuild the map entries rather than mutating keys in place.
for (const [key, entry] of [...tracked]) {
if (entry.table !== event.from) continue;
tracked.delete(key);
tracked.set(`${event.to}.${entry.column}`, { table: event.to, column: entry.column, files: entry.files });
}
continue;
}
const key = `${event.table}.${event.column}`;
if (event.type === "remove_column") {
tracked.delete(key);
Expand Down
37 changes: 37 additions & 0 deletions test/unit/migration-column-extraction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,21 @@ describe("extractSchemaEvents (#2551)", () => {
]);
});

it("extracts ALTER TABLE … RENAME TO as a single rename_table event, disambiguated from column rename (#9647)", () => {
// The exact rebuild-and-rename statement from migrations/0201_ledger_anchor_bittensor.sql.
expect(extractSchemaEvents("ALTER TABLE decision_ledger_anchors_new RENAME TO decision_ledger_anchors;")).toEqual([
{ type: "rename_table", from: "decision_ledger_anchors_new", to: "decision_ledger_anchors" },
]);
// The column-rename forms (with and without the COLUMN keyword) must NOT be misparsed as a table rename —
// they still produce the remove+define pair.
expect(extractSchemaEvents("ALTER TABLE widgets RENAME color TO hue;")).toEqual([
{ type: "remove_column", table: "widgets", column: "color" },
{ type: "define_column", table: "widgets", column: "hue" },
]);
// And a table named like the "to" keyword must not confuse either regex.
expect(extractSchemaEvents("ALTER TABLE t RENAME TO t2;")).toEqual([{ type: "rename_table", from: "t", to: "t2" }]);
});

it("extracts ADD COLUMN's terser SQLite form that omits the COLUMN keyword (#8368)", () => {
expect(extractSchemaEvents("ALTER TABLE widgets ADD color TEXT;")).toEqual([{ type: "define_column", table: "widgets", column: "color" }]);
});
Expand Down Expand Up @@ -210,6 +225,28 @@ describe("detectColumnCollisions (#2551)", () => {
expect(detectColumnCollisions(files)).toEqual([]);
});

it("REGRESSION: re-keys columns across a rebuild-and-rename so a later duplicate is still caught (#9647)", () => {
// The standard SQLite rebuild-and-rename (as migrations/0201 does): a new table is built with `backend`,
// then RENAMEd over the old one. Before #9647 the detector tracked the column under the *_new name and
// went blind — a later ADD COLUMN backend on the real table read as a fresh, non-colliding definition.
const files: Array<[string, string]> = [
["0001_a.sql", "CREATE TABLE t (id INTEGER, backend TEXT);"],
["0002_b.sql", "CREATE TABLE t_new (id INTEGER, backend TEXT); ALTER TABLE t_new RENAME TO t;"],
["0003_c.sql", "ALTER TABLE t ADD COLUMN backend TEXT;"],
];
// Exactly one collision on t.backend — the re-keyed column from 0002's renamed table collides with 0003's.
expect(detectColumnCollisions(files)).toEqual([{ table: "t", column: "backend", files: ["0002_b.sql", "0003_c.sql"] }]);
});

it("does not flag the renamed table's columns against the now-vacated old name (#9647)", () => {
const files: Array<[string, string]> = [
["0001_a.sql", "CREATE TABLE t_new (id INTEGER, c TEXT); ALTER TABLE t_new RENAME TO t;"],
// A brand-new table reusing the OLD (vacated) name must not collide with the columns that moved off it.
["0002_b.sql", "CREATE TABLE t_new (id INTEGER, c TEXT);"],
];
expect(detectColumnCollisions(files)).toEqual([]);
});

it("returns [] for an empty file list", () => {
expect(detectColumnCollisions([])).toEqual([]);
});
Expand Down