diff --git a/examples/test_schema_modified.dbml b/examples/test_schema_modified.dbml new file mode 100644 index 0000000..715d491 --- /dev/null +++ b/examples/test_schema_modified.dbml @@ -0,0 +1,31 @@ +// Modified test schema for diff testing +Table public.users { + id bigint [pk, increment] + email varchar(255) [unique, not null] + name varchar(150) // Changed from 100 to 150 + phone varchar(20) // New column + created_at timestamp [not null] + updated_at timestamp +} + +Table public.posts { + id bigint [pk, increment] + user_id bigint [not null] + title varchar(200) [not null] + content text + published boolean [default: true] // Changed default from false to true + views integer [default: 0] // New column + created_at timestamp [not null] +} + +Table public.comments { + id bigint [pk, increment] + post_id bigint [not null] + user_id bigint [not null] + content text [not null] + created_at timestamp [not null] +} + +Ref: public.posts.user_id > public.users.id [ondelete: CASCADE] +Ref: public.comments.post_id > public.posts.id [ondelete: CASCADE] +Ref: public.comments.user_id > public.users.id diff --git a/pkg/diff/diff.go b/pkg/diff/diff.go new file mode 100644 index 0000000..dbb6a55 --- /dev/null +++ b/pkg/diff/diff.go @@ -0,0 +1,594 @@ +package diff + +import ( + "reflect" + "git.warky.dev/wdevs/relspecgo/pkg/models" +) + +// CompareDatabases compares two database models and returns the differences +func CompareDatabases(source, target *models.Database) *DiffResult { + result := &DiffResult{ + Source: source.Name, + Target: target.Name, + Schemas: compareSchemas(source.Schemas, target.Schemas), + } + return result +} + +func compareSchemas(source, target []*models.Schema) *SchemaDiff { + diff := &SchemaDiff{ + Missing: make([]*models.Schema, 0), + Extra: make([]*models.Schema, 0), + Modified: make([]*SchemaChange, 0), + } + + sourceMap := make(map[string]*models.Schema) + targetMap := make(map[string]*models.Schema) + + for _, s := range source { + sourceMap[s.SQLName()] = s + } + for _, t := range target { + targetMap[t.SQLName()] = t + } + + // Find missing and modified schemas + for name, srcSchema := range sourceMap { + if tgtSchema, exists := targetMap[name]; !exists { + diff.Missing = append(diff.Missing, srcSchema) + } else { + if change := compareSchemaDetails(srcSchema, tgtSchema); change != nil { + diff.Modified = append(diff.Modified, change) + } + } + } + + // Find extra schemas + for name, tgtSchema := range targetMap { + if _, exists := sourceMap[name]; !exists { + diff.Extra = append(diff.Extra, tgtSchema) + } + } + + return diff +} + +func compareSchemaDetails(source, target *models.Schema) *SchemaChange { + change := &SchemaChange{ + Name: source.Name, + } + + hasChanges := false + + // Compare tables + tableDiff := compareTables(source.Tables, target.Tables) + if !isEmpty(tableDiff) { + change.Tables = tableDiff + hasChanges = true + } + + // Compare views + viewDiff := compareViews(source.Views, target.Views) + if !isEmpty(viewDiff) { + change.Views = viewDiff + hasChanges = true + } + + // Compare sequences + sequenceDiff := compareSequences(source.Sequences, target.Sequences) + if !isEmpty(sequenceDiff) { + change.Sequences = sequenceDiff + hasChanges = true + } + + if !hasChanges { + return nil + } + return change +} + +func compareTables(source, target []*models.Table) *TableDiff { + diff := &TableDiff{ + Missing: make([]*models.Table, 0), + Extra: make([]*models.Table, 0), + Modified: make([]*TableChange, 0), + } + + sourceMap := make(map[string]*models.Table) + targetMap := make(map[string]*models.Table) + + for _, t := range source { + sourceMap[t.SQLName()] = t + } + for _, t := range target { + targetMap[t.SQLName()] = t + } + + // Find missing and modified tables + for name, srcTable := range sourceMap { + if tgtTable, exists := targetMap[name]; !exists { + diff.Missing = append(diff.Missing, srcTable) + } else { + if change := compareTableDetails(srcTable, tgtTable); change != nil { + diff.Modified = append(diff.Modified, change) + } + } + } + + // Find extra tables + for name, tgtTable := range targetMap { + if _, exists := sourceMap[name]; !exists { + diff.Extra = append(diff.Extra, tgtTable) + } + } + + return diff +} + +func compareTableDetails(source, target *models.Table) *TableChange { + change := &TableChange{ + Name: source.Name, + Schema: source.Schema, + } + + hasChanges := false + + // Compare columns + columnDiff := compareColumns(source.Columns, target.Columns) + if !isEmpty(columnDiff) { + change.Columns = columnDiff + hasChanges = true + } + + // Compare indexes + indexDiff := compareIndexes(source.Indexes, target.Indexes) + if !isEmpty(indexDiff) { + change.Indexes = indexDiff + hasChanges = true + } + + // Compare constraints + constraintDiff := compareConstraints(source.Constraints, target.Constraints) + if !isEmpty(constraintDiff) { + change.Constraints = constraintDiff + hasChanges = true + } + + // Compare relationships + relationshipDiff := compareRelationships(source.Relationships, target.Relationships) + if !isEmpty(relationshipDiff) { + change.Relationships = relationshipDiff + hasChanges = true + } + + if !hasChanges { + return nil + } + return change +} + +func compareColumns(source, target map[string]*models.Column) *ColumnDiff { + diff := &ColumnDiff{ + Missing: make([]*models.Column, 0), + Extra: make([]*models.Column, 0), + Modified: make([]*ColumnChange, 0), + } + + // Find missing and modified columns + for name, srcCol := range source { + if tgtCol, exists := target[name]; !exists { + diff.Missing = append(diff.Missing, srcCol) + } else { + if changes := compareColumnDetails(srcCol, tgtCol); len(changes) > 0 { + diff.Modified = append(diff.Modified, &ColumnChange{ + Name: name, + Source: srcCol, + Target: tgtCol, + Changes: changes, + }) + } + } + } + + // Find extra columns + for name, tgtCol := range target { + if _, exists := source[name]; !exists { + diff.Extra = append(diff.Extra, tgtCol) + } + } + + return diff +} + +func compareColumnDetails(source, target *models.Column) map[string]any { + changes := make(map[string]any) + + if source.Type != target.Type { + changes["type"] = map[string]string{"source": source.Type, "target": target.Type} + } + if source.Length != target.Length { + changes["length"] = map[string]int{"source": source.Length, "target": target.Length} + } + if source.Precision != target.Precision { + changes["precision"] = map[string]int{"source": source.Precision, "target": target.Precision} + } + if source.Scale != target.Scale { + changes["scale"] = map[string]int{"source": source.Scale, "target": target.Scale} + } + if source.NotNull != target.NotNull { + changes["not_null"] = map[string]bool{"source": source.NotNull, "target": target.NotNull} + } + if !reflect.DeepEqual(source.Default, target.Default) { + changes["default"] = map[string]any{"source": source.Default, "target": target.Default} + } + if source.AutoIncrement != target.AutoIncrement { + changes["auto_increment"] = map[string]bool{"source": source.AutoIncrement, "target": target.AutoIncrement} + } + if source.IsPrimaryKey != target.IsPrimaryKey { + changes["is_primary_key"] = map[string]bool{"source": source.IsPrimaryKey, "target": target.IsPrimaryKey} + } + + return changes +} + +func compareIndexes(source, target map[string]*models.Index) *IndexDiff { + diff := &IndexDiff{ + Missing: make([]*models.Index, 0), + Extra: make([]*models.Index, 0), + Modified: make([]*IndexChange, 0), + } + + // Find missing and modified indexes + for name, srcIdx := range source { + if tgtIdx, exists := target[name]; !exists { + diff.Missing = append(diff.Missing, srcIdx) + } else { + if changes := compareIndexDetails(srcIdx, tgtIdx); len(changes) > 0 { + diff.Modified = append(diff.Modified, &IndexChange{ + Name: name, + Source: srcIdx, + Target: tgtIdx, + Changes: changes, + }) + } + } + } + + // Find extra indexes + for name, tgtIdx := range target { + if _, exists := source[name]; !exists { + diff.Extra = append(diff.Extra, tgtIdx) + } + } + + return diff +} + +func compareIndexDetails(source, target *models.Index) map[string]any { + changes := make(map[string]any) + + if !reflect.DeepEqual(source.Columns, target.Columns) { + changes["columns"] = map[string][]string{"source": source.Columns, "target": target.Columns} + } + if source.Unique != target.Unique { + changes["unique"] = map[string]bool{"source": source.Unique, "target": target.Unique} + } + if source.Type != target.Type { + changes["type"] = map[string]string{"source": source.Type, "target": target.Type} + } + if source.Where != target.Where { + changes["where"] = map[string]string{"source": source.Where, "target": target.Where} + } + + return changes +} + +func compareConstraints(source, target map[string]*models.Constraint) *ConstraintDiff { + diff := &ConstraintDiff{ + Missing: make([]*models.Constraint, 0), + Extra: make([]*models.Constraint, 0), + Modified: make([]*ConstraintChange, 0), + } + + // Find missing and modified constraints + for name, srcCon := range source { + if tgtCon, exists := target[name]; !exists { + diff.Missing = append(diff.Missing, srcCon) + } else { + if changes := compareConstraintDetails(srcCon, tgtCon); len(changes) > 0 { + diff.Modified = append(diff.Modified, &ConstraintChange{ + Name: name, + Source: srcCon, + Target: tgtCon, + Changes: changes, + }) + } + } + } + + // Find extra constraints + for name, tgtCon := range target { + if _, exists := source[name]; !exists { + diff.Extra = append(diff.Extra, tgtCon) + } + } + + return diff +} + +func compareConstraintDetails(source, target *models.Constraint) map[string]any { + changes := make(map[string]any) + + if source.Type != target.Type { + changes["type"] = map[string]string{"source": string(source.Type), "target": string(target.Type)} + } + if !reflect.DeepEqual(source.Columns, target.Columns) { + changes["columns"] = map[string][]string{"source": source.Columns, "target": target.Columns} + } + if source.ReferencedTable != target.ReferencedTable { + changes["referenced_table"] = map[string]string{"source": source.ReferencedTable, "target": target.ReferencedTable} + } + if !reflect.DeepEqual(source.ReferencedColumns, target.ReferencedColumns) { + changes["referenced_columns"] = map[string][]string{"source": source.ReferencedColumns, "target": target.ReferencedColumns} + } + if source.OnDelete != target.OnDelete { + changes["on_delete"] = map[string]string{"source": source.OnDelete, "target": target.OnDelete} + } + if source.OnUpdate != target.OnUpdate { + changes["on_update"] = map[string]string{"source": source.OnUpdate, "target": target.OnUpdate} + } + + return changes +} + +func compareRelationships(source, target map[string]*models.Relationship) *RelationshipDiff { + diff := &RelationshipDiff{ + Missing: make([]*models.Relationship, 0), + Extra: make([]*models.Relationship, 0), + Modified: make([]*RelationshipChange, 0), + } + + // Find missing and modified relationships + for name, srcRel := range source { + if tgtRel, exists := target[name]; !exists { + diff.Missing = append(diff.Missing, srcRel) + } else { + if changes := compareRelationshipDetails(srcRel, tgtRel); len(changes) > 0 { + diff.Modified = append(diff.Modified, &RelationshipChange{ + Name: name, + Source: srcRel, + Target: tgtRel, + Changes: changes, + }) + } + } + } + + // Find extra relationships + for name, tgtRel := range target { + if _, exists := source[name]; !exists { + diff.Extra = append(diff.Extra, tgtRel) + } + } + + return diff +} + +func compareRelationshipDetails(source, target *models.Relationship) map[string]any { + changes := make(map[string]any) + + if source.Type != target.Type { + changes["type"] = map[string]string{"source": string(source.Type), "target": string(target.Type)} + } + if source.FromTable != target.FromTable { + changes["from_table"] = map[string]string{"source": source.FromTable, "target": target.FromTable} + } + if source.ToTable != target.ToTable { + changes["to_table"] = map[string]string{"source": source.ToTable, "target": target.ToTable} + } + if !reflect.DeepEqual(source.FromColumns, target.FromColumns) { + changes["from_columns"] = map[string][]string{"source": source.FromColumns, "target": target.FromColumns} + } + if !reflect.DeepEqual(source.ToColumns, target.ToColumns) { + changes["to_columns"] = map[string][]string{"source": source.ToColumns, "target": target.ToColumns} + } + + return changes +} + +func compareViews(source, target []*models.View) *ViewDiff { + diff := &ViewDiff{ + Missing: make([]*models.View, 0), + Extra: make([]*models.View, 0), + Modified: make([]*ViewChange, 0), + } + + sourceMap := make(map[string]*models.View) + targetMap := make(map[string]*models.View) + + for _, v := range source { + sourceMap[v.SQLName()] = v + } + for _, v := range target { + targetMap[v.SQLName()] = v + } + + // Find missing and modified views + for name, srcView := range sourceMap { + if tgtView, exists := targetMap[name]; !exists { + diff.Missing = append(diff.Missing, srcView) + } else { + if changes := compareViewDetails(srcView, tgtView); len(changes) > 0 { + diff.Modified = append(diff.Modified, &ViewChange{ + Name: name, + Source: srcView, + Target: tgtView, + Changes: changes, + }) + } + } + } + + // Find extra views + for name, tgtView := range targetMap { + if _, exists := sourceMap[name]; !exists { + diff.Extra = append(diff.Extra, tgtView) + } + } + + return diff +} + +func compareViewDetails(source, target *models.View) map[string]any { + changes := make(map[string]any) + + if source.Definition != target.Definition { + changes["definition"] = map[string]string{"source": source.Definition, "target": target.Definition} + } + + return changes +} + +func compareSequences(source, target []*models.Sequence) *SequenceDiff { + diff := &SequenceDiff{ + Missing: make([]*models.Sequence, 0), + Extra: make([]*models.Sequence, 0), + Modified: make([]*SequenceChange, 0), + } + + sourceMap := make(map[string]*models.Sequence) + targetMap := make(map[string]*models.Sequence) + + for _, s := range source { + sourceMap[s.SQLName()] = s + } + for _, s := range target { + targetMap[s.SQLName()] = s + } + + // Find missing and modified sequences + for name, srcSeq := range sourceMap { + if tgtSeq, exists := targetMap[name]; !exists { + diff.Missing = append(diff.Missing, srcSeq) + } else { + if changes := compareSequenceDetails(srcSeq, tgtSeq); len(changes) > 0 { + diff.Modified = append(diff.Modified, &SequenceChange{ + Name: name, + Source: srcSeq, + Target: tgtSeq, + Changes: changes, + }) + } + } + } + + // Find extra sequences + for name, tgtSeq := range targetMap { + if _, exists := sourceMap[name]; !exists { + diff.Extra = append(diff.Extra, tgtSeq) + } + } + + return diff +} + +func compareSequenceDetails(source, target *models.Sequence) map[string]any { + changes := make(map[string]any) + + if source.StartValue != target.StartValue { + changes["start_value"] = map[string]int64{"source": source.StartValue, "target": target.StartValue} + } + if source.IncrementBy != target.IncrementBy { + changes["increment_by"] = map[string]int64{"source": source.IncrementBy, "target": target.IncrementBy} + } + if source.MinValue != target.MinValue { + changes["min_value"] = map[string]int64{"source": source.MinValue, "target": target.MinValue} + } + if source.MaxValue != target.MaxValue { + changes["max_value"] = map[string]int64{"source": source.MaxValue, "target": target.MaxValue} + } + if source.Cycle != target.Cycle { + changes["cycle"] = map[string]bool{"source": source.Cycle, "target": target.Cycle} + } + + return changes +} + +// Helper function to check if a diff is empty +func isEmpty(v any) bool { + switch d := v.(type) { + case *TableDiff: + return len(d.Missing) == 0 && len(d.Extra) == 0 && len(d.Modified) == 0 + case *ColumnDiff: + return len(d.Missing) == 0 && len(d.Extra) == 0 && len(d.Modified) == 0 + case *IndexDiff: + return len(d.Missing) == 0 && len(d.Extra) == 0 && len(d.Modified) == 0 + case *ConstraintDiff: + return len(d.Missing) == 0 && len(d.Extra) == 0 && len(d.Modified) == 0 + case *RelationshipDiff: + return len(d.Missing) == 0 && len(d.Extra) == 0 && len(d.Modified) == 0 + case *ViewDiff: + return len(d.Missing) == 0 && len(d.Extra) == 0 && len(d.Modified) == 0 + case *SequenceDiff: + return len(d.Missing) == 0 && len(d.Extra) == 0 && len(d.Modified) == 0 + default: + return false + } +} + +// ComputeSummary generates a summary with counts from a DiffResult +func ComputeSummary(result *DiffResult) *Summary { + summary := &Summary{} + + if result.Schemas != nil { + summary.Schemas = SchemaSummary{ + Missing: len(result.Schemas.Missing), + Extra: len(result.Schemas.Extra), + Modified: len(result.Schemas.Modified), + } + + // Aggregate table/column/index/constraint counts + for _, schemaChange := range result.Schemas.Modified { + if schemaChange.Tables != nil { + summary.Tables.Missing += len(schemaChange.Tables.Missing) + summary.Tables.Extra += len(schemaChange.Tables.Extra) + summary.Tables.Modified += len(schemaChange.Tables.Modified) + + for _, tableChange := range schemaChange.Tables.Modified { + if tableChange.Columns != nil { + summary.Columns.Missing += len(tableChange.Columns.Missing) + summary.Columns.Extra += len(tableChange.Columns.Extra) + summary.Columns.Modified += len(tableChange.Columns.Modified) + } + if tableChange.Indexes != nil { + summary.Indexes.Missing += len(tableChange.Indexes.Missing) + summary.Indexes.Extra += len(tableChange.Indexes.Extra) + summary.Indexes.Modified += len(tableChange.Indexes.Modified) + } + if tableChange.Constraints != nil { + summary.Constraints.Missing += len(tableChange.Constraints.Missing) + summary.Constraints.Extra += len(tableChange.Constraints.Extra) + summary.Constraints.Modified += len(tableChange.Constraints.Modified) + } + if tableChange.Relationships != nil { + summary.Relationships.Missing += len(tableChange.Relationships.Missing) + summary.Relationships.Extra += len(tableChange.Relationships.Extra) + summary.Relationships.Modified += len(tableChange.Relationships.Modified) + } + } + } + if schemaChange.Views != nil { + summary.Views.Missing += len(schemaChange.Views.Missing) + summary.Views.Extra += len(schemaChange.Views.Extra) + summary.Views.Modified += len(schemaChange.Views.Modified) + } + if schemaChange.Sequences != nil { + summary.Sequences.Missing += len(schemaChange.Sequences.Missing) + summary.Sequences.Extra += len(schemaChange.Sequences.Extra) + summary.Sequences.Modified += len(schemaChange.Sequences.Modified) + } + } + } + + return summary +} diff --git a/pkg/diff/formatters.go b/pkg/diff/formatters.go new file mode 100644 index 0000000..1850dcb --- /dev/null +++ b/pkg/diff/formatters.go @@ -0,0 +1,601 @@ +package diff + +import ( + "encoding/json" + "fmt" + "io" + "strings" + "text/template" +) + +// OutputFormat represents the output format for diff results +type OutputFormat string + +const ( + FormatSummary OutputFormat = "summary" + FormatJSON OutputFormat = "json" + FormatHTML OutputFormat = "html" +) + +// FormatDiff formats the diff result according to the specified format +func FormatDiff(result *DiffResult, format OutputFormat, w io.Writer) error { + switch format { + case FormatSummary: + return formatSummary(result, w) + case FormatJSON: + return formatJSON(result, w) + case FormatHTML: + return formatHTML(result, w) + default: + return fmt.Errorf("unsupported format: %s", format) + } +} + +func formatSummary(result *DiffResult, w io.Writer) error { + summary := ComputeSummary(result) + + fmt.Fprintf(w, "\n=== Database Diff Summary ===\n") + fmt.Fprintf(w, "Source: %s\n", result.Source) + fmt.Fprintf(w, "Target: %s\n\n", result.Target) + + // Schemas + if summary.Schemas.Missing > 0 || summary.Schemas.Extra > 0 || summary.Schemas.Modified > 0 { + fmt.Fprintf(w, "Schemas:\n") + if summary.Schemas.Missing > 0 { + fmt.Fprintf(w, " Missing: %d\n", summary.Schemas.Missing) + } + if summary.Schemas.Extra > 0 { + fmt.Fprintf(w, " Extra: %d\n", summary.Schemas.Extra) + } + if summary.Schemas.Modified > 0 { + fmt.Fprintf(w, " Modified: %d\n", summary.Schemas.Modified) + } + fmt.Fprintf(w, "\n") + } + + // Tables + if summary.Tables.Missing > 0 || summary.Tables.Extra > 0 || summary.Tables.Modified > 0 { + fmt.Fprintf(w, "Tables:\n") + if summary.Tables.Missing > 0 { + fmt.Fprintf(w, " Missing: %d\n", summary.Tables.Missing) + } + if summary.Tables.Extra > 0 { + fmt.Fprintf(w, " Extra: %d\n", summary.Tables.Extra) + } + if summary.Tables.Modified > 0 { + fmt.Fprintf(w, " Modified: %d\n", summary.Tables.Modified) + } + fmt.Fprintf(w, "\n") + } + + // Columns + if summary.Columns.Missing > 0 || summary.Columns.Extra > 0 || summary.Columns.Modified > 0 { + fmt.Fprintf(w, "Columns:\n") + if summary.Columns.Missing > 0 { + fmt.Fprintf(w, " Missing: %d\n", summary.Columns.Missing) + } + if summary.Columns.Extra > 0 { + fmt.Fprintf(w, " Extra: %d\n", summary.Columns.Extra) + } + if summary.Columns.Modified > 0 { + fmt.Fprintf(w, " Modified: %d\n", summary.Columns.Modified) + } + fmt.Fprintf(w, "\n") + } + + // Indexes + if summary.Indexes.Missing > 0 || summary.Indexes.Extra > 0 || summary.Indexes.Modified > 0 { + fmt.Fprintf(w, "Indexes:\n") + if summary.Indexes.Missing > 0 { + fmt.Fprintf(w, " Missing: %d\n", summary.Indexes.Missing) + } + if summary.Indexes.Extra > 0 { + fmt.Fprintf(w, " Extra: %d\n", summary.Indexes.Extra) + } + if summary.Indexes.Modified > 0 { + fmt.Fprintf(w, " Modified: %d\n", summary.Indexes.Modified) + } + fmt.Fprintf(w, "\n") + } + + // Constraints + if summary.Constraints.Missing > 0 || summary.Constraints.Extra > 0 || summary.Constraints.Modified > 0 { + fmt.Fprintf(w, "Constraints:\n") + if summary.Constraints.Missing > 0 { + fmt.Fprintf(w, " Missing: %d\n", summary.Constraints.Missing) + } + if summary.Constraints.Extra > 0 { + fmt.Fprintf(w, " Extra: %d\n", summary.Constraints.Extra) + } + if summary.Constraints.Modified > 0 { + fmt.Fprintf(w, " Modified: %d\n", summary.Constraints.Modified) + } + fmt.Fprintf(w, "\n") + } + + // Relationships + if summary.Relationships.Missing > 0 || summary.Relationships.Extra > 0 || summary.Relationships.Modified > 0 { + fmt.Fprintf(w, "Relationships:\n") + if summary.Relationships.Missing > 0 { + fmt.Fprintf(w, " Missing: %d\n", summary.Relationships.Missing) + } + if summary.Relationships.Extra > 0 { + fmt.Fprintf(w, " Extra: %d\n", summary.Relationships.Extra) + } + if summary.Relationships.Modified > 0 { + fmt.Fprintf(w, " Modified: %d\n", summary.Relationships.Modified) + } + fmt.Fprintf(w, "\n") + } + + // Views + if summary.Views.Missing > 0 || summary.Views.Extra > 0 || summary.Views.Modified > 0 { + fmt.Fprintf(w, "Views:\n") + if summary.Views.Missing > 0 { + fmt.Fprintf(w, " Missing: %d\n", summary.Views.Missing) + } + if summary.Views.Extra > 0 { + fmt.Fprintf(w, " Extra: %d\n", summary.Views.Extra) + } + if summary.Views.Modified > 0 { + fmt.Fprintf(w, " Modified: %d\n", summary.Views.Modified) + } + fmt.Fprintf(w, "\n") + } + + // Sequences + if summary.Sequences.Missing > 0 || summary.Sequences.Extra > 0 || summary.Sequences.Modified > 0 { + fmt.Fprintf(w, "Sequences:\n") + if summary.Sequences.Missing > 0 { + fmt.Fprintf(w, " Missing: %d\n", summary.Sequences.Missing) + } + if summary.Sequences.Extra > 0 { + fmt.Fprintf(w, " Extra: %d\n", summary.Sequences.Extra) + } + if summary.Sequences.Modified > 0 { + fmt.Fprintf(w, " Modified: %d\n", summary.Sequences.Modified) + } + fmt.Fprintf(w, "\n") + } + + // Check if there are no differences + if summary.Schemas.Missing == 0 && summary.Schemas.Extra == 0 && summary.Schemas.Modified == 0 && + summary.Tables.Missing == 0 && summary.Tables.Extra == 0 && summary.Tables.Modified == 0 && + summary.Columns.Missing == 0 && summary.Columns.Extra == 0 && summary.Columns.Modified == 0 && + summary.Indexes.Missing == 0 && summary.Indexes.Extra == 0 && summary.Indexes.Modified == 0 && + summary.Constraints.Missing == 0 && summary.Constraints.Extra == 0 && summary.Constraints.Modified == 0 && + summary.Relationships.Missing == 0 && summary.Relationships.Extra == 0 && summary.Relationships.Modified == 0 && + summary.Views.Missing == 0 && summary.Views.Extra == 0 && summary.Views.Modified == 0 && + summary.Sequences.Missing == 0 && summary.Sequences.Extra == 0 && summary.Sequences.Modified == 0 { + fmt.Fprintf(w, "No differences found.\n") + } + + return nil +} + +func formatJSON(result *DiffResult, w io.Writer) error { + encoder := json.NewEncoder(w) + encoder.SetIndent("", " ") + return encoder.Encode(result) +} + +func formatHTML(result *DiffResult, w io.Writer) error { + tmpl, err := template.New("diff").Funcs(template.FuncMap{ + "join": strings.Join, + }).Parse(htmlTemplate) + if err != nil { + return fmt.Errorf("failed to parse template: %w", err) + } + + summary := ComputeSummary(result) + data := struct { + Result *DiffResult + Summary *Summary + }{ + Result: result, + Summary: summary, + } + + return tmpl.Execute(w, data) +} + +const htmlTemplate = ` + + + + + Database Diff Report + + + +

