diff --git a/internal/diff/diff.go b/internal/diff/diff.go index 8258cf25..f0de5ba2 100644 --- a/internal/diff/diff.go +++ b/internal/diff/diff.go @@ -25,8 +25,10 @@ const ( DiffTypeTableIndexComment DiffTypeView DiffTypeViewComment - DiffTypeViewIndex - DiffTypeViewIndexComment + DiffTypeMaterializedView + DiffTypeMaterializedViewComment + DiffTypeMaterializedViewIndex + DiffTypeMaterializedViewIndexComment DiffTypeFunction DiffTypeProcedure DiffTypeSequence @@ -62,10 +64,14 @@ func (d DiffType) String() string { return "view" case DiffTypeViewComment: return "view.comment" - case DiffTypeViewIndex: - return "view.index" - case DiffTypeViewIndexComment: - return "view.index.comment" + case DiffTypeMaterializedView: + return "materialized_view" + case DiffTypeMaterializedViewComment: + return "materialized_view.comment" + case DiffTypeMaterializedViewIndex: + return "materialized_view.index" + case DiffTypeMaterializedViewIndexComment: + return "materialized_view.index.comment" case DiffTypeFunction: return "function" case DiffTypeProcedure: @@ -120,10 +126,14 @@ func (d *DiffType) UnmarshalJSON(data []byte) error { *d = DiffTypeView case "view.comment": *d = DiffTypeViewComment - case "view.index": - *d = DiffTypeViewIndex - case "view.index.comment": - *d = DiffTypeViewIndexComment + case "materialized_view": + *d = DiffTypeMaterializedView + case "materialized_view.comment": + *d = DiffTypeMaterializedViewComment + case "materialized_view.index": + *d = DiffTypeMaterializedViewIndex + case "materialized_view.index.comment": + *d = DiffTypeMaterializedViewIndexComment case "function": *d = DiffTypeFunction case "procedure": diff --git a/internal/diff/index.go b/internal/diff/index.go index 9b8553b3..8a231ce1 100644 --- a/internal/diff/index.go +++ b/internal/diff/index.go @@ -13,11 +13,6 @@ func generateCreateIndexesSQL(indexes []*ir.Index, targetSchema string, collecto generateCreateIndexesSQLWithType(indexes, targetSchema, collector, DiffTypeTableIndex, DiffTypeTableIndexComment) } -// generateCreateViewIndexesSQL generates CREATE INDEX statements for materialized view indexes -func generateCreateViewIndexesSQL(indexes []*ir.Index, targetSchema string, collector *diffCollector) { - generateCreateIndexesSQLWithType(indexes, targetSchema, collector, DiffTypeViewIndex, DiffTypeViewIndexComment) -} - // generateCreateIndexesSQLWithType generates CREATE INDEX statements with specified diff types func generateCreateIndexesSQLWithType(indexes []*ir.Index, targetSchema string, collector *diffCollector, indexType DiffType, commentType DiffType) { // Sort indexes by name for consistent ordering diff --git a/internal/diff/view.go b/internal/diff/view.go index 08d94194..96966213 100644 --- a/internal/diff/view.go +++ b/internal/diff/view.go @@ -16,9 +16,15 @@ func generateCreateViewsSQL(views []*ir.View, targetSchema string, collector *di // If compare mode, CREATE OR REPLACE, otherwise CREATE sql := generateViewSQL(view, targetSchema) + // Determine the diff type based on whether it's materialized + diffType := DiffTypeView + if view.Materialized { + diffType = DiffTypeMaterializedView + } + // Create context for this statement context := &diffContext{ - Type: DiffTypeView, + Type: diffType, Operation: DiffOperationCreate, Path: fmt.Sprintf("%s.%s", view.Schema, view.Name), Source: view, @@ -30,18 +36,35 @@ func generateCreateViewsSQL(views []*ir.View, targetSchema string, collector *di // Add view comment if view.Comment != "" { viewName := qualifyEntityName(view.Schema, view.Name, targetSchema) - sql := fmt.Sprintf("COMMENT ON VIEW %s IS %s;", viewName, quoteString(view.Comment)) + commentType := DiffTypeViewComment + if view.Materialized { + commentType = DiffTypeMaterializedViewComment + sql := fmt.Sprintf("COMMENT ON MATERIALIZED VIEW %s IS %s;", viewName, quoteString(view.Comment)) - // Create context for this statement - context := &diffContext{ - Type: DiffTypeViewComment, - Operation: DiffOperationCreate, - Path: fmt.Sprintf("%s.%s", view.Schema, view.Name), - Source: view, - CanRunInTransaction: true, - } + // Create context for this statement + context := &diffContext{ + Type: commentType, + Operation: DiffOperationCreate, + Path: fmt.Sprintf("%s.%s", view.Schema, view.Name), + Source: view, + CanRunInTransaction: true, + } - collector.collect(context, sql) + collector.collect(context, sql) + } else { + sql := fmt.Sprintf("COMMENT ON VIEW %s IS %s;", viewName, quoteString(view.Comment)) + + // Create context for this statement + context := &diffContext{ + Type: commentType, + Operation: DiffOperationCreate, + Path: fmt.Sprintf("%s.%s", view.Schema, view.Name), + Source: view, + CanRunInTransaction: true, + } + + collector.collect(context, sql) + } } // For materialized views, create indexes @@ -50,8 +73,8 @@ func generateCreateViewsSQL(views []*ir.View, targetSchema string, collector *di for _, index := range view.Indexes { indexList = append(indexList, index) } - // Generate index SQL for materialized view indexes - generateCreateViewIndexesSQL(indexList, targetSchema, collector) + // Generate index SQL for materialized view indexes - use MaterializedView types + generateCreateIndexesSQLWithType(indexList, targetSchema, collector, DiffTypeMaterializedViewIndex, DiffTypeMaterializedViewIndexComment) } } } @@ -80,7 +103,7 @@ func generateModifyViewsSQL(diffs []*viewDiff, targetSchema string, collector *d // Use DiffOperationAlter to categorize as a modification context := &diffContext{ - Type: DiffTypeView, + Type: DiffTypeMaterializedView, Operation: DiffOperationAlter, Path: fmt.Sprintf("%s.%s", diff.New.Schema, diff.New.Name), Source: diff, @@ -90,9 +113,9 @@ func generateModifyViewsSQL(diffs []*viewDiff, targetSchema string, collector *d // Add view comment if present if diff.New.Comment != "" { - sql := fmt.Sprintf("COMMENT ON VIEW %s IS %s;", viewName, quoteString(diff.New.Comment)) + sql := fmt.Sprintf("COMMENT ON MATERIALIZED VIEW %s IS %s;", viewName, quoteString(diff.New.Comment)) commentContext := &diffContext{ - Type: DiffTypeViewComment, + Type: DiffTypeMaterializedViewComment, Operation: DiffOperationCreate, Path: fmt.Sprintf("%s.%s", diff.New.Schema, diff.New.Name), Source: diff.New, @@ -107,7 +130,7 @@ func generateModifyViewsSQL(diffs []*viewDiff, targetSchema string, collector *d for _, index := range diff.New.Indexes { indexList = append(indexList, index) } - generateCreateViewIndexesSQL(indexList, targetSchema, collector) + generateCreateIndexesSQLWithType(indexList, targetSchema, collector, DiffTypeMaterializedViewIndex, DiffTypeMaterializedViewIndexComment) } continue // Skip the normal processing for this view @@ -126,33 +149,36 @@ func generateModifyViewsSQL(diffs []*viewDiff, targetSchema string, collector *d // Only generate COMMENT ON VIEW statement if comment actually changed if diff.CommentChanged { viewName := qualifyEntityName(diff.New.Schema, diff.New.Name, targetSchema) - if diff.NewComment == "" { - sql := fmt.Sprintf("COMMENT ON VIEW %s IS NULL;", viewName) - - // Create context for this statement - context := &diffContext{ - Type: DiffTypeView, - Operation: DiffOperationAlter, - Path: fmt.Sprintf("%s.%s", diff.New.Schema, diff.New.Name), - Source: diff, - CanRunInTransaction: true, - } - collector.collect(context, sql) + // Determine the diff type and SQL based on whether it's materialized + var sql string + var diffType DiffType + if diff.New.Materialized { + diffType = DiffTypeMaterializedView + if diff.NewComment == "" { + sql = fmt.Sprintf("COMMENT ON MATERIALIZED VIEW %s IS NULL;", viewName) + } else { + sql = fmt.Sprintf("COMMENT ON MATERIALIZED VIEW %s IS %s;", viewName, quoteString(diff.NewComment)) + } } else { - sql := fmt.Sprintf("COMMENT ON VIEW %s IS %s;", viewName, quoteString(diff.NewComment)) - - // Create context for this statement - context := &diffContext{ - Type: DiffTypeView, - Operation: DiffOperationAlter, - Path: fmt.Sprintf("%s.%s", diff.New.Schema, diff.New.Name), - Source: diff, - CanRunInTransaction: true, + diffType = DiffTypeView + if diff.NewComment == "" { + sql = fmt.Sprintf("COMMENT ON VIEW %s IS NULL;", viewName) + } else { + sql = fmt.Sprintf("COMMENT ON VIEW %s IS %s;", viewName, quoteString(diff.NewComment)) } + } - collector.collect(context, sql) + // Create context for this statement + context := &diffContext{ + Type: diffType, + Operation: DiffOperationAlter, + Path: fmt.Sprintf("%s.%s", diff.New.Schema, diff.New.Name), + Source: diff, + CanRunInTransaction: true, } + + collector.collect(context, sql) } // For materialized views, handle index modifications (only if indexes actually changed) @@ -162,8 +188,8 @@ func generateModifyViewsSQL(diffs []*viewDiff, targetSchema string, collector *d diff.AddedIndexes, diff.ModifiedIndexes, targetSchema, - DiffTypeViewIndex, - DiffTypeViewIndexComment, + DiffTypeMaterializedViewIndex, + DiffTypeMaterializedViewIndexComment, collector, ) } @@ -171,9 +197,15 @@ func generateModifyViewsSQL(diffs []*viewDiff, targetSchema string, collector *d // Create the new view (CREATE OR REPLACE works for regular views, materialized views are handled by drop/create cycle) sql := generateViewSQL(diff.New, targetSchema) + // Determine diff type based on whether it's materialized + diffType := DiffTypeView + if diff.New.Materialized { + diffType = DiffTypeMaterializedView + } + // Create context for this statement context := &diffContext{ - Type: DiffTypeView, + Type: diffType, Operation: DiffOperationAlter, Path: fmt.Sprintf("%s.%s", diff.New.Schema, diff.New.Name), Source: diff, @@ -185,18 +217,27 @@ func generateModifyViewsSQL(diffs []*viewDiff, targetSchema string, collector *d // Add view comment for recreated views if diff.New.Comment != "" { viewName := qualifyEntityName(diff.New.Schema, diff.New.Name, targetSchema) - sql := fmt.Sprintf("COMMENT ON VIEW %s IS %s;", viewName, quoteString(diff.New.Comment)) + var commentSQL string + var commentType DiffType + + if diff.New.Materialized { + commentSQL = fmt.Sprintf("COMMENT ON MATERIALIZED VIEW %s IS %s;", viewName, quoteString(diff.New.Comment)) + commentType = DiffTypeMaterializedViewComment + } else { + commentSQL = fmt.Sprintf("COMMENT ON VIEW %s IS %s;", viewName, quoteString(diff.New.Comment)) + commentType = DiffTypeViewComment + } // Create context for this statement context := &diffContext{ - Type: DiffTypeComment, + Type: commentType, Operation: DiffOperationCreate, Path: fmt.Sprintf("%s.%s", diff.New.Schema, diff.New.Name), Source: diff.New, CanRunInTransaction: true, } - collector.collect(context, sql) + collector.collect(context, commentSQL) } // For materialized views that were recreated, recreate indexes @@ -205,7 +246,7 @@ func generateModifyViewsSQL(diffs []*viewDiff, targetSchema string, collector *d for _, index := range diff.New.Indexes { indexList = append(indexList, index) } - generateCreateViewIndexesSQL(indexList, targetSchema, collector) + generateCreateIndexesSQLWithType(indexList, targetSchema, collector, DiffTypeMaterializedViewIndex, DiffTypeMaterializedViewIndexComment) } } } @@ -218,15 +259,18 @@ func generateDropViewsSQL(views []*ir.View, targetSchema string, collector *diff for _, view := range views { viewName := qualifyEntityName(view.Schema, view.Name, targetSchema) var sql string + var diffType DiffType if view.Materialized { sql = fmt.Sprintf("DROP MATERIALIZED VIEW %s RESTRICT;", viewName) + diffType = DiffTypeMaterializedView } else { sql = fmt.Sprintf("DROP VIEW IF EXISTS %s CASCADE;", viewName) + diffType = DiffTypeView } // Create context for this statement context := &diffContext{ - Type: DiffTypeView, + Type: diffType, Operation: DiffOperationDrop, Path: fmt.Sprintf("%s.%s", view.Schema, view.Name), Source: view, diff --git a/internal/dump/formatter.go b/internal/dump/formatter.go index b975ae66..accbb750 100644 --- a/internal/dump/formatter.go +++ b/internal/dump/formatter.go @@ -101,7 +101,7 @@ func (f *DumpFormatter) FormatMultiFile(diffs []diff.Diff, outputPath string) er } // Create files in dependency order - orderedDirs := []string{"types", "domains", "sequences", "functions", "procedures", "tables", "views"} + orderedDirs := []string{"types", "domains", "sequences", "functions", "procedures", "tables", "views", "materialized_views"} for _, dir := range orderedDirs { if objects, exists := filesByType[dir]; exists { @@ -224,14 +224,19 @@ func (f *DumpFormatter) getObjectDirectory(objectType string) string { return "procedures" case "table": return "tables" - case "view", "materialized view": + case "view": return "views" + case "materialized_view": + return "materialized_views" case "table.index", "table.trigger", "table.policy", "table.rls", "table.comment", "table.column.comment", "table.index.comment": // These are included with their tables return "tables" case "view.comment": // View comments are included with their views return "views" + case "materialized_view.comment", "materialized_view.index", "materialized_view.index.comment": + // Materialized view comments/indexes are included with their materialized views + return "materialized_views" case "comment": // Comments handled separately in FormatMultiFile return "tables" // fallback, will be overridden @@ -264,6 +269,30 @@ func (f *DumpFormatter) getGroupingName(step diff.Diff) string { if parts := strings.Split(step.Path, "."); len(parts) >= 2 { return parts[1] // Return view name } + case diff.DiffTypeMaterializedViewComment: + // For materialized view comments, group with materialized view + if step.Source != nil { + switch obj := step.Source.(type) { + case *ir.View: + return obj.Name // Materialized view comments group with materialized view + } + } + // Fallback: extract materialized view name from path + if parts := strings.Split(step.Path, "."); len(parts) >= 2 { + return parts[1] // Return materialized view name + } + case diff.DiffTypeMaterializedViewIndex, diff.DiffTypeMaterializedViewIndexComment: + // For materialized view indexes and their comments, group with materialized view + if step.Source != nil { + switch obj := step.Source.(type) { + case *ir.Index: + return obj.Table // Index's Table field contains the materialized view name + } + } + // Fallback: extract materialized view name from path (schema.mv.index) + if parts := strings.Split(step.Path, "."); len(parts) >= 2 { + return parts[1] // Return materialized view name + } case diff.DiffTypeComment: // For legacy comments, we need to determine the parent object // For index comments, group with parent table @@ -312,8 +341,12 @@ func (f *DumpFormatter) extractTableNameFromContext(step diff.Diff) string { // getCommentParentDirectory determines the directory for comment statements based on their parent object func (f *DumpFormatter) getCommentParentDirectory(step diff.Diff) string { if step.Source != nil { - switch step.Source.(type) { + switch obj := step.Source.(type) { case *ir.View: + // Check if it's a materialized view + if obj.Materialized { + return "materialized_views" + } return "views" case *ir.Table, *ir.Index: return "tables" @@ -366,7 +399,11 @@ func (f *DumpFormatter) formatObjectCommentHeader(step diff.Diff) string { displayType := strings.ToUpper(objectType) // Special handling for materialized views - if displayType == "VIEW" && step.Source != nil { + if displayType == "MATERIALIZED_VIEW" { + // Convert underscore to space for proper SQL comment format + displayType = "MATERIALIZED VIEW" + } else if displayType == "VIEW" && step.Source != nil { + // Also check if a regular view is actually materialized if view, ok := step.Source.(*ir.View); ok && view.Materialized { displayType = "MATERIALIZED VIEW" } diff --git a/internal/plan/plan.go b/internal/plan/plan.go index a0c58cf7..6fb5022e 100644 --- a/internal/plan/plan.go +++ b/internal/plan/plan.go @@ -87,18 +87,19 @@ type TypeSummary struct { type Type string const ( - TypeSchema Type = "schemas" - TypeType Type = "types" - TypeFunction Type = "functions" - TypeProcedure Type = "procedures" - TypeSequence Type = "sequences" - TypeTable Type = "tables" - TypeView Type = "views" - TypeIndex Type = "indexes" - TypeTrigger Type = "triggers" - TypePolicy Type = "policies" - TypeColumn Type = "columns" - TypeRLS Type = "rls" + TypeSchema Type = "schemas" + TypeType Type = "types" + TypeFunction Type = "functions" + TypeProcedure Type = "procedures" + TypeSequence Type = "sequences" + TypeTable Type = "tables" + TypeView Type = "views" + TypeMaterializedView Type = "materialized views" + TypeIndex Type = "indexes" + TypeTrigger Type = "triggers" + TypePolicy Type = "policies" + TypeColumn Type = "columns" + TypeRLS Type = "rls" ) // SQLFormat represents the different output formats for SQL generation @@ -121,6 +122,7 @@ func getObjectOrder() []Type { TypeSequence, TypeTable, TypeView, + TypeMaterializedView, TypeIndex, TypeTrigger, TypePolicy, @@ -149,19 +151,19 @@ func groupDiffs(diffs []diff.Diff) []ExecutionGroup { } } - // Track newly created views to avoid concurrent rewrites for their indexes - newlyCreatedViews := make(map[string]bool) + // Track newly created materialized views to avoid concurrent rewrites for their indexes + newlyCreatedMaterializedViews := make(map[string]bool) for _, d := range diffs { - if d.Type == diff.DiffTypeView && d.Operation == diff.DiffOperationCreate { - // Extract view name from path (schema.view) - newlyCreatedViews[d.Path] = true + if d.Type == diff.DiffTypeMaterializedView && d.Operation == diff.DiffOperationCreate { + // Extract materialized view name from path (schema.materialized_view) + newlyCreatedMaterializedViews[d.Path] = true } } // Convert diffs to steps for _, d := range diffs { // Try to generate rewrites if online operations are enabled - rewriteSteps := generateRewrite(d, newlyCreatedTables, newlyCreatedViews) + rewriteSteps := generateRewrite(d, newlyCreatedTables, newlyCreatedMaterializedViews) if len(rewriteSteps) > 0 { // For operations with rewrites, create one step per rewrite statement @@ -398,13 +400,19 @@ func (p *Plan) calculateSummaryFromSteps() PlanSummary { // Track tables that have sub-resource changes (these should be counted as modified) tablesWithSubResources := make(map[string]bool) // table_path -> true - // Track view operations by view path (including materialized views) + // Track view operations by view path (regular views only) viewOperations := make(map[string]string) // view_path -> operation // Track views that have sub-resource changes (these should be counted as modified) viewsWithSubResources := make(map[string]bool) // view_path -> true - // Track non-table/non-view operations + // Track materialized view operations by path + materializedViewOperations := make(map[string]string) // materialized_view_path -> operation + + // Track materialized views that have sub-resource changes + materializedViewsWithSubResources := make(map[string]bool) // materialized_view_path -> true + + // Track non-table/non-view/non-materialized-view operations nonTableOperations := make(map[string][]string) // objType -> []operations // Use source diffs for summary calculation if available, @@ -447,8 +455,9 @@ func (p *Plan) calculateSummaryFromSteps() PlanSummary { } } - // First pass: identify all views to distinguish them from tables + // First pass: identify all views and materialized views to distinguish them from tables viewPaths := make(map[string]bool) + materializedViewPaths := make(map[string]bool) for _, step := range dataToProcess { stepObjTypeStr := step.Type if !strings.HasSuffix(stepObjTypeStr, "s") { @@ -456,12 +465,20 @@ func (p *Plan) calculateSummaryFromSteps() PlanSummary { } if stepObjTypeStr == "views" { viewPaths[step.Path] = true + } else if stepObjTypeStr == "materialized_views" { + materializedViewPaths[step.Path] = true } else if strings.HasPrefix(step.Type, "view.") { // For view sub-resources, extract the parent view path parentPath := extractTablePathFromSubResource(step.Path, step.Type) if parentPath != "" { viewPaths[parentPath] = true } + } else if strings.HasPrefix(step.Type, "materialized_view.") { + // For materialized view sub-resources, extract the parent path + parentPath := extractTablePathFromSubResource(step.Path, step.Type) + if parentPath != "" { + materializedViewPaths[parentPath] = true + } } } @@ -478,15 +495,21 @@ func (p *Plan) calculateSummaryFromSteps() PlanSummary { } else if stepObjTypeStr == "views" { // For views, track unique view paths and their primary operation viewOperations[step.Path] = step.Operation + } else if stepObjTypeStr == "materialized_views" { + // For materialized views, track unique paths and their primary operation + materializedViewOperations[step.Path] = step.Operation } else if isSubResource(step.Type) { - // For sub-resources, check if parent is a view or table + // For sub-resources, check if parent is a view, materialized view, or table parentPath := extractTablePathFromSubResource(step.Path, step.Type) if parentPath != "" { - if viewPaths[parentPath] { - // Parent is a view, track under views + if materializedViewPaths[parentPath] { + // Parent is a materialized view + materializedViewsWithSubResources[parentPath] = true + } else if viewPaths[parentPath] { + // Parent is a view viewsWithSubResources[parentPath] = true } else { - // Parent is a table, track under tables + // Parent is a table tablesWithSubResources[parentPath] = true } } @@ -564,7 +587,41 @@ func (p *Plan) calculateSummaryFromSteps() PlanSummary { summary.ByType["views"] = stats } - // Count non-table/non-view operations (each operation counted individually) + // Count materialized view operations (one per unique materialized view) + // Include both direct materialized view operations and materialized views with sub-resource changes + allAffectedMaterializedViews := make(map[string]string) + + // First, add direct materialized view operations + for mvPath, operation := range materializedViewOperations { + allAffectedMaterializedViews[mvPath] = operation + } + + // Then, add materialized views that only have sub-resource changes (count as "alter") + for mvPath := range materializedViewsWithSubResources { + if _, alreadyCounted := allAffectedMaterializedViews[mvPath]; !alreadyCounted { + allAffectedMaterializedViews[mvPath] = "alter" // Sub-resource changes = materialized view modification + } + } + + if len(allAffectedMaterializedViews) > 0 { + stats := summary.ByType["materialized views"] + for _, operation := range allAffectedMaterializedViews { + switch operation { + case "create": + stats.Add++ + summary.Add++ + case "alter": + stats.Change++ + summary.Change++ + case "drop": + stats.Destroy++ + summary.Destroy++ + } + } + summary.ByType["materialized views"] = stats + } + + // Count non-table/non-view/non-materialized-view operations (each operation counted individually) for objType, operations := range nonTableOperations { stats := summary.ByType[objType] for _, operation := range operations { @@ -597,6 +654,9 @@ func (p *Plan) writeDetailedChangesFromSteps(summary *strings.Builder, displayNa } else if objType == "views" { // For views, group all changes by view path to avoid duplicates p.writeViewChanges(summary, c) + } else if objType == "materialized views" { + // For materialized views, group all changes by path to avoid duplicates + p.writeMaterializedViewChanges(summary, c) } else { // For non-table/non-view objects, use the original logic p.writeNonTableChanges(summary, objType, c) @@ -823,11 +883,126 @@ func (p *Plan) writeViewChanges(summary *strings.Builder, c *color.Color) { return subResourceList[i].path < subResourceList[j].path }) + for _, subRes := range subResourceList { + var subSymbol string + switch subRes.operation { + case "create": + subSymbol = c.PlanSymbol("add") + case "alter": + subSymbol = c.PlanSymbol("change") + case "drop": + subSymbol = c.PlanSymbol("destroy") + default: + subSymbol = c.PlanSymbol("change") + } + // Clean up sub-resource type for display (remove "view." prefix) + displaySubType := strings.TrimPrefix(subRes.subType, "view.") + fmt.Fprintf(summary, " %s %s (%s)\n", subSymbol, getLastPathComponent(subRes.path), displaySubType) + } + } + } +} + +// writeMaterializedViewChanges handles materialized view-specific output with proper grouping +func (p *Plan) writeMaterializedViewChanges(summary *strings.Builder, c *color.Color) { + // Group all changes by materialized view path and track operations + mvOperations := make(map[string]string) // mv_path -> operation + subResources := make(map[string][]struct { + operation string + path string + subType string + }) + + // Track all seen operations globally to avoid duplicates across groups + seenOperations := make(map[string]bool) // "path.operation.subType" -> true + + // Use source diffs for summary calculation + for _, step := range p.SourceDiffs { + // Normalize object type + stepObjTypeStr := step.Type.String() + if !strings.HasSuffix(stepObjTypeStr, "s") { + stepObjTypeStr += "s" + } + + if stepObjTypeStr == "materialized_views" { + // This is a materialized view-level change, record the operation + mvOperations[step.Path] = step.Operation.String() + } else if isSubResource(step.Type.String()) && strings.HasPrefix(step.Type.String(), "materialized_view.") { + // This is a materialized view sub-resource change + mvPath := extractTablePathFromSubResource(step.Path, step.Type.String()) + if mvPath != "" { + // Deduplicate all operations based on (type, operation, path) triplet + operationKey := step.Path + "." + step.Operation.String() + "." + step.Type.String() + if !seenOperations[operationKey] { + seenOperations[operationKey] = true + subResources[mvPath] = append(subResources[mvPath], struct { + operation string + path string + subType string + }{ + operation: step.Operation.String(), + path: step.Path, + subType: step.Type.String(), + }) + } + } + } + } + + // Get all unique materialized view paths (from both direct changes and sub-resources) + allMVs := make(map[string]bool) + for mvPath := range mvOperations { + allMVs[mvPath] = true + } + for mvPath := range subResources { + allMVs[mvPath] = true + } + + // Sort materialized view paths for consistent output + var sortedMVs []string + for mvPath := range allMVs { + sortedMVs = append(sortedMVs, mvPath) + } + sort.Strings(sortedMVs) + + // Display each materialized view once with all its changes + for _, mvPath := range sortedMVs { + var symbol string + if operation, hasDirectChange := mvOperations[mvPath]; hasDirectChange { + // Materialized view has direct changes, use the operation to determine symbol + switch operation { + case "create": + symbol = c.PlanSymbol("add") + case "alter": + symbol = c.PlanSymbol("change") + case "drop": + symbol = c.PlanSymbol("destroy") + default: + symbol = c.PlanSymbol("change") + } + } else { + // Materialized view has no direct changes, only sub-resource changes + // Sub-resource changes to existing materialized views should always be considered modifications + symbol = c.PlanSymbol("change") + } + + fmt.Fprintf(summary, " %s %s\n", symbol, getLastPathComponent(mvPath)) + + // Show sub-resources for this materialized view + if subResourceList, exists := subResources[mvPath]; exists { + // Sort sub-resources by type then path + sort.Slice(subResourceList, func(i, j int) bool { + if subResourceList[i].subType != subResourceList[j].subType { + return subResourceList[i].subType < subResourceList[j].subType + } + return subResourceList[i].path < subResourceList[j].path + }) + for _, subRes := range subResourceList { // Handle online index replacement display - if subRes.subType == diff.DiffTypeViewIndex.String() && subRes.operation == diff.DiffOperationAlter.String() { + if subRes.subType == diff.DiffTypeMaterializedViewIndex.String() && subRes.operation == diff.DiffOperationAlter.String() { subSymbol := c.PlanSymbol("change") - displaySubType := strings.TrimPrefix(subRes.subType, "view.") + displaySubType := strings.TrimPrefix(subRes.subType, "materialized_view.") fmt.Fprintf(summary, " %s %s (%s - concurrent rebuild)\n", subSymbol, getLastPathComponent(subRes.path), displaySubType) continue } @@ -843,8 +1018,8 @@ func (p *Plan) writeViewChanges(summary *strings.Builder, c *color.Color) { default: subSymbol = c.PlanSymbol("change") } - // Clean up sub-resource type for display (remove "view." prefix) - displaySubType := strings.TrimPrefix(subRes.subType, "view.") + // Clean up sub-resource type for display (remove "materialized_view." prefix) + displaySubType := strings.TrimPrefix(subRes.subType, "materialized_view.") fmt.Fprintf(summary, " %s %s (%s)\n", subSymbol, getLastPathComponent(subRes.path), displaySubType) } } @@ -901,10 +1076,11 @@ func (p *Plan) writeNonTableChanges(summary *strings.Builder, objType string, c } } -// isSubResource checks if the given type is a sub-resource of tables or views +// isSubResource checks if the given type is a sub-resource of tables, views, or materialized views func isSubResource(objType string) bool { return (strings.HasPrefix(objType, "table.") && objType != "table") || - (strings.HasPrefix(objType, "view.") && objType != "view") + (strings.HasPrefix(objType, "view.") && objType != "view") || + (strings.HasPrefix(objType, "materialized_view.") && objType != "materialized_view") } // getLastPathComponent extracts the last component from a dot-separated path @@ -916,7 +1092,7 @@ func getLastPathComponent(path string) string { return path } -// extractTablePathFromSubResource extracts the parent table or view path from a sub-resource path +// extractTablePathFromSubResource extracts the parent table, view, or materialized view path from a sub-resource path func extractTablePathFromSubResource(subResourcePath, subResourceType string) string { if strings.HasPrefix(subResourceType, "table.") { // For sub-resources, the path format depends on the sub-resource type: @@ -958,6 +1134,26 @@ func extractTablePathFromSubResource(subResourcePath, subResourceType string) st // If only 2 parts, it's likely "schema.view" already return subResourcePath } + } else if strings.HasPrefix(subResourceType, "materialized_view.") { + // For materialized view sub-resources, the path format is similar: + // - "schema.mv.resource_name" -> "schema.mv" (indexes, comments) + // - "schema.mv" -> "schema.mv" (materialized view-level comments) + parts := strings.Split(subResourcePath, ".") + + // Special handling for materialized view-level changes + if subResourceType == "materialized_view.comment" { + // For materialized view comments, the path is already the materialized view path + return subResourcePath + } + + if len(parts) >= 2 { + // For other sub-resources, return the first two parts as materialized view path + if len(parts) >= 3 { + return parts[0] + "." + parts[1] + } + // If only 2 parts, it's likely "schema.materialized_view" already + return subResourcePath + } } return "" } diff --git a/internal/plan/rewrite.go b/internal/plan/rewrite.go index 40b94408..4c845aea 100644 --- a/internal/plan/rewrite.go +++ b/internal/plan/rewrite.go @@ -16,7 +16,7 @@ type RewriteStep struct { } // generateRewrite generates rewrite steps for a diff if online operations are enabled -func generateRewrite(d diff.Diff, newlyCreatedTables map[string]bool, newlyCreatedViews map[string]bool) []RewriteStep { +func generateRewrite(d diff.Diff, newlyCreatedTables map[string]bool, newlyCreatedMaterializedViews map[string]bool) []RewriteStep { // Dispatch to specific rewrite generators based on diff type and source switch d.Type { case diff.DiffTypeTableIndex: @@ -39,14 +39,14 @@ func generateRewrite(d diff.Diff, newlyCreatedTables map[string]bool, newlyCreat return generateIndexChangeRewriteFromIndex(index) } } - case diff.DiffTypeViewIndex: + case diff.DiffTypeMaterializedViewIndex: switch d.Operation { case diff.DiffOperationCreate: if index, ok := d.Source.(*ir.Index); ok { - // Skip rewrite for indexes on newly created views - viewKey := index.Schema + "." + index.Table - if newlyCreatedViews[viewKey] { - return nil // No rewrite needed for indexes on new views + // Skip rewrite for indexes on newly created materialized views + mvKey := index.Schema + "." + index.Table + if newlyCreatedMaterializedViews[mvKey] { + return nil // No rewrite needed for indexes on new materialized views } return generateIndexRewrite(index) } diff --git a/testdata/diff/comment/add_index_comment/plan.json b/testdata/diff/comment/add_index_comment/plan.json index 6e27679b..1a3b5876 100644 --- a/testdata/diff/comment/add_index_comment/plan.json +++ b/testdata/diff/comment/add_index_comment/plan.json @@ -22,7 +22,7 @@ }, { "sql": "COMMENT ON INDEX idx_users_summary_email IS 'Index for email search on summary';", - "type": "view.index.comment", + "type": "materialized_view.index.comment", "operation": "alter", "path": "public.users_summary.idx_users_summary_email" } diff --git a/testdata/diff/comment/add_index_comment/plan.txt b/testdata/diff/comment/add_index_comment/plan.txt index 11470697..6e13de2f 100644 --- a/testdata/diff/comment/add_index_comment/plan.txt +++ b/testdata/diff/comment/add_index_comment/plan.txt @@ -2,14 +2,14 @@ Plan: 2 to modify. Summary by type: tables: 1 to modify - views: 1 to modify + materialized views: 1 to modify Tables: ~ users ~ idx_users_created_at (index.comment) ~ idx_users_email (index.comment) -Views: +Materialized views: ~ users_summary ~ idx_users_summary_email (index.comment) diff --git a/testdata/diff/create_index/drop_index/plan.json b/testdata/diff/create_index/drop_index/plan.json index b0d999d0..3d989711 100644 --- a/testdata/diff/create_index/drop_index/plan.json +++ b/testdata/diff/create_index/drop_index/plan.json @@ -16,7 +16,7 @@ }, { "sql": "DROP INDEX IF EXISTS idx_product_summary_price;", - "type": "view.index", + "type": "materialized_view.index", "operation": "drop", "path": "public.product_summary.idx_product_summary_price" } diff --git a/testdata/diff/create_index/drop_index/plan.txt b/testdata/diff/create_index/drop_index/plan.txt index cafe46cb..cf6f93e2 100644 --- a/testdata/diff/create_index/drop_index/plan.txt +++ b/testdata/diff/create_index/drop_index/plan.txt @@ -2,13 +2,13 @@ Plan: 2 to modify. Summary by type: tables: 1 to modify - views: 1 to modify + materialized views: 1 to modify Tables: ~ products - idx_products_category_price (index) -Views: +Materialized views: ~ product_summary - idx_product_summary_price (index) diff --git a/testdata/diff/create_materialized_view/add_materialized_view/plan.json b/testdata/diff/create_materialized_view/add_materialized_view/plan.json index c1a7ca76..a26ae1d8 100644 --- a/testdata/diff/create_materialized_view/add_materialized_view/plan.json +++ b/testdata/diff/create_materialized_view/add_materialized_view/plan.json @@ -1,6 +1,6 @@ { "version": "1.0.0", - "pgschema_version": "1.0.0", + "pgschema_version": "1.2.1", "created_at": "1970-01-01T00:00:00Z", "source_fingerprint": { "hash": "9d5778b7b11d01c6ae81040401fac81834395f4d86bd09b45077a694a1301edd" @@ -10,7 +10,7 @@ "steps": [ { "sql": "CREATE MATERIALIZED VIEW IF NOT EXISTS active_employees AS\n SELECT\n id,\n name,\n salary\n FROM employees\n WHERE status = 'active';", - "type": "view", + "type": "materialized_view", "operation": "create", "path": "public.active_employees" } diff --git a/testdata/diff/create_materialized_view/add_materialized_view/plan.txt b/testdata/diff/create_materialized_view/add_materialized_view/plan.txt index bab2c846..dec0e492 100644 --- a/testdata/diff/create_materialized_view/add_materialized_view/plan.txt +++ b/testdata/diff/create_materialized_view/add_materialized_view/plan.txt @@ -1,9 +1,9 @@ Plan: 1 to add. Summary by type: - views: 1 to add + materialized views: 1 to add -Views: +Materialized views: + active_employees DDL to be executed: diff --git a/testdata/diff/create_materialized_view/alter_materialized_view/plan.json b/testdata/diff/create_materialized_view/alter_materialized_view/plan.json index fd314171..d21c2c11 100644 --- a/testdata/diff/create_materialized_view/alter_materialized_view/plan.json +++ b/testdata/diff/create_materialized_view/alter_materialized_view/plan.json @@ -10,13 +10,13 @@ "steps": [ { "sql": "DROP MATERIALIZED VIEW active_employees RESTRICT;", - "type": "view", + "type": "materialized_view", "operation": "alter", "path": "public.active_employees" }, { "sql": "CREATE MATERIALIZED VIEW IF NOT EXISTS active_employees AS\n SELECT\n id,\n name,\n salary,\n status\n FROM employees\n WHERE status = 'active';", - "type": "view", + "type": "materialized_view", "operation": "alter", "path": "public.active_employees" } diff --git a/testdata/diff/create_materialized_view/alter_materialized_view/plan.txt b/testdata/diff/create_materialized_view/alter_materialized_view/plan.txt index 405cd78e..cb7b4bd5 100644 --- a/testdata/diff/create_materialized_view/alter_materialized_view/plan.txt +++ b/testdata/diff/create_materialized_view/alter_materialized_view/plan.txt @@ -1,9 +1,9 @@ Plan: 1 to modify. Summary by type: - views: 1 to modify + materialized views: 1 to modify -Views: +Materialized views: ~ active_employees DDL to be executed: diff --git a/testdata/diff/create_materialized_view/drop_materialized_view/plan.json b/testdata/diff/create_materialized_view/drop_materialized_view/plan.json index debc6f37..97dd50c8 100644 --- a/testdata/diff/create_materialized_view/drop_materialized_view/plan.json +++ b/testdata/diff/create_materialized_view/drop_materialized_view/plan.json @@ -1,6 +1,6 @@ { "version": "1.0.0", - "pgschema_version": "1.0.0", + "pgschema_version": "1.2.1", "created_at": "1970-01-01T00:00:00Z", "source_fingerprint": { "hash": "825ab80224c8d3a5fbec609848e3e09ca74a2b041cbc2996898b4daaf5b0e40b" @@ -10,7 +10,7 @@ "steps": [ { "sql": "DROP MATERIALIZED VIEW active_employees RESTRICT;", - "type": "view", + "type": "materialized_view", "operation": "drop", "path": "public.active_employees" } diff --git a/testdata/diff/create_materialized_view/drop_materialized_view/plan.txt b/testdata/diff/create_materialized_view/drop_materialized_view/plan.txt index 28833dee..1cdbe82c 100644 --- a/testdata/diff/create_materialized_view/drop_materialized_view/plan.txt +++ b/testdata/diff/create_materialized_view/drop_materialized_view/plan.txt @@ -1,9 +1,9 @@ Plan: 1 to drop. Summary by type: - views: 1 to drop + materialized views: 1 to drop -Views: +Materialized views: - active_employees DDL to be executed: diff --git a/testdata/diff/migrate/v4/plan.json b/testdata/diff/migrate/v4/plan.json index b7915693..5c72f22f 100644 --- a/testdata/diff/migrate/v4/plan.json +++ b/testdata/diff/migrate/v4/plan.json @@ -28,13 +28,13 @@ }, { "sql": "CREATE MATERIALIZED VIEW IF NOT EXISTS employee_salary_summary AS\n SELECT\n d.dept_no,\n d.dept_name,\n count(e.emp_no) AS employee_count,\n avg(s.amount) AS avg_salary,\n max(s.amount) AS max_salary,\n min(s.amount) AS min_salary\n FROM employee e\n JOIN dept_emp de ON e.emp_no = de.emp_no\n JOIN department d ON de.dept_no = d.dept_no\n JOIN salary s ON e.emp_no = s.emp_no\n WHERE de.to_date = '9999-01-01'::date AND s.to_date = '9999-01-01'::date\n GROUP BY d.dept_no, d.dept_name;", - "type": "view", + "type": "materialized_view", "operation": "create", "path": "public.employee_salary_summary" }, { "sql": "CREATE INDEX IF NOT EXISTS idx_emp_salary_summary_dept_no ON employee_salary_summary (dept_no);", - "type": "view.index", + "type": "materialized_view.index", "operation": "create", "path": "public.employee_salary_summary.idx_emp_salary_summary_dept_no" } diff --git a/testdata/diff/migrate/v4/plan.txt b/testdata/diff/migrate/v4/plan.txt index fae52865..c35b842b 100644 --- a/testdata/diff/migrate/v4/plan.txt +++ b/testdata/diff/migrate/v4/plan.txt @@ -4,7 +4,8 @@ Summary by type: functions: 1 to modify procedures: 1 to add tables: 2 to modify - views: 3 to add + views: 2 to add + materialized views: 1 to add Functions: ~ log_dml_operations @@ -22,6 +23,8 @@ Tables: Views: + current_dept_emp + dept_emp_latest_date + +Materialized views: + employee_salary_summary + idx_emp_salary_summary_dept_no (index) diff --git a/testdata/diff/online/add_materialized_view_index/plan.json b/testdata/diff/online/add_materialized_view_index/plan.json index 6bcff657..fe330e4c 100644 --- a/testdata/diff/online/add_materialized_view_index/plan.json +++ b/testdata/diff/online/add_materialized_view_index/plan.json @@ -10,7 +10,7 @@ "steps": [ { "sql": "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_user_summary_created_at ON user_summary (created_at);", - "type": "view.index", + "type": "materialized_view.index", "operation": "create", "path": "public.user_summary.idx_user_summary_created_at" } @@ -24,7 +24,7 @@ "type": "wait", "message": "Creating index idx_user_summary_created_at" }, - "type": "view.index", + "type": "materialized_view.index", "operation": "create", "path": "public.user_summary.idx_user_summary_created_at" } diff --git a/testdata/diff/online/add_materialized_view_index/plan.txt b/testdata/diff/online/add_materialized_view_index/plan.txt index 0991ae37..2ec00239 100644 --- a/testdata/diff/online/add_materialized_view_index/plan.txt +++ b/testdata/diff/online/add_materialized_view_index/plan.txt @@ -1,9 +1,9 @@ Plan: 1 to modify. Summary by type: - views: 1 to modify + materialized views: 1 to modify -Views: +Materialized views: ~ user_summary + idx_user_summary_created_at (index) diff --git a/testdata/diff/online/alter_materialized_view_index/plan.json b/testdata/diff/online/alter_materialized_view_index/plan.json index e3fb6dd9..10f93c6c 100644 --- a/testdata/diff/online/alter_materialized_view_index/plan.json +++ b/testdata/diff/online/alter_materialized_view_index/plan.json @@ -10,7 +10,7 @@ "steps": [ { "sql": "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_user_summary_email_pgschema_new ON user_summary (email, status);", - "type": "view.index", + "type": "materialized_view.index", "operation": "alter", "path": "public.user_summary.idx_user_summary_email" } @@ -24,7 +24,7 @@ "type": "wait", "message": "Creating index idx_user_summary_email_pgschema_new" }, - "type": "view.index", + "type": "materialized_view.index", "operation": "alter", "path": "public.user_summary.idx_user_summary_email" } @@ -34,13 +34,13 @@ "steps": [ { "sql": "DROP INDEX idx_user_summary_email;", - "type": "view.index", + "type": "materialized_view.index", "operation": "alter", "path": "public.user_summary.idx_user_summary_email" }, { "sql": "ALTER INDEX idx_user_summary_email_pgschema_new RENAME TO idx_user_summary_email;", - "type": "view.index", + "type": "materialized_view.index", "operation": "alter", "path": "public.user_summary.idx_user_summary_email" } diff --git a/testdata/diff/online/alter_materialized_view_index/plan.txt b/testdata/diff/online/alter_materialized_view_index/plan.txt index e1ef8eee..2784c19a 100644 --- a/testdata/diff/online/alter_materialized_view_index/plan.txt +++ b/testdata/diff/online/alter_materialized_view_index/plan.txt @@ -1,9 +1,9 @@ Plan: 1 to modify. Summary by type: - views: 1 to modify + materialized views: 1 to modify -Views: +Materialized views: ~ user_summary ~ idx_user_summary_email (index - concurrent rebuild)