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
9 changes: 9 additions & 0 deletions cmd/plan/plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -499,6 +499,15 @@ func normalizeSchemaNames(irData *ir.IR, fromSchema, toSchema string) {
}
index.Where = replaceString(index.Where)
}

// Normalize schema names in view triggers (e.g., INSTEAD OF triggers)
for _, trigger := range view.Triggers {
if trigger.Schema == fromSchema {
trigger.Schema = toSchema
}
trigger.Function = stripQualifiers(replaceString(trigger.Function))
trigger.Condition = stripQualifiers(replaceString(trigger.Condition))
}
}

// Functions
Expand Down
32 changes: 24 additions & 8 deletions internal/diff/diff.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const (
DiffTypeTableColumnComment
DiffTypeTableIndexComment
DiffTypeView
DiffTypeViewTrigger
DiffTypeViewComment
DiffTypeMaterializedView
DiffTypeMaterializedViewComment
Expand Down Expand Up @@ -67,6 +68,8 @@ func (d DiffType) String() string {
return "table.index.comment"
case DiffTypeView:
return "view"
case DiffTypeViewTrigger:
return "view.trigger"
case DiffTypeViewComment:
return "view.comment"
case DiffTypeMaterializedView:
Expand Down Expand Up @@ -137,6 +140,8 @@ func (d *DiffType) UnmarshalJSON(data []byte) error {
*d = DiffTypeTableIndexComment
case "view":
*d = DiffTypeView
case "view.trigger":
*d = DiffTypeViewTrigger
case "view.comment":
*d = DiffTypeViewComment
case "materialized_view":
Expand Down Expand Up @@ -349,10 +354,13 @@ type viewDiff struct {
CommentChanged bool
OldComment string
NewComment string
AddedIndexes []*ir.Index // For materialized views
DroppedIndexes []*ir.Index // For materialized views
ModifiedIndexes []*IndexDiff // For materialized views
RequiresRecreate bool // For materialized views with structural changes that require DROP + CREATE
AddedIndexes []*ir.Index // For materialized views
DroppedIndexes []*ir.Index // For materialized views
ModifiedIndexes []*IndexDiff // For materialized views
AddedTriggers []*ir.Trigger // For INSTEAD OF triggers on views
DroppedTriggers []*ir.Trigger // For INSTEAD OF triggers on views
ModifiedTriggers []*triggerDiff // For INSTEAD OF triggers on views
RequiresRecreate bool // For materialized views with structural changes that require DROP + CREATE
}

// tableDiff represents changes to a table
Expand Down Expand Up @@ -813,7 +821,11 @@ func GenerateMigration(oldIR, newIR *ir.IR, targetSchema string) []Diff {
}
}

if structurallyDifferent || commentChanged || indexesChanged {
// Diff triggers on views (e.g., INSTEAD OF triggers)
addedTriggers, droppedTriggers, modifiedTriggers := diffViewTriggers(oldView, newView)
triggersChanged := len(addedTriggers) > 0 || len(droppedTriggers) > 0 || len(modifiedTriggers) > 0

if structurallyDifferent || commentChanged || indexesChanged || triggersChanged {
// For materialized views with structural changes, mark for recreation
if newView.Materialized && structurallyDifferent {
diff.modifiedViews = append(diff.modifiedViews, &viewDiff{
Expand All @@ -824,8 +836,11 @@ func GenerateMigration(oldIR, newIR *ir.IR, targetSchema string) []Diff {
} else {
// For regular views or comment-only changes, use the modify approach
viewDiff := &viewDiff{
Old: oldView,
New: newView,
Old: oldView,
New: newView,
AddedTriggers: addedTriggers,
DroppedTriggers: droppedTriggers,
ModifiedTriggers: modifiedTriggers,
}

// Check for comment changes
Expand Down Expand Up @@ -1628,8 +1643,9 @@ func (d *ddlDiff) generateDropSQL(targetSchema string, collector *diffCollector,
generateDropPrivilegesSQL(d.droppedPrivileges, targetSchema, collector)
generateDropDefaultPrivilegesSQL(d.droppedDefaultPrivileges, targetSchema, collector)

// Drop triggers from modified tables first (triggers depend on functions)
// Drop triggers from modified tables and views first (triggers depend on functions)
generateDropTriggersFromModifiedTables(d.modifiedTables, targetSchema, collector)
generateDropTriggersFromModifiedViews(d.modifiedViews, targetSchema, collector)

// Drop functions
generateDropFunctionsSQL(d.droppedFunctions, targetSchema, collector)
Expand Down
58 changes: 58 additions & 0 deletions internal/diff/trigger.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,39 @@ func generateDropTriggersFromModifiedTables(tables []*tableDiff, targetSchema st
}
}

// generateDropTriggersFromModifiedViews collects and drops all triggers from modified views
// This ensures view triggers are dropped before their associated functions
func generateDropTriggersFromModifiedViews(views []*viewDiff, targetSchema string, collector *diffCollector) {
var allTriggers []*ir.Trigger

// Collect all dropped triggers from modified views
for _, viewDiff := range views {
for _, trigger := range viewDiff.DroppedTriggers {
allTriggers = append(allTriggers, trigger)
}
}

// Sort all triggers by name for consistent ordering
sort.Slice(allTriggers, func(i, j int) bool {
return allTriggers[i].Name < allTriggers[j].Name
})

// Generate DROP TRIGGER statements for all collected triggers
for _, trigger := range allTriggers {
tableName := getTableNameWithSchema(trigger.Schema, trigger.Table, targetSchema)
sql := fmt.Sprintf("DROP TRIGGER IF EXISTS %s ON %s;", trigger.Name, tableName)

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

// generateTriggerSQLWithMode generates CREATE [OR REPLACE] TRIGGER or CREATE CONSTRAINT TRIGGER statement
func generateTriggerSQLWithMode(trigger *ir.Trigger, targetSchema string) string {
// Build event list in standard order: INSERT, UPDATE, DELETE, TRUNCATE
Expand Down Expand Up @@ -242,3 +275,28 @@ func generateTriggerSQLWithMode(trigger *ir.Trigger, targetSchema string) string

return stmt
}

// generateCreateViewTriggersSQL generates CREATE TRIGGER statements for view triggers (e.g., INSTEAD OF)
func generateCreateViewTriggersSQL(triggers []*ir.Trigger, targetSchema string, collector *diffCollector) {
sortedTriggers := make([]*ir.Trigger, len(triggers))
copy(sortedTriggers, triggers)
sort.Slice(sortedTriggers, func(i, j int) bool {
return sortedTriggers[i].Name < sortedTriggers[j].Name
})

for _, trigger := range sortedTriggers {
sql := generateTriggerSQLWithMode(trigger, targetSchema)

context := &diffContext{
Type: DiffTypeViewTrigger,
Operation: DiffOperationCreate,
Path: fmt.Sprintf("%s.%s.%s", trigger.Schema, trigger.Table, trigger.Name),
Source: trigger,
CanRunInTransaction: true,
}

collector.collect(context, sql)
}
}


108 changes: 106 additions & 2 deletions internal/diff/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,15 @@ func generateCreateViewsSQL(views []*ir.View, targetSchema string, collector *di
// Generate index SQL for materialized view indexes - use MaterializedView types
generateCreateIndexesSQLWithType(indexList, targetSchema, collector, DiffTypeMaterializedViewIndex, DiffTypeMaterializedViewIndexComment)
}

// Create triggers on this view (e.g., INSTEAD OF triggers)
if len(view.Triggers) > 0 {
triggerList := make([]*ir.Trigger, 0, len(view.Triggers))
for _, trigger := range view.Triggers {
triggerList = append(triggerList, trigger)
}
generateCreateViewTriggersSQL(triggerList, targetSchema, collector)
}
}
}

Expand Down Expand Up @@ -227,8 +236,12 @@ func generateModifyViewsSQL(diffs []*viewDiff, targetSchema string, collector *d
hasIndexChanges := len(diff.AddedIndexes) > 0 || len(diff.DroppedIndexes) > 0 || len(diff.ModifiedIndexes) > 0
indexOnlyChange := diff.New.Materialized && hasIndexChanges && definitionsEqual && !diff.CommentChanged

// Handle comment-only or index-only changes
if commentOnlyChange || indexOnlyChange {
// Check if only triggers changed (for INSTEAD OF triggers on views)
hasTriggerChanges := len(diff.AddedTriggers) > 0 || len(diff.DroppedTriggers) > 0 || len(diff.ModifiedTriggers) > 0
triggerOnlyChange := hasTriggerChanges && definitionsEqual && !diff.CommentChanged && !hasIndexChanges

// Handle non-structural changes (comment-only, index-only, or trigger-only)
if commentOnlyChange || indexOnlyChange || triggerOnlyChange {
// Only generate COMMENT ON VIEW statement if comment actually changed
if diff.CommentChanged {
viewName := qualifyEntityName(diff.New.Schema, diff.New.Name, targetSchema)
Expand Down Expand Up @@ -332,6 +345,47 @@ func generateModifyViewsSQL(diffs []*viewDiff, targetSchema string, collector *d
generateCreateIndexesSQLWithType(indexList, targetSchema, collector, DiffTypeMaterializedViewIndex, DiffTypeMaterializedViewIndexComment)
}
}

// Handle trigger changes (e.g., INSTEAD OF triggers) - applies to both branches above
// Note: DroppedTriggers are skipped here because they are already processed in the DROP phase
// (see generateDropTriggersFromModifiedViews in trigger.go)
if len(diff.AddedTriggers) > 0 {
generateCreateViewTriggersSQL(diff.AddedTriggers, targetSchema, collector)
}
for _, triggerDiff := range diff.ModifiedTriggers {
if triggerDiff.New.IsConstraint {
viewName := getTableNameWithSchema(diff.New.Schema, diff.New.Name, targetSchema)
dropSQL := fmt.Sprintf("DROP TRIGGER IF EXISTS %s ON %s;", triggerDiff.Old.Name, viewName)
dropContext := &diffContext{
Type: DiffTypeViewTrigger,
Operation: DiffOperationDrop,
Path: fmt.Sprintf("%s.%s.%s", diff.New.Schema, diff.New.Name, triggerDiff.Old.Name),
Source: triggerDiff.Old,
CanRunInTransaction: true,
}
collector.collect(dropContext, dropSQL)

createSQL := generateTriggerSQLWithMode(triggerDiff.New, targetSchema)
createContext := &diffContext{
Type: DiffTypeViewTrigger,
Operation: DiffOperationCreate,
Path: fmt.Sprintf("%s.%s.%s", diff.New.Schema, diff.New.Name, triggerDiff.New.Name),
Source: triggerDiff.New,
CanRunInTransaction: true,
}
collector.collect(createContext, createSQL)
} else {
sql := generateTriggerSQLWithMode(triggerDiff.New, targetSchema)
context := &diffContext{
Type: DiffTypeViewTrigger,
Operation: DiffOperationAlter,
Path: fmt.Sprintf("%s.%s.%s", diff.New.Schema, diff.New.Name, triggerDiff.New.Name),
Source: triggerDiff.New,
CanRunInTransaction: true,
}
collector.collect(context, sql)
}
}
}

// Phase 2: Recreate all dependent views AFTER all materialized views have been processed.
Expand Down Expand Up @@ -432,6 +486,56 @@ func generateViewSQL(view *ir.View, targetSchema string) string {
return fmt.Sprintf("%s %s AS\n%s;", createClause, viewName, view.Definition)
}

// diffViewTriggers computes added, dropped, and modified triggers between two views
func diffViewTriggers(oldView, newView *ir.View) ([]*ir.Trigger, []*ir.Trigger, []*triggerDiff) {
oldTriggers := oldView.Triggers
newTriggers := newView.Triggers
if oldTriggers == nil {
oldTriggers = make(map[string]*ir.Trigger)
}
if newTriggers == nil {
newTriggers = make(map[string]*ir.Trigger)
}

var added []*ir.Trigger
var dropped []*ir.Trigger
var modified []*triggerDiff

for name, trigger := range newTriggers {
if _, exists := oldTriggers[name]; !exists {
added = append(added, trigger)
}
}
for name, trigger := range oldTriggers {
if _, exists := newTriggers[name]; !exists {
dropped = append(dropped, trigger)
}
}
for name, newTrigger := range newTriggers {
if oldTrigger, exists := oldTriggers[name]; exists {
if !triggersEqual(oldTrigger, newTrigger) {
modified = append(modified, &triggerDiff{
Old: oldTrigger,
New: newTrigger,
})
}
}
}

// Sort for deterministic output (Go map iteration is random)
sort.Slice(added, func(i, j int) bool {
return added[i].Name < added[j].Name
})
sort.Slice(dropped, func(i, j int) bool {
return dropped[i].Name < dropped[j].Name
})
sort.Slice(modified, func(i, j int) bool {
return modified[i].Old.Name < modified[j].Old.Name
})

return added, dropped, modified
}

// viewsEqual compares two views for equality
// Both IRs come from pg_get_viewdef() at the same PostgreSQL version, so string comparison is sufficient
func viewsEqual(old, new *ir.View) bool {
Expand Down
12 changes: 12 additions & 0 deletions internal/dump/formatter.go
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,9 @@ func (f *DumpFormatter) getObjectDirectory(objectType string) string {
case "table.index", "table.trigger", "table.constraint", "table.policy", "table.rls", "table.comment", "table.column.comment", "table.index.comment":
// These are included with their tables
return "tables"
case "view.trigger":
// View triggers are included with their views
return "views"
case "view.comment":
// View comments are included with their views
return "views"
Expand Down Expand Up @@ -269,6 +272,15 @@ func (f *DumpFormatter) getGroupingName(step diff.Diff) string {
if parts := strings.Split(step.Path, "."); len(parts) >= 2 {
return parts[1] // Return table name
}
case diff.DiffTypeViewTrigger:
// For view triggers, group with view
if tableName := f.extractTableNameFromContext(step); tableName != "" {
return tableName
}
// Fallback: extract view name from path
if parts := strings.Split(step.Path, "."); len(parts) >= 2 {
return parts[1] // Return view name
}
case diff.DiffTypeViewComment:
// For view comments, group with view
if step.Source != nil {
Expand Down
Loading