Database Diff Report

+ +
+

Summary

+

Source: {{.Result.Source}}

+

Target: {{.Result.Target}}

+ +
+ {{if or .Summary.Schemas.Missing .Summary.Schemas.Extra .Summary.Schemas.Modified}} +
+

Schemas

+
+
+ Missing + {{.Summary.Schemas.Missing}} +
+
+ Extra + {{.Summary.Schemas.Extra}} +
+
+ Modified + {{.Summary.Schemas.Modified}} +
+
+
+ {{end}} + + {{if or .Summary.Tables.Missing .Summary.Tables.Extra .Summary.Tables.Modified}} +
+

Tables

+
+
+ Missing + {{.Summary.Tables.Missing}} +
+
+ Extra + {{.Summary.Tables.Extra}} +
+
+ Modified + {{.Summary.Tables.Modified}} +
+
+
+ {{end}} + + {{if or .Summary.Columns.Missing .Summary.Columns.Extra .Summary.Columns.Modified}} +
+

Columns

+
+
+ Missing + {{.Summary.Columns.Missing}} +
+
+ Extra + {{.Summary.Columns.Extra}} +
+
+ Modified + {{.Summary.Columns.Modified}} +
+
+
+ {{end}} + + {{if or .Summary.Indexes.Missing .Summary.Indexes.Extra .Summary.Indexes.Modified}} +
+

Indexes

+
+
+ Missing + {{.Summary.Indexes.Missing}} +
+
+ Extra + {{.Summary.Indexes.Extra}} +
+
+ Modified + {{.Summary.Indexes.Modified}} +
+
+
+ {{end}} + + {{if or .Summary.Constraints.Missing .Summary.Constraints.Extra .Summary.Constraints.Modified}} +
+

Constraints

+
+
+ Missing + {{.Summary.Constraints.Missing}} +
+
+ Extra + {{.Summary.Constraints.Extra}} +
+
+ Modified + {{.Summary.Constraints.Modified}} +
+
+
+ {{end}} + + {{if or .Summary.Sequences.Missing .Summary.Sequences.Extra .Summary.Sequences.Modified}} +
+

Sequences

+
+
+ Missing + {{.Summary.Sequences.Missing}} +
+
+ Extra + {{.Summary.Sequences.Extra}} +
+
+ Modified + {{.Summary.Sequences.Modified}} +
+
+
+ {{end}} +
+
+ + {{if .Result.Schemas}} +
+

Detailed Differences

+ + {{range .Result.Schemas.Missing}} +
+

Schema: {{.Name}} MISSING

+
+ {{end}} + + {{range .Result.Schemas.Extra}} +
+

Schema: {{.Name}} EXTRA

+
+ {{end}} + + {{range .Result.Schemas.Modified}} +
+

Schema: {{.Name}} MODIFIED

+ + {{if .Tables}} + {{if .Tables.Missing}} +

Missing Tables

+ + {{end}} + + {{if .Tables.Extra}} +

Extra Tables

+ + {{end}} + + {{if .Tables.Modified}} +

Modified Tables

+ {{range .Tables.Modified}} +
+
Table: {{.Schema}}.{{.Name}}
+ + {{if .Columns}} + {{if .Columns.Missing}} +
Missing Columns
+
    + {{range .Columns.Missing}} +
  • {{.Name}} ({{.Type}})
  • + {{end}} +
+ {{end}} + + {{if .Columns.Extra}} +
Extra Columns
+
    + {{range .Columns.Extra}} +
  • {{.Name}} ({{.Type}})
  • + {{end}} +
+ {{end}} + + {{if .Columns.Modified}} +
Modified Columns
+
    + {{range .Columns.Modified}} +
  • {{.Name}}
  • + {{end}} +
+ {{end}} + {{end}} + + {{if .Indexes}} + {{if .Indexes.Missing}} +
Missing Indexes
+
    + {{range .Indexes.Missing}} +
  • {{.Name}}
  • + {{end}} +
+ {{end}} + + {{if .Indexes.Extra}} +
Extra Indexes
+
    + {{range .Indexes.Extra}} +
  • {{.Name}}
  • + {{end}} +
