From 17dc1e2f200fba06af864b8cff7403097b699b59 Mon Sep 17 00:00:00 2001 From: Kiran Muddukrishna Date: Sat, 17 Oct 2026 09:10:25 +1100 Subject: [PATCH] capabilities: mark rows migrate refuses as refused The matrix published attach/detach partition and drop/reindex as migrate-supported, but migrate refuses all four. A guard test now runs every migrate-supported row through migrate.Run. --- README.md | 5 +- docs/capabilities.md | 25 +- docs/limitations.md | 3 + pkg/capabilities/capabilities.go | 2 +- pkg/capabilities/capabilities.yaml | 52 ++-- .../front_door_integration_test.go | 278 ++++++++++++++++++ 6 files changed, 329 insertions(+), 36 deletions(-) create mode 100644 pkg/capabilities/front_door_integration_test.go diff --git a/README.md b/README.md index be7e78e0..63f8cfc1 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,10 @@ refusal — never a silently wrong or incomplete result: lock. Virtual generated columns (PostgreSQL 18+) are not modeled: export refuses them and a desired file that redeclares one fails the diff. - **Partitioned tables** support in-place statement changes, but cannot be - expressed in or exported to desired files. + expressed in or exported to desired files, and `migrate` refuses + `ATTACH PARTITION` and `DETACH PARTITION`. +- **Index maintenance** is not driven: `migrate` refuses `DROP INDEX` and + `REINDEX` (plain or `CONCURRENTLY`); see the `--accept-blocking` note below. - **Unlogged tables and explicit column collations** are outside the declarative model: converging either is a table (or column) rewrite, so export and diff refuse rather than plan one. diff --git a/docs/capabilities.md b/docs/capabilities.md index 3e46ace8..275575ad 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -96,7 +96,7 @@ Two consequences follow, and they explain most of this page: | Tier | Meaning | What you see today | | --- | --- | --- | -| **T1 — supported today** | The engine executes the change through an online-safe pattern | Execution (or the safer rewritten sequence), exit 0; where `migrate` refuses a plain index-maintenance form and names its `CONCURRENTLY` idiom, `--accept-blocking` runs the plain form under budgets and exits 3 | +| **T1 — supported today** | The engine executes the change through an online-safe pattern | Execution (or the safer rewritten sequence), exit 0 | | **T2 — planned** | A known online pattern exists (or requires the copy-and-swap engine); building it is on the roadmap | A **typed refusal** naming the reason, exit 2 — never a silent fallback to a blocking form | | **T3 — out of scope by design** | No online-safety problem to solve, solving it belongs to a different tool class, or PostgreSQL offers no online mechanism to build on | A typed refusal or a parse-level rejection, with the reason stating *why it is not planned* | @@ -113,8 +113,11 @@ them is a limitation: tool (data-change runners, provisioning/IaC, convergence planners, expand/contract frameworks). The row's "Online-safety problem?" column names the class to look for. - **❌ no online mechanism exists** — PostgreSQL itself provides no online pattern to - build on, so pg-sprite refuses rather than silently run the blocking form. These are - the only rows where "unsupported" is the honest reading. + build on, so pg-sprite refuses rather than silently run the blocking form. The one + exception is index maintenance (`DROP INDEX`, `REINDEX`): the online form is a plain + `CONCURRENTLY` statement, which `migrate` refuses to wrap and names so you run it + directly; for a single-relation plain form, `--accept-blocking` runs it under budgets + and exits 3. Every matrix table carries an **"Online-safety problem?"** column: "Yes" means there is a readers-and-writers problem for an online engine to solve (pg-sprite solves it, plans to, @@ -182,8 +185,8 @@ are the canonical example. > `make gen-capabilities`; do not edit the generated regions below by hand. -**56 operations: 19 supported today, 21 planned behind a typed refusal, 14 out of scope -by design, and 2 with no online mechanism in PostgreSQL to build on.** +**56 operations: 15 supported today, 23 planned behind a typed refusal, 14 out of scope +by design, and 4 with no online mechanism for pg-sprite to build on.** Status legend: ✅ T1 (supported today) · 🟡 T2 (planned; typed refusal today) · @@ -230,8 +233,8 @@ review the object warrants) · | Operation | Status | Engine path | Online-safety problem? | Behavior and why | | --- | --- | --- | --- | --- | | `CREATE [UNIQUE] INDEX` on a plain table — including partial, expression, covering (`INCLUDE`), GIN/GiST/BRIN | ✅ | native, safer sequence | Yes | Executed as (or rewritten to) `CREATE INDEX CONCURRENTLY`, with validity verification, typed invalid-index outcomes, and a proven recovery for abandoned leftovers (`RebuildAbandonedIndex`, or `DropAbandonedIndex` to remove the leftover without rebuilding; both library-only; [runbook](invalid-index-recovery.md)) | -| `DROP INDEX` | ✅ | native, safer sequence | Yes | Rewritten to `DROP INDEX CONCURRENTLY`; flagged **destructive**. `migrate` refuses the plain form and names the idiom; a single-index `DROP INDEX` can be run as-is under bounded budgets with `--accept-blocking SCHEMA.TABLE` (exit 3, never 0) | -| `REINDEX` | ✅ | native, safer sequence | Yes | Rewritten to `REINDEX ... CONCURRENTLY`. `migrate` refuses the plain form and names the idiom; a plain `REINDEX INDEX` or `REINDEX TABLE` can be run as-is under bounded budgets with `--accept-blocking SCHEMA.TABLE` (exit 3, never 0). `REINDEX SCHEMA`, `DATABASE`, and `SYSTEM` stay refused | +| `DROP INDEX` | ❌ | — | Yes — the online form is a direct `CONCURRENTLY` statement that `migrate` does not drive | `migrate` never runs this statement: the plain form is refused and names `DROP INDEX CONCURRENTLY`, and the concurrent form is refused as already safe — run it directly against the database. A single-index plain `DROP INDEX` can be run as-is under bounded budgets with `--accept-blocking SCHEMA.TABLE` (exit 3, never 0). `diff` reports an index to remove as a **destructive** statement | +| `REINDEX` | ❌ | — | Yes — the online form is a direct `CONCURRENTLY` statement that `migrate` does not drive | `migrate` never runs this statement: the plain form is refused and names `REINDEX ... CONCURRENTLY`, and the concurrent form is refused as already safe — run it directly against the database. A plain `REINDEX INDEX` or `REINDEX TABLE` can be run as-is under bounded budgets with `--accept-blocking SCHEMA.TABLE` (exit 3, never 0). `REINDEX SCHEMA`, `DATABASE`, and `SYSTEM` stay refused. A desired schema cannot express a reindex, so `diff` never produces one | | Index build on a **partitioned parent** | 🟡 | native, planned flow | Yes | PostgreSQL has no parent-level `CONCURRENTLY`; the blocking form is refused by policy (`--force` does not bypass it, and `--accept-blocking` does not reach it yet). The partition-aware flow — `CREATE INDEX ON ONLY` → per-partition CIC → `ATTACH PARTITION`, with crash-resume per leaf — is planned | | `ADD CONSTRAINT ... USING INDEX` on a partitioned parent | ❌ | — | Yes — unsolvable today | PostgreSQL does not support adopting an index on a partitioned parent in any supported version; refused before execution | @@ -242,8 +245,8 @@ review the object warrants) · | Operation | Status | Engine path | Online-safety problem? | Behavior and why | | --- | --- | --- | --- | --- | | `CREATE TABLE ... PARTITION OF` | 🟡 | native, planned flow | Yes | Typed refusal at both doors: the imperative door does not take `CREATE TABLE`, and the declarative create path refuses the form at plan time and re-checks it at apply — attaching a partition takes a brief `ACCESS EXCLUSIVE` on the **parent**, which the greenfield absence proof does not cover. The partition-aware flow is planned | -| `ATTACH PARTITION` | ✅ | native, as-is | Yes | Executed; the safer idiom (pre-prove the bound with a validated `CHECK` so the attach skips its scan) is surfaced as guidance. A classify-first flow that constructs the proof itself is planned | -| `DETACH PARTITION [CONCURRENTLY]` | ✅ | native, safer sequence | Yes | `CONCURRENTLY` is the idiom; the blocking form is rewritten to it | +| `ATTACH PARTITION` | 🟡 | native, planned flow | Yes | Typed refusal, nothing executed: the attach scans the partition to validate its bound unless a validated `CHECK` already proves it, and the engine does not yet construct that proof. The safer idiom (pre-prove the bound with a validated `CHECK` so the attach skips its scan) is surfaced as guidance. The classify-first flow that constructs the proof itself is planned. A desired schema cannot express an attach, so `diff` never produces one | +| `DETACH PARTITION [CONCURRENTLY]` | 🟡 | native, planned flow | Yes | `CONCURRENTLY` is the idiom and the blocking form is rewritten to it (`--dry-run` and `suggest` show the rewrite), but `migrate` refuses both forms before anything executes: a cancelled concurrent detach leaves a detach-pending partition state the executor does not yet own recovering. A desired schema cannot express a detach, so `diff` never produces one | | Partitioned parents in the **declarative model** | 🟡 | native, planned flow | Yes | Typed refusal: the model does not yet carry partition keys, and rendering a partitioned parent as a plain `CREATE TABLE` would be silently wrong | | Partitioned tables in **copy-and-swap** | 🟡 | copy-and-swap | Yes | Root-vs-leaf publication semantics and per-partition swap; sequenced after the copy engine core | @@ -426,8 +429,8 @@ Three related jobs stay with humans on purpose: the recovery handles, and what the rest license a human to do is [invalid-index-recovery.md](invalid-index-recovery.md). - **Index maintenance (`REINDEX` automation, bloat-driven rebuild scheduling).** The - engine executes `REINDEX CONCURRENTLY` when asked (see matrix); *deciding* when an - index needs rebuilding is monitoring-and-operations territory. Automating it is + engine does not drive `REINDEX` at all (see matrix) — run `REINDEX ... CONCURRENTLY` + directly; *deciding* when an index needs rebuilding is monitoring-and-operations territory. Automating it is considered but on hold for the same ownership reasons as above. --- diff --git a/docs/limitations.md b/docs/limitations.md index cdb7491b..7aa96e9a 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -13,6 +13,9 @@ not escape hatches: | Index builds on a partitioned parent | PostgreSQL cannot build an index concurrently at the parent level. PostgreSQL supports a plain blocking build, but pg-sprite refuses it by policy because it takes `ACCESS EXCLUSIVE`; `--force` does not bypass this decision. `migrate --accept-blocking` does not reach this refusal yet either — it covers the single-relation `DROP INDEX` / `REINDEX` refusals — so an operator who chooses a maintenance-window blocking build runs it outside the CLI today; a library caller can run it through `executor.ExecuteAcceptedBlocking`, which marks the verdict as executed without online safety ([lock-budgeted-passthrough.md](lock-budgeted-passthrough.md)). The partition-aware `CREATE INDEX ON ONLY` → per-partition `CREATE INDEX CONCURRENTLY` → `ATTACH PARTITION` flow is planned but not yet implemented. | | `ADD CONSTRAINT ... USING INDEX` on a partitioned parent | PostgreSQL does not support adopting an existing index on a partitioned parent in any supported version. pg-sprite refuses before execution. | | `ADD FOREIGN KEY ... NOT VALID` on a partitioned parent | PostgreSQL does not support this before version 18, so pg-sprite refuses it on versions 14–17. It is supported on version 18 and later. | +| `DROP INDEX` and `REINDEX`, plain or `CONCURRENTLY` | `migrate` never runs index maintenance. The plain form is refused with `index-statement` and names its `CONCURRENTLY` idiom; the concurrent form is refused as already safe, so run it directly against the database. `migrate --accept-blocking SCHEMA.TABLE` runs a single-relation plain `DROP INDEX`, `REINDEX INDEX`, or `REINDEX TABLE` as-is under bounded budgets and exits 3 ([lock-budgeted-passthrough.md](lock-budgeted-passthrough.md)). | +| `ATTACH PARTITION` | Refused with `not-native-safe-rewrite-required`, nothing executed: the engine does not yet construct the validated `CHECK` that lets the attach skip its scan. Pre-prove the bound yourself, then attach outside `migrate`. | +| `DETACH PARTITION` and `DETACH PARTITION ... CONCURRENTLY` | Refused with `unsupported-statement` before anything executes: the plain form is rewritten to `CONCURRENTLY`, and the executor does not drive that step because a cancelled concurrent detach leaves a detach-pending state it does not yet own recovering. Run `DETACH PARTITION ... CONCURRENTLY` directly. | | Copy-and-swap | The copy-and-swap backend is not yet available. Statements that require it route to `refuse`; pg-sprite never falls through to a blocking rewrite. | | `ALTER COLUMN TYPE ... USING ` the copy cannot reproduce | The copy-and-swap route is still closed (above); when it opens, the shadow builder admits a `USING` expression only when it can evaluate it again, identically, in the chunk copy, the verifier's recopy, and every change the catch-up flushes. Three rules, each refused as `shadow-statement-conversion` before any shadow is committed: the expression must be **immutable** by the server's own classification (no `now()`, `random()`, `current_setting()`, session-dependent casts such as `text` to `timestamptz`, or functions declared `STABLE` or `VOLATILE`; checked by trying it as a stored generated column's expression on the empty shadow inside the build's transaction, then rolling that attempt back), it must **name only the column it retypes**, bare and unqualified (no neighbouring column, no `t.col`, no whole-row `t.*`), and it must not retype the **primary key**, whose values every chunk range, watermark, and resume clear are expressed in. The expression is also refused when the deparser cannot prove it rendered the text faithfully. Rewrite the expression in terms of the retyped column alone and of `IMMUTABLE` functions, or retype without `USING` so the server's assignment cast converts the value; the refusal names the column, the expression, and that rewrite. The spellings most often refused, and what to write instead, are in [`USING` expressions the copy reproduces](#using-expressions-the-copy-reproduces) below. | | A concurrent index build whose name is occupied by an invalid index, or whose table carries quarantined debris | The build refuses before running and never drops the occupant: an invalid entry is either abandoned debris or someone's build still in progress, and a drop by name cannot tell them apart. The refusal is typed by proof (`invalid-index-abandoned`, `invalid-index-build-in-flight`, `invalid-index-other-table`, `invalid-index-not-droppable`, `invalid-index-builder-unobservable`), and a table carrying an entry a previous recovery quarantined (`pgsprite_abandoned_`) but did not drop is refused the same way, so debris never goes quiet. The proven removal — `executor.RebuildAbandonedIndex`, or `executor.DropAbandonedIndex` to remove the leftover without running the build; both library-only today, no CLI verb — takes a bounded `SHARE UPDATE EXCLUSIVE` lock on the table (which any running concurrent index command holds), re-verifies the entry by OID, renames it to an identity-derived name and drops that, all within one shared budget; it refuses a visible in-flight build, another table's entry, or an entry the server will not drop concurrently (a partitioned table's index, an index partition, a constraint's index), and reports a lock budget rather than wait behind a build it cannot see. From the CLI, the [runbook](invalid-index-recovery.md) applies. | diff --git a/pkg/capabilities/capabilities.go b/pkg/capabilities/capabilities.go index fd8f6bb9..954284eb 100644 --- a/pkg/capabilities/capabilities.go +++ b/pkg/capabilities/capabilities.go @@ -224,7 +224,7 @@ func RenderDocument(input []byte, rows []Row) ([]byte, error) { counts[r.StatusMark]++ tiers[r.Tier]++ } - summary := fmt.Sprintf("**%d operations: %d supported today, %d planned behind a typed refusal, %d out of scope\nby design, and %d with no online mechanism in PostgreSQL to build on.**", len(rows), tiers[TierOne], tiers[TierTwo], counts[StatusNoSafetyProblem]+counts[StatusOtherTool], counts[StatusNoOnlineMechanism]) + summary := fmt.Sprintf("**%d operations: %d supported today, %d planned behind a typed refusal, %d out of scope\nby design, and %d with no online mechanism for pg-sprite to build on.**", len(rows), tiers[TierOne], tiers[TierTwo], counts[StatusNoSafetyProblem]+counts[StatusOtherTool], counts[StatusNoOnlineMechanism]) var err error out, err = replaceRegion(out, "summary", summary) if err != nil { diff --git a/pkg/capabilities/capabilities.yaml b/pkg/capabilities/capabilities.yaml index 68167e68..81a1038e 100644 --- a/pkg/capabilities/capabilities.yaml +++ b/pkg/capabilities/capabilities.yaml @@ -220,25 +220,29 @@ rows: - id: drop-index area: "indexes" operation: "`DROP INDEX`" - tier: "t1" - status_mark: "✅" - engine_path: "native_safer_sequence" + tier: "t3" + status_mark: "❌" + engine_path: "none" online_safety_problem: true + online_safety_detail: "the online form is a direct `CONCURRENTLY` statement that `migrate` does not drive" front_doors: - migrate: supported + migrate: refused diff: supported - reason_notes: "Rewritten to `DROP INDEX CONCURRENTLY`; flagged **destructive**. `migrate` refuses the plain form and names the idiom; a single-index `DROP INDEX` can be run as-is under bounded budgets with `--accept-blocking SCHEMA.TABLE` (exit 3, never 0)" + refusal_reason: index-statement + reason_notes: "`migrate` never runs this statement: the plain form is refused and names `DROP INDEX CONCURRENTLY`, and the concurrent form is refused as already safe — run it directly against the database. A single-index plain `DROP INDEX` can be run as-is under bounded budgets with `--accept-blocking SCHEMA.TABLE` (exit 3, never 0). `diff` reports an index to remove as a **destructive** statement" - id: reindex area: "indexes" operation: "`REINDEX`" - tier: "t1" - status_mark: "✅" - engine_path: "native_safer_sequence" + tier: "t3" + status_mark: "❌" + engine_path: "none" online_safety_problem: true + online_safety_detail: "the online form is a direct `CONCURRENTLY` statement that `migrate` does not drive" front_doors: - migrate: supported - diff: supported - reason_notes: "Rewritten to `REINDEX ... CONCURRENTLY`. `migrate` refuses the plain form and names the idiom; a plain `REINDEX INDEX` or `REINDEX TABLE` can be run as-is under bounded budgets with `--accept-blocking SCHEMA.TABLE` (exit 3, never 0). `REINDEX SCHEMA`, `DATABASE`, and `SYSTEM` stay refused" + migrate: refused + diff: not_applicable + refusal_reason: index-statement + reason_notes: "`migrate` never runs this statement: the plain form is refused and names `REINDEX ... CONCURRENTLY`, and the concurrent form is refused as already safe — run it directly against the database. A plain `REINDEX INDEX` or `REINDEX TABLE` can be run as-is under bounded budgets with `--accept-blocking SCHEMA.TABLE` (exit 3, never 0). `REINDEX SCHEMA`, `DATABASE`, and `SYSTEM` stay refused. A desired schema cannot express a reindex, so `diff` never produces one" - id: index-build-on-a-partitioned-parent area: "indexes" operation: "Index build on a **partitioned parent**" @@ -279,25 +283,27 @@ rows: - id: attach-partition area: "partitioned_tables" operation: "`ATTACH PARTITION`" - tier: "t1" - status_mark: "✅" - engine_path: "native_as_is" + tier: "t2" + status_mark: "🟡" + engine_path: "native_planned_flow" online_safety_problem: true front_doors: - migrate: supported - diff: supported - reason_notes: "Executed; the safer idiom (pre-prove the bound with a validated `CHECK` so the attach skips its scan) is surfaced as guidance. A classify-first flow that constructs the proof itself is planned" + migrate: refused + diff: not_applicable + refusal_reason: not-native-safe-rewrite-required + reason_notes: "Typed refusal, nothing executed: the attach scans the partition to validate its bound unless a validated `CHECK` already proves it, and the engine does not yet construct that proof. The safer idiom (pre-prove the bound with a validated `CHECK` so the attach skips its scan) is surfaced as guidance. The classify-first flow that constructs the proof itself is planned. A desired schema cannot express an attach, so `diff` never produces one" - id: detach-partition-concurrently area: "partitioned_tables" operation: "`DETACH PARTITION [CONCURRENTLY]`" - tier: "t1" - status_mark: "✅" - engine_path: "native_safer_sequence" + tier: "t2" + status_mark: "🟡" + engine_path: "native_planned_flow" online_safety_problem: true front_doors: - migrate: supported - diff: supported - reason_notes: "`CONCURRENTLY` is the idiom; the blocking form is rewritten to it" + migrate: refused + diff: not_applicable + refusal_reason: unsupported-statement + reason_notes: "`CONCURRENTLY` is the idiom and the blocking form is rewritten to it (`--dry-run` and `suggest` show the rewrite), but `migrate` refuses both forms before anything executes: a cancelled concurrent detach leaves a detach-pending partition state the executor does not yet own recovering. A desired schema cannot express a detach, so `diff` never produces one" - id: partitioned-parents-in-the-declarative-model area: "partitioned_tables" operation: "Partitioned parents in the **declarative model**" diff --git a/pkg/capabilities/front_door_integration_test.go b/pkg/capabilities/front_door_integration_test.go new file mode 100644 index 00000000..8d28e05b --- /dev/null +++ b/pkg/capabilities/front_door_integration_test.go @@ -0,0 +1,278 @@ +package capabilities_test + +import ( + "fmt" + "slices" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/block/pg-sprite/internal/testutil" + "github.com/block/pg-sprite/pkg/capabilities" + "github.com/block/pg-sprite/pkg/dbconn" + "github.com/block/pg-sprite/pkg/migrate" + "github.com/block/pg-sprite/pkg/statement" + "github.com/block/pg-sprite/pkg/verdict" +) + +// migrateClaim is one row's published promise that the migrate front door +// executes the operation: the schema it needs, and the statements that +// exercise it. %[1]s is the fixture schema, already safely named. +type migrateClaim struct { + setup string + statements []string + // minMajor is the oldest PostgreSQL major the row claims support on; + // zero means every supported major. + minMajor int +} + +// migrateClaims maps every capability row whose imperative `migrate` door is +// published as supported to statements that must run through the real +// migrate.Run (gate, classification, routing, preflight, and sequence +// admission). A row the engine refuses cannot be published as supported: +// consumers read the matrix to decide whether to submit the statement. +var migrateClaims = map[string]migrateClaim{ + "add-column-no-default-or-constant-default": { + setup: ` + CREATE TABLE %[1]s.accounts ( + id bigint PRIMARY KEY + )`, + statements: []string{ + `ALTER TABLE %[1]s.accounts ADD COLUMN nickname text`, + `ALTER TABLE %[1]s.accounts ADD COLUMN tier int NOT NULL DEFAULT 0`, + }, + }, + "drop-column": { + setup: ` + CREATE TABLE %[1]s.accounts ( + id bigint PRIMARY KEY, + legacy_id int + )`, + statements: []string{`ALTER TABLE %[1]s.accounts DROP COLUMN legacy_id`}, + }, + "alter-column-type-binary-coercible-proven-against-live-column-facts": { + setup: ` + CREATE TABLE %[1]s.accounts ( + id bigint PRIMARY KEY, + nickname varchar(50) + )`, + statements: []string{`ALTER TABLE %[1]s.accounts ALTER COLUMN nickname TYPE varchar(100)`}, + }, + "set-default-drop-default-drop-not-null": { + setup: ` + CREATE TABLE %[1]s.accounts ( + id bigint PRIMARY KEY, + nickname text NOT NULL + )`, + statements: []string{ + `ALTER TABLE %[1]s.accounts ALTER COLUMN nickname SET DEFAULT 'anon'`, + `ALTER TABLE %[1]s.accounts ALTER COLUMN nickname DROP DEFAULT`, + `ALTER TABLE %[1]s.accounts ALTER COLUMN nickname DROP NOT NULL`, + }, + }, + "set-not-null": { + setup: ` + CREATE TABLE %[1]s.accounts ( + id bigint PRIMARY KEY, + nickname text + )`, + statements: []string{`ALTER TABLE %[1]s.accounts ALTER COLUMN nickname SET NOT NULL`}, + }, + "rename-column-rename-table": { + setup: ` + CREATE TABLE %[1]s.accounts ( + id bigint PRIMARY KEY, + nickname text + )`, + statements: []string{ + `ALTER TABLE %[1]s.accounts RENAME COLUMN nickname TO display_name`, + `ALTER TABLE %[1]s.accounts RENAME TO members`, + }, + }, + "add-primary-key-add-unique-plain-key-columns": { + setup: ` + CREATE TABLE %[1]s.accounts ( + id bigint NOT NULL, + email text NOT NULL + )`, + statements: []string{ + `ALTER TABLE %[1]s.accounts ADD PRIMARY KEY (id)`, + `ALTER TABLE %[1]s.accounts ADD CONSTRAINT accounts_email_key UNIQUE (email)`, + }, + }, + "add-check-add-foreign-key-imperative": { + setup: ` + CREATE TABLE %[1]s.owners ( + id bigint PRIMARY KEY + ); + CREATE TABLE %[1]s.accounts ( + id bigint PRIMARY KEY, + owner_id bigint, + balance int + )`, + statements: []string{ + `ALTER TABLE %[1]s.accounts ADD CONSTRAINT balance_nonnegative CHECK (balance >= 0)`, + `ALTER TABLE %[1]s.accounts ADD CONSTRAINT accounts_owner_fk FOREIGN KEY (owner_id) REFERENCES %[1]s.owners (id)`, + }, + }, + "add-constraint-not-valid-using-index-validate-constraint": { + setup: ` + CREATE TABLE %[1]s.accounts ( + id bigint PRIMARY KEY, + balance int + )`, + statements: []string{ + `ALTER TABLE %[1]s.accounts ADD CONSTRAINT balance_nonnegative CHECK (balance >= 0) NOT VALID`, + `ALTER TABLE %[1]s.accounts VALIDATE CONSTRAINT balance_nonnegative`, + }, + }, + "add-foreign-key-not-valid-on-a-partitioned-parent": { + setup: ` + CREATE TABLE %[1]s.owners ( + id bigint PRIMARY KEY + ); + CREATE TABLE %[1]s.events ( + id bigint NOT NULL, + owner_id bigint, + PRIMARY KEY (id) + ) PARTITION BY RANGE (id)`, + statements: []string{ + `ALTER TABLE %[1]s.events ADD CONSTRAINT events_owner_fk FOREIGN KEY (owner_id) REFERENCES %[1]s.owners (id) NOT VALID`, + }, + minMajor: 18, + }, + "drop-constraint": { + setup: ` + CREATE TABLE %[1]s.accounts ( + id bigint PRIMARY KEY, + balance int, + CONSTRAINT balance_nonnegative CHECK (balance >= 0) + )`, + statements: []string{`ALTER TABLE %[1]s.accounts DROP CONSTRAINT balance_nonnegative`}, + }, + "create-unique-index-on-a-plain-table-including-partial-expression-covering-include-gin-gist-brin": { + setup: ` + CREATE TABLE %[1]s.accounts ( + id bigint PRIMARY KEY, + email text NOT NULL, + deleted_at timestamptz + )`, + statements: []string{ + `CREATE UNIQUE INDEX accounts_email_live_idx ON %[1]s.accounts (lower(email)) WHERE deleted_at IS NULL`, + }, + }, + "drop-index": { + setup: ` + CREATE TABLE %[1]s.accounts ( + id bigint PRIMARY KEY, + email text NOT NULL + ); + CREATE INDEX accounts_email_idx ON %[1]s.accounts (email)`, + statements: []string{ + `DROP INDEX %[1]s.accounts_email_idx`, + `DROP INDEX CONCURRENTLY %[1]s.accounts_email_idx`, + }, + }, + "reindex": { + setup: ` + CREATE TABLE %[1]s.accounts ( + id bigint PRIMARY KEY, + email text NOT NULL + ); + CREATE INDEX accounts_email_idx ON %[1]s.accounts (email)`, + statements: []string{ + `REINDEX INDEX %[1]s.accounts_email_idx`, + `REINDEX INDEX CONCURRENTLY %[1]s.accounts_email_idx`, + }, + }, + "attach-partition": { + setup: ` + CREATE TABLE %[1]s.events ( + id bigint NOT NULL, + PRIMARY KEY (id) + ) PARTITION BY RANGE (id); + CREATE TABLE %[1]s.events_low ( + id bigint NOT NULL, + PRIMARY KEY (id) + )`, + statements: []string{ + `ALTER TABLE %[1]s.events ATTACH PARTITION %[1]s.events_low FOR VALUES FROM (0) TO (1000)`, + }, + }, + "detach-partition-concurrently": { + setup: ` + CREATE TABLE %[1]s.events ( + id bigint NOT NULL, + PRIMARY KEY (id) + ) PARTITION BY RANGE (id); + CREATE TABLE %[1]s.events_low PARTITION OF %[1]s.events FOR VALUES FROM (0) TO (1000); + CREATE TABLE %[1]s.events_high PARTITION OF %[1]s.events FOR VALUES FROM (1000) TO (2000)`, + statements: []string{ + `ALTER TABLE %[1]s.events DETACH PARTITION %[1]s.events_low`, + `ALTER TABLE %[1]s.events DETACH PARTITION %[1]s.events_high CONCURRENTLY`, + }, + }, +} + +// declarativeOnlyClaims are the rows whose migrate door is the desired-state +// path (migrate --desired), not an imperative statement; their execution is +// covered by the desired-state integration tests in pkg/migrate. +var declarativeOnlyClaims = []string{ + "plain-tables-their-indexes", + "greenfield-create-table-apply-the-table-does-not-exist-yet-a-fresh-database-or-a-new-table-in-a-live-one", + "declarative-row-security-library", +} + +// Every capability row that publishes `front_doors.migrate: supported` must +// have its representative statements actually executed by migrate.Run. A +// refusal at the gate, the router, preflight, or sequence admission means the +// matrix promised an operation the shipped migrate command rejects. +func TestMigrateSupportedRowsExecute(t *testing.T) { + rows, err := capabilities.Rows() + require.NoError(t, err) + + var supported []string + for _, row := range rows { + if row.FrontDoors.Migrate == capabilities.DoorSupported { + supported = append(supported, row.ID) + } + } + for _, id := range supported { + _, imperative := migrateClaims[id] + assert.True(t, imperative || slices.Contains(declarativeOnlyClaims, id), + "row %s publishes migrate: supported but has no representative statement in migrateClaims", id) + } + + url := testutil.StartPostgres(t) + pool, err := dbconn.NewPool(t.Context(), dbconn.Config{URL: url}) + require.NoError(t, err) + defer pool.Close() + major, err := dbconn.ServerMajor(t.Context(), pool) + require.NoError(t, err) + + for _, id := range supported { + claim, ok := migrateClaims[id] + if !ok { + continue + } + t.Run(id, func(t *testing.T) { + if major < claim.minMajor { + t.Skipf("row claims support from PostgreSQL %d; server is %d", claim.minMajor, major) + } + schema := testutil.NewSchema(t, pool) + _, err := pool.Exec(t.Context(), fmt.Sprintf(claim.setup, schema)) + require.NoError(t, err) + + for _, sql := range claim.statements { + sql = fmt.Sprintf(sql, schema) + st, err := statement.ParseOne(sql) + require.NoError(t, err) + v, err := migrate.Run(t.Context(), pool, st, migrate.DefaultOptions()) + require.NoError(t, err, sql) + assert.Equal(t, verdict.OutcomeExecuted, v.Outcome, + "%s: the matrix publishes migrate as supported but it answered %s (%s): %s", sql, v.Outcome, v.Reason, v.Detail) + } + }) + } +}