Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 70 additions & 2 deletions internal/diff/diff.go
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,11 @@ type ddlDiff struct {
addedColumnPrivileges []*ir.ColumnPrivilege
droppedColumnPrivileges []*ir.ColumnPrivilege
modifiedColumnPrivileges []*columnPrivilegeDiff
// Newly-added views that reference newly-added columns on modified tables.
// Created in the modify phase, AFTER generateModifyTablesSQL, so the columns
// exist when the view body is parsed (issue #414).
deferredAddedViews []*ir.View
functionsAwaitingDeferredViews []*ir.Function
}

// schemaDiff represents changes to a schema
Expand Down Expand Up @@ -1609,8 +1614,61 @@ func (d *ddlDiff) generateCreateSQL(targetSchema string, collector *diffCollecto
// Note: We need to create triggers for ALL tables, not just the original d.addedTables
generateCreateTriggersFromTables(d.addedTables, targetSchema, collector)

// Create views
generateCreateViewsSQL(d.addedViews, targetSchema, collector)
// Create views, deferring any whose body references a newly-added column on a
// modified table. Those columns are emitted by generateModifyTablesSQL during
// the modify phase, so deferred views are created there (issue #414)
addedColLookup := buildModifiedTableAddedColumnLookup(d.modifiedTables)
viewsToCreateNow := d.addedViews
if len(addedColLookup) > 0 {
viewsToCreateNow = nil
for _, v := range d.addedViews {
if viewReferencesAddedColumn(v, addedColLookup) {
d.deferredAddedViews = append(d.deferredAddedViews, v)
} else {
viewsToCreateNow = append(viewsToCreateNow, v)
}
}

// Transitive closure: also defer any view whose body references a view
// already in deferredAddedViews. Iterate to fixpoint so chains of any
// length (V3 -> V2 -> V1 -> added column) move together. Walking
// viewsToCreateNow in order preserves topological ordering on each pass.
// Each iteration reads d.deferredAddedViews fresh, so a view appended
// during this pass is visible to the very next sibling examined — that
// is what lets a topo-sorted chain drain in a single pass.
for {
var stillNow []*ir.View
added := false
for _, v := range viewsToCreateNow {
if viewReferencesAnyDeferredView(v, d.deferredAddedViews) {
d.deferredAddedViews = append(d.deferredAddedViews, v)
added = true
} else {
stillNow = append(stillNow, v)
}
}
viewsToCreateNow = stillNow
if !added {
break
}
}
}
generateCreateViewsSQL(viewsToCreateNow, targetSchema, collector)

// If any views were deferred, also defer functions whose view dependency is
// on those deferred views — they must be created after the views exist.
if len(d.deferredAddedViews) > 0 {
deferredViewLookup := buildViewLookup(d.deferredAddedViews)
var keepNow []*ir.Function
for _, fn := range functionsWithViewDeps {
if functionReferencesNewView(fn, deferredViewLookup) {
d.functionsAwaitingDeferredViews = append(d.functionsAwaitingDeferredViews, fn)
} else {
keepNow = append(keepNow, fn)
}
}
functionsWithViewDeps = keepNow
Comment on lines +1617 to +1670
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Deferral is not transitive across chained new views

If a newly-added view V2 depends on a newly-added view V1, and V1 is deferred (because V1's body references an added column), V2 is checked only against addedColLookup — it won't reference the modified table directly, so it stays in viewsToCreateNow. PostgreSQL then tries to CREATE VIEW v2 during the create phase while V1 doesn't yet exist, producing "ERROR: relation 'v1' does not exist".

A complete fix would also defer any view in viewsToCreateNow whose body references a deferred view (i.e., a name that appears in buildViewLookup(d.deferredAddedViews)). Without that transitive step, multi-level view chains still produce a different error than the one fixed here.

}
Comment on lines +1617 to +1671
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

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

Splitting d.addedViews into viewsToCreateNow and d.deferredAddedViews can violate the existing topological ordering of added views: an added view that depends on another added view may be created in the CREATE phase even if its dependency was deferred to the MODIFY phase, causing CREATE VIEW to fail. Consider also deferring any added views that depend (directly/transitively) on deferredAddedViews, or keep all dependent views together and re-toposort within each emission phase to preserve view-to-view dependency order.

Copilot uses AI. Check for mistakes.

// Create functions WITH view dependencies (now that views exist)
// These functions reference views in their return type or parameter types (issue #300)
Expand Down Expand Up @@ -1646,6 +1704,16 @@ func (d *ddlDiff) generateModifySQL(targetSchema string, collector *diffCollecto
// Modify tables
generateModifyTablesSQL(d.modifiedTables, d.droppedTables, targetSchema, collector)

// Create views deferred from generateCreateSQL — their bodies reference
// columns just added by ALTER TABLE above (issue #414). Likewise, emit
// any functions whose view dependency was on those deferred views.
if len(d.deferredAddedViews) > 0 {
generateCreateViewsSQL(d.deferredAddedViews, targetSchema, collector)
}
if len(d.functionsAwaitingDeferredViews) > 0 {
generateCreateFunctionsSQL(d.functionsAwaitingDeferredViews, targetSchema, collector)
}

// Find views that depend on views being recreated (issue #268, #308)
// Handles both materialized views and regular views with RequiresRecreate
// Exclude newly added views - they will be created in CREATE phase after recreated views
Expand Down
61 changes: 61 additions & 0 deletions internal/diff/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -769,6 +769,67 @@ func viewDependsOnTable(view *ir.View, tableSchema, tableName string) bool {
return false
}

// buildModifiedTableAddedColumnLookup returns a map of lowercased schema.tableName
// to a set of lowercased column names being added by ALTER TABLE on that table.
func buildModifiedTableAddedColumnLookup(modifiedTables []*tableDiff) map[string]map[string]struct{} {
lookup := make(map[string]map[string]struct{})
for _, td := range modifiedTables {
if len(td.AddedColumns) == 0 {
continue
}
key := strings.ToLower(td.Table.Schema + "." + td.Table.Name)
cols := make(map[string]struct{}, len(td.AddedColumns))
for _, c := range td.AddedColumns {
cols[strings.ToLower(c.Name)] = struct{}{}
}
lookup[key] = cols
}
return lookup
}

// viewReferencesAnyDeferredView reports whether the view's body references any
// of the provided deferred views by name. Used for transitive deferral so that
// view chains (V2 -> V1 -> added column) move together to the modify phase.
func viewReferencesAnyDeferredView(view *ir.View, deferred []*ir.View) bool {
if view == nil || view.Definition == "" || len(deferred) == 0 {
return false
}
for _, dv := range deferred {
if viewDependsOnView(view, dv.Name) {
return true
}
if dv.Schema != "" && viewDependsOnView(view, dv.Schema+"."+dv.Name) {
return true
}
}
return false
}

// viewReferencesAddedColumn reports whether the view's definition references
// any modified table AND at least one of the columns being added to that table.
// Both checks are required to avoid deferring views that simply happen to
// mention a column name being added to an unrelated table.
func viewReferencesAddedColumn(view *ir.View, addedCols map[string]map[string]struct{}) bool {
if view == nil || view.Definition == "" || len(addedCols) == 0 {
return false
}
for tableKey, cols := range addedCols {
parts := strings.SplitN(tableKey, ".", 2)
if len(parts) != 2 {
continue
}
if !viewDependsOnTable(view, parts[0], parts[1]) {
continue
}
for col := range cols {
if containsIdentifier(view.Definition, col) {
return true
}
}
}
Comment on lines +812 to +829
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

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

viewReferencesAddedColumn can call containsIdentifier (which recompiles a regex via regexp.MatchString) once per (modified table × added column) for every added view. On large schemas this can become noticeably expensive during planning. Consider optimizing by compiling regexes once per column (or using a non-regex identifier scan) and/or precomputing a lowercased view definition and using a cheaper boundary-aware search.

Copilot uses AI. Check for mistakes.
return false
}

// dependentViewsContext tracks views that depend on views being recreated
type dependentViewsContext struct {
// dependents maps view key (schema.name) to list of dependent views
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
ALTER TABLE foo ADD COLUMN run_id uuid;

CREATE OR REPLACE VIEW foo_base AS
SELECT id,
run_id
FROM foo
WHERE run_id IS NOT NULL;

CREATE OR REPLACE VIEW foo_summary AS
SELECT id
FROM foo_base;

CREATE OR REPLACE FUNCTION get_foo_summary()
RETURNS SETOF foo_summary
LANGUAGE sql
STABLE
AS $$ SELECT * FROM foo_summary
$$;
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
CREATE TABLE foo (
id bigint PRIMARY KEY,
run_id uuid
);

CREATE OR REPLACE VIEW foo_base AS
SELECT id, run_id FROM foo WHERE run_id IS NOT NULL;

CREATE OR REPLACE VIEW foo_summary AS
SELECT id FROM foo_base;

CREATE OR REPLACE FUNCTION get_foo_summary()
RETURNS SETOF foo_summary
LANGUAGE sql STABLE
AS $$ SELECT * FROM foo_summary $$;
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
CREATE TABLE foo (
id bigint PRIMARY KEY
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"version": "1.0.0",
"pgschema_version": "1.9.0",
"created_at": "1970-01-01T00:00:00Z",
"source_fingerprint": {
"hash": "e1fb0e7b8fda0362df6ecdbc88f6910f1faaa4d896de95796aa412b913e18858"
},
"groups": [
{
"steps": [
{
"sql": "ALTER TABLE foo ADD COLUMN run_id uuid;",
"type": "table.column",
"operation": "create",
"path": "public.foo.run_id"
},
{
"sql": "CREATE OR REPLACE VIEW foo_base AS\n SELECT id,\n run_id\n FROM foo\n WHERE run_id IS NOT NULL;",
"type": "view",
"operation": "create",
"path": "public.foo_base"
},
{
"sql": "CREATE OR REPLACE VIEW foo_summary AS\n SELECT id\n FROM foo_base;",
"type": "view",
"operation": "create",
"path": "public.foo_summary"
},
{
"sql": "CREATE OR REPLACE FUNCTION get_foo_summary()\nRETURNS SETOF foo_summary\nLANGUAGE sql\nSTABLE\nAS $$ SELECT * FROM foo_summary\n$$;",
"type": "function",
"operation": "create",
"path": "public.get_foo_summary"
}
]
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
ALTER TABLE foo ADD COLUMN run_id uuid;

CREATE OR REPLACE VIEW foo_base AS
SELECT id,
run_id
FROM foo
WHERE run_id IS NOT NULL;

CREATE OR REPLACE VIEW foo_summary AS
SELECT id
FROM foo_base;

CREATE OR REPLACE FUNCTION get_foo_summary()
RETURNS SETOF foo_summary
LANGUAGE sql
STABLE
AS $$ SELECT * FROM foo_summary
$$;
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
Plan: 3 to add, 1 to modify.

Summary by type:
functions: 1 to add
tables: 1 to modify
views: 2 to add

Functions:
+ get_foo_summary

Tables:
~ foo
+ run_id (column)

Views:
+ foo_base
+ foo_summary

DDL to be executed:
--------------------------------------------------

ALTER TABLE foo ADD COLUMN run_id uuid;

CREATE OR REPLACE VIEW foo_base AS
SELECT id,
run_id
FROM foo
WHERE run_id IS NOT NULL;

CREATE OR REPLACE VIEW foo_summary AS
SELECT id
FROM foo_base;

CREATE OR REPLACE FUNCTION get_foo_summary()
RETURNS SETOF foo_summary
LANGUAGE sql
STABLE
AS $$ SELECT * FROM foo_summary
$$;
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
ALTER TABLE foo ADD COLUMN run_id uuid;

CREATE OR REPLACE VIEW foo_base AS
SELECT id,
run_id
FROM foo
WHERE run_id IS NOT NULL;

CREATE OR REPLACE VIEW foo_summary AS
SELECT id
FROM foo_base;
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
CREATE TABLE foo (
id bigint PRIMARY KEY,
run_id uuid
);

CREATE OR REPLACE VIEW foo_base AS
SELECT id, run_id FROM foo WHERE run_id IS NOT NULL;

CREATE OR REPLACE VIEW foo_summary AS
SELECT id FROM foo_base;
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
CREATE TABLE foo (
id bigint PRIMARY KEY
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{
"version": "1.0.0",
"pgschema_version": "1.9.0",
"created_at": "1970-01-01T00:00:00Z",
"source_fingerprint": {
"hash": "e1fb0e7b8fda0362df6ecdbc88f6910f1faaa4d896de95796aa412b913e18858"
},
"groups": [
{
"steps": [
{
"sql": "ALTER TABLE foo ADD COLUMN run_id uuid;",
"type": "table.column",
"operation": "create",
"path": "public.foo.run_id"
},
{
"sql": "CREATE OR REPLACE VIEW foo_base AS\n SELECT id,\n run_id\n FROM foo\n WHERE run_id IS NOT NULL;",
"type": "view",
"operation": "create",
"path": "public.foo_base"
},
{
"sql": "CREATE OR REPLACE VIEW foo_summary AS\n SELECT id\n FROM foo_base;",
"type": "view",
"operation": "create",
"path": "public.foo_summary"
}
]
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
ALTER TABLE foo ADD COLUMN run_id uuid;

CREATE OR REPLACE VIEW foo_base AS
SELECT id,
run_id
FROM foo
WHERE run_id IS NOT NULL;

CREATE OR REPLACE VIEW foo_summary AS
SELECT id
FROM foo_base;
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
Plan: 2 to add, 1 to modify.

Summary by type:
tables: 1 to modify
views: 2 to add

Tables:
~ foo
+ run_id (column)

Views:
+ foo_base
+ foo_summary

DDL to be executed:
--------------------------------------------------

ALTER TABLE foo ADD COLUMN run_id uuid;

CREATE OR REPLACE VIEW foo_base AS
SELECT id,
run_id
FROM foo
WHERE run_id IS NOT NULL;

CREATE OR REPLACE VIEW foo_summary AS
SELECT id
FROM foo_base;
Loading