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.
600 lines
16 KiB
Go
600 lines
16 KiB
Go
package diff
|
|
|
|
import (
|
|
"reflect"
|
|
"testing"
|
|
|
|
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
|
)
|
|
|
|
func TestCompareDatabases(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
source *models.Database
|
|
target *models.Database
|
|
want func(*DiffResult) bool
|
|
}{
|
|
{
|
|
name: "identical databases",
|
|
source: &models.Database{
|
|
Name: "source",
|
|
Schemas: []*models.Schema{},
|
|
},
|
|
target: &models.Database{
|
|
Name: "target",
|
|
Schemas: []*models.Schema{},
|
|
},
|
|
want: func(r *DiffResult) bool {
|
|
return r.Source == "source" && r.Target == "target" &&
|
|
len(r.Schemas.Missing) == 0 && len(r.Schemas.Extra) == 0
|
|
},
|
|
},
|
|
{
|
|
name: "different schemas",
|
|
source: &models.Database{
|
|
Name: "source",
|
|
Schemas: []*models.Schema{
|
|
{Name: "public"},
|
|
},
|
|
},
|
|
target: &models.Database{
|
|
Name: "target",
|
|
Schemas: []*models.Schema{},
|
|
},
|
|
want: func(r *DiffResult) bool {
|
|
return len(r.Schemas.Missing) == 1 && r.Schemas.Missing[0].Name == "public"
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := CompareDatabases(tt.source, tt.target)
|
|
if !tt.want(got) {
|
|
t.Errorf("CompareDatabases() result doesn't match expectations")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCompareColumns(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
source map[string]*models.Column
|
|
target map[string]*models.Column
|
|
want func(*ColumnDiff) bool
|
|
}{
|
|
{
|
|
name: "identical columns",
|
|
source: map[string]*models.Column{},
|
|
target: map[string]*models.Column{},
|
|
want: func(d *ColumnDiff) bool {
|
|
return len(d.Missing) == 0 && len(d.Extra) == 0 && len(d.Modified) == 0
|
|
},
|
|
},
|
|
{
|
|
name: "missing column",
|
|
source: map[string]*models.Column{
|
|
"id": {Name: "id", Type: "integer"},
|
|
},
|
|
target: map[string]*models.Column{},
|
|
want: func(d *ColumnDiff) bool {
|
|
return len(d.Missing) == 1 && d.Missing[0].Name == "id"
|
|
},
|
|
},
|
|
{
|
|
name: "extra column",
|
|
source: map[string]*models.Column{},
|
|
target: map[string]*models.Column{
|
|
"id": {Name: "id", Type: "integer"},
|
|
},
|
|
want: func(d *ColumnDiff) bool {
|
|
return len(d.Extra) == 1 && d.Extra[0].Name == "id"
|
|
},
|
|
},
|
|
{
|
|
name: "modified column type",
|
|
source: map[string]*models.Column{
|
|
"id": {Name: "id", Type: "integer"},
|
|
},
|
|
target: map[string]*models.Column{
|
|
"id": {Name: "id", Type: "bigint"},
|
|
},
|
|
want: func(d *ColumnDiff) bool {
|
|
return len(d.Modified) == 1 && d.Modified[0].Name == "id" &&
|
|
d.Modified[0].Changes["type"] != nil
|
|
},
|
|
},
|
|
{
|
|
name: "modified column nullable",
|
|
source: map[string]*models.Column{
|
|
"name": {Name: "name", Type: "text", NotNull: true},
|
|
},
|
|
target: map[string]*models.Column{
|
|
"name": {Name: "name", Type: "text", NotNull: false},
|
|
},
|
|
want: func(d *ColumnDiff) bool {
|
|
return len(d.Modified) == 1 && d.Modified[0].Changes["not_null"] != nil
|
|
},
|
|
},
|
|
{
|
|
name: "modified column length",
|
|
source: map[string]*models.Column{
|
|
"name": {Name: "name", Type: "varchar", Length: 100},
|
|
},
|
|
target: map[string]*models.Column{
|
|
"name": {Name: "name", Type: "varchar", Length: 255},
|
|
},
|
|
want: func(d *ColumnDiff) bool {
|
|
return len(d.Modified) == 1 && d.Modified[0].Changes["length"] != nil
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := compareColumns(tt.source, tt.target)
|
|
if !tt.want(got) {
|
|
t.Errorf("compareColumns() result doesn't match expectations")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestCompareColumns_Deterministic verifies that Missing/Extra entries are
|
|
// always reported in the same (alphabetical) order across repeated calls,
|
|
// instead of following Go's randomized map iteration order over the
|
|
// source/target column maps.
|
|
func TestCompareColumns_Deterministic(t *testing.T) {
|
|
source := map[string]*models.Column{
|
|
"zeta": {Name: "zeta", Type: "text"},
|
|
"alpha": {Name: "alpha", Type: "text"},
|
|
"mu": {Name: "mu", Type: "text"},
|
|
}
|
|
target := map[string]*models.Column{
|
|
"omega": {Name: "omega", Type: "text"},
|
|
"delta": {Name: "delta", Type: "text"},
|
|
"charlie": {Name: "charlie", Type: "text"},
|
|
}
|
|
|
|
wantMissing := []string{"alpha", "mu", "zeta"}
|
|
wantExtra := []string{"charlie", "delta", "omega"}
|
|
|
|
for i := 0; i < 25; i++ {
|
|
got := compareColumns(source, target)
|
|
|
|
gotMissing := make([]string, len(got.Missing))
|
|
for j, c := range got.Missing {
|
|
gotMissing[j] = c.Name
|
|
}
|
|
gotExtra := make([]string, len(got.Extra))
|
|
for j, c := range got.Extra {
|
|
gotExtra[j] = c.Name
|
|
}
|
|
|
|
if !reflect.DeepEqual(gotMissing, wantMissing) {
|
|
t.Fatalf("compareColumns() Missing = %v, want %v (run %d)", gotMissing, wantMissing, i)
|
|
}
|
|
if !reflect.DeepEqual(gotExtra, wantExtra) {
|
|
t.Fatalf("compareColumns() Extra = %v, want %v (run %d)", gotExtra, wantExtra, i)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCompareColumnDetails(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
source *models.Column
|
|
target *models.Column
|
|
want int // number of changes
|
|
}{
|
|
{
|
|
name: "identical columns",
|
|
source: &models.Column{Name: "id", Type: "integer"},
|
|
target: &models.Column{Name: "id", Type: "integer"},
|
|
want: 0,
|
|
},
|
|
{
|
|
name: "type change",
|
|
source: &models.Column{Name: "id", Type: "integer"},
|
|
target: &models.Column{Name: "id", Type: "bigint"},
|
|
want: 1,
|
|
},
|
|
{
|
|
name: "length change",
|
|
source: &models.Column{Name: "name", Type: "varchar", Length: 100},
|
|
target: &models.Column{Name: "name", Type: "varchar", Length: 255},
|
|
want: 1,
|
|
},
|
|
{
|
|
name: "precision change",
|
|
source: &models.Column{Name: "price", Type: "numeric", Precision: 10},
|
|
target: &models.Column{Name: "price", Type: "numeric", Precision: 12},
|
|
want: 1,
|
|
},
|
|
{
|
|
name: "scale change",
|
|
source: &models.Column{Name: "price", Type: "numeric", Scale: 2},
|
|
target: &models.Column{Name: "price", Type: "numeric", Scale: 4},
|
|
want: 1,
|
|
},
|
|
{
|
|
name: "not null change",
|
|
source: &models.Column{Name: "name", Type: "text", NotNull: true},
|
|
target: &models.Column{Name: "name", Type: "text", NotNull: false},
|
|
want: 1,
|
|
},
|
|
{
|
|
name: "auto increment change",
|
|
source: &models.Column{Name: "id", Type: "integer", AutoIncrement: true},
|
|
target: &models.Column{Name: "id", Type: "integer", AutoIncrement: false},
|
|
want: 1,
|
|
},
|
|
{
|
|
name: "primary key change",
|
|
source: &models.Column{Name: "id", Type: "integer", IsPrimaryKey: true},
|
|
target: &models.Column{Name: "id", Type: "integer", IsPrimaryKey: false},
|
|
want: 1,
|
|
},
|
|
{
|
|
name: "multiple changes",
|
|
source: &models.Column{Name: "id", Type: "integer", NotNull: true, AutoIncrement: true},
|
|
target: &models.Column{Name: "id", Type: "bigint", NotNull: false, AutoIncrement: false},
|
|
want: 3,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := compareColumnDetails(tt.source, tt.target)
|
|
if len(got) != tt.want {
|
|
t.Errorf("compareColumnDetails() = %d changes, want %d", len(got), tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCompareIndexes(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
source map[string]*models.Index
|
|
target map[string]*models.Index
|
|
want func(*IndexDiff) bool
|
|
}{
|
|
{
|
|
name: "identical indexes",
|
|
source: map[string]*models.Index{},
|
|
target: map[string]*models.Index{},
|
|
want: func(d *IndexDiff) bool {
|
|
return len(d.Missing) == 0 && len(d.Extra) == 0 && len(d.Modified) == 0
|
|
},
|
|
},
|
|
{
|
|
name: "missing index",
|
|
source: map[string]*models.Index{
|
|
"idx_name": {Name: "idx_name", Columns: []string{"name"}},
|
|
},
|
|
target: map[string]*models.Index{},
|
|
want: func(d *IndexDiff) bool {
|
|
return len(d.Missing) == 1 && d.Missing[0].Name == "idx_name"
|
|
},
|
|
},
|
|
{
|
|
name: "extra index",
|
|
source: map[string]*models.Index{},
|
|
target: map[string]*models.Index{
|
|
"idx_name": {Name: "idx_name", Columns: []string{"name"}},
|
|
},
|
|
want: func(d *IndexDiff) bool {
|
|
return len(d.Extra) == 1 && d.Extra[0].Name == "idx_name"
|
|
},
|
|
},
|
|
{
|
|
name: "modified index uniqueness",
|
|
source: map[string]*models.Index{
|
|
"idx_name": {Name: "idx_name", Columns: []string{"name"}, Unique: false},
|
|
},
|
|
target: map[string]*models.Index{
|
|
"idx_name": {Name: "idx_name", Columns: []string{"name"}, Unique: true},
|
|
},
|
|
want: func(d *IndexDiff) bool {
|
|
return len(d.Modified) == 1 && d.Modified[0].Name == "idx_name"
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := compareIndexes(tt.source, tt.target)
|
|
if !tt.want(got) {
|
|
t.Errorf("compareIndexes() result doesn't match expectations")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCompareConstraints(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
source map[string]*models.Constraint
|
|
target map[string]*models.Constraint
|
|
want func(*ConstraintDiff) bool
|
|
}{
|
|
{
|
|
name: "identical constraints",
|
|
source: map[string]*models.Constraint{},
|
|
target: map[string]*models.Constraint{},
|
|
want: func(d *ConstraintDiff) bool {
|
|
return len(d.Missing) == 0 && len(d.Extra) == 0 && len(d.Modified) == 0
|
|
},
|
|
},
|
|
{
|
|
name: "missing constraint",
|
|
source: map[string]*models.Constraint{
|
|
"pk_id": {Name: "pk_id", Type: "PRIMARY KEY", Columns: []string{"id"}},
|
|
},
|
|
target: map[string]*models.Constraint{},
|
|
want: func(d *ConstraintDiff) bool {
|
|
return len(d.Missing) == 1 && d.Missing[0].Name == "pk_id"
|
|
},
|
|
},
|
|
{
|
|
name: "extra constraint",
|
|
source: map[string]*models.Constraint{},
|
|
target: map[string]*models.Constraint{
|
|
"pk_id": {Name: "pk_id", Type: "PRIMARY KEY", Columns: []string{"id"}},
|
|
},
|
|
want: func(d *ConstraintDiff) bool {
|
|
return len(d.Extra) == 1 && d.Extra[0].Name == "pk_id"
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := compareConstraints(tt.source, tt.target)
|
|
if !tt.want(got) {
|
|
t.Errorf("compareConstraints() result doesn't match expectations")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCompareRelationships(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
source map[string]*models.Relationship
|
|
target map[string]*models.Relationship
|
|
want func(*RelationshipDiff) bool
|
|
}{
|
|
{
|
|
name: "identical relationships",
|
|
source: map[string]*models.Relationship{},
|
|
target: map[string]*models.Relationship{},
|
|
want: func(d *RelationshipDiff) bool {
|
|
return len(d.Missing) == 0 && len(d.Extra) == 0 && len(d.Modified) == 0
|
|
},
|
|
},
|
|
{
|
|
name: "missing relationship",
|
|
source: map[string]*models.Relationship{
|
|
"fk_user": {Name: "fk_user", Type: "FOREIGN KEY"},
|
|
},
|
|
target: map[string]*models.Relationship{},
|
|
want: func(d *RelationshipDiff) bool {
|
|
return len(d.Missing) == 1 && d.Missing[0].Name == "fk_user"
|
|
},
|
|
},
|
|
{
|
|
name: "extra relationship",
|
|
source: map[string]*models.Relationship{},
|
|
target: map[string]*models.Relationship{
|
|
"fk_user": {Name: "fk_user", Type: "FOREIGN KEY"},
|
|
},
|
|
want: func(d *RelationshipDiff) bool {
|
|
return len(d.Extra) == 1 && d.Extra[0].Name == "fk_user"
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := compareRelationships(tt.source, tt.target)
|
|
if !tt.want(got) {
|
|
t.Errorf("compareRelationships() result doesn't match expectations")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCompareTables(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
source []*models.Table
|
|
target []*models.Table
|
|
want func(*TableDiff) bool
|
|
}{
|
|
{
|
|
name: "identical tables",
|
|
source: []*models.Table{},
|
|
target: []*models.Table{},
|
|
want: func(d *TableDiff) bool {
|
|
return len(d.Missing) == 0 && len(d.Extra) == 0 && len(d.Modified) == 0
|
|
},
|
|
},
|
|
{
|
|
name: "missing table",
|
|
source: []*models.Table{
|
|
{Name: "users", Schema: "public"},
|
|
},
|
|
target: []*models.Table{},
|
|
want: func(d *TableDiff) bool {
|
|
return len(d.Missing) == 1 && d.Missing[0].Name == "users"
|
|
},
|
|
},
|
|
{
|
|
name: "extra table",
|
|
source: []*models.Table{},
|
|
target: []*models.Table{
|
|
{Name: "users", Schema: "public"},
|
|
},
|
|
want: func(d *TableDiff) bool {
|
|
return len(d.Extra) == 1 && d.Extra[0].Name == "users"
|
|
},
|
|
},
|
|
{
|
|
name: "modified table",
|
|
source: []*models.Table{
|
|
{
|
|
Name: "users",
|
|
Schema: "public",
|
|
Columns: map[string]*models.Column{
|
|
"id": {Name: "id", Type: "integer"},
|
|
},
|
|
},
|
|
},
|
|
target: []*models.Table{
|
|
{
|
|
Name: "users",
|
|
Schema: "public",
|
|
Columns: map[string]*models.Column{
|
|
"id": {Name: "id", Type: "bigint"},
|
|
},
|
|
},
|
|
},
|
|
want: func(d *TableDiff) bool {
|
|
return len(d.Modified) == 1 && d.Modified[0].Name == "users"
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := compareTables(tt.source, tt.target)
|
|
if !tt.want(got) {
|
|
t.Errorf("compareTables() result doesn't match expectations")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCompareSchemas(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
source []*models.Schema
|
|
target []*models.Schema
|
|
want func(*SchemaDiff) bool
|
|
}{
|
|
{
|
|
name: "identical schemas",
|
|
source: []*models.Schema{},
|
|
target: []*models.Schema{},
|
|
want: func(d *SchemaDiff) bool {
|
|
return len(d.Missing) == 0 && len(d.Extra) == 0 && len(d.Modified) == 0
|
|
},
|
|
},
|
|
{
|
|
name: "missing schema",
|
|
source: []*models.Schema{
|
|
{Name: "public"},
|
|
},
|
|
target: []*models.Schema{},
|
|
want: func(d *SchemaDiff) bool {
|
|
return len(d.Missing) == 1 && d.Missing[0].Name == "public"
|
|
},
|
|
},
|
|
{
|
|
name: "extra schema",
|
|
source: []*models.Schema{},
|
|
target: []*models.Schema{
|
|
{Name: "public"},
|
|
},
|
|
want: func(d *SchemaDiff) bool {
|
|
return len(d.Extra) == 1 && d.Extra[0].Name == "public"
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := compareSchemas(tt.source, tt.target)
|
|
if !tt.want(got) {
|
|
t.Errorf("compareSchemas() result doesn't match expectations")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIsEmpty(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
v interface{}
|
|
want bool
|
|
}{
|
|
{"empty ColumnDiff", &ColumnDiff{Missing: []*models.Column{}, Extra: []*models.Column{}, Modified: []*ColumnChange{}}, true},
|
|
{"ColumnDiff with missing", &ColumnDiff{Missing: []*models.Column{{Name: "id"}}, Extra: []*models.Column{}, Modified: []*ColumnChange{}}, false},
|
|
{"ColumnDiff with extra", &ColumnDiff{Missing: []*models.Column{}, Extra: []*models.Column{{Name: "id"}}, Modified: []*ColumnChange{}}, false},
|
|
{"empty IndexDiff", &IndexDiff{Missing: []*models.Index{}, Extra: []*models.Index{}, Modified: []*IndexChange{}}, true},
|
|
{"IndexDiff with missing", &IndexDiff{Missing: []*models.Index{{Name: "idx"}}, Extra: []*models.Index{}, Modified: []*IndexChange{}}, false},
|
|
{"empty TableDiff", &TableDiff{Missing: []*models.Table{}, Extra: []*models.Table{}, Modified: []*TableChange{}}, true},
|
|
{"TableDiff with extra", &TableDiff{Missing: []*models.Table{}, Extra: []*models.Table{{Name: "users"}}, Modified: []*TableChange{}}, false},
|
|
{"empty ConstraintDiff", &ConstraintDiff{Missing: []*models.Constraint{}, Extra: []*models.Constraint{}, Modified: []*ConstraintChange{}}, true},
|
|
{"empty RelationshipDiff", &RelationshipDiff{Missing: []*models.Relationship{}, Extra: []*models.Relationship{}, Modified: []*RelationshipChange{}}, true},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := isEmpty(tt.v)
|
|
if got != tt.want {
|
|
t.Errorf("isEmpty() = %v, want %v", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestComputeSummary(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
result *DiffResult
|
|
want func(*Summary) bool
|
|
}{
|
|
{
|
|
name: "empty diff",
|
|
result: &DiffResult{
|
|
Schemas: &SchemaDiff{
|
|
Missing: []*models.Schema{},
|
|
Extra: []*models.Schema{},
|
|
Modified: []*SchemaChange{},
|
|
},
|
|
},
|
|
want: func(s *Summary) bool {
|
|
return s.Schemas.Missing == 0 && s.Schemas.Extra == 0 && s.Schemas.Modified == 0
|
|
},
|
|
},
|
|
{
|
|
name: "schemas with differences",
|
|
result: &DiffResult{
|
|
Schemas: &SchemaDiff{
|
|
Missing: []*models.Schema{{Name: "schema1"}},
|
|
Extra: []*models.Schema{{Name: "schema2"}, {Name: "schema3"}},
|
|
Modified: []*SchemaChange{
|
|
{Name: "public"},
|
|
},
|
|
},
|
|
},
|
|
want: func(s *Summary) bool {
|
|
return s.Schemas.Missing == 1 && s.Schemas.Extra == 2 && s.Schemas.Modified == 1
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := ComputeSummary(tt.result)
|
|
if !tt.want(got) {
|
|
t.Errorf("ComputeSummary() result doesn't match expectations")
|
|
}
|
|
})
|
|
}
|
|
}
|