Skip to content

Commit 0a8d679

Browse files
committed
no-mistakes(review): fix(migrate-ts): recreate unchanged Postgres views over retyped untracked tables Pass 2c now covers Postgres: an unchanged view is dropped before and created after a table change when its stored body names an altered table outside dependsOn and the altered column (column-scoped). Unit case and an executed live Postgres case added; both fail without the fix. F2: the CHANGELOG bullet "SQLite and D1 are unchanged" now says the replace rule does not apply to SQLite and D1. MySQL: migrate-ts Dialect is postgres | sqlite | d1; no MySQL path exists, no change
1 parent 9964716 commit 0a8d679

5 files changed

Lines changed: 98 additions & 11 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1039,9 +1039,9 @@ until you regenerate.
10391039
- **The snapshot no longer records a view's columns.** Nothing reads them. The format version
10401040
stays `3`, and an older snapshot that carries them still loads; they are dropped on read.
10411041
`ExpectedView.columns` is removed from `@metaobjectsdev/codegen-ts` for the same reason.
1042-
- SQLite and D1 are unchanged. Neither has `CREATE OR REPLACE VIEW`; a replaced view is
1043-
written as `DROP VIEW` and `CREATE VIEW` there, as it always was, so there is nothing to
1044-
refuse.
1042+
- The replace rule does not apply to SQLite and D1. Neither has `CREATE OR REPLACE VIEW`; a
1043+
replaced view is written as `DROP VIEW` and `CREATE VIEW` there, as it always was, so there
1044+
is nothing to refuse.
10451045
- **The `--allow` hint for a blocked view change names a token the parser accepts.** It printed
10461046
`--allow replace-view`, which `meta migrate` rejects (`invalid --allow token`). It names
10471047
`adopt-view` for an unfingerprinted view and `drop-view-cascade` when the drop would cascade.
@@ -1051,7 +1051,11 @@ until you regenerate.
10511051
- **A column change recreates only the views that read the column.** On Postgres a new default or a
10521052
changed nullability no longer drops and rebuilds the views over the table (neither blocks a
10531053
view), and a type change recreates only the views whose SQL names the column (a view that
1054-
selects `*` or joins `NATURAL` reads columns it does not name, and is always recreated). SQLite and D1 still
1054+
selects `*` or joins `NATURAL` reads columns it does not name, and is always recreated). That
1055+
now holds for a table the model does not list for the view as well: an unchanged `@sql` view
1056+
that joins such a table is dropped before and created after a retype of a column it reads,
1057+
where the migration failed at apply before (`cannot alter type of a column used by a view or
1058+
rule`). The table is found by name in the view's stored definition. SQLite and D1 still
10551059
recreate every view over a rebuilt table, and so does a `diff()` called with no `dialect`,
10561060
whose changes may be emitted for SQLite. `verify --db` and `meta migrate` now print the full
10571061
column type in a type change (`text(255) -> text`, `timestamp -> timestamptz`), so a length or

‎docs/features/migrations-and-drift.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -464,6 +464,13 @@ A drop and a create of the same view needs no `--allow`. It has two costs a repl
464464
Plan with `--from-db` to get the in-place replace when it is legal, which keeps both. Otherwise
465465
grant the privileges again after the migration is applied.
466466

467+
A view whose definition does **not** change is dropped and created too, when the migration
468+
retypes, drops or renames a column the view reads: Postgres refuses to retype or drop it
469+
while the view stands. The table may be one the model does not list for the view, which is
470+
the case of an `@sql` body that joins it. `meta migrate` finds it by name in the view's
471+
stored definition, and recreates the view only when that definition also names the changed
472+
column. The same two costs apply.
473+
467474
SQLite and D1 have no `CREATE OR REPLACE VIEW`: every replaced view is written as `DROP VIEW`
468475
and `CREATE VIEW` there, so there is no refusal to avoid and no probe. They refuse something
469476
else: a `DROP COLUMN`, or the rebuild of a table, while a view that reads the table stands. So

