package diff import ( "fmt" "reflect" "sort" "strconv" "strings" "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 } // Compare scripts scriptDiff := compareScripts(source.Scripts, target.Scripts) if !isEmpty(scriptDiff) { change.Scripts = scriptDiff 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) sourceType, sourceLength, sourceDefault := comparableColumn(source) targetType, targetLength, targetDefault := comparableColumn(target) if sourceType != targetType { changes["type"] = map[string]string{"source": source.Type, "target": target.Type} } if sourceLength != targetLength { 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(sourceDefault, targetDefault) { changes["default"] = map[string]any{"source": sourceDefault, "target": targetDefault} } 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 } // comparableColumn accepts DBML's compact type/default spelling as well as // PostgreSQL's normalized fields (for example varchar(255) vs varchar + 255). func comparableColumn(column *models.Column) (string, int, any) { typeName := strings.TrimSpace(column.Type) defaultValue := column.Default lower := strings.ToLower(typeName) if i := strings.Index(lower, " default "); i >= 0 { if defaultValue == nil { defaultValue = strings.TrimSpace(typeName[i+len(" default "):]) } typeName = strings.TrimSpace(typeName[:i]) } length := column.Length if open := strings.LastIndex(typeName, "("); open >= 0 && strings.HasSuffix(typeName, ")") { if parsed, err := strconv.Atoi(strings.TrimSpace(typeName[open+1 : len(typeName)-1])); err == nil && length == 0 { length = parsed } typeName = strings.TrimSpace(typeName[:open]) } return strings.ToLower(typeName), length, defaultValue } 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), } // Match by name first, then by definition. PostgreSQL and DBML can assign // different names to the same index (for example, posts_user_id_title_idx // and uidx_posts_user_id_title), so a name-only comparison reports false // drift after a merge/diff round trip. unmatchedSource := make(map[string]*models.Index, len(source)) unmatchedTarget := make(map[string]*models.Index, len(target)) for name, index := range source { unmatchedSource[name] = index } for name, index := range target { unmatchedTarget[name] = index } for _, name := range sortedKeys(source) { srcIdx := source[name] tgtIdx, exists := target[name] if !exists { continue } delete(unmatchedSource, name) delete(unmatchedTarget, name) if changes := compareIndexDetails(srcIdx, tgtIdx); len(changes) > 0 { diff.Modified = append(diff.Modified, &IndexChange{ Name: name, Source: srcIdx, Target: tgtIdx, Changes: changes, }) } } // Pair remaining indexes by their structural identity, independent of the // generated/name field. The sorted iteration makes ambiguous matches // deterministic; duplicate definitions are still represented as separate // indexes by consuming one target at a time. remainingTarget := make(map[string][]*models.Index) for _, name := range sortedKeys(unmatchedTarget) { index := unmatchedTarget[name] key := indexDefinitionKey(index) remainingTarget[key] = append(remainingTarget[key], index) } for _, name := range sortedKeys(unmatchedSource) { srcIdx := unmatchedSource[name] key := indexDefinitionKey(srcIdx) candidates := remainingTarget[key] if len(candidates) == 0 { diff.Missing = append(diff.Missing, srcIdx) continue } tgtIdx := candidates[0] remainingTarget[key] = candidates[1:] if changes := compareIndexDetails(srcIdx, tgtIdx); len(changes) > 0 { diff.Modified = append(diff.Modified, &IndexChange{ Name: srcIdx.Name, Source: srcIdx, Target: tgtIdx, Changes: changes, }) } } for _, key := range sortedKeys(remainingTarget) { for _, index := range remainingTarget[key] { diff.Extra = append(diff.Extra, index) } } return diff } func indexDefinitionKey(index *models.Index) string { return fmt.Sprintf("%t:%s:%s", index.Unique, strings.Join(index.Columns, ","), strings.Join(index.Include, ",")) } func comparableIndexType(indexType string) string { indexType = strings.ToLower(strings.TrimSpace(indexType)) if indexType == "" { return "btree" } return indexType } 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 comparableIndexType(source.Type) != comparableIndexType(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 } // Compare constraints. // Primary-key constraints are excluded: a PK is already represented by the // column's IsPrimaryKey flag, which compareColumns already compares. The // PostgreSQL reader additionally materialises each PK as a primary_key // constraint and a unique btree index; the DBML reader keeps PKs as column // flags only. Comparing the constraint maps directly would therefore report // every PK as an "extra" constraint and the generated index as an "extra" // index on a freshly-applied schema. Filtering them here keeps the round // trip stable without losing real PK information. func compareConstraints(source, target map[string]*models.Constraint) *ConstraintDiff { filteredSource := filterPrimaryKeyConstraints(source) filteredTarget := filterPrimaryKeyConstraints(target) sourceByKey := make(map[string]*models.Constraint, len(filteredSource)) targetByKey := make(map[string]*models.Constraint, len(filteredTarget)) for _, constraint := range filteredSource { sourceByKey[constraintCompareKey(constraint)] = constraint } for _, constraint := range filteredTarget { targetByKey[constraintCompareKey(constraint)] = constraint } 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(sourceByKey) { srcCon := sourceByKey[name] if tgtCon, exists := targetByKey[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(targetByKey) { tgtCon := targetByKey[name] if _, exists := sourceByKey[name]; !exists { diff.Extra = append(diff.Extra, tgtCon) } } return diff } // filterPrimaryKeyConstraints drops primary_key constraints from a single // map. Primary keys are compared by the column IsPrimaryKey flag in // compareColumns, so comparing the primary_key constraints here only // produces duplicate "extra" entries (every PK is extra on the DBML side). // Other constraint types are preserved untouched. func filterPrimaryKeyConstraints(m map[string]*models.Constraint) map[string]*models.Constraint { out := make(map[string]*models.Constraint, len(m)) for name, c := range m { if c.Type == models.PrimaryKeyConstraint { continue } out[name] = c } return out } func constraintCompareKey(constraint *models.Constraint) string { if constraint.Type != models.ForeignKeyConstraint { return constraint.SQLName() } return fmt.Sprintf("fk:%s:%s:%s:%s:%s:%s", strings.ToLower(constraint.Schema), strings.ToLower(constraint.Table), strings.Join(constraint.Columns, ","), strings.ToLower(constraint.ReferencedSchema), strings.ToLower(constraint.ReferencedTable), strings.Join(constraint.ReferencedColumns, ",")) } 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 normalizeConstraintAction(source.OnDelete) != normalizeConstraintAction(target.OnDelete) { changes["on_delete"] = map[string]string{"source": source.OnDelete, "target": target.OnDelete} } if normalizeConstraintAction(source.OnUpdate) != normalizeConstraintAction(target.OnUpdate) { changes["on_update"] = map[string]string{"source": source.OnUpdate, "target": target.OnUpdate} } return changes } func normalizeConstraintAction(action string) string { if strings.EqualFold(strings.TrimSpace(action), "NO ACTION") { return "" } return strings.ToUpper(strings.TrimSpace(action)) } 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 } func compareScripts(source, target []*models.Script) *ScriptDiff { diff := &ScriptDiff{ Missing: make([]*models.Script, 0), Extra: make([]*models.Script, 0), Modified: make([]*ScriptChange, 0), } sourceMap := make(map[string]*models.Script) targetMap := make(map[string]*models.Script) for _, s := range source { sourceMap[scriptCompareKey(s)] = s } for _, s := range target { targetMap[scriptCompareKey(s)] = s } for _, name := range sortedKeys(sourceMap) { srcScript := sourceMap[name] if tgtScript, exists := targetMap[name]; !exists { diff.Missing = append(diff.Missing, srcScript) } else if changes := compareScriptDetails(srcScript, tgtScript); len(changes) > 0 { diff.Modified = append(diff.Modified, &ScriptChange{ Name: srcScript.Name, Source: srcScript, Target: tgtScript, Changes: changes, }) } } for _, name := range sortedKeys(targetMap) { tgtScript := targetMap[name] if _, exists := sourceMap[name]; !exists { diff.Extra = append(diff.Extra, tgtScript) } } return diff } func scriptCompareKey(script *models.Script) string { return fmt.Sprintf("%d:%d:%s", script.Priority, script.Sequence, script.SQLName()) } func compareScriptDetails(source, target *models.Script) map[string]any { changes := make(map[string]any) if source.SQL != target.SQL { changes["sql"] = map[string]string{"source": source.SQL, "target": target.SQL} } if source.Rollback != target.Rollback { changes["rollback"] = map[string]string{"source": source.Rollback, "target": target.Rollback} } if !reflect.DeepEqual(source.RunAfter, target.RunAfter) { changes["run_after"] = map[string][]string{"source": source.RunAfter, "target": target.RunAfter} } if source.Schema != target.Schema { changes["schema"] = map[string]string{"source": source.Schema, "target": target.Schema} } if source.Version != target.Version { changes["version"] = map[string]string{"source": source.Version, "target": target.Version} } if source.Priority != target.Priority { changes["priority"] = map[string]int{"source": source.Priority, "target": target.Priority} } if source.Sequence != target.Sequence { changes["sequence"] = map[string]uint{"source": source.Sequence, "target": target.Sequence} } 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 case *ScriptDiff: 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) } if schemaChange.Scripts != nil { summary.Scripts.Missing += len(schemaChange.Scripts.Missing) summary.Scripts.Extra += len(schemaChange.Scripts.Extra) summary.Scripts.Modified += len(schemaChange.Scripts.Modified) } } } return summary }