Files
relspecgo/pkg/diff/diff.go
T
warkanum 3b88c386a1 fix(codegen): sort map iteration to make generated output deterministic
Table.Columns/Constraints/Indexes/Relationships are Go maps, and every
writer, reader, diff, inspector, and merge code path that iterated them
directly was subject to Go's randomized map order, so identical input
could produce different output (or a different in-report violation/diff
order) on every run. Most visibly this showed up as bun/gorm `unique:`
struct tags changing order across consecutive `make models` runs with no
source change.

Fixed by sorting map iteration (by Sequence then Name, or alphabetically
for string-keyed maps) everywhere the order affects generated output or
first-match tie-break logic, across the bun, gorm, sqlite, dbml, drawdb,
pgsql, prisma, graphql, typeorm, drizzle, and dctx writers; the dctx,
prisma, and typeorm readers; the shared models.GetPrimaryKey/
GetForeignKeys helpers; pkg/diff, pkg/inspector, and pkg/merge; and the
TUI column/relationship pickers in pkg/ui.
2026-08-10 20:54:40 +02:00

624 lines
18 KiB
Go

package diff
import (
"reflect"
"sort"
"git.warky.dev/wdevs/relspecgo/pkg/models"
)
// sortedKeys returns a map's keys sorted alphabetically, so callers get a
// deterministic iteration order instead of Go's randomized map order.
func sortedKeys[T any](m map[string]T) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
// 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 := range sortedKeys(sourceMap) {
srcSchema := sourceMap[name]
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 := range sortedKeys(targetMap) {
tgtSchema := targetMap[name]
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 := range sortedKeys(sourceMap) {
srcTable := sourceMap[name]
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 := range sortedKeys(targetMap) {
tgtTable := targetMap[name]
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 := range sortedKeys(source) {
srcCol := source[name]
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 := range sortedKeys(target) {
tgtCol := target[name]
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 := range sortedKeys(source) {
srcIdx := source[name]
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 := range sortedKeys(target) {
tgtIdx := target[name]
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 := range sortedKeys(source) {
srcCon := source[name]
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 := range sortedKeys(target) {
tgtCon := target[name]
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 := range sortedKeys(source) {
srcRel := source[name]
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 := range sortedKeys(target) {
tgtRel := target[name]
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 := range sortedKeys(sourceMap) {
srcView := sourceMap[name]
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 := range sortedKeys(targetMap) {
tgtView := targetMap[name]
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 := range sortedKeys(sourceMap) {
srcSeq := sourceMap[name]
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 := range sortedKeys(targetMap) {
tgtSeq := targetMap[name]
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
}