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.
768 lines
21 KiB
Go
768 lines
21 KiB
Go
// Package merge provides utilities for merging database schemas.
|
|
// It allows combining schemas from multiple sources while avoiding duplicates,
|
|
// supporting only additive operations (no deletion or modification of existing items).
|
|
package merge
|
|
|
|
import (
|
|
"fmt"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
|
"git.warky.dev/wdevs/relspecgo/pkg/pgsql"
|
|
)
|
|
|
|
// MergeResult represents the result of a merge operation
|
|
type MergeResult struct {
|
|
SchemasAdded int
|
|
TablesAdded int
|
|
ColumnsAdded int
|
|
ConstraintsAdded int
|
|
IndexesAdded int
|
|
RelationsAdded int
|
|
DomainsAdded int
|
|
EnumsAdded int
|
|
ViewsAdded int
|
|
SequencesAdded int
|
|
TypeConflicts []ColumnTypeConflict
|
|
}
|
|
|
|
// ColumnTypeConflict describes a column that exists in both schemas but with incompatible types.
|
|
type ColumnTypeConflict struct {
|
|
Schema string
|
|
Table string
|
|
Column string
|
|
TargetType string
|
|
SourceType string
|
|
}
|
|
|
|
// MergeOptions contains options for merge operations
|
|
type MergeOptions struct {
|
|
SkipDomains bool
|
|
SkipRelations bool
|
|
SkipEnums bool
|
|
SkipViews bool
|
|
SkipSequences bool
|
|
SkipTableNames map[string]bool // Tables to skip during merge (keyed by table name)
|
|
}
|
|
|
|
// MergeDatabases merges the source database into the target database.
|
|
// Only adds missing items; existing items are not modified.
|
|
func MergeDatabases(target, source *models.Database, opts *MergeOptions) *MergeResult {
|
|
if opts == nil {
|
|
opts = &MergeOptions{}
|
|
}
|
|
|
|
result := &MergeResult{}
|
|
|
|
if target == nil || source == nil {
|
|
return result
|
|
}
|
|
|
|
// Merge schemas and their contents
|
|
result.merge(target, source, opts)
|
|
|
|
return result
|
|
}
|
|
|
|
func (r *MergeResult) merge(target, source *models.Database, opts *MergeOptions) {
|
|
// Create maps of existing schemas for quick lookup
|
|
existingSchemas := make(map[string]*models.Schema)
|
|
for _, schema := range target.Schemas {
|
|
existingSchemas[schema.SQLName()] = schema
|
|
}
|
|
|
|
// Merge schemas
|
|
for _, srcSchema := range source.Schemas {
|
|
schemaName := srcSchema.SQLName()
|
|
if tgtSchema, exists := existingSchemas[schemaName]; exists {
|
|
// Schema exists, merge its contents
|
|
r.mergeSchemaContents(tgtSchema, srcSchema, opts)
|
|
} else {
|
|
// Schema doesn't exist, add it
|
|
newSchema := cloneSchema(srcSchema)
|
|
target.Schemas = append(target.Schemas, newSchema)
|
|
r.SchemasAdded++
|
|
}
|
|
}
|
|
|
|
// Merge domains if not skipped
|
|
if !opts.SkipDomains {
|
|
r.mergeDomains(target, source)
|
|
}
|
|
}
|
|
|
|
func (r *MergeResult) mergeSchemaContents(target, source *models.Schema, opts *MergeOptions) {
|
|
// Merge tables
|
|
r.mergeTables(target, source, opts)
|
|
|
|
// Merge views if not skipped
|
|
if !opts.SkipViews {
|
|
r.mergeViews(target, source)
|
|
}
|
|
|
|
// Merge sequences if not skipped
|
|
if !opts.SkipSequences {
|
|
r.mergeSequences(target, source)
|
|
}
|
|
|
|
// Merge enums if not skipped
|
|
if !opts.SkipEnums {
|
|
r.mergeEnums(target, source)
|
|
}
|
|
|
|
// Merge relations if not skipped
|
|
if !opts.SkipRelations {
|
|
r.mergeRelations(target, source)
|
|
}
|
|
}
|
|
|
|
func (r *MergeResult) mergeTables(schema *models.Schema, source *models.Schema, opts *MergeOptions) {
|
|
// Create map of existing tables
|
|
existingTables := make(map[string]*models.Table)
|
|
for _, table := range schema.Tables {
|
|
existingTables[table.SQLName()] = table
|
|
}
|
|
|
|
// Merge tables
|
|
for _, srcTable := range source.Tables {
|
|
tableName := srcTable.SQLName()
|
|
|
|
// Skip if table is in the skip list (case-insensitive)
|
|
if opts != nil && opts.SkipTableNames != nil && opts.SkipTableNames[strings.ToLower(tableName)] {
|
|
continue
|
|
}
|
|
|
|
if tgtTable, exists := existingTables[tableName]; exists {
|
|
// Table exists, merge its columns, constraints, and indexes
|
|
r.mergeColumns(tgtTable, srcTable)
|
|
r.mergeConstraints(tgtTable, srcTable)
|
|
r.mergeIndexes(tgtTable, srcTable)
|
|
} else {
|
|
// Table doesn't exist, add it
|
|
newTable := cloneTable(srcTable)
|
|
schema.Tables = append(schema.Tables, newTable)
|
|
r.TablesAdded++
|
|
// Count columns in the newly added table
|
|
r.ColumnsAdded += len(newTable.Columns)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (r *MergeResult) mergeColumns(table *models.Table, srcTable *models.Table) {
|
|
// Create map of existing columns
|
|
existingColumns := make(map[string]*models.Column)
|
|
for colName := range table.Columns {
|
|
existingColumns[colName] = table.Columns[colName]
|
|
}
|
|
|
|
// Merge columns in deterministic (alphabetical) order so that, when a
|
|
// TypeConflicts entry is recorded, its position in the report doesn't
|
|
// depend on Go's randomized map iteration order.
|
|
srcColNames := make([]string, 0, len(srcTable.Columns))
|
|
for colName := range srcTable.Columns {
|
|
srcColNames = append(srcColNames, colName)
|
|
}
|
|
sort.Strings(srcColNames)
|
|
|
|
for _, colName := range srcColNames {
|
|
srcCol := srcTable.Columns[colName]
|
|
if tgtCol, exists := existingColumns[colName]; !exists {
|
|
// Column doesn't exist, add it
|
|
newCol := cloneColumn(srcCol)
|
|
table.Columns[colName] = newCol
|
|
r.ColumnsAdded++
|
|
} else if columnTypeConflict(tgtCol, srcCol) {
|
|
r.TypeConflicts = append(r.TypeConflicts, ColumnTypeConflict{
|
|
Schema: firstNonEmpty(table.Schema, srcTable.Schema, srcCol.Schema),
|
|
Table: firstNonEmpty(table.Name, srcTable.Name, srcCol.Table),
|
|
Column: firstNonEmpty(tgtCol.Name, srcCol.Name, colName),
|
|
TargetType: describeColumnType(tgtCol),
|
|
SourceType: describeColumnType(srcCol),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func (r *MergeResult) mergeConstraints(table *models.Table, srcTable *models.Table) {
|
|
// Initialize constraints map if nil
|
|
if table.Constraints == nil {
|
|
table.Constraints = make(map[string]*models.Constraint)
|
|
}
|
|
|
|
// Create map of existing constraints
|
|
existingConstraints := make(map[string]*models.Constraint)
|
|
for constName := range table.Constraints {
|
|
existingConstraints[constName] = table.Constraints[constName]
|
|
}
|
|
|
|
// Merge constraints
|
|
for constName, srcConst := range srcTable.Constraints {
|
|
if _, exists := existingConstraints[constName]; !exists {
|
|
// Constraint doesn't exist, add it
|
|
newConst := cloneConstraint(srcConst)
|
|
table.Constraints[constName] = newConst
|
|
r.ConstraintsAdded++
|
|
}
|
|
}
|
|
}
|
|
|
|
func (r *MergeResult) mergeIndexes(table *models.Table, srcTable *models.Table) {
|
|
// Initialize indexes map if nil
|
|
if table.Indexes == nil {
|
|
table.Indexes = make(map[string]*models.Index)
|
|
}
|
|
|
|
// Create map of existing indexes
|
|
existingIndexes := make(map[string]*models.Index)
|
|
for idxName := range table.Indexes {
|
|
existingIndexes[idxName] = table.Indexes[idxName]
|
|
}
|
|
|
|
// Merge indexes
|
|
for idxName, srcIdx := range srcTable.Indexes {
|
|
if _, exists := existingIndexes[idxName]; !exists {
|
|
// Index doesn't exist, add it
|
|
newIdx := cloneIndex(srcIdx)
|
|
table.Indexes[idxName] = newIdx
|
|
r.IndexesAdded++
|
|
}
|
|
}
|
|
}
|
|
|
|
func (r *MergeResult) mergeViews(schema *models.Schema, source *models.Schema) {
|
|
// Create map of existing views
|
|
existingViews := make(map[string]*models.View)
|
|
for _, view := range schema.Views {
|
|
existingViews[view.SQLName()] = view
|
|
}
|
|
|
|
// Merge views
|
|
for _, srcView := range source.Views {
|
|
viewName := srcView.SQLName()
|
|
if _, exists := existingViews[viewName]; !exists {
|
|
// View doesn't exist, add it
|
|
newView := cloneView(srcView)
|
|
schema.Views = append(schema.Views, newView)
|
|
r.ViewsAdded++
|
|
}
|
|
}
|
|
}
|
|
|
|
func (r *MergeResult) mergeSequences(schema *models.Schema, source *models.Schema) {
|
|
// Create map of existing sequences
|
|
existingSequences := make(map[string]*models.Sequence)
|
|
for _, seq := range schema.Sequences {
|
|
existingSequences[seq.SQLName()] = seq
|
|
}
|
|
|
|
// Merge sequences
|
|
for _, srcSeq := range source.Sequences {
|
|
seqName := srcSeq.SQLName()
|
|
if _, exists := existingSequences[seqName]; !exists {
|
|
// Sequence doesn't exist, add it
|
|
newSeq := cloneSequence(srcSeq)
|
|
schema.Sequences = append(schema.Sequences, newSeq)
|
|
r.SequencesAdded++
|
|
}
|
|
}
|
|
}
|
|
|
|
func (r *MergeResult) mergeEnums(schema *models.Schema, source *models.Schema) {
|
|
// Create map of existing enums
|
|
existingEnums := make(map[string]*models.Enum)
|
|
for _, enum := range schema.Enums {
|
|
existingEnums[enum.SQLName()] = enum
|
|
}
|
|
|
|
// Merge enums
|
|
for _, srcEnum := range source.Enums {
|
|
enumName := srcEnum.SQLName()
|
|
if _, exists := existingEnums[enumName]; !exists {
|
|
// Enum doesn't exist, add it
|
|
newEnum := cloneEnum(srcEnum)
|
|
schema.Enums = append(schema.Enums, newEnum)
|
|
r.EnumsAdded++
|
|
}
|
|
}
|
|
}
|
|
|
|
func (r *MergeResult) mergeRelations(schema *models.Schema, source *models.Schema) {
|
|
// Create map of existing relations
|
|
existingRelations := make(map[string]*models.Relationship)
|
|
for _, rel := range schema.Relations {
|
|
existingRelations[rel.SQLName()] = rel
|
|
}
|
|
|
|
// Merge relations
|
|
for _, srcRel := range source.Relations {
|
|
if _, exists := existingRelations[srcRel.SQLName()]; !exists {
|
|
// Relation doesn't exist, add it
|
|
newRel := cloneRelation(srcRel)
|
|
schema.Relations = append(schema.Relations, newRel)
|
|
r.RelationsAdded++
|
|
}
|
|
}
|
|
}
|
|
|
|
func (r *MergeResult) mergeDomains(target *models.Database, source *models.Database) {
|
|
// Create map of existing domains
|
|
existingDomains := make(map[string]*models.Domain)
|
|
for _, domain := range target.Domains {
|
|
existingDomains[domain.SQLName()] = domain
|
|
}
|
|
|
|
// Merge domains
|
|
for _, srcDomain := range source.Domains {
|
|
domainName := srcDomain.SQLName()
|
|
if _, exists := existingDomains[domainName]; !exists {
|
|
// Domain doesn't exist, add it
|
|
newDomain := cloneDomain(srcDomain)
|
|
target.Domains = append(target.Domains, newDomain)
|
|
r.DomainsAdded++
|
|
}
|
|
}
|
|
}
|
|
|
|
// Clone functions to create deep copies of models
|
|
|
|
func cloneSchema(schema *models.Schema) *models.Schema {
|
|
if schema == nil {
|
|
return nil
|
|
}
|
|
newSchema := &models.Schema{
|
|
Name: schema.Name,
|
|
Description: schema.Description,
|
|
Owner: schema.Owner,
|
|
Comment: schema.Comment,
|
|
Sequence: schema.Sequence,
|
|
UpdatedAt: schema.UpdatedAt,
|
|
Tables: make([]*models.Table, 0),
|
|
Views: make([]*models.View, 0),
|
|
Sequences: make([]*models.Sequence, 0),
|
|
Enums: make([]*models.Enum, 0),
|
|
Relations: make([]*models.Relationship, 0),
|
|
}
|
|
|
|
if schema.Permissions != nil {
|
|
newSchema.Permissions = make(map[string]string)
|
|
for k, v := range schema.Permissions {
|
|
newSchema.Permissions[k] = v
|
|
}
|
|
}
|
|
|
|
if schema.Metadata != nil {
|
|
newSchema.Metadata = make(map[string]interface{})
|
|
for k, v := range schema.Metadata {
|
|
newSchema.Metadata[k] = v
|
|
}
|
|
}
|
|
|
|
if schema.Scripts != nil {
|
|
newSchema.Scripts = make([]*models.Script, len(schema.Scripts))
|
|
copy(newSchema.Scripts, schema.Scripts)
|
|
}
|
|
|
|
// Clone tables
|
|
for _, table := range schema.Tables {
|
|
newSchema.Tables = append(newSchema.Tables, cloneTable(table))
|
|
}
|
|
|
|
// Clone views
|
|
for _, view := range schema.Views {
|
|
newSchema.Views = append(newSchema.Views, cloneView(view))
|
|
}
|
|
|
|
// Clone sequences
|
|
for _, seq := range schema.Sequences {
|
|
newSchema.Sequences = append(newSchema.Sequences, cloneSequence(seq))
|
|
}
|
|
|
|
// Clone enums
|
|
for _, enum := range schema.Enums {
|
|
newSchema.Enums = append(newSchema.Enums, cloneEnum(enum))
|
|
}
|
|
|
|
// Clone relations
|
|
for _, rel := range schema.Relations {
|
|
newSchema.Relations = append(newSchema.Relations, cloneRelation(rel))
|
|
}
|
|
|
|
return newSchema
|
|
}
|
|
|
|
func cloneTable(table *models.Table) *models.Table {
|
|
if table == nil {
|
|
return nil
|
|
}
|
|
newTable := &models.Table{
|
|
Name: table.Name,
|
|
Description: table.Description,
|
|
Schema: table.Schema,
|
|
Comment: table.Comment,
|
|
Sequence: table.Sequence,
|
|
UpdatedAt: table.UpdatedAt,
|
|
Columns: make(map[string]*models.Column),
|
|
Constraints: make(map[string]*models.Constraint),
|
|
Indexes: make(map[string]*models.Index),
|
|
}
|
|
|
|
if table.Metadata != nil {
|
|
newTable.Metadata = make(map[string]interface{})
|
|
for k, v := range table.Metadata {
|
|
newTable.Metadata[k] = v
|
|
}
|
|
}
|
|
|
|
// Clone columns
|
|
for colName, col := range table.Columns {
|
|
newTable.Columns[colName] = cloneColumn(col)
|
|
}
|
|
|
|
// Clone constraints
|
|
for constName, constraint := range table.Constraints {
|
|
newTable.Constraints[constName] = cloneConstraint(constraint)
|
|
}
|
|
|
|
// Clone indexes
|
|
for idxName, index := range table.Indexes {
|
|
newTable.Indexes[idxName] = cloneIndex(index)
|
|
}
|
|
|
|
return newTable
|
|
}
|
|
|
|
func cloneColumn(col *models.Column) *models.Column {
|
|
if col == nil {
|
|
return nil
|
|
}
|
|
newCol := &models.Column{
|
|
Name: col.Name,
|
|
Type: col.Type,
|
|
Description: col.Description,
|
|
Comment: col.Comment,
|
|
IsPrimaryKey: col.IsPrimaryKey,
|
|
NotNull: col.NotNull,
|
|
Default: col.Default,
|
|
Precision: col.Precision,
|
|
Scale: col.Scale,
|
|
Length: col.Length,
|
|
Sequence: col.Sequence,
|
|
AutoIncrement: col.AutoIncrement,
|
|
Collation: col.Collation,
|
|
}
|
|
|
|
return newCol
|
|
}
|
|
|
|
func columnTypeConflict(target, source *models.Column) bool {
|
|
if target == nil || source == nil {
|
|
return false
|
|
}
|
|
|
|
tType, tLen, tPrec, tScale := extractTypeParts(target)
|
|
sType, sLen, sPrec, sScale := extractTypeParts(source)
|
|
|
|
return tType != sType || tLen != sLen || tPrec != sPrec || tScale != sScale
|
|
}
|
|
|
|
// extractTypeParts returns the canonical base type and dimensions for a column,
|
|
// handling the case where dimensions are embedded in the type string (e.g. "char(2)")
|
|
// rather than stored in the separate Length/Precision/Scale fields.
|
|
func extractTypeParts(col *models.Column) (baseType string, length, precision, scale int) {
|
|
typeName := strings.ToLower(strings.TrimSpace(col.Type))
|
|
length, precision, scale = col.Length, col.Precision, col.Scale
|
|
|
|
if idx := strings.Index(typeName, "("); idx >= 0 {
|
|
inner := strings.TrimRight(strings.TrimSpace(typeName[idx+1:]), ")")
|
|
typeName = strings.TrimSpace(typeName[:idx])
|
|
parts := strings.Split(inner, ",")
|
|
if len(parts) == 2 {
|
|
if p, err := strconv.Atoi(strings.TrimSpace(parts[0])); err == nil && p > 0 && precision == 0 {
|
|
precision = p
|
|
}
|
|
if s, err := strconv.Atoi(strings.TrimSpace(parts[1])); err == nil && s > 0 && scale == 0 {
|
|
scale = s
|
|
}
|
|
} else if len(parts) == 1 {
|
|
if l, err := strconv.Atoi(strings.TrimSpace(parts[0])); err == nil && l > 0 && length == 0 && precision == 0 {
|
|
length = l
|
|
}
|
|
}
|
|
}
|
|
|
|
// serial/bigserial/smallserial are sugar over an integer column plus a
|
|
// sequence default; PostgreSQL itself reports the underlying integer
|
|
// type back for such columns, so treat them as equivalent here to avoid
|
|
// spurious conflicts between a DBML "bigserial" source and a live-read
|
|
// "bigint" target (or vice versa).
|
|
typeName = pgsql.SerialUnderlyingType(typeName)
|
|
|
|
return typeName, length, precision, scale
|
|
}
|
|
|
|
func describeColumnType(col *models.Column) string {
|
|
if col == nil {
|
|
return ""
|
|
}
|
|
|
|
typeName := strings.TrimSpace(col.Type)
|
|
if typeName == "" {
|
|
return ""
|
|
}
|
|
|
|
switch {
|
|
case col.Precision > 0 && col.Scale > 0:
|
|
return fmt.Sprintf("%s(%d,%d)", typeName, col.Precision, col.Scale)
|
|
case col.Precision > 0:
|
|
return fmt.Sprintf("%s(%d)", typeName, col.Precision)
|
|
case col.Length > 0:
|
|
return fmt.Sprintf("%s(%d)", typeName, col.Length)
|
|
default:
|
|
return typeName
|
|
}
|
|
}
|
|
|
|
func firstNonEmpty(values ...string) string {
|
|
for _, value := range values {
|
|
if strings.TrimSpace(value) != "" {
|
|
return value
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func cloneConstraint(constraint *models.Constraint) *models.Constraint {
|
|
if constraint == nil {
|
|
return nil
|
|
}
|
|
newConstraint := &models.Constraint{
|
|
Type: constraint.Type,
|
|
Columns: make([]string, len(constraint.Columns)),
|
|
ReferencedTable: constraint.ReferencedTable,
|
|
ReferencedSchema: constraint.ReferencedSchema,
|
|
ReferencedColumns: make([]string, len(constraint.ReferencedColumns)),
|
|
OnUpdate: constraint.OnUpdate,
|
|
OnDelete: constraint.OnDelete,
|
|
Expression: constraint.Expression,
|
|
Name: constraint.Name,
|
|
Deferrable: constraint.Deferrable,
|
|
InitiallyDeferred: constraint.InitiallyDeferred,
|
|
Sequence: constraint.Sequence,
|
|
}
|
|
copy(newConstraint.Columns, constraint.Columns)
|
|
copy(newConstraint.ReferencedColumns, constraint.ReferencedColumns)
|
|
return newConstraint
|
|
}
|
|
|
|
func cloneIndex(index *models.Index) *models.Index {
|
|
if index == nil {
|
|
return nil
|
|
}
|
|
newIndex := &models.Index{
|
|
Name: index.Name,
|
|
Description: index.Description,
|
|
Table: index.Table,
|
|
Schema: index.Schema,
|
|
Columns: make([]string, len(index.Columns)),
|
|
Unique: index.Unique,
|
|
Type: index.Type,
|
|
Where: index.Where,
|
|
Concurrent: index.Concurrent,
|
|
Include: make([]string, len(index.Include)),
|
|
Comment: index.Comment,
|
|
Sequence: index.Sequence,
|
|
}
|
|
copy(newIndex.Columns, index.Columns)
|
|
copy(newIndex.Include, index.Include)
|
|
return newIndex
|
|
}
|
|
|
|
func cloneView(view *models.View) *models.View {
|
|
if view == nil {
|
|
return nil
|
|
}
|
|
newView := &models.View{
|
|
Name: view.Name,
|
|
Description: view.Description,
|
|
Schema: view.Schema,
|
|
Definition: view.Definition,
|
|
Comment: view.Comment,
|
|
Sequence: view.Sequence,
|
|
Columns: make(map[string]*models.Column),
|
|
}
|
|
|
|
if view.Metadata != nil {
|
|
newView.Metadata = make(map[string]interface{})
|
|
for k, v := range view.Metadata {
|
|
newView.Metadata[k] = v
|
|
}
|
|
}
|
|
|
|
// Clone columns
|
|
for colName, col := range view.Columns {
|
|
newView.Columns[colName] = cloneColumn(col)
|
|
}
|
|
|
|
return newView
|
|
}
|
|
|
|
func cloneSequence(seq *models.Sequence) *models.Sequence {
|
|
if seq == nil {
|
|
return nil
|
|
}
|
|
newSeq := &models.Sequence{
|
|
Name: seq.Name,
|
|
Description: seq.Description,
|
|
Schema: seq.Schema,
|
|
StartValue: seq.StartValue,
|
|
MinValue: seq.MinValue,
|
|
MaxValue: seq.MaxValue,
|
|
IncrementBy: seq.IncrementBy,
|
|
CacheSize: seq.CacheSize,
|
|
Cycle: seq.Cycle,
|
|
OwnedByTable: seq.OwnedByTable,
|
|
OwnedByColumn: seq.OwnedByColumn,
|
|
Comment: seq.Comment,
|
|
Sequence: seq.Sequence,
|
|
}
|
|
return newSeq
|
|
}
|
|
|
|
func cloneEnum(enum *models.Enum) *models.Enum {
|
|
if enum == nil {
|
|
return nil
|
|
}
|
|
newEnum := &models.Enum{
|
|
Name: enum.Name,
|
|
Values: make([]string, len(enum.Values)),
|
|
Schema: enum.Schema,
|
|
}
|
|
copy(newEnum.Values, enum.Values)
|
|
return newEnum
|
|
}
|
|
|
|
func cloneRelation(rel *models.Relationship) *models.Relationship {
|
|
if rel == nil {
|
|
return nil
|
|
}
|
|
newRel := &models.Relationship{
|
|
Name: rel.Name,
|
|
Type: rel.Type,
|
|
FromTable: rel.FromTable,
|
|
FromSchema: rel.FromSchema,
|
|
FromColumns: make([]string, len(rel.FromColumns)),
|
|
ToTable: rel.ToTable,
|
|
ToSchema: rel.ToSchema,
|
|
ToColumns: make([]string, len(rel.ToColumns)),
|
|
ForeignKey: rel.ForeignKey,
|
|
ThroughTable: rel.ThroughTable,
|
|
ThroughSchema: rel.ThroughSchema,
|
|
Description: rel.Description,
|
|
Sequence: rel.Sequence,
|
|
}
|
|
|
|
if rel.Properties != nil {
|
|
newRel.Properties = make(map[string]string)
|
|
for k, v := range rel.Properties {
|
|
newRel.Properties[k] = v
|
|
}
|
|
}
|
|
|
|
copy(newRel.FromColumns, rel.FromColumns)
|
|
copy(newRel.ToColumns, rel.ToColumns)
|
|
return newRel
|
|
}
|
|
|
|
func cloneDomain(domain *models.Domain) *models.Domain {
|
|
if domain == nil {
|
|
return nil
|
|
}
|
|
newDomain := &models.Domain{
|
|
Name: domain.Name,
|
|
Description: domain.Description,
|
|
Comment: domain.Comment,
|
|
Sequence: domain.Sequence,
|
|
Tables: make([]*models.DomainTable, len(domain.Tables)),
|
|
}
|
|
|
|
if domain.Metadata != nil {
|
|
newDomain.Metadata = make(map[string]interface{})
|
|
for k, v := range domain.Metadata {
|
|
newDomain.Metadata[k] = v
|
|
}
|
|
}
|
|
|
|
copy(newDomain.Tables, domain.Tables)
|
|
return newDomain
|
|
}
|
|
|
|
// GetMergeSummary returns a human-readable summary of the merge result
|
|
func GetMergeSummary(result *MergeResult) string {
|
|
if result == nil {
|
|
return "No merge result available"
|
|
}
|
|
|
|
lines := []string{
|
|
"=== Merge Summary ===",
|
|
fmt.Sprintf("Schemas added: %d", result.SchemasAdded),
|
|
fmt.Sprintf("Tables added: %d", result.TablesAdded),
|
|
fmt.Sprintf("Columns added: %d", result.ColumnsAdded),
|
|
fmt.Sprintf("Constraints added: %d", result.ConstraintsAdded),
|
|
fmt.Sprintf("Indexes added: %d", result.IndexesAdded),
|
|
fmt.Sprintf("Views added: %d", result.ViewsAdded),
|
|
fmt.Sprintf("Sequences added: %d", result.SequencesAdded),
|
|
fmt.Sprintf("Enums added: %d", result.EnumsAdded),
|
|
fmt.Sprintf("Relations added: %d", result.RelationsAdded),
|
|
fmt.Sprintf("Domains added: %d", result.DomainsAdded),
|
|
fmt.Sprintf("Type conflicts: %d", len(result.TypeConflicts)),
|
|
}
|
|
|
|
totalAdded := result.SchemasAdded + result.TablesAdded + result.ColumnsAdded +
|
|
result.ConstraintsAdded + result.IndexesAdded +
|
|
result.ViewsAdded + result.SequencesAdded + result.EnumsAdded +
|
|
result.RelationsAdded + result.DomainsAdded
|
|
|
|
lines = append(lines, fmt.Sprintf("Total items added: %d", totalAdded))
|
|
|
|
summary := ""
|
|
for _, line := range lines {
|
|
summary += line + "\n"
|
|
}
|
|
|
|
return summary
|
|
}
|
|
|
|
// GetColumnTypeConflictSummary returns a short, human-readable conflict summary.
|
|
func GetColumnTypeConflictSummary(result *MergeResult, limit int) string {
|
|
if result == nil || len(result.TypeConflicts) == 0 {
|
|
return ""
|
|
}
|
|
if limit <= 0 {
|
|
limit = len(result.TypeConflicts)
|
|
}
|
|
|
|
lines := make([]string, 0, min(limit, len(result.TypeConflicts))+1)
|
|
lines = append(lines, "column type conflicts detected:")
|
|
for i, conflict := range result.TypeConflicts {
|
|
if i >= limit {
|
|
break
|
|
}
|
|
lines = append(lines, fmt.Sprintf(" - %s.%s.%s: target=%s source=%s",
|
|
conflict.Schema, conflict.Table, conflict.Column, conflict.TargetType, conflict.SourceType))
|
|
}
|
|
if len(result.TypeConflicts) > limit {
|
|
lines = append(lines, fmt.Sprintf(" ... and %d more", len(result.TypeConflicts)-limit))
|
|
}
|
|
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func min(a, b int) int {
|
|
if a < b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|