Merge conflict detection compared bigserial (DBML) against bigint (live PostgreSQL read of an existing serial column) as incompatible types, since serial is sugar over an integer column plus a sequence default and PostgreSQL always reports back the underlying integer type. Add SerialUnderlyingType and use it when comparing column types for conflicts. QuoteDefaultValue also wrapped bare keyword expressions like CURRENT_DATE in string quotes because they contain no parentheses, unlike function-call defaults such as now(). Recognize known bare keyword defaults and leave them unquoted across CREATE TABLE, ALTER TABLE ADD COLUMN, and ALTER COLUMN SET DEFAULT generation.
724 lines
16 KiB
Go
724 lines
16 KiB
Go
package merge
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
|
)
|
|
|
|
func TestMergeDatabases_NilInputs(t *testing.T) {
|
|
result := MergeDatabases(nil, nil, nil)
|
|
if result == nil {
|
|
t.Fatal("Expected non-nil result")
|
|
}
|
|
if result.SchemasAdded != 0 {
|
|
t.Errorf("Expected 0 schemas added, got %d", result.SchemasAdded)
|
|
}
|
|
}
|
|
|
|
func TestMergeDatabases_NewSchema(t *testing.T) {
|
|
target := &models.Database{
|
|
Schemas: []*models.Schema{
|
|
{Name: "public"},
|
|
},
|
|
}
|
|
source := &models.Database{
|
|
Schemas: []*models.Schema{
|
|
{Name: "auth"},
|
|
},
|
|
}
|
|
|
|
result := MergeDatabases(target, source, nil)
|
|
if result.SchemasAdded != 1 {
|
|
t.Errorf("Expected 1 schema added, got %d", result.SchemasAdded)
|
|
}
|
|
if len(target.Schemas) != 2 {
|
|
t.Errorf("Expected 2 schemas in target, got %d", len(target.Schemas))
|
|
}
|
|
}
|
|
|
|
func TestMergeDatabases_ExistingSchema(t *testing.T) {
|
|
target := &models.Database{
|
|
Schemas: []*models.Schema{
|
|
{Name: "public"},
|
|
},
|
|
}
|
|
source := &models.Database{
|
|
Schemas: []*models.Schema{
|
|
{Name: "public"},
|
|
},
|
|
}
|
|
|
|
result := MergeDatabases(target, source, nil)
|
|
if result.SchemasAdded != 0 {
|
|
t.Errorf("Expected 0 schemas added, got %d", result.SchemasAdded)
|
|
}
|
|
if len(target.Schemas) != 1 {
|
|
t.Errorf("Expected 1 schema in target, got %d", len(target.Schemas))
|
|
}
|
|
}
|
|
|
|
func TestMergeTables_NewTable(t *testing.T) {
|
|
target := &models.Database{
|
|
Schemas: []*models.Schema{
|
|
{
|
|
Name: "public",
|
|
Tables: []*models.Table{
|
|
{
|
|
Name: "users",
|
|
Schema: "public",
|
|
Columns: map[string]*models.Column{},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
source := &models.Database{
|
|
Schemas: []*models.Schema{
|
|
{
|
|
Name: "public",
|
|
Tables: []*models.Table{
|
|
{
|
|
Name: "posts",
|
|
Schema: "public",
|
|
Columns: map[string]*models.Column{},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
result := MergeDatabases(target, source, nil)
|
|
if result.TablesAdded != 1 {
|
|
t.Errorf("Expected 1 table added, got %d", result.TablesAdded)
|
|
}
|
|
if len(target.Schemas[0].Tables) != 2 {
|
|
t.Errorf("Expected 2 tables in target schema, got %d", len(target.Schemas[0].Tables))
|
|
}
|
|
}
|
|
|
|
func TestMergeColumns_NewColumn(t *testing.T) {
|
|
target := &models.Database{
|
|
Schemas: []*models.Schema{
|
|
{
|
|
Name: "public",
|
|
Tables: []*models.Table{
|
|
{
|
|
Name: "users",
|
|
Schema: "public",
|
|
Columns: map[string]*models.Column{
|
|
"id": {Name: "id", Type: "int"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
source := &models.Database{
|
|
Schemas: []*models.Schema{
|
|
{
|
|
Name: "public",
|
|
Tables: []*models.Table{
|
|
{
|
|
Name: "users",
|
|
Schema: "public",
|
|
Columns: map[string]*models.Column{
|
|
"email": {Name: "email", Type: "varchar"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
result := MergeDatabases(target, source, nil)
|
|
if result.ColumnsAdded != 1 {
|
|
t.Errorf("Expected 1 column added, got %d", result.ColumnsAdded)
|
|
}
|
|
if len(target.Schemas[0].Tables[0].Columns) != 2 {
|
|
t.Errorf("Expected 2 columns in target table, got %d", len(target.Schemas[0].Tables[0].Columns))
|
|
}
|
|
}
|
|
|
|
func TestMergeColumns_TypeConflictIsDetected(t *testing.T) {
|
|
target := &models.Database{
|
|
Schemas: []*models.Schema{
|
|
{
|
|
Name: "public",
|
|
Tables: []*models.Table{
|
|
{
|
|
Name: "users",
|
|
Schema: "public",
|
|
Columns: map[string]*models.Column{
|
|
"email": {Name: "email", Type: "varchar", Length: 255},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
source := &models.Database{
|
|
Schemas: []*models.Schema{
|
|
{
|
|
Name: "public",
|
|
Tables: []*models.Table{
|
|
{
|
|
Name: "users",
|
|
Schema: "public",
|
|
Columns: map[string]*models.Column{
|
|
"email": {Name: "email", Type: "text"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
result := MergeDatabases(target, source, nil)
|
|
|
|
if len(result.TypeConflicts) != 1 {
|
|
t.Fatalf("Expected 1 type conflict, got %d", len(result.TypeConflicts))
|
|
}
|
|
conflict := result.TypeConflicts[0]
|
|
if conflict.Schema != "public" || conflict.Table != "users" || conflict.Column != "email" {
|
|
t.Fatalf("Unexpected conflict location: %+v", conflict)
|
|
}
|
|
if conflict.TargetType != "varchar(255)" {
|
|
t.Fatalf("Expected target type varchar(255), got %q", conflict.TargetType)
|
|
}
|
|
if conflict.SourceType != "text" {
|
|
t.Fatalf("Expected source type text, got %q", conflict.SourceType)
|
|
}
|
|
|
|
if got := target.Schemas[0].Tables[0].Columns["email"].Type; got != "varchar" {
|
|
t.Fatalf("Expected target column type to remain unchanged, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestMergeColumns_SerialVsUnderlyingIntegerIsNotAConflict(t *testing.T) {
|
|
target := &models.Database{
|
|
Schemas: []*models.Schema{
|
|
{
|
|
Name: "public",
|
|
Tables: []*models.Table{
|
|
{
|
|
Name: "users",
|
|
Schema: "public",
|
|
Columns: map[string]*models.Column{
|
|
// As reported back by a live PostgreSQL read of an
|
|
// existing serial primary key column.
|
|
"id": {Name: "id", Type: "bigint"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
source := &models.Database{
|
|
Schemas: []*models.Schema{
|
|
{
|
|
Name: "public",
|
|
Tables: []*models.Table{
|
|
{
|
|
Name: "users",
|
|
Schema: "public",
|
|
Columns: map[string]*models.Column{
|
|
// As declared in a DBML source spec.
|
|
"id": {Name: "id", Type: "bigserial"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
result := MergeDatabases(target, source, nil)
|
|
|
|
if len(result.TypeConflicts) != 0 {
|
|
t.Fatalf("Expected no type conflicts for bigserial vs bigint, got %d: %+v", len(result.TypeConflicts), result.TypeConflicts)
|
|
}
|
|
}
|
|
|
|
func TestMergeConstraints_NewConstraint(t *testing.T) {
|
|
target := &models.Database{
|
|
Schemas: []*models.Schema{
|
|
{
|
|
Name: "public",
|
|
Tables: []*models.Table{
|
|
{
|
|
Name: "users",
|
|
Schema: "public",
|
|
Columns: map[string]*models.Column{},
|
|
Constraints: map[string]*models.Constraint{},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
source := &models.Database{
|
|
Schemas: []*models.Schema{
|
|
{
|
|
Name: "public",
|
|
Tables: []*models.Table{
|
|
{
|
|
Name: "users",
|
|
Schema: "public",
|
|
Columns: map[string]*models.Column{},
|
|
Constraints: map[string]*models.Constraint{
|
|
"ukey_users_email": {
|
|
Type: models.UniqueConstraint,
|
|
Columns: []string{"email"},
|
|
Name: "ukey_users_email",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
result := MergeDatabases(target, source, nil)
|
|
if result.ConstraintsAdded != 1 {
|
|
t.Errorf("Expected 1 constraint added, got %d", result.ConstraintsAdded)
|
|
}
|
|
if len(target.Schemas[0].Tables[0].Constraints) != 1 {
|
|
t.Errorf("Expected 1 constraint in target table, got %d", len(target.Schemas[0].Tables[0].Constraints))
|
|
}
|
|
}
|
|
|
|
func TestMergeConstraints_NilConstraintsMap(t *testing.T) {
|
|
target := &models.Database{
|
|
Schemas: []*models.Schema{
|
|
{
|
|
Name: "public",
|
|
Tables: []*models.Table{
|
|
{
|
|
Name: "users",
|
|
Schema: "public",
|
|
Columns: map[string]*models.Column{},
|
|
Constraints: nil, // Nil map
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
source := &models.Database{
|
|
Schemas: []*models.Schema{
|
|
{
|
|
Name: "public",
|
|
Tables: []*models.Table{
|
|
{
|
|
Name: "users",
|
|
Schema: "public",
|
|
Columns: map[string]*models.Column{},
|
|
Constraints: map[string]*models.Constraint{
|
|
"ukey_users_email": {
|
|
Type: models.UniqueConstraint,
|
|
Columns: []string{"email"},
|
|
Name: "ukey_users_email",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
result := MergeDatabases(target, source, nil)
|
|
if result.ConstraintsAdded != 1 {
|
|
t.Errorf("Expected 1 constraint added, got %d", result.ConstraintsAdded)
|
|
}
|
|
if target.Schemas[0].Tables[0].Constraints == nil {
|
|
t.Error("Expected constraints map to be initialized")
|
|
}
|
|
if len(target.Schemas[0].Tables[0].Constraints) != 1 {
|
|
t.Errorf("Expected 1 constraint in target table, got %d", len(target.Schemas[0].Tables[0].Constraints))
|
|
}
|
|
}
|
|
|
|
func TestMergeIndexes_NewIndex(t *testing.T) {
|
|
target := &models.Database{
|
|
Schemas: []*models.Schema{
|
|
{
|
|
Name: "public",
|
|
Tables: []*models.Table{
|
|
{
|
|
Name: "users",
|
|
Schema: "public",
|
|
Columns: map[string]*models.Column{},
|
|
Indexes: map[string]*models.Index{},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
source := &models.Database{
|
|
Schemas: []*models.Schema{
|
|
{
|
|
Name: "public",
|
|
Tables: []*models.Table{
|
|
{
|
|
Name: "users",
|
|
Schema: "public",
|
|
Columns: map[string]*models.Column{},
|
|
Indexes: map[string]*models.Index{
|
|
"idx_users_email": {
|
|
Name: "idx_users_email",
|
|
Columns: []string{"email"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
result := MergeDatabases(target, source, nil)
|
|
if result.IndexesAdded != 1 {
|
|
t.Errorf("Expected 1 index added, got %d", result.IndexesAdded)
|
|
}
|
|
if len(target.Schemas[0].Tables[0].Indexes) != 1 {
|
|
t.Errorf("Expected 1 index in target table, got %d", len(target.Schemas[0].Tables[0].Indexes))
|
|
}
|
|
}
|
|
|
|
func TestMergeIndexes_NilIndexesMap(t *testing.T) {
|
|
target := &models.Database{
|
|
Schemas: []*models.Schema{
|
|
{
|
|
Name: "public",
|
|
Tables: []*models.Table{
|
|
{
|
|
Name: "users",
|
|
Schema: "public",
|
|
Columns: map[string]*models.Column{},
|
|
Indexes: nil, // Nil map
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
source := &models.Database{
|
|
Schemas: []*models.Schema{
|
|
{
|
|
Name: "public",
|
|
Tables: []*models.Table{
|
|
{
|
|
Name: "users",
|
|
Schema: "public",
|
|
Columns: map[string]*models.Column{},
|
|
Indexes: map[string]*models.Index{
|
|
"idx_users_email": {
|
|
Name: "idx_users_email",
|
|
Columns: []string{"email"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
result := MergeDatabases(target, source, nil)
|
|
if result.IndexesAdded != 1 {
|
|
t.Errorf("Expected 1 index added, got %d", result.IndexesAdded)
|
|
}
|
|
if target.Schemas[0].Tables[0].Indexes == nil {
|
|
t.Error("Expected indexes map to be initialized")
|
|
}
|
|
if len(target.Schemas[0].Tables[0].Indexes) != 1 {
|
|
t.Errorf("Expected 1 index in target table, got %d", len(target.Schemas[0].Tables[0].Indexes))
|
|
}
|
|
}
|
|
|
|
func TestMergeOptions_SkipTableNames(t *testing.T) {
|
|
target := &models.Database{
|
|
Schemas: []*models.Schema{
|
|
{
|
|
Name: "public",
|
|
Tables: []*models.Table{
|
|
{
|
|
Name: "users",
|
|
Schema: "public",
|
|
Columns: map[string]*models.Column{},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
source := &models.Database{
|
|
Schemas: []*models.Schema{
|
|
{
|
|
Name: "public",
|
|
Tables: []*models.Table{
|
|
{
|
|
Name: "migrations",
|
|
Schema: "public",
|
|
Columns: map[string]*models.Column{},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
opts := &MergeOptions{
|
|
SkipTableNames: map[string]bool{
|
|
"migrations": true,
|
|
},
|
|
}
|
|
|
|
result := MergeDatabases(target, source, opts)
|
|
if result.TablesAdded != 0 {
|
|
t.Errorf("Expected 0 tables added (skipped), got %d", result.TablesAdded)
|
|
}
|
|
if len(target.Schemas[0].Tables) != 1 {
|
|
t.Errorf("Expected 1 table in target schema, got %d", len(target.Schemas[0].Tables))
|
|
}
|
|
}
|
|
|
|
func TestMergeViews_NewView(t *testing.T) {
|
|
target := &models.Database{
|
|
Schemas: []*models.Schema{
|
|
{
|
|
Name: "public",
|
|
Views: []*models.View{},
|
|
},
|
|
},
|
|
}
|
|
source := &models.Database{
|
|
Schemas: []*models.Schema{
|
|
{
|
|
Name: "public",
|
|
Views: []*models.View{
|
|
{
|
|
Name: "user_summary",
|
|
Schema: "public",
|
|
Definition: "SELECT * FROM users",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
result := MergeDatabases(target, source, nil)
|
|
if result.ViewsAdded != 1 {
|
|
t.Errorf("Expected 1 view added, got %d", result.ViewsAdded)
|
|
}
|
|
if len(target.Schemas[0].Views) != 1 {
|
|
t.Errorf("Expected 1 view in target schema, got %d", len(target.Schemas[0].Views))
|
|
}
|
|
}
|
|
|
|
func TestMergeEnums_NewEnum(t *testing.T) {
|
|
target := &models.Database{
|
|
Schemas: []*models.Schema{
|
|
{
|
|
Name: "public",
|
|
Enums: []*models.Enum{},
|
|
},
|
|
},
|
|
}
|
|
source := &models.Database{
|
|
Schemas: []*models.Schema{
|
|
{
|
|
Name: "public",
|
|
Enums: []*models.Enum{
|
|
{
|
|
Name: "user_role",
|
|
Schema: "public",
|
|
Values: []string{"admin", "user"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
result := MergeDatabases(target, source, nil)
|
|
if result.EnumsAdded != 1 {
|
|
t.Errorf("Expected 1 enum added, got %d", result.EnumsAdded)
|
|
}
|
|
if len(target.Schemas[0].Enums) != 1 {
|
|
t.Errorf("Expected 1 enum in target schema, got %d", len(target.Schemas[0].Enums))
|
|
}
|
|
}
|
|
|
|
func TestMergeDomains_NewDomain(t *testing.T) {
|
|
target := &models.Database{
|
|
Domains: []*models.Domain{},
|
|
}
|
|
source := &models.Database{
|
|
Domains: []*models.Domain{
|
|
{
|
|
Name: "auth",
|
|
Description: "Authentication domain",
|
|
},
|
|
},
|
|
}
|
|
|
|
result := MergeDatabases(target, source, nil)
|
|
if result.DomainsAdded != 1 {
|
|
t.Errorf("Expected 1 domain added, got %d", result.DomainsAdded)
|
|
}
|
|
if len(target.Domains) != 1 {
|
|
t.Errorf("Expected 1 domain in target, got %d", len(target.Domains))
|
|
}
|
|
}
|
|
|
|
func TestMergeRelations_NewRelation(t *testing.T) {
|
|
target := &models.Database{
|
|
Schemas: []*models.Schema{
|
|
{
|
|
Name: "public",
|
|
Relations: []*models.Relationship{},
|
|
},
|
|
},
|
|
}
|
|
source := &models.Database{
|
|
Schemas: []*models.Schema{
|
|
{
|
|
Name: "public",
|
|
Relations: []*models.Relationship{
|
|
{
|
|
Name: "fk_posts_user",
|
|
Type: models.OneToMany,
|
|
FromTable: "posts",
|
|
FromColumns: []string{"user_id"},
|
|
ToTable: "users",
|
|
ToColumns: []string{"id"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
result := MergeDatabases(target, source, nil)
|
|
if result.RelationsAdded != 1 {
|
|
t.Errorf("Expected 1 relation added, got %d", result.RelationsAdded)
|
|
}
|
|
if len(target.Schemas[0].Relations) != 1 {
|
|
t.Errorf("Expected 1 relation in target schema, got %d", len(target.Schemas[0].Relations))
|
|
}
|
|
}
|
|
|
|
func TestGetMergeSummary(t *testing.T) {
|
|
result := &MergeResult{
|
|
SchemasAdded: 1,
|
|
TablesAdded: 2,
|
|
ColumnsAdded: 5,
|
|
ConstraintsAdded: 3,
|
|
IndexesAdded: 2,
|
|
ViewsAdded: 1,
|
|
TypeConflicts: []ColumnTypeConflict{
|
|
{Schema: "public", Table: "users", Column: "email", TargetType: "varchar(255)", SourceType: "text"},
|
|
},
|
|
}
|
|
|
|
summary := GetMergeSummary(result)
|
|
if summary == "" {
|
|
t.Error("Expected non-empty summary")
|
|
}
|
|
if len(summary) < 50 {
|
|
t.Errorf("Summary seems too short: %s", summary)
|
|
}
|
|
if !strings.Contains(summary, "Type conflicts: 1") {
|
|
t.Errorf("Expected type conflict count in summary, got: %s", summary)
|
|
}
|
|
}
|
|
|
|
func TestGetMergeSummary_Nil(t *testing.T) {
|
|
summary := GetMergeSummary(nil)
|
|
if summary == "" {
|
|
t.Error("Expected non-empty summary for nil result")
|
|
}
|
|
}
|
|
|
|
func TestComplexMerge(t *testing.T) {
|
|
// Target with existing structure
|
|
target := &models.Database{
|
|
Schemas: []*models.Schema{
|
|
{
|
|
Name: "public",
|
|
Tables: []*models.Table{
|
|
{
|
|
Name: "users",
|
|
Schema: "public",
|
|
Columns: map[string]*models.Column{
|
|
"id": {Name: "id", Type: "int"},
|
|
},
|
|
Constraints: map[string]*models.Constraint{},
|
|
Indexes: map[string]*models.Index{},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
// Source with new columns, constraints, and indexes
|
|
source := &models.Database{
|
|
Schemas: []*models.Schema{
|
|
{
|
|
Name: "public",
|
|
Tables: []*models.Table{
|
|
{
|
|
Name: "users",
|
|
Schema: "public",
|
|
Columns: map[string]*models.Column{
|
|
"email": {Name: "email", Type: "varchar"},
|
|
"guid": {Name: "guid", Type: "uuid"},
|
|
},
|
|
Constraints: map[string]*models.Constraint{
|
|
"ukey_users_email": {
|
|
Type: models.UniqueConstraint,
|
|
Columns: []string{"email"},
|
|
Name: "ukey_users_email",
|
|
},
|
|
"ukey_users_guid": {
|
|
Type: models.UniqueConstraint,
|
|
Columns: []string{"guid"},
|
|
Name: "ukey_users_guid",
|
|
},
|
|
},
|
|
Indexes: map[string]*models.Index{
|
|
"idx_users_email": {
|
|
Name: "idx_users_email",
|
|
Columns: []string{"email"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
result := MergeDatabases(target, source, nil)
|
|
|
|
// Verify counts
|
|
if result.ColumnsAdded != 2 {
|
|
t.Errorf("Expected 2 columns added, got %d", result.ColumnsAdded)
|
|
}
|
|
if result.ConstraintsAdded != 2 {
|
|
t.Errorf("Expected 2 constraints added, got %d", result.ConstraintsAdded)
|
|
}
|
|
if result.IndexesAdded != 1 {
|
|
t.Errorf("Expected 1 index added, got %d", result.IndexesAdded)
|
|
}
|
|
|
|
// Verify target has merged data
|
|
table := target.Schemas[0].Tables[0]
|
|
if len(table.Columns) != 3 {
|
|
t.Errorf("Expected 3 columns in merged table, got %d", len(table.Columns))
|
|
}
|
|
if len(table.Constraints) != 2 {
|
|
t.Errorf("Expected 2 constraints in merged table, got %d", len(table.Constraints))
|
|
}
|
|
if len(table.Indexes) != 1 {
|
|
t.Errorf("Expected 1 index in merged table, got %d", len(table.Indexes))
|
|
}
|
|
|
|
// Verify specific constraint
|
|
if _, exists := table.Constraints["ukey_users_guid"]; !exists {
|
|
t.Error("Expected ukey_users_guid constraint to exist")
|
|
}
|
|
}
|