Skip to content

fix: compare view definitions through their canonical form - #315

Draft
dilame wants to merge 1 commit into
stripe:mainfrom
dilame:fix/view-definition-canonical
Draft

dilame wants to merge 1 commit into
stripe:mainfrom
dilame:fix/view-definition-canonical

Conversation

@dilame

@dilame dilame commented Sep 27, 2026

Copy link
Copy Markdown

What changed

A view and a materialized view now carry, next to the definition a plan emits, the canonical form of that definition: the text PostgreSQL returns when the relation is created from the definition and read back. The diff compares the canonical form.

  • View and MaterializedView gain ViewDefinitionCanonical. The schema fetcher fills it with one round trip: create the definition as a temporary view in pg_temp, read pg_get_viewdef back, drop it. A materialized view goes through a temporary view over the same query, because a materialized view cannot be created in pg_temp.
  • The view and materialized-view generators compare the canonical definition when deciding whether the view changed.
  • The temporary view needs one session, so the fetcher acquires a dedicated connection when it was handed a pool. Anything that is not a pool is already one connection, and GetSchema already disables concurrency for those.

Why

pg_get_viewdef is not idempotent. Deparsing names an output column the definition left unnamed, so re-creating a view from that text can change the view it was re-created from:

CREATE TABLE s.t(id int);
CREATE VIEW s.v AS
  SELECT x.id, x.k
  FROM (SELECT id, 'credit'::text AS k FROM s.t
        UNION ALL
        SELECT id, 'incentive' FROM s.t) x;
-- deparse:                      'incentive'::text
-- create a view from it, deparse again: 'incentive'::text AS text

The second UNION branch is an untyped literal: the parser leaves its resname unset, so the branch is deparsed without an alias; parsing 'incentive'::text instead gives the item a resname of text, which the next deparse prints. The view's output column names are unaffected — they come from the union's first branch.

A view is compared by its definition text, so two databases that hold the same view under two texts read as a difference. Two failures follow. Plan validation builds the current schema in a temporary database and then reports that rebuild as a difference from the schema it was built from, and does not build at all. And a plan that re-creates such a view never converges: each re-creation lands on a new text, so the next plan re-creates it again.

One round trip through the parser and the deparser is a fixed point, which is what makes the comparison stable: it is the same for both databases, and a change to the view's query is still a change.

Known limitation

The definition a plan emits is unchanged, so a database the tool has written holds the text pg_get_viewdef produced for the view the tool created, while a database built by running the target DDL holds the text that DDL deparses to. For a view of the shape above those two texts differ. That difference is not introduced here — creating the view from the target's text already has that outcome — and this change does not remove it. What it removes is the difference read from a view that already exists, and with it the plan that re-created such a view on every run.

Validation

go test ./...

New acceptance cases, each failing without the comparison change: a view, a recursive view, and a materialized view whose definition holds an untyped literal in a nested UNION, unchanged between the two schemas, must produce an empty plan and pass validation.

A view's definition is compared as text, but the text pg_get_viewdef returns
is not necessarily its own output. Deparsing names an output column the
definition left unnamed — an untyped literal in a UNION branch inside a
subquery — and creating a view from that text names the column after its type,
so the re-created view deparses to a different text than the view it was
created from.

Two consequences, both reachable with a database whose schema is the desired
one: a plan validation rebuilds the current schema in a temporary database and
then reports the rebuild as a difference from the schema it was built from and
fails; and a plan that re-creates such a view never converges, because each
re-creation changes the text again.

The fetcher now records, next to the definition a plan emits, the definition's
canonical form: the text PostgreSQL returns when the view is created from that
definition and read back. One round trip through the parser and the deparser is
a fixed point. The diff compares the canonical form, so two databases that hold
the same view under two texts are equal, while a change to the view is still a
change. A materialized view goes through the same round trip with a temporary
view, because a materialized view cannot be created in pg_temp.

The definition a plan emits is unchanged.
@dilame
dilame force-pushed the fix/view-definition-canonical branch from 8bb8b85 to 8790d14 Compare September 27, 2026 01:07
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.

1 participant