Skip to content

feat(planner): refuse retyping or dropping a column a view reads - #223

Open
aparajon wants to merge 3 commits into
armand/lint-concurrent-in-transactionfrom
armand/dependent-view-refusal
Open

aparajon wants to merge 3 commits into
armand/lint-concurrent-in-transactionfrom
armand/dependent-view-refusal

Conversation

@aparajon

Copy link
Copy Markdown
Collaborator

Why

PostgreSQL refuses ALTER COLUMN TYPE and DROP COLUMN on 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

  • Both front doors (migrate and the declarative diff) read, from pg_depend, which views and materialized views read each column of the target table (preflight.ColumnDependentViews).
  • The planner refuses a type change or drop of such a column with a new dependent-view reason. The decision names the views in a new dependent_views field, and the refusal detail and dry-run text name them and say to drop or redefine them first.
  • A rename is still allowed, since a view follows the column. The server's own refusal stays the backstop for a view created after planning.
  • The new reason and field are contract changes: the plan report moves to format_version 10, and the lint report, which embeds the reasons, to 4. The linter has no live facts and never reports the reason.
  • Integration tests cover the catalog read, both front doors, and the cases that must still run.

Stacked on #222, since both bump the lint format_version.

An apply widens app.users.email from varchar(50) to varchar(100) while app.active_users and app.mail_report select it.

Before                                   After

┌───────────────────────────────┐        ┌───────────────────────────────┐
│ dry-run                       │        │ dry-run                       │
│ binary-coercible, native      │        │ dependent-view, refused       │
│ "runs as written"             │ ✗      │ names both views              │
└───────────────┬───────────────┘        └───────────────┬───────────────┘
                │ run                                    │ run
                ▼                                        ▼
┌───────────────────────────────┐        ┌───────────────────────────────┐
│ server refuses: SQLSTATE 0A000│        │ refused at plan time          │
│ execution-failed, no reason,  │ ✗      │ nothing sent to the server;   │
│ views not named               │        │ detail names both views       │
└───────────────────────────────┘        └───────────────────────────────┘
Dry-run, before
statement 1:
  ALTER TABLE app.users ALTER COLUMN email TYPE varchar(100);

note[binary-coercible]:
  ALTER COLUMN email TYPE varchar(100) — PostgreSQL relabels the type in
  place; no table rewrite

note:
  runs as written

plan:
  app.users (PostgreSQL 16.15) — 1 statement, 1 step to run, 0 refused

The run then failed:

failed (execution-failed)
  table:     app.users
  statement: ALTER TABLE app.users ALTER COLUMN email TYPE varchar(100)
  detail:    sequence step 1 of 1 failed; no earlier steps had committed — Code names the outcome and any state the failed step itself left
  failed at: step 1: ALTER TABLE app.users ALTER COLUMN email TYPE varchar(100)
Dry-run, after
statement 1:
  ALTER TABLE app.users ALTER COLUMN email TYPE varchar(100);

error[unsupported-statement]:
  refused — ALTER COLUMN email TYPE varchar(100) changes a column read by
  "app"."active_users", "app"."mail_report", and PostgreSQL refuses to
  retype or drop a column while a view reads it

note:
  refusal class: capability-boundary

note[dependent-view]:
  ALTER COLUMN email TYPE varchar(100) — changes a column a view reads;
  PostgreSQL refuses this until the view is dropped or redefined

help:
  drop or redefine those views first, then re-plan

plan:
  app.users (PostgreSQL 16.15) — 1 statement, 0 steps to run, 1 refused

The run is refused the same way:

refused (unsupported-statement)
  table:     app.users
  class:     capability-boundary
  statement: ALTER TABLE app.users ALTER COLUMN email TYPE varchar(100)
  detail:    ALTER COLUMN email TYPE varchar(100) changes a column read by "app"."active_users", "app"."mail_report", and PostgreSQL refuses to retype or drop a column while a view reads it; drop or redefine those views first, then re-plan

🤖 Generated with Claude Code

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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-view diagnostics and the dependent_views decision 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

Comment thread pkg/planner/planner.go Outdated
Comment thread pkg/preflight/dependent_views.go Outdated
aparajon and others added 2 commits October 17, 2026 11:48
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@aparajon
aparajon force-pushed the armand/dependent-view-refusal branch from d52166f to 7f5473a Compare October 17, 2026 15:53
@aparajon
aparajon marked this pull request as ready for review October 17, 2026 16:13
…ction' into armand/dependent-view-refusal

# Conflicts:
#	pkg/migrate/facts.go
#	pkg/planner/planner.go
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants