Skip to content
Merged
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
129 changes: 121 additions & 8 deletions internal/diff/diff.go
Original file line number Diff line number Diff line change
Expand Up @@ -908,14 +908,108 @@ func GenerateMigration(oldIR, newIR *ir.IR, targetSchema string) []Diff {
// collectMigrationSQL populates the collector with SQL statements for the diff
// The collector must not be nil
func (d *ddlDiff) collectMigrationSQL(targetSchema string, collector *diffCollector) {
// Pre-drop materialized views that depend on tables being modified/dropped
// This must happen BEFORE table operations to avoid dependency errors
preDroppedViews := d.generatePreDropMaterializedViewsSQL(targetSchema, collector)

// First: Drop operations (in reverse dependency order)
d.generateDropSQL(targetSchema, collector)
d.generateDropSQL(targetSchema, collector, preDroppedViews)

// Then: Create operations (in dependency order)
d.generateCreateSQL(targetSchema, collector)

// Finally: Modify operations
d.generateModifySQL(targetSchema, collector)
d.generateModifySQL(targetSchema, collector, preDroppedViews)
}

// generatePreDropMaterializedViewsSQL drops materialized views that depend on
// tables being modified or dropped. This must happen before table operations.
// Returns a set of pre-dropped view keys (schema.name) to avoid duplicate drops.
func (d *ddlDiff) generatePreDropMaterializedViewsSQL(targetSchema string, collector *diffCollector) map[string]bool {
preDropped := make(map[string]bool)

// Build set of tables being modified or dropped
affectedTables := make(map[string]*ir.Table)
for _, table := range d.droppedTables {
key := table.Schema + "." + table.Name
affectedTables[key] = table
}
for _, tableDiff := range d.modifiedTables {
key := tableDiff.Table.Schema + "." + tableDiff.Table.Name
affectedTables[key] = tableDiff.Table
}

if len(affectedTables) == 0 {
return preDropped
}

// Check modifiedViews with RequiresRecreate for dependencies on affected tables
for _, viewDiff := range d.modifiedViews {
if !viewDiff.RequiresRecreate || !viewDiff.New.Materialized {
continue
}

viewKey := viewDiff.Old.Schema + "." + viewDiff.Old.Name
if preDropped[viewKey] {
continue
}

// Check if this view depends on any affected table
for _, table := range affectedTables {
if viewDependsOnTable(viewDiff.Old, table.Schema, table.Name) {
// Pre-drop this materialized view
viewName := qualifyEntityName(viewDiff.Old.Schema, viewDiff.Old.Name, targetSchema)
sql := fmt.Sprintf("DROP MATERIALIZED VIEW %s RESTRICT;", viewName)

context := &diffContext{
Type: DiffTypeMaterializedView,
Operation: DiffOperationDrop,
Path: fmt.Sprintf("%s.%s", viewDiff.Old.Schema, viewDiff.Old.Name),
Source: viewDiff.Old,
CanRunInTransaction: true,
}
collector.collect(context, sql)

preDropped[viewKey] = true
break
}
}
}

// Check droppedViews for dependencies on dropped tables (for CASCADE scenario)
for _, view := range d.droppedViews {
if !view.Materialized {
continue
}

viewKey := view.Schema + "." + view.Name
if preDropped[viewKey] {
continue
}

// Check if this view depends on any dropped table
for _, table := range d.droppedTables {
if viewDependsOnTable(view, table.Schema, table.Name) {
// Pre-drop this materialized view before the table CASCADE
viewName := qualifyEntityName(view.Schema, view.Name, targetSchema)
sql := fmt.Sprintf("DROP MATERIALIZED VIEW %s RESTRICT;", viewName)

context := &diffContext{
Type: DiffTypeMaterializedView,
Operation: DiffOperationDrop,
Path: fmt.Sprintf("%s.%s", view.Schema, view.Name),
Source: view,
CanRunInTransaction: true,
}
collector.collect(context, sql)

preDropped[viewKey] = true
break
}
}
}

return preDropped
}

// generateCreateSQL generates CREATE statements in dependency order
Expand Down Expand Up @@ -989,7 +1083,8 @@ func (d *ddlDiff) generateCreateSQL(targetSchema string, collector *diffCollecto
}

// generateModifySQL generates ALTER statements
func (d *ddlDiff) generateModifySQL(targetSchema string, collector *diffCollector) {
// preDroppedViews contains views that were already dropped in the pre-drop phase
func (d *ddlDiff) generateModifySQL(targetSchema string, collector *diffCollector, preDroppedViews map[string]bool) {
// Modify schemas
// Note: Schema modification is out of scope for schema-level comparisons

Expand All @@ -1002,8 +1097,8 @@ func (d *ddlDiff) generateModifySQL(targetSchema string, collector *diffCollecto
// Modify tables
generateModifyTablesSQL(d.modifiedTables, targetSchema, collector)

// Modify views
generateModifyViewsSQL(d.modifiedViews, targetSchema, collector)
// Modify views - pass preDroppedViews to skip DROP for already-dropped views
generateModifyViewsSQL(d.modifiedViews, targetSchema, collector, preDroppedViews)

// Modify functions
generateModifyFunctionsSQL(d.modifiedFunctions, targetSchema, collector)
Expand All @@ -1014,7 +1109,8 @@ func (d *ddlDiff) generateModifySQL(targetSchema string, collector *diffCollecto
}

// generateDropSQL generates DROP statements in reverse dependency order
func (d *ddlDiff) generateDropSQL(targetSchema string, collector *diffCollector) {
// preDroppedViews contains views that were already dropped in the pre-drop phase
func (d *ddlDiff) generateDropSQL(targetSchema string, collector *diffCollector, preDroppedViews map[string]bool) {

// Drop triggers from modified tables first (triggers depend on functions)
generateDropTriggersFromModifiedTables(d.modifiedTables, targetSchema, collector)
Expand All @@ -1025,8 +1121,9 @@ func (d *ddlDiff) generateDropSQL(targetSchema string, collector *diffCollector)
// Drop procedures
generateDropProceduresSQL(d.droppedProcedures, targetSchema, collector)

// Drop views
generateDropViewsSQL(d.droppedViews, targetSchema, collector)
// Drop views - filter out pre-dropped ones to avoid duplicate drops
viewsToDrop := filterPreDroppedViews(d.droppedViews, preDroppedViews)
generateDropViewsSQL(viewsToDrop, targetSchema, collector)

// Drop tables
generateDropTablesSQL(d.droppedTables, targetSchema, collector)
Expand All @@ -1041,6 +1138,22 @@ func (d *ddlDiff) generateDropSQL(targetSchema string, collector *diffCollector)
// Note: Schema deletion is out of scope for schema-level comparisons
}

// filterPreDroppedViews returns views that haven't been pre-dropped
func filterPreDroppedViews(views []*ir.View, preDropped map[string]bool) []*ir.View {
if len(preDropped) == 0 {
return views
}

filtered := make([]*ir.View, 0, len(views))
for _, view := range views {
key := view.Schema + "." + view.Name
if !preDropped[key] {
filtered = append(filtered, view)
}
}
return filtered
}

// getTableNameWithSchema returns the table name with schema qualification only when necessary
// If the table schema is different from the target schema, it returns "schema.table"
// If they are the same, it returns just "table"
Expand Down
83 changes: 62 additions & 21 deletions internal/diff/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,36 +79,53 @@ func generateCreateViewsSQL(views []*ir.View, targetSchema string, collector *di
}

// generateModifyViewsSQL generates CREATE OR REPLACE VIEW statements or comment changes
func generateModifyViewsSQL(diffs []*viewDiff, targetSchema string, collector *diffCollector) {
// preDroppedViews contains views that were already dropped in the pre-drop phase
func generateModifyViewsSQL(diffs []*viewDiff, targetSchema string, collector *diffCollector, preDroppedViews map[string]bool) {
for _, diff := range diffs {
// Handle materialized views that require recreation (DROP + CREATE)
if diff.RequiresRecreate {
viewKey := diff.New.Schema + "." + diff.New.Name
Copy link

Copilot AI Dec 3, 2025

Choose a reason for hiding this comment

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

The view key is constructed using diff.New.Schema + "." + diff.New.Name, but in generatePreDropMaterializedViewsSQL (line 952 of diff.go), the key is constructed using viewDiff.Old.Schema + "." + viewDiff.Old.Name. This inconsistency could cause the pre-dropped view check to fail if the view is renamed or moved to a different schema. Consider using the Old view's schema and name for both key construction and lookup to ensure consistency.

Suggested change
viewKey := diff.New.Schema + "." + diff.New.Name
viewKey := diff.Old.Schema + "." + diff.Old.Name

Copilot uses AI. Check for mistakes.
viewName := qualifyEntityName(diff.New.Schema, diff.New.Name, targetSchema)

// DROP the old materialized view
dropSQL := fmt.Sprintf("DROP MATERIALIZED VIEW %s RESTRICT;", viewName)
createSQL := generateViewSQL(diff.New, targetSchema)
// Check if already pre-dropped
if preDroppedViews != nil && preDroppedViews[viewKey] {
// Skip DROP, only CREATE since view was already dropped in pre-drop phase
createSQL := generateViewSQL(diff.New, targetSchema)

statements := []SQLStatement{
{
SQL: dropSQL,
CanRunInTransaction: true,
},
{
SQL: createSQL,
context := &diffContext{
Type: DiffTypeMaterializedView,
Operation: DiffOperationCreate,
Path: fmt.Sprintf("%s.%s", diff.New.Schema, diff.New.Name),
Source: diff,
CanRunInTransaction: true,
},
}
}
collector.collect(context, createSQL)
} else {
// DROP the old materialized view
dropSQL := fmt.Sprintf("DROP MATERIALIZED VIEW %s RESTRICT;", viewName)
createSQL := generateViewSQL(diff.New, targetSchema)

statements := []SQLStatement{
{
SQL: dropSQL,
CanRunInTransaction: true,
},
{
SQL: createSQL,
CanRunInTransaction: true,
},
}

// Use DiffOperationAlter to categorize as a modification
context := &diffContext{
Type: DiffTypeMaterializedView,
Operation: DiffOperationAlter,
Path: fmt.Sprintf("%s.%s", diff.New.Schema, diff.New.Name),
Source: diff,
CanRunInTransaction: true,
// Use DiffOperationAlter to categorize as a modification
context := &diffContext{
Type: DiffTypeMaterializedView,
Operation: DiffOperationAlter,
Path: fmt.Sprintf("%s.%s", diff.New.Schema, diff.New.Name),
Source: diff,
CanRunInTransaction: true,
}
collector.collectStatements(context, statements)
}
collector.collectStatements(context, statements)

// Add view comment if present
if diff.New.Comment != "" {
Expand Down Expand Up @@ -330,3 +347,27 @@ func viewDependsOnView(viewA *ir.View, viewBName string) bool {
// This can be enhanced with proper dependency parsing later
return strings.Contains(strings.ToLower(viewA.Definition), strings.ToLower(viewBName))
}

// viewDependsOnTable checks if a view depends on a specific table
// by checking if the table name appears in the view definition
func viewDependsOnTable(view *ir.View, tableSchema, tableName string) bool {
if view == nil || view.Definition == "" {
return false
}

def := strings.ToLower(view.Definition)
tableNameLower := strings.ToLower(tableName)

// Check for unqualified table name
if strings.Contains(def, tableNameLower) {
return true
Comment on lines +361 to +363
Copy link

Copilot AI Dec 3, 2025

Choose a reason for hiding this comment

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

The strings.Contains check for unqualified table names can produce false positives. For example, if tableName is "product", it will incorrectly match "products" or "product_items" in the view definition. Consider using word boundary matching (e.g., with regex \b) or a more sophisticated SQL parsing approach to avoid matching partial table names.

Copilot uses AI. Check for mistakes.
}

// Check for qualified table name (schema.table)
qualifiedName := strings.ToLower(tableSchema + "." + tableName)
if strings.Contains(def, qualifiedName) {
return true
}

return false
}
11 changes: 11 additions & 0 deletions testdata/diff/dependency/table_to_materialized_view/diff.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
DROP MATERIALIZED VIEW expensive_products RESTRICT;

ALTER TABLE products ADD COLUMN category text;

CREATE MATERIALIZED VIEW IF NOT EXISTS expensive_products AS
SELECT id,
name,
price,
category
FROM products
WHERE price > 100::numeric;
11 changes: 11 additions & 0 deletions testdata/diff/dependency/table_to_materialized_view/new.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
-- Table structure changed (added column), materialized view updated to use it
CREATE TABLE products (
id integer PRIMARY KEY,
name text NOT NULL,
price numeric(10,2),
category text
);

-- Materialized view now includes category
CREATE MATERIALIZED VIEW expensive_products AS
SELECT id, name, price, category FROM products WHERE price > 100;
10 changes: 10 additions & 0 deletions testdata/diff/dependency/table_to_materialized_view/old.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
-- Base table referenced by a materialized view
CREATE TABLE products (
id integer PRIMARY KEY,
name text NOT NULL,
price numeric(10,2)
);

-- Materialized view that depends on the products table
CREATE MATERIALIZED VIEW expensive_products AS
SELECT id, name, price FROM products WHERE price > 100;
32 changes: 32 additions & 0 deletions testdata/diff/dependency/table_to_materialized_view/plan.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{
"version": "1.0.0",
"pgschema_version": "1.4.3",
"created_at": "1970-01-01T00:00:00Z",
"source_fingerprint": {
"hash": "55a7680d1ce73c3b56fbc34065ad9197a57668cc1cdcd0854bce421c2fa33d4b"
},
"groups": [
{
"steps": [
{
"sql": "DROP MATERIALIZED VIEW expensive_products RESTRICT;",
"type": "materialized_view",
"operation": "drop",
"path": "public.expensive_products"
},
{
"sql": "ALTER TABLE products ADD COLUMN category text;",
"type": "table.column",
"operation": "create",
"path": "public.products.category"
},
{
"sql": "CREATE MATERIALIZED VIEW IF NOT EXISTS expensive_products AS\n SELECT id,\n name,\n price,\n category\n FROM products\n WHERE price > 100::numeric;",
"type": "materialized_view",
"operation": "create",
"path": "public.expensive_products"
}
]
}
]
}
11 changes: 11 additions & 0 deletions testdata/diff/dependency/table_to_materialized_view/plan.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
DROP MATERIALIZED VIEW expensive_products RESTRICT;

ALTER TABLE products ADD COLUMN category text;

CREATE MATERIALIZED VIEW IF NOT EXISTS expensive_products AS
SELECT id,
name,
price,
category
FROM products
WHERE price > 100::numeric;
27 changes: 27 additions & 0 deletions testdata/diff/dependency/table_to_materialized_view/plan.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
Plan: 1 to add, 1 to modify.

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

Tables:
~ products
+ category (column)

Materialized views:
+ expensive_products

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

DROP MATERIALIZED VIEW expensive_products RESTRICT;

ALTER TABLE products ADD COLUMN category text;

CREATE MATERIALIZED VIEW IF NOT EXISTS expensive_products AS
SELECT id,
name,
price,
category
FROM products
WHERE price > 100::numeric;
9 changes: 9 additions & 0 deletions testdata/diff/dependency/table_to_view/diff.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
ALTER TABLE products ADD COLUMN category text;

CREATE OR REPLACE VIEW expensive_products AS
SELECT id,
name,
price,
category
FROM products
WHERE price > 100::numeric;
Loading