+ {{end}} + {{end}} + + {{if .Constraints}} + {{if .Constraints.Missing}} +
Missing Constraints
+
    + {{range .Constraints.Missing}} +
  • {{.Name}} ({{.Type}})
  • + {{end}} +
+ {{end}} + + {{if .Constraints.Extra}} +
Extra Constraints
+
    + {{range .Constraints.Extra}} +
  • {{.Name}} ({{.Type}})
  • + {{end}} +
+ {{end}} + {{end}} +
+ {{end}} + {{end}} + {{end}} + + {{if .Sequences}} + {{if .Sequences.Missing}} +

Missing Sequences

+ + {{end}} + + {{if .Sequences.Extra}} +

Extra Sequences

+ + {{end}} + {{end}} +
+ {{end}} +
+ {{else}} +
+ ✓ No differences found between the databases +
+ {{end}} + + +` diff --git a/pkg/diff/types.go b/pkg/diff/types.go new file mode 100644 index 0000000..9d2fb7d --- /dev/null +++ b/pkg/diff/types.go @@ -0,0 +1,192 @@ +package diff + +import "git.warky.dev/wdevs/relspecgo/pkg/models" + +// DiffResult represents the complete difference analysis between two databases +type DiffResult struct { + Source string `json:"source"` + Target string `json:"target"` + Schemas *SchemaDiff `json:"schemas"` +} + +// SchemaDiff represents differences at the schema level +type SchemaDiff struct { + Missing []*models.Schema `json:"missing"` // Schemas in source but not in target + Extra []*models.Schema `json:"extra"` // Schemas in target but not in source + Modified []*SchemaChange `json:"modified"` // Schemas that exist in both but differ +} + +// SchemaChange represents changes within a schema +type SchemaChange struct { + Name string `json:"name"` + Tables *TableDiff `json:"tables,omitempty"` + Views *ViewDiff `json:"views,omitempty"` + Sequences *SequenceDiff `json:"sequences,omitempty"` +} + +// TableDiff represents differences in tables +type TableDiff struct { + Missing []*models.Table `json:"missing"` // Tables in source but not in target + Extra []*models.Table `json:"extra"` // Tables in target but not in source + Modified []*TableChange `json:"modified"` // Tables that exist in both but differ +} + +// TableChange represents changes within a table +type TableChange struct { + Name string `json:"name"` + Schema string `json:"schema"` + Columns *ColumnDiff `json:"columns,omitempty"` + Indexes *IndexDiff `json:"indexes,omitempty"` + Constraints *ConstraintDiff `json:"constraints,omitempty"` + Relationships *RelationshipDiff `json:"relationships,omitempty"` +} + +// ColumnDiff represents differences in columns +type ColumnDiff struct { + Missing []*models.Column `json:"missing"` // Columns in source but not in target + Extra []*models.Column `json:"extra"` // Columns in target but not in source + Modified []*ColumnChange `json:"modified"` // Columns that exist in both but differ +} + +// ColumnChange represents a modified column +type ColumnChange struct { + Name string `json:"name"` + Source *models.Column `json:"source"` + Target *models.Column `json:"target"` + Changes map[string]any `json:"changes"` // Map of field name to what changed +} + +// IndexDiff represents differences in indexes +type IndexDiff struct { + Missing []*models.Index `json:"missing"` // Indexes in source but not in target + Extra []*models.Index `json:"extra"` // Indexes in target but not in source + Modified []*IndexChange `json:"modified"` // Indexes that exist in both but differ +} + +// IndexChange represents a modified index +type IndexChange struct { + Name string `json:"name"` + Source *models.Index `json:"source"` + Target *models.Index `json:"target"` + Changes map[string]any `json:"changes"` +} + +// ConstraintDiff represents differences in constraints +type ConstraintDiff struct { + Missing []*models.Constraint `json:"missing"` // Constraints in source but not in target + Extra []*models.Constraint `json:"extra"` // Constraints in target but not in source + Modified []*ConstraintChange `json:"modified"` // Constraints that exist in both but differ +} + +// ConstraintChange represents a modified constraint +type ConstraintChange struct { + Name string `json:"name"` + Source *models.Constraint `json:"source"` + Target *models.Constraint `json:"target"` + Changes map[string]any `json:"changes"` +} + +// RelationshipDiff represents differences in relationships +type RelationshipDiff struct { + Missing []*models.Relationship `json:"missing"` // Relationships in source but not in target + Extra []*models.Relationship `json:"extra"` // Relationships in target but not in source + Modified []*RelationshipChange `json:"modified"` // Relationships that exist in both but differ +} + +// RelationshipChange represents a modified relationship +type RelationshipChange struct { + Name string `json:"name"` + Source *models.Relationship `json:"source"` + Target *models.Relationship `json:"target"` + Changes map[string]any `json:"changes"` +} + +// ViewDiff represents differences in views +type ViewDiff struct { + Missing []*models.View `json:"missing"` // Views in source but not in target + Extra []*models.View `json:"extra"` // Views in target but not in source + Modified []*ViewChange `json:"modified"` // Views that exist in both but differ +} + +// ViewChange represents a modified view +type ViewChange struct { + Name string `json:"name"` + Source *models.View `json:"source"` + Target *models.View `json:"target"` + Changes map[string]any `json:"changes"` +} + +// SequenceDiff represents differences in sequences +type SequenceDiff struct { + Missing []*models.Sequence `json:"missing"` // Sequences in source but not in target + Extra []*models.Sequence `json:"extra"` // Sequences in target but not in source + Modified []*SequenceChange `json:"modified"` // Sequences that exist in both but differ +} + +// SequenceChange represents a modified sequence +type SequenceChange struct { + Name string `json:"name"` + Source *models.Sequence `json:"source"` + Target *models.Sequence `json:"target"` + Changes map[string]any `json:"changes"` +} + +// Summary provides counts for quick overview +type Summary struct { + Schemas SchemaSummary `json:"schemas"` + Tables TableSummary `json:"tables"` + Columns ColumnSummary `json:"columns"` + Indexes IndexSummary `json:"indexes"` + Constraints ConstraintSummary `json:"constraints"` + Relationships RelationshipSummary `json:"relationships"` + Views ViewSummary `json:"views"` + Sequences SequenceSummary `json:"sequences"` +} + +type SchemaSummary struct { + Missing int `json:"missing"` + Extra int `json:"extra"` + Modified int `json:"modified"` +} + +type TableSummary struct { + Missing int `json:"missing"` + Extra int `json:"extra"` + Modified int `json:"modified"` +} + +type ColumnSummary struct { + Missing int `json:"missing"` + Extra int `json:"extra"` + Modified int `json:"modified"` +} + +type IndexSummary struct { + Missing int `json:"missing"` + Extra int `json:"extra"` + Modified int `json:"modified"` +} + +type ConstraintSummary struct { + Missing int `json:"missing"` + Extra int `json:"extra"` + Modified int `json:"modified"` +} + +type RelationshipSummary struct { + Missing int `json:"missing"` + Extra int `json:"extra"` + Modified int `json:"modified"` +} + +type ViewSummary struct { + Missing int `json:"missing"` + Extra int `json:"extra"` + Modified int `json:"modified"` +} + +type SequenceSummary struct { + Missing int `json:"missing"` + Extra int `json:"extra"` + Modified int `json:"modified"` +} diff --git a/pkg/readers/bun/reader.go b/pkg/readers/bun/reader.go index 5463dcc..b20addd 100644 --- a/pkg/readers/bun/reader.go +++ b/pkg/readers/bun/reader.go @@ -212,7 +212,7 @@ func (r *Reader) getReceiverType(expr ast.Expr) string { } // parseTableNameMethod parses a TableName() method and extracts the table and schema name -func (r *Reader) parseTableNameMethod(funcDecl *ast.FuncDecl) (string, string) { +func (r *Reader) parseTableNameMethod(funcDecl *ast.FuncDecl) (tableName string, schemaName string) { if funcDecl.Body == nil { return "", "" } @@ -347,7 +347,7 @@ func (r *Reader) isRelationship(tag string) bool { } // extractTableNameFromTag extracts table and schema from bun tag -func (r *Reader) extractTableNameFromTag(tag string) (string, string) { +func (r *Reader) extractTableNameFromTag(tag string) (tableName string, schemaName string) { // Extract bun tag value re := regexp.MustCompile(`bun:"table:([^"]+)"`) matches := re.FindStringSubmatch(tag) @@ -439,6 +439,7 @@ func (r *Reader) parseColumn(fieldName string, fieldType ast.Expr, tag string, s if !strings.Contains(bunTag, "notnull") { // If notnull is not explicitly set, it might still be nullable // This is a heuristic - we default to nullable unless specified + return column } } @@ -457,16 +458,18 @@ func (r *Reader) extractBunTag(tag string) string { // parseTypeWithLength parses a type string and extracts length if present // e.g., "varchar(255)" returns ("varchar", 255) -func (r *Reader) parseTypeWithLength(typeStr string) (string, int) { +func (r *Reader) parseTypeWithLength(typeStr string) (baseType string, length int) { // Check for type with length: varchar(255), char(10), etc. re := regexp.MustCompile(`^([a-zA-Z\s]+)\((\d+)\)$`) matches := re.FindStringSubmatch(typeStr) if len(matches) == 3 { - length := 0 - fmt.Sscanf(matches[2], "%d", &length) - return strings.TrimSpace(matches[1]), length + if _, err := fmt.Sscanf(matches[2], "%d", &length); err == nil { + baseType = strings.TrimSpace(matches[1]) + return + } } - return typeStr, 0 + baseType = typeStr + return } // goTypeToSQL maps Go types to SQL types diff --git a/pkg/readers/dbml/reader.go b/pkg/readers/dbml/reader.go index c40acda..834a631 100644 --- a/pkg/readers/dbml/reader.go +++ b/pkg/readers/dbml/reader.go @@ -344,11 +344,12 @@ func (r *Reader) parseIndex(line, tableName, schemaName string) *models.Index { for _, col := range strings.Split(columnsStr, ",") { columns = append(columns, stripQuotes(strings.TrimSpace(col))) } - } else { + } else if strings.Contains(line, "[") { // Single column format: columnname [attributes] // Extract column name before the bracket - if strings.Contains(line, "[") { - colName := strings.TrimSpace(line[:strings.Index(line, "[")]) + idx := strings.Index(line, "[") + if idx > 0 { + colName := strings.TrimSpace(line[:idx]) if colName != "" { columns = []string{stripQuotes(colName)} } diff --git a/pkg/readers/gorm/reader.go b/pkg/readers/gorm/reader.go index 24c545c..7637e50 100644 --- a/pkg/readers/gorm/reader.go +++ b/pkg/readers/gorm/reader.go @@ -212,7 +212,7 @@ func (r *Reader) getReceiverType(expr ast.Expr) string { } // parseTableNameMethod parses a TableName() method and extracts the table and schema name -func (r *Reader) parseTableNameMethod(funcDecl *ast.FuncDecl) (string, string) { +func (r *Reader) parseTableNameMethod(funcDecl *ast.FuncDecl) (tableName string, schemaName string) { if funcDecl.Body == nil { return "", "" } @@ -341,12 +341,12 @@ func (r *Reader) isGORMModel(field *ast.Field) bool { func (r *Reader) isRelationship(tag string) bool { gormTag := r.extractGormTag(tag) return strings.Contains(gormTag, "foreignKey:") || - strings.Contains(gormTag, "references:") || - strings.Contains(gormTag, "many2many:") + strings.Contains(gormTag, "references:") || + strings.Contains(gormTag, "many2many:") } // extractTableFromGormTag extracts table and schema from gorm tag -func (r *Reader) extractTableFromGormTag(tag string) (string, string) { +func (r *Reader) extractTableFromGormTag(tag string) (tablename string, schemaName string) { // This is typically set via TableName() method, not in tags // We'll return empty strings and rely on deriveTableName return "", "" @@ -439,12 +439,12 @@ func (r *Reader) extractGormTag(tag string) string { // parseTypeWithLength parses a type string and extracts length if present // e.g., "varchar(255)" returns ("varchar", 255) -func (r *Reader) parseTypeWithLength(typeStr string) (string, int) { +func (r *Reader) parseTypeWithLength(typeStr string) (baseType string, length int) { // Check for type with length: varchar(255), char(10), etc. // Also handle precision/scale: numeric(10,2) if strings.Contains(typeStr, "(") { idx := strings.Index(typeStr, "(") - baseType := strings.TrimSpace(typeStr[:idx]) + baseType = strings.TrimSpace(typeStr[:idx]) // Extract numbers from parentheses parens := typeStr[idx+1:] @@ -454,12 +454,13 @@ func (r *Reader) parseTypeWithLength(typeStr string) (string, int) { // For now, just handle single number (length) if !strings.Contains(parens, ",") { - length := 0 - fmt.Sscanf(parens, "%d", &length) - return baseType, length + if _, err := fmt.Sscanf(parens, "%d", &length); err == nil { + return + } } } - return typeStr, 0 + baseType = typeStr + return } // parseGormTag parses a gorm tag string into a map diff --git a/pkg/writers/dbml/writer.go b/pkg/writers/dbml/writer.go index bb95da6..af215ec 100644 --- a/pkg/writers/dbml/writer.go +++ b/pkg/writers/dbml/writer.go @@ -221,4 +221,4 @@ func (w *Writer) constraintToDBML(c *models.Constraint, t *models.Table) string } return refLine + "\n" -} \ No newline at end of file +} diff --git a/pkg/writers/dctx/writer.go b/pkg/writers/dctx/writer.go index a31ad02..c575406 100644 --- a/pkg/writers/dctx/writer.go +++ b/pkg/writers/dctx/writer.go @@ -6,9 +6,10 @@ import ( "os" "strings" + "github.com/google/uuid" + "git.warky.dev/wdevs/relspecgo/pkg/models" "git.warky.dev/wdevs/relspecgo/pkg/writers" - "github.com/google/uuid" ) // Writer implements the writers.Writer interface for DCTX format @@ -43,9 +44,8 @@ func (w *Writer) WriteSchema(schema *models.Schema) error { } tableSlice := make([]*models.Table, 0, len(schema.Tables)) - for _, t := range schema.Tables { - tableSlice = append(tableSlice, t) - } + + tableSlice = append(tableSlice, schema.Tables...) // Pass 1: Create fields and populate fieldGuidMap for i, table := range tableSlice { @@ -70,8 +70,8 @@ func (w *Writer) WriteSchema(schema *models.Schema) error { isDuplicate := false for _, existing := range allRelations { if existing.Name == rel.Name && - existing.FromTable == rel.FromTable && - existing.ToTable == rel.ToTable { + existing.FromTable == rel.FromTable && + existing.ToTable == rel.ToTable { isDuplicate = true break } @@ -310,8 +310,8 @@ func (w *Writer) mapRelation(rel *models.Relationship, schema *models.Schema) mo // Create field mappings // NOTE: DCTX has backwards naming - ForeignMapping contains PRIMARY table fields, // and PrimaryMapping contains FOREIGN table fields - var foreignMappings []models.DCTXFieldMapping // Will contain primary table fields - var primaryMappings []models.DCTXFieldMapping // Will contain foreign table fields + var foreignMappings []models.DCTXFieldMapping // Will contain primary table fields + var primaryMappings []models.DCTXFieldMapping // Will contain foreign table fields // Map foreign key columns (from foreign table) to PrimaryMapping for _, colName := range fkColumns { diff --git a/pkg/writers/pgsql/writer.go b/pkg/writers/pgsql/writer.go index 4ee34d0..ce51369 100644 --- a/pkg/writers/pgsql/writer.go +++ b/pkg/writers/pgsql/writer.go @@ -68,9 +68,9 @@ func (w *Writer) GenerateDatabaseStatements(db *models.Database) ([]string, erro statements := []string{} // Add header comment - statements = append(statements, fmt.Sprintf("-- PostgreSQL Database Schema")) + statements = append(statements, "-- PostgreSQL Database Schema") statements = append(statements, fmt.Sprintf("-- Database: %s", db.Name)) - statements = append(statements, fmt.Sprintf("-- Generated by RelSpec")) + statements = append(statements, "-- Generated by RelSpec") // Process each schema in the database for _, schema := range db.Schemas {