From c3a85ba892c8e28dcf2ce7215cb0eec38d7cfae6 Mon Sep 17 00:00:00 2001 From: Tianzhou Date: Sat, 11 Oct 2025 23:12:35 +0800 Subject: [PATCH] feat: materialized_view_index --- internal/diff/diff.go | 118 +++++++- internal/diff/index.go | 16 +- internal/diff/view.go | 277 ++++++++++++++++-- internal/plan/plan.go | 251 +++++++++++++++- internal/plan/rewrite.go | 22 +- ir/inspector.go | 14 +- ir/ir.go | 11 +- ir/parser.go | 23 +- .../comment_on/add_index_comment/diff.sql | 2 + .../diff/comment_on/add_index_comment/new.sql | 12 +- .../diff/comment_on/add_index_comment/old.sql | 10 +- .../comment_on/add_index_comment/plan.json | 10 +- .../comment_on/add_index_comment/plan.sql | 2 + .../comment_on/add_index_comment/plan.txt | 9 +- .../diff/create_index/drop_index/diff.sql | 2 + testdata/diff/create_index/drop_index/new.sql | 9 +- testdata/diff/create_index/drop_index/old.sql | 11 +- .../diff/create_index/drop_index/plan.json | 10 +- .../diff/create_index/drop_index/plan.sql | 2 + .../diff/create_index/drop_index/plan.txt | 9 +- .../alter_materialized_view/plan.json | 6 +- .../alter_materialized_view/plan.txt | 7 +- .../add_materialized_view_index/diff.sql | 1 + .../add_materialized_view_index/new.sql | 14 + .../add_materialized_view_index/old.sql | 12 + .../add_materialized_view_index/plan.json | 34 +++ .../add_materialized_view_index/plan.sql | 13 + .../add_materialized_view_index/plan.txt | 27 ++ .../alter_materialized_view_index/diff.sql | 3 + .../alter_materialized_view_index/new.sql | 18 ++ .../alter_materialized_view_index/old.sql | 18 ++ .../alter_materialized_view_index/plan.json | 50 ++++ .../alter_materialized_view_index/plan.sql | 17 ++ .../alter_materialized_view_index/plan.txt | 32 ++ testdata/dump/sakila/pgschema.sql | 8 +- 35 files changed, 1002 insertions(+), 78 deletions(-) create mode 100644 testdata/diff/online/add_materialized_view_index/diff.sql create mode 100644 testdata/diff/online/add_materialized_view_index/new.sql create mode 100644 testdata/diff/online/add_materialized_view_index/old.sql create mode 100644 testdata/diff/online/add_materialized_view_index/plan.json create mode 100644 testdata/diff/online/add_materialized_view_index/plan.sql create mode 100644 testdata/diff/online/add_materialized_view_index/plan.txt create mode 100644 testdata/diff/online/alter_materialized_view_index/diff.sql create mode 100644 testdata/diff/online/alter_materialized_view_index/new.sql create mode 100644 testdata/diff/online/alter_materialized_view_index/old.sql create mode 100644 testdata/diff/online/alter_materialized_view_index/plan.json create mode 100644 testdata/diff/online/alter_materialized_view_index/plan.sql create mode 100644 testdata/diff/online/alter_materialized_view_index/plan.txt diff --git a/internal/diff/diff.go b/internal/diff/diff.go index fca48810..6803700e 100644 --- a/internal/diff/diff.go +++ b/internal/diff/diff.go @@ -25,6 +25,8 @@ const ( DiffTypeTableIndexComment DiffTypeView DiffTypeViewComment + DiffTypeViewIndex + DiffTypeViewIndexComment DiffTypeFunction DiffTypeProcedure DiffTypeSequence @@ -60,6 +62,10 @@ func (d DiffType) String() string { return "view" case DiffTypeViewComment: return "view.comment" + case DiffTypeViewIndex: + return "view.index" + case DiffTypeViewIndexComment: + return "view.index.comment" case DiffTypeFunction: return "function" case DiffTypeProcedure: @@ -114,6 +120,10 @@ 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 "function": *d = DiffTypeFunction case "procedure": @@ -262,11 +272,15 @@ type triggerDiff struct { // viewDiff represents changes to a view type viewDiff struct { - Old *ir.View - New *ir.View - CommentChanged bool - OldComment string - NewComment string + Old *ir.View + New *ir.View + 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 } // tableDiff represents changes to a table @@ -676,13 +690,53 @@ func GenerateMigration(oldIR, newIR *ir.IR, targetSchema string) []Diff { structurallyDifferent := !viewsEqual(oldView, newView) commentChanged := oldView.Comment != newView.Comment - if structurallyDifferent || commentChanged { - // For materialized views with structural changes, use DROP + CREATE approach + // Check if indexes changed for materialized views + indexesChanged := false + if newView.Materialized { + oldIndexCount := 0 + newIndexCount := 0 + if oldView.Indexes != nil { + oldIndexCount = len(oldView.Indexes) + } + if newView.Indexes != nil { + newIndexCount = len(newView.Indexes) + } + indexesChanged = oldIndexCount != newIndexCount + + // If counts are same, check if any indexes are different (added/removed/modified) + if !indexesChanged && oldIndexCount > 0 { + // Check for added or removed indexes + for indexName := range newView.Indexes { + if _, exists := oldView.Indexes[indexName]; !exists { + indexesChanged = true + break + } + } + + // Check for modified indexes (structure or comments) + if !indexesChanged { + for indexName, newIndex := range newView.Indexes { + if oldIndex, exists := oldView.Indexes[indexName]; exists { + structurallyEqual := indexesStructurallyEqual(oldIndex, newIndex) + commentChanged := oldIndex.Comment != newIndex.Comment + if !structurallyEqual || commentChanged { + indexesChanged = true + break + } + } + } + } + } + } + + if structurallyDifferent || commentChanged || indexesChanged { + // For materialized views with structural changes, mark for recreation if newView.Materialized && structurallyDifferent { - // Add old materialized view to dropped views - diff.droppedViews = append(diff.droppedViews, oldView) - // Add new materialized view to added views - diff.addedViews = append(diff.addedViews, newView) + diff.modifiedViews = append(diff.modifiedViews, &viewDiff{ + Old: oldView, + New: newView, + RequiresRecreate: true, + }) } else { // For regular views or comment-only changes, use the modify approach viewDiff := &viewDiff{ @@ -697,6 +751,48 @@ func GenerateMigration(oldIR, newIR *ir.IR, targetSchema string) []Diff { viewDiff.NewComment = newView.Comment } + // For materialized views, also diff indexes + if newView.Materialized { + oldIndexes := oldView.Indexes + newIndexes := newView.Indexes + if oldIndexes == nil { + oldIndexes = make(map[string]*ir.Index) + } + if newIndexes == nil { + newIndexes = make(map[string]*ir.Index) + } + + // Find added indexes + for indexName, index := range newIndexes { + if _, exists := oldIndexes[indexName]; !exists { + viewDiff.AddedIndexes = append(viewDiff.AddedIndexes, index) + } + } + + // Find dropped indexes + for indexName, index := range oldIndexes { + if _, exists := newIndexes[indexName]; !exists { + viewDiff.DroppedIndexes = append(viewDiff.DroppedIndexes, index) + } + } + + // Find modified indexes + for indexName, newIndex := range newIndexes { + if oldIndex, exists := oldIndexes[indexName]; exists { + structurallyEqual := indexesStructurallyEqual(oldIndex, newIndex) + commentChanged := oldIndex.Comment != newIndex.Comment + + // If either structure changed or comment changed, treat as modification + if !structurallyEqual || commentChanged { + viewDiff.ModifiedIndexes = append(viewDiff.ModifiedIndexes, &IndexDiff{ + Old: oldIndex, + New: newIndex, + }) + } + } + } + } + diff.modifiedViews = append(diff.modifiedViews, viewDiff) } } diff --git a/internal/diff/index.go b/internal/diff/index.go index 8d25f65b..b4756988 100644 --- a/internal/diff/index.go +++ b/internal/diff/index.go @@ -8,8 +8,18 @@ import ( "github.com/pgschema/pgschema/ir" ) -// generateCreateIndexesSQL generates CREATE INDEX statements +// generateCreateIndexesSQL generates CREATE INDEX statements for table indexes func generateCreateIndexesSQL(indexes []*ir.Index, targetSchema string, collector *diffCollector) { + 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 sortedIndexes := make([]*ir.Index, len(indexes)) copy(sortedIndexes, indexes) @@ -27,7 +37,7 @@ func generateCreateIndexesSQL(indexes []*ir.Index, targetSchema string, collecto // Create context for this statement context := &diffContext{ - Type: DiffTypeTableIndex, + Type: indexType, Operation: DiffOperationCreate, Path: fmt.Sprintf("%s.%s.%s", index.Schema, index.Table, index.Name), Source: index, @@ -43,7 +53,7 @@ func generateCreateIndexesSQL(indexes []*ir.Index, targetSchema string, collecto // Create context for this statement context := &diffContext{ - Type: DiffTypeTableIndexComment, + Type: commentType, Operation: DiffOperationCreate, Path: fmt.Sprintf("%s.%s.%s", index.Schema, index.Table, index.Name), Source: index, diff --git a/internal/diff/view.go b/internal/diff/view.go index fc27f022..1cd97093 100644 --- a/internal/diff/view.go +++ b/internal/diff/view.go @@ -2,6 +2,7 @@ package diff import ( "fmt" + "sort" "strings" pg_query "github.com/pganalyze/pg_query_go/v6" @@ -43,44 +44,265 @@ func generateCreateViewsSQL(views []*ir.View, targetSchema string, collector *di collector.collect(context, sql) } + + // For materialized views, create indexes + if view.Materialized && view.Indexes != nil { + indexList := make([]*ir.Index, 0, len(view.Indexes)) + for _, index := range view.Indexes { + indexList = append(indexList, index) + } + // Generate index SQL for materialized view indexes + generateCreateViewIndexesSQL(indexList, targetSchema, collector) + } } } // generateModifyViewsSQL generates CREATE OR REPLACE VIEW statements or comment changes func generateModifyViewsSQL(diffs []*viewDiff, targetSchema string, collector *diffCollector) { for _, diff := range diffs { - // Check if only the comment changed and definition is semantically identical - definitionsEqual := diff.Old.Definition == diff.New.Definition || compareViewDefinitionsSemantically(diff.Old.Definition, diff.New.Definition) - commentOnlyChange := diff.CommentChanged && definitionsEqual && diff.Old.Materialized == diff.New.Materialized - if commentOnlyChange { - // Only generate COMMENT ON VIEW statement for comment-only changes + // Handle materialized views that require recreation (DROP + CREATE) + if diff.RequiresRecreate { 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, + // 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, + }, + } - collector.collect(context, sql) - } else { - sql := fmt.Sprintf("COMMENT ON VIEW %s IS %s;", viewName, quoteString(diff.NewComment)) + // Use DiffOperationAlter to categorize as a modification + context := &diffContext{ + Type: DiffTypeView, + Operation: DiffOperationAlter, + Path: fmt.Sprintf("%s.%s", diff.New.Schema, diff.New.Name), + Source: diff, + CanRunInTransaction: true, + } + collector.collectStatements(context, statements) - // Create context for this statement - context := &diffContext{ - Type: DiffTypeView, - Operation: DiffOperationAlter, + // Add view comment if present + if diff.New.Comment != "" { + sql := fmt.Sprintf("COMMENT ON VIEW %s IS %s;", viewName, quoteString(diff.New.Comment)) + commentContext := &diffContext{ + Type: DiffTypeViewComment, + Operation: DiffOperationCreate, Path: fmt.Sprintf("%s.%s", diff.New.Schema, diff.New.Name), - Source: diff, + Source: diff.New, CanRunInTransaction: true, } + collector.collect(commentContext, sql) + } + + // Recreate indexes for materialized views + if diff.New.Materialized && diff.New.Indexes != nil { + indexList := make([]*ir.Index, 0, len(diff.New.Indexes)) + for _, index := range diff.New.Indexes { + indexList = append(indexList, index) + } + generateCreateViewIndexesSQL(indexList, targetSchema, collector) + } - collector.collect(context, sql) + continue // Skip the normal processing for this view + } + + // Check if only the comment changed and definition is semantically identical + definitionsEqual := diff.Old.Definition == diff.New.Definition || compareViewDefinitionsSemantically(diff.Old.Definition, diff.New.Definition) + commentOnlyChange := diff.CommentChanged && definitionsEqual && diff.Old.Materialized == diff.New.Materialized + + // Check if only indexes changed (for materialized views) + 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 { + // 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) + } 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, + } + + collector.collect(context, sql) + } + } + + // For materialized views, handle index modifications (only if indexes actually changed) + if diff.New.Materialized && hasIndexChanges { + // Identify indexes that need online replacement (dropped and added with same name) + onlineReplacements := make(map[string]*ir.Index) + regularDrops := []*ir.Index{} + + for _, droppedIndex := range diff.DroppedIndexes { + foundReplacement := false + for _, addedIndex := range diff.AddedIndexes { + if droppedIndex.Name == addedIndex.Name { + onlineReplacements[droppedIndex.Name] = addedIndex + foundReplacement = true + break + } + } + if !foundReplacement { + regularDrops = append(regularDrops, droppedIndex) + } + } + + // Remove replaced indexes from added list + remainingAdded := []*ir.Index{} + for _, addedIndex := range diff.AddedIndexes { + if _, isReplacement := onlineReplacements[addedIndex.Name]; !isReplacement { + remainingAdded = append(remainingAdded, addedIndex) + } + } + + // Drop indexes that are not being replaced + for _, index := range regularDrops { + sql := fmt.Sprintf("DROP INDEX IF EXISTS %s;", qualifyEntityName(index.Schema, index.Name, targetSchema)) + context := &diffContext{ + Type: DiffTypeViewIndex, + Operation: DiffOperationDrop, + Path: fmt.Sprintf("%s.%s.%s", index.Schema, index.Table, index.Name), + Source: index, + CanRunInTransaction: true, + } + collector.collect(context, sql) + } + + // Handle modified indexes + for _, indexDiff := range diff.ModifiedIndexes { + // Check if only comment changed + structurallyEqual := indexesStructurallyEqual(indexDiff.Old, indexDiff.New) + commentChanged := indexDiff.Old.Comment != indexDiff.New.Comment + + if structurallyEqual && commentChanged { + // Only comment changed - generate COMMENT ON INDEX statement + indexName := qualifyEntityName(indexDiff.New.Schema, indexDiff.New.Name, targetSchema) + var sql string + if indexDiff.New.Comment == "" { + sql = fmt.Sprintf("COMMENT ON INDEX %s IS NULL;", indexName) + } else { + sql = fmt.Sprintf("COMMENT ON INDEX %s IS %s;", indexName, quoteString(indexDiff.New.Comment)) + } + + context := &diffContext{ + Type: DiffTypeViewIndexComment, + Operation: DiffOperationAlter, + Path: fmt.Sprintf("%s.%s.%s", indexDiff.New.Schema, indexDiff.New.Table, indexDiff.New.Name), + Source: indexDiff, + CanRunInTransaction: true, + } + collector.collect(context, sql) + } else { + // Structure changed - use online replacement approach + dropSQL := fmt.Sprintf("DROP INDEX IF EXISTS %s;", qualifyEntityName(indexDiff.Old.Schema, indexDiff.Old.Name, targetSchema)) + canonicalSQL := generateIndexSQL(indexDiff.New, targetSchema, false) + + statements := []SQLStatement{ + { + SQL: dropSQL, + CanRunInTransaction: true, + }, + { + SQL: canonicalSQL, + CanRunInTransaction: true, + }, + } + + alterContext := &diffContext{ + Type: DiffTypeViewIndex, + Operation: DiffOperationAlter, + Path: fmt.Sprintf("%s.%s.%s", indexDiff.New.Schema, indexDiff.New.Table, indexDiff.New.Name), + Source: indexDiff, + CanRunInTransaction: true, + } + collector.collectStatements(alterContext, statements) + } + } + + // Process index replacements with online approach + if len(onlineReplacements) > 0 { + // Sort for deterministic order + sortedOnlineIndexNames := make([]string, 0, len(onlineReplacements)) + for indexName := range onlineReplacements { + sortedOnlineIndexNames = append(sortedOnlineIndexNames, indexName) + } + sort.Strings(sortedOnlineIndexNames) + + for _, indexName := range sortedOnlineIndexNames { + newIndex := onlineReplacements[indexName] + + // Step 1: DROP old index, Step 2: CREATE new index + dropSQL := fmt.Sprintf("DROP INDEX IF EXISTS %s;", qualifyEntityName(newIndex.Schema, indexName, targetSchema)) + canonicalSQL := generateIndexSQL(newIndex, targetSchema, false) + + statements := []SQLStatement{ + { + SQL: dropSQL, + CanRunInTransaction: true, + }, + { + SQL: canonicalSQL, + CanRunInTransaction: true, + }, + } + + alterContext := &diffContext{ + Type: DiffTypeViewIndex, + Operation: DiffOperationAlter, + Path: fmt.Sprintf("%s.%s.%s", newIndex.Schema, newIndex.Table, indexName), + Source: newIndex, + CanRunInTransaction: true, + } + collector.collectStatements(alterContext, statements) + + // Add index comment if present as a separate operation + if newIndex.Comment != "" { + qualifiedIndexName := qualifyEntityName(newIndex.Schema, indexName, targetSchema) + sql := fmt.Sprintf("COMMENT ON INDEX %s IS %s;", qualifiedIndexName, quoteString(newIndex.Comment)) + + context := &diffContext{ + Type: DiffTypeViewIndexComment, + Operation: DiffOperationCreate, + Path: fmt.Sprintf("%s.%s.%s", newIndex.Schema, newIndex.Table, indexName), + Source: newIndex, + CanRunInTransaction: true, + } + collector.collect(context, sql) + } + } + } + + // Create new indexes (not replacements) + generateCreateViewIndexesSQL(remainingAdded, targetSchema, collector) } } else { // Create the new view (CREATE OR REPLACE works for regular views, materialized views are handled by drop/create cycle) @@ -113,6 +335,15 @@ func generateModifyViewsSQL(diffs []*viewDiff, targetSchema string, collector *d collector.collect(context, sql) } + + // For materialized views that were recreated, recreate indexes + if diff.New.Materialized && diff.New.Indexes != nil { + indexList := make([]*ir.Index, 0, len(diff.New.Indexes)) + for _, index := range diff.New.Indexes { + indexList = append(indexList, index) + } + generateCreateViewIndexesSQL(indexList, targetSchema, collector) + } } } } diff --git a/internal/plan/plan.go b/internal/plan/plan.go index e2211bba..a0c58cf7 100644 --- a/internal/plan/plan.go +++ b/internal/plan/plan.go @@ -149,10 +149,19 @@ func groupDiffs(diffs []diff.Diff) []ExecutionGroup { } } + // Track newly created views to avoid concurrent rewrites for their indexes + newlyCreatedViews := 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 + } + } + // Convert diffs to steps for _, d := range diffs { // Try to generate rewrites if online operations are enabled - rewriteSteps := generateRewrite(d, newlyCreatedTables) + rewriteSteps := generateRewrite(d, newlyCreatedTables, newlyCreatedViews) if len(rewriteSteps) > 0 { // For operations with rewrites, create one step per rewrite statement @@ -389,7 +398,13 @@ 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 non-table operations + // Track view operations by view path (including materialized views) + 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 nonTableOperations := make(map[string][]string) // objType -> []operations // Use source diffs for summary calculation if available, @@ -432,6 +447,24 @@ func (p *Plan) calculateSummaryFromSteps() PlanSummary { } } + // First pass: identify all views to distinguish them from tables + viewPaths := make(map[string]bool) + for _, step := range dataToProcess { + stepObjTypeStr := step.Type + if !strings.HasSuffix(stepObjTypeStr, "s") { + stepObjTypeStr += "s" + } + if stepObjTypeStr == "views" { + viewPaths[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 + } + } + } + for _, step := range dataToProcess { // Normalize object type to match the expected format (add 's' for plural) stepObjTypeStr := step.Type @@ -442,14 +475,23 @@ func (p *Plan) calculateSummaryFromSteps() PlanSummary { if stepObjTypeStr == "tables" { // For tables, track unique table paths and their primary operation tableOperations[step.Path] = step.Operation + } else if stepObjTypeStr == "views" { + // For views, track unique view paths and their primary operation + viewOperations[step.Path] = step.Operation } else if isSubResource(step.Type) { - // For sub-resources, track which tables have sub-resource changes - tablePath := extractTablePathFromSubResource(step.Path, step.Type) - if tablePath != "" { - tablesWithSubResources[tablePath] = true + // For sub-resources, check if parent is a view or table + parentPath := extractTablePathFromSubResource(step.Path, step.Type) + if parentPath != "" { + if viewPaths[parentPath] { + // Parent is a view, track under views + viewsWithSubResources[parentPath] = true + } else { + // Parent is a table, track under tables + tablesWithSubResources[parentPath] = true + } } } else { - // For non-table objects, track each operation + // For non-table/non-view objects, track each operation nonTableOperations[stepObjTypeStr] = append(nonTableOperations[stepObjTypeStr], step.Operation) } } @@ -488,7 +530,41 @@ func (p *Plan) calculateSummaryFromSteps() PlanSummary { summary.ByType["tables"] = stats } - // Count non-table operations (each operation counted individually) + // Count view operations (one per unique view) + // Include both direct view operations and views with sub-resource changes + allAffectedViews := make(map[string]string) + + // First, add direct view operations + for viewPath, operation := range viewOperations { + allAffectedViews[viewPath] = operation + } + + // Then, add views that only have sub-resource changes (count as "alter") + for viewPath := range viewsWithSubResources { + if _, alreadyCounted := allAffectedViews[viewPath]; !alreadyCounted { + allAffectedViews[viewPath] = "alter" // Sub-resource changes = view modification + } + } + + if len(allAffectedViews) > 0 { + stats := summary.ByType["views"] + for _, operation := range allAffectedViews { + switch operation { + case "create": + stats.Add++ + summary.Add++ + case "alter": + stats.Change++ + summary.Change++ + case "drop": + stats.Destroy++ + summary.Destroy++ + } + } + summary.ByType["views"] = stats + } + + // Count non-table/non-view operations (each operation counted individually) for objType, operations := range nonTableOperations { stats := summary.ByType[objType] for _, operation := range operations { @@ -518,8 +594,11 @@ func (p *Plan) writeDetailedChangesFromSteps(summary *strings.Builder, displayNa if objType == "tables" { // For tables, group all changes by table path to avoid duplicates p.writeTableChanges(summary, c) + } else if objType == "views" { + // For views, group all changes by view path to avoid duplicates + p.writeViewChanges(summary, c) } else { - // For non-table objects, use the original logic + // For non-table/non-view objects, use the original logic p.writeNonTableChanges(summary, objType, c) } @@ -550,8 +629,8 @@ func (p *Plan) writeTableChanges(summary *strings.Builder, c *color.Color) { if stepObjTypeStr == "tables" { // This is a table-level change, record the operation tableOperations[step.Path] = step.Operation.String() - } else if isSubResource(step.Type.String()) { - // This is a sub-resource change + } else if isSubResource(step.Type.String()) && strings.HasPrefix(step.Type.String(), "table.") { + // This is a table sub-resource change (skip view sub-resources) tablePath := extractTablePathFromSubResource(step.Path, step.Type.String()) if tablePath != "" { // Deduplicate all operations based on (type, operation, path) triplet @@ -649,6 +728,129 @@ func (p *Plan) writeTableChanges(summary *strings.Builder, c *color.Color) { } } +// writeViewChanges handles view-specific output with proper grouping +func (p *Plan) writeViewChanges(summary *strings.Builder, c *color.Color) { + // Group all changes by view path and track operations + viewOperations := make(map[string]string) // view_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 == "views" { + // This is a view-level change, record the operation + viewOperations[step.Path] = step.Operation.String() + } else if isSubResource(step.Type.String()) && strings.HasPrefix(step.Type.String(), "view.") { + // This is a view sub-resource change + viewPath := extractTablePathFromSubResource(step.Path, step.Type.String()) + if viewPath != "" { + // 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[viewPath] = append(subResources[viewPath], struct { + operation string + path string + subType string + }{ + operation: step.Operation.String(), + path: step.Path, + subType: step.Type.String(), + }) + } + } + } + } + + // Get all unique view paths (from both direct view changes and sub-resources) + allViews := make(map[string]bool) + for viewPath := range viewOperations { + allViews[viewPath] = true + } + for viewPath := range subResources { + allViews[viewPath] = true + } + + // Sort view paths for consistent output + var sortedViews []string + for viewPath := range allViews { + sortedViews = append(sortedViews, viewPath) + } + sort.Strings(sortedViews) + + // Display each view once with all its changes + for _, viewPath := range sortedViews { + var symbol string + if operation, hasDirectChange := viewOperations[viewPath]; hasDirectChange { + // 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 { + // View has no direct changes, only sub-resource changes + // Sub-resource changes to existing views should always be considered modifications + symbol = c.PlanSymbol("change") + } + + fmt.Fprintf(summary, " %s %s\n", symbol, getLastPathComponent(viewPath)) + + // Show sub-resources for this view + if subResourceList, exists := subResources[viewPath]; 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() { + subSymbol := c.PlanSymbol("change") + displaySubType := strings.TrimPrefix(subRes.subType, "view.") + fmt.Fprintf(summary, " %s %s (%s - concurrent rebuild)\n", subSymbol, getLastPathComponent(subRes.path), displaySubType) + continue + } + + 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) + } + } + } +} + // writeNonTableChanges handles non-table objects with the original logic func (p *Plan) writeNonTableChanges(summary *strings.Builder, objType string, c *color.Color) { // Collect changes for this object type @@ -699,9 +901,10 @@ func (p *Plan) writeNonTableChanges(summary *strings.Builder, objType string, c } } -// isSubResource checks if the given type is a sub-resource of tables +// isSubResource checks if the given type is a sub-resource of tables or views func isSubResource(objType string) bool { - return strings.HasPrefix(objType, "table.") && objType != "table" + return (strings.HasPrefix(objType, "table.") && objType != "table") || + (strings.HasPrefix(objType, "view.") && objType != "view") } // getLastPathComponent extracts the last component from a dot-separated path @@ -713,7 +916,7 @@ func getLastPathComponent(path string) string { return path } -// extractTablePathFromSubResource extracts the parent table path from a sub-resource path +// extractTablePathFromSubResource extracts the parent table or 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: @@ -735,6 +938,26 @@ func extractTablePathFromSubResource(subResourcePath, subResourceType string) st // If only 2 parts, it's likely "schema.table" already return subResourcePath } + } else if strings.HasPrefix(subResourceType, "view.") { + // For view sub-resources, the path format is similar: + // - "schema.view.resource_name" -> "schema.view" (indexes, comments) + // - "schema.view" -> "schema.view" (view-level comments) + parts := strings.Split(subResourcePath, ".") + + // Special handling for view-level changes + if subResourceType == "view.comment" { + // For view comments, the path is already the view path + return subResourcePath + } + + if len(parts) >= 2 { + // For other sub-resources, return the first two parts as view path + if len(parts) >= 3 { + return parts[0] + "." + parts[1] + } + // If only 2 parts, it's likely "schema.view" already + return subResourcePath + } } return "" } diff --git a/internal/plan/rewrite.go b/internal/plan/rewrite.go index efb4d90e..40b94408 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) []RewriteStep { +func generateRewrite(d diff.Diff, newlyCreatedTables map[string]bool, newlyCreatedViews map[string]bool) []RewriteStep { // Dispatch to specific rewrite generators based on diff type and source switch d.Type { case diff.DiffTypeTableIndex: @@ -39,6 +39,26 @@ func generateRewrite(d diff.Diff, newlyCreatedTables map[string]bool) []RewriteS return generateIndexChangeRewriteFromIndex(index) } } + case diff.DiffTypeViewIndex: + 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 + } + return generateIndexRewrite(index) + } + case diff.DiffOperationAlter: + // For index changes, handle similarly to table indexes + if indexDiff, ok := d.Source.(*diff.IndexDiff); ok { + return generateIndexChangeRewrite(indexDiff) + } else if index, ok := d.Source.(*ir.Index); ok { + // This handles index replacements where the source is the new index + return generateIndexChangeRewriteFromIndex(index) + } + } case diff.DiffTypeTableConstraint: if d.Operation == diff.DiffOperationCreate { if constraint, ok := d.Source.(*ir.Constraint); ok { diff --git a/ir/inspector.go b/ir/inspector.go index 95fca69b..3668de8c 100644 --- a/ir/inspector.go +++ b/ir/inspector.go @@ -58,7 +58,6 @@ func (i *Inspector) BuildIR(ctx context.Context, targetSchema string) (*IR, erro funcs: []func(context.Context, *IR, string) error{ i.buildColumns, i.buildConstraints, - i.buildIndexes, i.buildPartitions, }, } @@ -106,6 +105,11 @@ func (i *Inspector) BuildIR(ctx context.Context, targetSchema string) (*IR, erro return nil, err } + // Build indexes after views are loaded (indexes can reference materialized views) + if err := i.buildIndexes(ctx, schema, targetSchema); err != nil { + return nil, fmt.Errorf("failed to build indexes: %w", err) + } + // Normalize the IR normalizeIR(schema) @@ -724,9 +728,15 @@ func (i *Inspector) buildIndexes(ctx context.Context, schema *IR, targetSchema s // Store the original definition - simplification will be done during read time in diff module // Definition is now generated on demand, not stored - // Add index to table only + // Add index to table or materialized view if table, exists := dbSchema.Tables[tableName]; exists { table.Indexes[indexName] = index + } else if view, exists := dbSchema.Views[tableName]; exists && view.Materialized { + // Initialize Indexes map if nil + if view.Indexes == nil { + view.Indexes = make(map[string]*Index) + } + view.Indexes[indexName] = index } } diff --git a/ir/ir.go b/ir/ir.go index 63a71387..dffcff91 100644 --- a/ir/ir.go +++ b/ir/ir.go @@ -110,11 +110,12 @@ type TableDependency struct { // View represents a database view type View struct { - Schema string `json:"schema"` - Name string `json:"name"` - Definition string `json:"definition"` - Comment string `json:"comment,omitempty"` - Materialized bool `json:"materialized,omitempty"` + Schema string `json:"schema"` + Name string `json:"name"` + Definition string `json:"definition"` + Comment string `json:"comment,omitempty"` + Materialized bool `json:"materialized,omitempty"` + Indexes map[string]*Index `json:"indexes,omitempty"` // For materialized views only } // Function represents a database function diff --git a/ir/parser.go b/ir/parser.go index e1aac39c..5faeb9fd 100644 --- a/ir/parser.go +++ b/ir/parser.go @@ -2477,9 +2477,15 @@ func (p *Parser) parseCreateIndex(indexStmt *pg_query.IndexStmt) error { // Simplification will be done during read time in diff module // Definition is now generated on demand, not stored - // Add index to table only + // Add index to table or materialized view if table, exists := dbSchema.Tables[tableName]; exists { table.Indexes[indexName] = index + } else if view, exists := dbSchema.Views[tableName]; exists && view.Materialized { + // Initialize Indexes map if nil + if view.Indexes == nil { + view.Indexes = make(map[string]*Index) + } + view.Indexes[indexName] = index } return nil @@ -3712,13 +3718,28 @@ func (p *Parser) parseComment(stmt *pg_query.CommentStmt) error { // Find and set comment on index if schemaName != "" && indexName != "" { dbSchema := p.schema.getOrCreateSchema(schemaName) + found := false + // Search through all tables for the index for _, table := range dbSchema.Tables { if idx, exists := table.Indexes[indexName]; exists { idx.Comment = comment + found = true break } } + + // If not found in tables, search through materialized views + if !found { + for _, view := range dbSchema.Views { + if view.Materialized && view.Indexes != nil { + if idx, exists := view.Indexes[indexName]; exists { + idx.Comment = comment + break + } + } + } + } } case pg_query.ObjectType_OBJECT_VIEW: diff --git a/testdata/diff/comment_on/add_index_comment/diff.sql b/testdata/diff/comment_on/add_index_comment/diff.sql index 4195538c..46546dae 100644 --- a/testdata/diff/comment_on/add_index_comment/diff.sql +++ b/testdata/diff/comment_on/add_index_comment/diff.sql @@ -1,3 +1,5 @@ COMMENT ON INDEX idx_users_created_at IS 'Index for chronological user queries'; COMMENT ON INDEX idx_users_email IS 'Index for fast user lookup by email'; + +COMMENT ON INDEX idx_users_summary_email IS 'Index for email search on summary'; diff --git a/testdata/diff/comment_on/add_index_comment/new.sql b/testdata/diff/comment_on/add_index_comment/new.sql index a6032ebd..1bc86163 100644 --- a/testdata/diff/comment_on/add_index_comment/new.sql +++ b/testdata/diff/comment_on/add_index_comment/new.sql @@ -8,4 +8,14 @@ CREATE INDEX idx_users_email ON users(email); CREATE INDEX idx_users_created_at ON users(created_at); COMMENT ON INDEX idx_users_email IS 'Index for fast user lookup by email'; -COMMENT ON INDEX idx_users_created_at IS 'Index for chronological user queries'; \ No newline at end of file +COMMENT ON INDEX idx_users_created_at IS 'Index for chronological user queries'; + +CREATE MATERIALIZED VIEW users_summary AS +SELECT + email, + created_at +FROM users; + +CREATE INDEX idx_users_summary_email ON users_summary (email); + +COMMENT ON INDEX idx_users_summary_email IS 'Index for email search on summary'; \ No newline at end of file diff --git a/testdata/diff/comment_on/add_index_comment/old.sql b/testdata/diff/comment_on/add_index_comment/old.sql index da88e2f1..069e6f00 100644 --- a/testdata/diff/comment_on/add_index_comment/old.sql +++ b/testdata/diff/comment_on/add_index_comment/old.sql @@ -5,4 +5,12 @@ CREATE TABLE users ( ); CREATE INDEX idx_users_email ON users(email); -CREATE INDEX idx_users_created_at ON users(created_at); \ No newline at end of file +CREATE INDEX idx_users_created_at ON users(created_at); + +CREATE MATERIALIZED VIEW users_summary AS +SELECT + email, + created_at +FROM users; + +CREATE INDEX idx_users_summary_email ON users_summary (email); \ No newline at end of file diff --git a/testdata/diff/comment_on/add_index_comment/plan.json b/testdata/diff/comment_on/add_index_comment/plan.json index fb2f2e5a..6e27679b 100644 --- a/testdata/diff/comment_on/add_index_comment/plan.json +++ b/testdata/diff/comment_on/add_index_comment/plan.json @@ -1,9 +1,9 @@ { "version": "1.0.0", - "pgschema_version": "1.0.0", + "pgschema_version": "1.2.1", "created_at": "1970-01-01T00:00:00Z", "source_fingerprint": { - "hash": "a61c96de760c916be032238800e657d811cb49b36866ac5cd29fb430566f8f75" + "hash": "f2b6c3b7292d356f4656aa5fdc97d8f5434d21e2586fdd6cb4be001cdf0562b6" }, "groups": [ { @@ -19,6 +19,12 @@ "type": "table.index.comment", "operation": "alter", "path": "public.users.idx_users_email" + }, + { + "sql": "COMMENT ON INDEX idx_users_summary_email IS 'Index for email search on summary';", + "type": "view.index.comment", + "operation": "alter", + "path": "public.users_summary.idx_users_summary_email" } ] } diff --git a/testdata/diff/comment_on/add_index_comment/plan.sql b/testdata/diff/comment_on/add_index_comment/plan.sql index 4195538c..46546dae 100644 --- a/testdata/diff/comment_on/add_index_comment/plan.sql +++ b/testdata/diff/comment_on/add_index_comment/plan.sql @@ -1,3 +1,5 @@ COMMENT ON INDEX idx_users_created_at IS 'Index for chronological user queries'; COMMENT ON INDEX idx_users_email IS 'Index for fast user lookup by email'; + +COMMENT ON INDEX idx_users_summary_email IS 'Index for email search on summary'; diff --git a/testdata/diff/comment_on/add_index_comment/plan.txt b/testdata/diff/comment_on/add_index_comment/plan.txt index c60b02bc..11470697 100644 --- a/testdata/diff/comment_on/add_index_comment/plan.txt +++ b/testdata/diff/comment_on/add_index_comment/plan.txt @@ -1,16 +1,23 @@ -Plan: 1 to modify. +Plan: 2 to modify. Summary by type: tables: 1 to modify + views: 1 to modify Tables: ~ users ~ idx_users_created_at (index.comment) ~ idx_users_email (index.comment) +Views: + ~ users_summary + ~ idx_users_summary_email (index.comment) + DDL to be executed: -------------------------------------------------- COMMENT ON INDEX idx_users_created_at IS 'Index for chronological user queries'; COMMENT ON INDEX idx_users_email IS 'Index for fast user lookup by email'; + +COMMENT ON INDEX idx_users_summary_email IS 'Index for email search on summary'; diff --git a/testdata/diff/create_index/drop_index/diff.sql b/testdata/diff/create_index/drop_index/diff.sql index ed67b364..5aa822c7 100644 --- a/testdata/diff/create_index/drop_index/diff.sql +++ b/testdata/diff/create_index/drop_index/diff.sql @@ -1 +1,3 @@ DROP INDEX IF EXISTS idx_products_category_price; + +DROP INDEX IF EXISTS idx_product_summary_price; diff --git a/testdata/diff/create_index/drop_index/new.sql b/testdata/diff/create_index/drop_index/new.sql index b731ccc3..9bc60100 100644 --- a/testdata/diff/create_index/drop_index/new.sql +++ b/testdata/diff/create_index/drop_index/new.sql @@ -3,4 +3,11 @@ CREATE TABLE public.products ( name text, price numeric(10,2), category_id integer -); \ No newline at end of file +); + +CREATE MATERIALIZED VIEW public.product_summary AS +SELECT + id, + name, + price +FROM products; \ No newline at end of file diff --git a/testdata/diff/create_index/drop_index/old.sql b/testdata/diff/create_index/drop_index/old.sql index e9cfee2f..45ca8602 100644 --- a/testdata/diff/create_index/drop_index/old.sql +++ b/testdata/diff/create_index/drop_index/old.sql @@ -5,4 +5,13 @@ CREATE TABLE public.products ( category_id integer ); -CREATE INDEX idx_products_category_price ON public.products USING btree (category_id, price DESC); \ No newline at end of file +CREATE INDEX idx_products_category_price ON public.products USING btree (category_id, price DESC); + +CREATE MATERIALIZED VIEW public.product_summary AS +SELECT + id, + name, + price +FROM products; + +CREATE INDEX idx_product_summary_price ON public.product_summary(price); \ No newline at end of file diff --git a/testdata/diff/create_index/drop_index/plan.json b/testdata/diff/create_index/drop_index/plan.json index e5728bee..b0d999d0 100644 --- a/testdata/diff/create_index/drop_index/plan.json +++ b/testdata/diff/create_index/drop_index/plan.json @@ -1,9 +1,9 @@ { "version": "1.0.0", - "pgschema_version": "1.0.0", + "pgschema_version": "1.2.1", "created_at": "1970-01-01T00:00:00Z", "source_fingerprint": { - "hash": "03128911b2384c0f03c34a04126b3f7dae43a03e008be96c65319c74987064ce" + "hash": "78957d26f5c2d21a634a3c10a0fcaa19dc94471aa229713777d555ea992c7575" }, "groups": [ { @@ -13,6 +13,12 @@ "type": "table.index", "operation": "drop", "path": "public.products.idx_products_category_price" + }, + { + "sql": "DROP INDEX IF EXISTS idx_product_summary_price;", + "type": "view.index", + "operation": "drop", + "path": "public.product_summary.idx_product_summary_price" } ] } diff --git a/testdata/diff/create_index/drop_index/plan.sql b/testdata/diff/create_index/drop_index/plan.sql index ed67b364..5aa822c7 100644 --- a/testdata/diff/create_index/drop_index/plan.sql +++ b/testdata/diff/create_index/drop_index/plan.sql @@ -1 +1,3 @@ DROP INDEX IF EXISTS idx_products_category_price; + +DROP INDEX IF EXISTS 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 5837a2ca..cafe46cb 100644 --- a/testdata/diff/create_index/drop_index/plan.txt +++ b/testdata/diff/create_index/drop_index/plan.txt @@ -1,13 +1,20 @@ -Plan: 1 to modify. +Plan: 2 to modify. Summary by type: tables: 1 to modify + views: 1 to modify Tables: ~ products - idx_products_category_price (index) +Views: + ~ product_summary + - idx_product_summary_price (index) + DDL to be executed: -------------------------------------------------- DROP INDEX IF EXISTS idx_products_category_price; + +DROP INDEX IF EXISTS idx_product_summary_price; 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 3077feca..fd314171 100644 --- a/testdata/diff/create_materialized_view/alter_materialized_view/plan.json +++ b/testdata/diff/create_materialized_view/alter_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" @@ -11,13 +11,13 @@ { "sql": "DROP MATERIALIZED VIEW active_employees RESTRICT;", "type": "view", - "operation": "drop", + "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", - "operation": "create", + "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 f19850bc..405cd78e 100644 --- a/testdata/diff/create_materialized_view/alter_materialized_view/plan.txt +++ b/testdata/diff/create_materialized_view/alter_materialized_view/plan.txt @@ -1,11 +1,10 @@ -Plan: 1 to add, 1 to drop. +Plan: 1 to modify. Summary by type: - views: 1 to add, 1 to drop + views: 1 to modify Views: - - active_employees - + active_employees + ~ active_employees DDL to be executed: -------------------------------------------------- diff --git a/testdata/diff/online/add_materialized_view_index/diff.sql b/testdata/diff/online/add_materialized_view_index/diff.sql new file mode 100644 index 00000000..6f7d2218 --- /dev/null +++ b/testdata/diff/online/add_materialized_view_index/diff.sql @@ -0,0 +1 @@ +CREATE INDEX IF NOT EXISTS idx_user_summary_created_at ON user_summary (created_at); diff --git a/testdata/diff/online/add_materialized_view_index/new.sql b/testdata/diff/online/add_materialized_view_index/new.sql new file mode 100644 index 00000000..8f7dd0ee --- /dev/null +++ b/testdata/diff/online/add_materialized_view_index/new.sql @@ -0,0 +1,14 @@ +CREATE TABLE public.users ( + id integer NOT NULL, + email text, + created_at timestamp with time zone +); + +CREATE MATERIALIZED VIEW public.user_summary AS +SELECT + id, + email, + created_at +FROM users; + +CREATE INDEX idx_user_summary_created_at ON public.user_summary(created_at); diff --git a/testdata/diff/online/add_materialized_view_index/old.sql b/testdata/diff/online/add_materialized_view_index/old.sql new file mode 100644 index 00000000..9c53bca5 --- /dev/null +++ b/testdata/diff/online/add_materialized_view_index/old.sql @@ -0,0 +1,12 @@ +CREATE TABLE public.users ( + id integer NOT NULL, + email text, + created_at timestamp with time zone +); + +CREATE MATERIALIZED VIEW public.user_summary AS +SELECT + id, + email, + created_at +FROM users; diff --git a/testdata/diff/online/add_materialized_view_index/plan.json b/testdata/diff/online/add_materialized_view_index/plan.json new file mode 100644 index 00000000..6bcff657 --- /dev/null +++ b/testdata/diff/online/add_materialized_view_index/plan.json @@ -0,0 +1,34 @@ +{ + "version": "1.0.0", + "pgschema_version": "1.2.1", + "created_at": "1970-01-01T00:00:00Z", + "source_fingerprint": { + "hash": "95f4677f3f81cd0ade758c8b4fc977d116faff8f9e4ab8fe4717543b64e1c5e6" + }, + "groups": [ + { + "steps": [ + { + "sql": "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_user_summary_created_at ON user_summary (created_at);", + "type": "view.index", + "operation": "create", + "path": "public.user_summary.idx_user_summary_created_at" + } + ] + }, + { + "steps": [ + { + "sql": "SELECT \n COALESCE(i.indisvalid, false) as done,\n CASE \n WHEN p.blocks_total > 0 THEN p.blocks_done * 100 / p.blocks_total\n ELSE 0\n END as progress\nFROM pg_class c\nLEFT JOIN pg_index i ON c.oid = i.indexrelid\nLEFT JOIN pg_stat_progress_create_index p ON c.oid = p.index_relid\nWHERE c.relname = 'idx_user_summary_created_at';", + "directive": { + "type": "wait", + "message": "Creating index idx_user_summary_created_at" + }, + "type": "view.index", + "operation": "create", + "path": "public.user_summary.idx_user_summary_created_at" + } + ] + } + ] +} diff --git a/testdata/diff/online/add_materialized_view_index/plan.sql b/testdata/diff/online/add_materialized_view_index/plan.sql new file mode 100644 index 00000000..d0657d51 --- /dev/null +++ b/testdata/diff/online/add_materialized_view_index/plan.sql @@ -0,0 +1,13 @@ +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_user_summary_created_at ON user_summary (created_at); + +-- pgschema:wait +SELECT + COALESCE(i.indisvalid, false) as done, + CASE + WHEN p.blocks_total > 0 THEN p.blocks_done * 100 / p.blocks_total + ELSE 0 + END as progress +FROM pg_class c +LEFT JOIN pg_index i ON c.oid = i.indexrelid +LEFT JOIN pg_stat_progress_create_index p ON c.oid = p.index_relid +WHERE c.relname = '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 new file mode 100644 index 00000000..0991ae37 --- /dev/null +++ b/testdata/diff/online/add_materialized_view_index/plan.txt @@ -0,0 +1,27 @@ +Plan: 1 to modify. + +Summary by type: + views: 1 to modify + +Views: + ~ user_summary + + idx_user_summary_created_at (index) + +DDL to be executed: +-------------------------------------------------- + +-- Transaction Group #1 +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_user_summary_created_at ON user_summary (created_at); + +-- Transaction Group #2 +-- pgschema:wait +SELECT + COALESCE(i.indisvalid, false) as done, + CASE + WHEN p.blocks_total > 0 THEN p.blocks_done * 100 / p.blocks_total + ELSE 0 + END as progress +FROM pg_class c +LEFT JOIN pg_index i ON c.oid = i.indexrelid +LEFT JOIN pg_stat_progress_create_index p ON c.oid = p.index_relid +WHERE c.relname = 'idx_user_summary_created_at'; diff --git a/testdata/diff/online/alter_materialized_view_index/diff.sql b/testdata/diff/online/alter_materialized_view_index/diff.sql new file mode 100644 index 00000000..af6abcbf --- /dev/null +++ b/testdata/diff/online/alter_materialized_view_index/diff.sql @@ -0,0 +1,3 @@ +DROP INDEX IF EXISTS idx_user_summary_email; + +CREATE INDEX IF NOT EXISTS idx_user_summary_email ON user_summary (email, status); diff --git a/testdata/diff/online/alter_materialized_view_index/new.sql b/testdata/diff/online/alter_materialized_view_index/new.sql new file mode 100644 index 00000000..42e4a6ef --- /dev/null +++ b/testdata/diff/online/alter_materialized_view_index/new.sql @@ -0,0 +1,18 @@ +CREATE TABLE public.users ( + id integer NOT NULL, + email text, + username text, + created_at timestamp with time zone, + status text +); + +CREATE MATERIALIZED VIEW public.user_summary AS +SELECT + id, + email, + username, + created_at, + status +FROM users; + +CREATE INDEX idx_user_summary_email ON public.user_summary (email, status); diff --git a/testdata/diff/online/alter_materialized_view_index/old.sql b/testdata/diff/online/alter_materialized_view_index/old.sql new file mode 100644 index 00000000..b2ce8242 --- /dev/null +++ b/testdata/diff/online/alter_materialized_view_index/old.sql @@ -0,0 +1,18 @@ +CREATE TABLE public.users ( + id integer NOT NULL, + email text, + username text, + created_at timestamp with time zone, + status text +); + +CREATE MATERIALIZED VIEW public.user_summary AS +SELECT + id, + email, + username, + created_at, + status +FROM users; + +CREATE INDEX idx_user_summary_email ON public.user_summary (email); diff --git a/testdata/diff/online/alter_materialized_view_index/plan.json b/testdata/diff/online/alter_materialized_view_index/plan.json new file mode 100644 index 00000000..e3fb6dd9 --- /dev/null +++ b/testdata/diff/online/alter_materialized_view_index/plan.json @@ -0,0 +1,50 @@ +{ + "version": "1.0.0", + "pgschema_version": "1.2.1", + "created_at": "1970-01-01T00:00:00Z", + "source_fingerprint": { + "hash": "bc26d319de76e0013b5cf852e51081495a21e0eb790e4e337d5f173bf8527f52" + }, + "groups": [ + { + "steps": [ + { + "sql": "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_user_summary_email_pgschema_new ON user_summary (email, status);", + "type": "view.index", + "operation": "alter", + "path": "public.user_summary.idx_user_summary_email" + } + ] + }, + { + "steps": [ + { + "sql": "SELECT \n COALESCE(i.indisvalid, false) as done,\n CASE \n WHEN p.blocks_total > 0 THEN p.blocks_done * 100 / p.blocks_total\n ELSE 0\n END as progress\nFROM pg_class c\nLEFT JOIN pg_index i ON c.oid = i.indexrelid\nLEFT JOIN pg_stat_progress_create_index p ON c.oid = p.index_relid\nWHERE c.relname = 'idx_user_summary_email_pgschema_new';", + "directive": { + "type": "wait", + "message": "Creating index idx_user_summary_email_pgschema_new" + }, + "type": "view.index", + "operation": "alter", + "path": "public.user_summary.idx_user_summary_email" + } + ] + }, + { + "steps": [ + { + "sql": "DROP INDEX idx_user_summary_email;", + "type": "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", + "operation": "alter", + "path": "public.user_summary.idx_user_summary_email" + } + ] + } + ] +} diff --git a/testdata/diff/online/alter_materialized_view_index/plan.sql b/testdata/diff/online/alter_materialized_view_index/plan.sql new file mode 100644 index 00000000..178af26f --- /dev/null +++ b/testdata/diff/online/alter_materialized_view_index/plan.sql @@ -0,0 +1,17 @@ +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_user_summary_email_pgschema_new ON user_summary (email, status); + +-- pgschema:wait +SELECT + COALESCE(i.indisvalid, false) as done, + CASE + WHEN p.blocks_total > 0 THEN p.blocks_done * 100 / p.blocks_total + ELSE 0 + END as progress +FROM pg_class c +LEFT JOIN pg_index i ON c.oid = i.indexrelid +LEFT JOIN pg_stat_progress_create_index p ON c.oid = p.index_relid +WHERE c.relname = 'idx_user_summary_email_pgschema_new'; + +DROP INDEX idx_user_summary_email; + +ALTER INDEX idx_user_summary_email_pgschema_new RENAME TO 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 new file mode 100644 index 00000000..e1ef8eee --- /dev/null +++ b/testdata/diff/online/alter_materialized_view_index/plan.txt @@ -0,0 +1,32 @@ +Plan: 1 to modify. + +Summary by type: + views: 1 to modify + +Views: + ~ user_summary + ~ idx_user_summary_email (index - concurrent rebuild) + +DDL to be executed: +-------------------------------------------------- + +-- Transaction Group #1 +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_user_summary_email_pgschema_new ON user_summary (email, status); + +-- Transaction Group #2 +-- pgschema:wait +SELECT + COALESCE(i.indisvalid, false) as done, + CASE + WHEN p.blocks_total > 0 THEN p.blocks_done * 100 / p.blocks_total + ELSE 0 + END as progress +FROM pg_class c +LEFT JOIN pg_index i ON c.oid = i.indexrelid +LEFT JOIN pg_stat_progress_create_index p ON c.oid = p.index_relid +WHERE c.relname = 'idx_user_summary_email_pgschema_new'; + +-- Transaction Group #3 +DROP INDEX idx_user_summary_email; + +ALTER INDEX idx_user_summary_email_pgschema_new RENAME TO idx_user_summary_email; diff --git a/testdata/dump/sakila/pgschema.sql b/testdata/dump/sakila/pgschema.sql index 3416abda..5f4cf7f2 100644 --- a/testdata/dump/sakila/pgschema.sql +++ b/testdata/dump/sakila/pgschema.sql @@ -3,7 +3,7 @@ -- -- Dumped from database version PostgreSQL 17.5 --- Dumped by pgschema version 1.2.0 +-- Dumped by pgschema version 1.2.1 -- @@ -1050,6 +1050,12 @@ CREATE MATERIALIZED VIEW IF NOT EXISTS rental_by_category AS GROUP BY c.name ORDER BY total_sales DESC; +-- +-- Name: rental_category; Type: INDEX; Schema: -; Owner: - +-- + +CREATE UNIQUE INDEX IF NOT EXISTS rental_category ON rental_by_category (category); + -- -- Name: sales_by_film_category; Type: VIEW; Schema: -; Owner: - --