‎server/typescript/packages/integration-tests/test/view-replace-probe-pg.test.ts‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -573,6 +573,41 @@ describe("an @sql body in a plan that changes a table's columns (postgres)", ()
573573
expect((await plan(after.expected)).changes).toEqual([]);
574574
}, 60_000);
575575

576+
// The view's definition does not change here, so nothing replaces it: it only has to be
577+
// out of the way while the column it reads is retyped.
578+
test("an unchanged body in a plan that retypes a column it reads is dropped and created, and applies", async () => {
579+
const before = await model(scoredView(BEFORE.scored), SUMMARY_VIEW, [], BEFORE.others);
580+
await migrate(before.expected);
581+
const after = await model(scoredView(BEFORE.scored), SUMMARY_VIEW, [], [badge("long")]);
582+
expect(after.view.fingerprint).toBe(before.view.fingerprint);
583+
expect(after.view.dependsOn).toEqual(["programs"]);
584+
await expect(sql.raw(`ALTER TABLE "badges" ALTER COLUMN "points" TYPE BIGINT`).execute(k))
585+
.rejects.toThrow(/cannot alter type of a column used by a view or rule/);
586+
587+
const changed = await plan(after.expected);
588+
expect(changed.blocked).toEqual([]);
589+
expect(kindsOf(changed.changes)).toEqual(["change-column-type", "create-view", "drop-view"]);
590+
await apply(changed.changes);
591+
592+
expect(await scalar(`SELECT format_type(atttypid, atttypmod) FROM pg_attribute
593+
WHERE attrelid = 'public.badges'::regclass AND attname = 'points'`)).toBe("bigint");
594+
expect(await columnTypes(SUMMARY_VIEW)).toEqual({ id: "bigint", scored: "bigint" });
595+
expect((await plan(after.expected)).changes).toEqual([]);
596+
}, 60_000);
597+
598+
// Postgres retypes a column no view reads with the view standing, so the view stays.
599+
test("an unchanged body in a plan that retypes a column it does not read is left standing", async () => {
600+
await migrate((await model(scoredView(OVER_BADGES), SUMMARY_VIEW, [], BEFORE.others)).expected);
601+
const oid = await viewOid(SUMMARY_VIEW);
602+
const after = await model(scoredView(OVER_BADGES), SUMMARY_VIEW, [], [badge("long")]);
603+
604+
const changed = await plan(after.expected);
605+
expect(kindsOf(changed.changes)).toEqual(["change-column-type"]);
606+
await apply(changed.changes);
607+
expect(await viewOid(SUMMARY_VIEW)).toBe(oid);
608+
expect((await plan(after.expected)).changes).toEqual([]);
609+
}, 60_000);
610+
576611
// A derived body lists every table it reads, so a change to another table cannot reach it.
577612
test("a derived view that changes beside the same table change is replaced in place", async () => {
578613
const flag = (field: string) => summary(computed("flag", "boolean", { op: "isNull", arg: { field } }));

‎server/typescript/packages/migrate-ts/src/diff/index.ts‎

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1237,13 +1237,18 @@ function recreateViewsDependingOnChangedTables(
12371237
return viewReadsAnyColumn(sql, cols, readsEverything);
12381238
});
12391239
const prior = actualById.get(id);
1240-
// Where a triggering change rebuilds the table, the view in the database is stranded by
1241-
// every table ITS body reads, and that body may read one `dependsOn` leaves out: a
1242-
// table the new body no longer reads, or one an `@sql` body joins (ADR-0043). On
1243-
// Postgres the same case is settled when the view is planned (pushViewUpdate).
1244-
if (!columnScoped && prior !== undefined) {
1245-
for (const t of alteredTables.keys()) {
1246-
if (!tracked.includes(t) && liveViewReadsTable(prior, liveName(t))) tables.push(t);
1240+
// The view in the database is stranded by every table ITS body reads, and that body
1241+
// may read one `dependsOn` leaves out: a table the new body no longer reads, or one an
1242+
// `@sql` body joins (ADR-0043). Its own text says which. On Postgres it is stranded
1243+
// only when it also reads an altered column, as above. (A Postgres view whose
1244+
// definition changes never gets this far as a replace: pushViewUpdate plans the pair.)
1245+
if (prior !== undefined) {
1246+
const priorReadsEverything = prior.sql !== undefined && viewReadsEveryColumn(prior.sql);
1247+
for (const [t, cols] of alteredTables) {
1248+
if (tracked.includes(t) || !liveViewReadsTable(prior, liveName(t))) continue;
1249+
if (cols === null || prior.sql === undefined || viewReadsAnyColumn(prior.sql, cols, priorReadsEverything)) {
1250+
tables.push(t);
1251+
}
12471252
}
12481253
}
12491254
if (tables.length === 0) continue;

‎server/typescript/packages/migrate-ts/test/unit/diff-view-recreate-columns.test.ts‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,3 +118,39 @@ describe("D4 — a view is recreated only when the migration alters a column it
118118
expect(kinds(r)).not.toContain("replace-view");
119119
});
120120
});
121+
122+
// A view's `dependsOn` lists the tables the model knows it reads. An `@sql` body may join
123+
// another one (ADR-0043), and Postgres refuses `ALTER COLUMN … TYPE` on a column that a
124+
// standing view reads, whichever table holds it
125+
// (https://www.postgresql.org/docs/current/sql-altertable.html, SET DATA TYPE). The view's
126+
// stored body is what names that table.
127+
describe("an unchanged view whose stored body reads a table outside dependsOn (postgres)", () => {
128+
const POINTS: ColumnDescriptor = { name: "points", sqlType: { kind: "integer", bits: 32 }, nullable: false };
129+
const BONUS: ColumnDescriptor = { name: "bonus", sqlType: { kind: "integer", bits: 32 }, nullable: false };
130+
const wide = (c: ColumnDescriptor): ColumnDescriptor => ({ ...c, sqlType: { kind: "integer", bits: 64 } });
131+
const scores = (cols: ColumnDescriptor[]): TableDescriptor =>
132+
({ name: "scores", columns: [ID, ...cols], indexes: [], foreignKeys: [], primaryKey: ["id"], checks: [] });
133+
const JOINED = "SELECT g.id AS id, (SELECT count(*) FROM scores s WHERE s.points > 0) AS scored FROM games g";
134+
const joined = view({ sql: JOINED, fingerprint: viewFingerprint(JOINED) });
135+
const run = (v: ViewDescriptor, expected: ColumnDescriptor[]) =>
136+
diff(
137+
{ tables: [table([ID, LABEL]), scores(expected)], views: [v] },
138+
{ tables: [table([ID, LABEL]), scores([POINTS, BONUS])], views: [v] },
139+
{ dialect: "postgres", allow: { typeChange: true } },
140+
);
141+
142+
test("a type change to a column the body reads recreates it, and names the table", async () => {
143+
const r = await run(joined, [wide(POINTS), BONUS]);
144+
expect(kinds(r)).toEqual(["change-column-type", "drop-view", "create-view"]);
145+
expect(r.changes[1]).toMatchObject({ view: "v_games", reason: { kind: "unchanged", tables: ["scores"] } });
146+
expect(r.blocked).toEqual([]);
147+
});
148+
149+
test("a type change to a column of that table the body does not read leaves it alone", async () => {
150+
expect(kinds(await run(joined, [POINTS, wide(BONUS)]))).toEqual(["change-column-type"]);
151+
});
152+
153+
test("a view whose body does not name that table is left alone", async () => {
154+
expect(kinds(await run(view(), [wide(POINTS), BONUS]))).toEqual(["change-column-type"]);
155+
});
156+
});

0 commit comments

Comments
 (0)