Repository navigation
feat(planner): refuse retyping or dropping a column a view reads - #223
Conversation
PostgreSQL refuses every CONCURRENTLY form inside an explicit block, so a script that wraps one in BEGIN ... COMMIT fails at that statement and rolls back. Lint now tracks the block a script opens and reports the operation as concurrent-in-transaction, an error, with how to fix it. statement.TransactionControlOf reads BEGIN, COMMIT, and their variants from the parse tree. The lint report moves to format_version 3. Co-Authored-By: Claude Opus 5.5 <[email protected]>
There was a problem hiding this comment.
🟡 Changes recommended
Dependency discovery misses affected descendants, and the classifier incorrectly refuses explicit CASCADE drops.
2 open issues
What changed in this PR
Adds live view-dependency checks so pg-sprite can refuse blocked column changes during planning and name the affected views.
Changes:
- Reads column dependencies for imperative and declarative planning.
- Adds
dependent-viewdiagnostics and thedependent_viewsdecision field. - Updates tests, documentation, and plan/lint contract versions.
| File | Description |
|---|---|
| README.md | Summarizes the new refusal. |
| pkg/preflight/dependent_views.go | Reads column-to-view dependencies. |
| pkg/preflight/dependent_views_integration_test.go | Tests catalog dependency discovery. |
| pkg/planner/planner.go | Adds dependency facts and refusal classification. |
| pkg/planner/planner_test.go | Tests refusal and unaffected operations. |
| pkg/plan/plan.go | Bumps plan format to 10. |
| pkg/plan/plan_test.go | Updates contract assertions. |
| pkg/migrate/verdicts.go | Names views in refusal details. |
| pkg/migrate/facts.go | Loads dependencies for imperative planning. |
| pkg/migrate/dependent_view_integration_test.go | Tests imperative refusal and allowed changes. |
| pkg/lint/lint.go | Bumps lint format to 4. |
| pkg/lint/lint_test.go | Updates JSON version assertions. |
| pkg/diffplan/diffplan.go | Loads dependencies for declarative planning. |
| pkg/diffplan/dependent_view_integration_test.go | Tests declarative refusal. |
| pkg/capabilities/capabilities.yaml | Records dependency-related restrictions. |
| internal/cli/dryrun_text.go | Renders view names and remediation. |
| internal/cli/dryrun_text_test.go | Tests refusal rendering. |
| docs/postgres-online-ddl-reference.md | Explains the new reason. |
| docs/plan-report.md | Documents the version and field. |
| docs/optimistic-attempt.md | Records plan-time refusal behavior. |
| docs/lint-report.md | Documents the vocabulary version change. |
| docs/limitations.md | Describes dependency restrictions. |
| docs/cli-output-examples.md | Updates JSON versions. |
| docs/capabilities.md | Updates column-change capabilities. |
| docs/binary-coercible-type-changes.md | Explains early dependency refusal. |
| demo/tour.sh | Updates contract-version checks. |
Review effort: Balanced
REINDEX (CONCURRENTLY false) and (CONCURRENTLY 0) rebuild with the blocking lock, which PostgreSQL runs inside a transaction block, but the parser counted any CONCURRENTLY option as concurrent. Lint flagged them as concurrent-in-transaction, and the concurrent rewrite appended a second CONCURRENTLY option the server refuses as conflicting. The option's value is now read as the server's defGetBoolean reads it, through one helper that replaces the duplicate in ops.go, and the rewrite replaces a disabled option instead of adding one. Co-Authored-By: Claude Opus 5.5 <[email protected]>
PostgreSQL refuses ALTER COLUMN TYPE and DROP COLUMN on a column that a view or materialized view reads, even for a type change it would otherwise relabel without a rewrite. The planner classified both as native, so the statement reached the server and failed there with an untyped error that did not say which views were in the way. Both front doors now read, for the target table, which views and materialized views pg_depend records as reading each column, and the planner refuses a type change or drop of such a column with the new dependent-view reason. The decision names the views in a new dependent_views field, and the refusal detail and dry-run text tell the operator to drop or redefine them first. A rename is still allowed, since a view follows the column. The server's own refusal remains the backstop for a view created after planning. The new reason grows the closed Reasons vocabulary and the decision gains a field, so the plan report moves to format version 10 and the lint report, which embeds the reasons, to version 4. The linter has no live facts and never reports the reason. Co-Authored-By: Claude Opus 5.5 <[email protected]>
A DROP COLUMN ... CASCADE drops the views that read the column, which the server accepts, so it is no longer refused; it stays destructive. The parser records CASCADE on the operation. A change without ONLY reaches the column on every partition and inheritance child, so a view on one of them blocks it too. The lookup now walks the inheritance tree and reports, per column of the table, the views on the whole tree and on the table alone; a change under ONLY is judged by the table's own. Co-Authored-By: Claude Opus 5.5 <[email protected]>
d52166f to
7f5473a
Compare
…t-in-transaction # Conflicts: # pkg/lint/lint.go # pkg/lint/lint_test.go # pkg/statement/rewrite.go
…ction' into armand/dependent-view-refusal # Conflicts: # pkg/migrate/facts.go # pkg/planner/planner.go
…c's lint row Co-Authored-By: Claude Opus 5.5 <[email protected]>
|
🤖 Review findings - created by Kiran's code review agent - for pg-sprite/pull/223, e35b869. Verdict: 6 findings — 0 blocking, 2 non-blocking (a recursive DROP COLUMN is wrongly refused when a child's column survives the drop), 4 suggestions. Non-blocking
General suggestions
The one thing that could have broken, verifiedCould
Verified correct
No local builds or tests were run; this review is source inspection only. CI at this head is fully green (16/16). This review was generated by Claude Code (claude-opus-5-5). |
…ction' into armand/dependent-view-refusal
A recursive DROP COLUMN drops a child's copy only when the child inherits the column from this one parent alone and does not declare it, so a view reading a child's own copy no longer refuses the drop. The preflight read now returns Drop reach alongside Tree and Own, the read runs only for a statement that retypes or drops a column, the dry-run text names views only when the first refusal is a dependent view, and the verdict carries the view list as dependent_views. Co-Authored-By: Claude Opus 5.5 <[email protected]>
|
🤖 Thanks, all six are addressed in 48ce870.
Addressed by Claude Code |
|
🤖 Reviewed at head Verdict: one correctness gap remains in recursive DROP COLUMN dependency discovery. PostgreSQL still rejects the unsafe drop; the gap makes the dry-run miss a blocker that this change intends to identify. 1. [P2, blocker] Account for multiple inheritance paths from the same dropped ancestor. In pkg/preflight/dependent_views.go:68–69, Action item
OSS and integration: naming the blocking views and explaining the remedy improves standalone usability. Both front doors populate the same typed facts, the verdict now exposes Validation: inspected the full delta and exact-head surrounding source, including classification, both fact-loading paths, renderers, tests, and the PostgreSQL deletion algorithm. The previous review's local-column, redundant-query, first-refusal, and typed-verdict concerns are addressed. CI is successful across the reported checks, including PostgreSQL 14–18. I did not run local builds or database tests; the diamond finding is source-derived and needs the regression above. Review generated by Codex. |
JashLal
left a comment
There was a problem hiding this comment.
🤖 Approved by Jash’s AI agent (Codex) under the authorized review workflow. The accompanying review records any blockers and validation limits; this approval does not negate them.
…e drop A recursive DROP COLUMN lowers a child's inheritance count once per dropped parent and drops the child's copy on the last one, so a child that inherits the column along two paths from the dropped table loses it. Drop reach is now a replay of that walk over the catalog facts instead of an attinhcount = 1 filter, which left a view on such a child out of the plan. Co-Authored-By: Claude Opus 5.5 <[email protected]>
|
🤖 Thanks, you're right about the diamond. Fixed in 50c24fd.
Addressed by Claude Code |
…-refusal # Conflicts: # demo/tour.sh # docs/cli-output-examples.md # docs/lint-report.md # pkg/lint/lint.go # pkg/lint/lint_test.go
…-refusal # Conflicts: # docs/capabilities.md
…-marker-absent-row * origin/main: (37 commits) executor: qualify the create-owner check and two reads with pg_catalog (#204) docs: renames never reach copy-and-swap (#231) executor: type a lost bounded-attempt commit as outcome-unknown (#200) dbconn: refuse session timeouts below one millisecond (#201) preflight: qualify every decision read with pg_catalog (#202) feat(planner): refuse retyping or dropping a column a view reads (#223) copier: lock the shadow for writing before confirming the table lock (#193) router: run a type rewrite through copy-and-swap (#184) schemachange: qualify shadow, swap-inspection, fence, and name reads (#209) executor: warm the parser before timing the lock-budget test (#212) test(migrate): prove every native capability row runs without a rewrite (#221) feat(lint): flag a CONCURRENTLY operation inside a transaction block (#222) schemachange: enable the write fence always and check it at swap (#187) planner: refuse the relabel route for type shapes it cannot fully read (#220) planner: name an unnamed CHECK or FOREIGN KEY so its online sequence can be built (#219) progress: detach and bound the build progress read from its observer (#199) statement: refuse accepted-blocking DROP INDEX ... CASCADE (#189) checksum: hold the catch-up's flushes in Go while a live repair recopies (#191) capabilities: mark rows migrate refuses as refused (#188) fix(migrate): report a desired-state run executed only when a fresh plan is empty (#217) ... # Conflicts: # SAFETY.md # pkg/applier/catchup.go # pkg/applier/doc.go
…ge-exit-code * origin/main: (37 commits) executor: qualify the create-owner check and two reads with pg_catalog (#204) docs: renames never reach copy-and-swap (#231) executor: type a lost bounded-attempt commit as outcome-unknown (#200) dbconn: refuse session timeouts below one millisecond (#201) preflight: qualify every decision read with pg_catalog (#202) feat(planner): refuse retyping or dropping a column a view reads (#223) copier: lock the shadow for writing before confirming the table lock (#193) router: run a type rewrite through copy-and-swap (#184) schemachange: qualify shadow, swap-inspection, fence, and name reads (#209) executor: warm the parser before timing the lock-budget test (#212) test(migrate): prove every native capability row runs without a rewrite (#221) feat(lint): flag a CONCURRENTLY operation inside a transaction block (#222) schemachange: enable the write fence always and check it at swap (#187) planner: refuse the relabel route for type shapes it cannot fully read (#220) planner: name an unnamed CHECK or FOREIGN KEY so its online sequence can be built (#219) progress: detach and bound the build progress read from its observer (#199) statement: refuse accepted-blocking DROP INDEX ... CASCADE (#189) checksum: hold the catch-up's flushes in Go while a live repair recopies (#191) capabilities: mark rows migrate refuses as refused (#188) fix(migrate): report a desired-state run executed only when a fresh plan is empty (#217) ... # Conflicts: # README.md
…nts-qualified-reads * origin/main: cli: exit 1 and write usage errors to stderr (#206) applier: hold an unmoved marker image whose shadow row is absent (#203) executor: qualify the create-owner check and two reads with pg_catalog (#204) docs: renames never reach copy-and-swap (#231) executor: type a lost bounded-attempt commit as outcome-unknown (#200) dbconn: refuse session timeouts below one millisecond (#201) preflight: qualify every decision read with pg_catalog (#202) feat(planner): refuse retyping or dropping a column a view reads (#223) copier: lock the shadow for writing before confirming the table lock (#193)
…accessor * origin/main: (26 commits) cli: exit 1 and write usage errors to stderr (#206) applier: hold an unmoved marker image whose shadow row is absent (#203) executor: qualify the create-owner check and two reads with pg_catalog (#204) docs: renames never reach copy-and-swap (#231) executor: type a lost bounded-attempt commit as outcome-unknown (#200) dbconn: refuse session timeouts below one millisecond (#201) preflight: qualify every decision read with pg_catalog (#202) feat(planner): refuse retyping or dropping a column a view reads (#223) copier: lock the shadow for writing before confirming the table lock (#193) router: run a type rewrite through copy-and-swap (#184) schemachange: qualify shadow, swap-inspection, fence, and name reads (#209) executor: warm the parser before timing the lock-budget test (#212) test(migrate): prove every native capability row runs without a rewrite (#221) feat(lint): flag a CONCURRENTLY operation inside a transaction block (#222) schemachange: enable the write fence always and check it at swap (#187) planner: refuse the relabel route for type shapes it cannot fully read (#220) planner: name an unnamed CHECK or FOREIGN KEY so its online sequence can be built (#219) progress: detach and bound the build progress read from its observer (#199) statement: refuse accepted-blocking DROP INDEX ... CASCADE (#189) checksum: hold the catch-up's flushes in Go while a live repair recopies (#191) ... # Conflicts: # docs/copy-and-swap-design.md # docs/invariants.md
…-qualified-reads * origin/main: (40 commits) schemachange: qualify the dependent catalog reads with pg_catalog (#210) cli: exit 1 and write usage errors to stderr (#206) applier: hold an unmoved marker image whose shadow row is absent (#203) executor: qualify the create-owner check and two reads with pg_catalog (#204) docs: renames never reach copy-and-swap (#231) executor: type a lost bounded-attempt commit as outcome-unknown (#200) dbconn: refuse session timeouts below one millisecond (#201) preflight: qualify every decision read with pg_catalog (#202) feat(planner): refuse retyping or dropping a column a view reads (#223) copier: lock the shadow for writing before confirming the table lock (#193) router: run a type rewrite through copy-and-swap (#184) schemachange: qualify shadow, swap-inspection, fence, and name reads (#209) executor: warm the parser before timing the lock-budget test (#212) test(migrate): prove every native capability row runs without a rewrite (#221) feat(lint): flag a CONCURRENTLY operation inside a transaction block (#222) schemachange: enable the write fence always and check it at swap (#187) planner: refuse the relabel route for type shapes it cannot fully read (#220) planner: name an unnamed CHECK or FOREIGN KEY so its online sequence can be built (#219) progress: detach and bound the build progress read from its observer (#199) statement: refuse accepted-blocking DROP INDEX ... CASCADE (#189) ...
…esume-restart * origin/main: migrate: qualify the target resolve and accept-blocking reads (#205) decode: make an absent column's value unreadable (#218) schemachange: qualify the dependent catalog reads with pg_catalog (#210) cli: exit 1 and write usage errors to stderr (#206) applier: hold an unmoved marker image whose shadow row is absent (#203) executor: qualify the create-owner check and two reads with pg_catalog (#204) docs: renames never reach copy-and-swap (#231) executor: type a lost bounded-attempt commit as outcome-unknown (#200) dbconn: refuse session timeouts below one millisecond (#201) preflight: qualify every decision read with pg_catalog (#202) feat(planner): refuse retyping or dropping a column a view reads (#223) copier: lock the shadow for writing before confirming the table lock (#193) # Conflicts: # cmd/pg-sprite/main.go

Why
PostgreSQL refuses
ALTER COLUMN TYPEandDROP COLUMNon a column a view or materialized view reads, even for a type change it would otherwise relabel without a rewrite. The planner classified both as native, so the dry-run said "runs as written" and the real run then failed at the server with an untyped error that did not say which views were in the way. Copy-and-swap already refuses tables with dependent views, but only by count, and the native path never checked at all.What
migrateand the declarative diff) read, frompg_depend, which views and materialized views read each column of the target table (preflight.ColumnDependentViews).dependent-viewreason. The decision names the views in a newdependent_viewsfield, and the refusal detail and dry-run text name them and say to drop or redefine them first.format_version10, and the lint report, which embeds the reasons, to 4. The linter has no live facts and never reports the reason.Stacked on #222, since both bump the lint
format_version.An apply widens
app.users.emailfromvarchar(50)tovarchar(100)whileapp.active_usersandapp.mail_reportselect it.Dry-run, before
The run then failed:
Dry-run, after
The run is refused the same way:
🤖 Generated with Claude Code