ormada_schema/
diff.rs

1//! Schema diff algorithm for generating migrations
2//!
3//! Compares two schema states and generates the operations needed to transform
4//! one into the other.
5
6use crate::types::*;
7
8/// Operations that can be performed on a database schema
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub enum SchemaOperation {
11    /// Create a new table
12    CreateTable(TableSchema),
13    /// Drop a table
14    DropTable(String),
15    /// Rename a table
16    RenameTable { from: String, to: String },
17    /// Add a column to a table
18    AddColumn { table: String, column: ColumnSchema },
19    /// Drop a column from a table
20    DropColumn { table: String, column: String },
21    /// Rename a column
22    RenameColumn { table: String, from: String, to: String },
23    /// Alter a column (type, nullable, default, etc.)
24    AlterColumn {
25        table: String,
26        column: String,
27        changes: ColumnChanges,
28    },
29    /// Create an index
30    CreateIndex { table: String, index: IndexSchema },
31    /// Drop an index
32    DropIndex { table: String, name: String },
33    /// Add a foreign key constraint
34    AddForeignKey {
35        table: String,
36        foreign_key: ForeignKeySchema,
37    },
38    /// Drop a foreign key constraint
39    DropForeignKey { table: String, name: String },
40}
41
42/// Changes to apply to a column
43#[derive(Debug, Clone, PartialEq, Eq, Default)]
44pub struct ColumnChanges {
45    /// New column type (if changed)
46    pub column_type: Option<ColumnType>,
47    /// New nullable setting (if changed)
48    pub nullable: Option<bool>,
49    /// New default value (Some(None) = remove default)
50    pub default: Option<Option<String>>,
51    /// New max_length (if changed)
52    pub max_length: Option<Option<u32>>,
53    /// New unique setting (if changed)
54    pub unique: Option<bool>,
55}
56
57impl ColumnChanges {
58    /// Check if there are any changes
59    pub fn is_empty(&self) -> bool {
60        self.column_type.is_none()
61            && self.nullable.is_none()
62            && self.default.is_none()
63            && self.max_length.is_none()
64            && self.unique.is_none()
65    }
66}
67
68/// Generate diff between two schema states
69///
70/// Returns a list of operations to transform `current` into `target`.
71pub fn generate_diff(current: &[TableSchema], target: &[TableSchema]) -> Vec<SchemaOperation> {
72    let mut operations = Vec::new();
73
74    let current_tables: std::collections::HashMap<&str, &TableSchema> =
75        current.iter().map(|t| (t.name.as_str(), t)).collect();
76    let target_tables: std::collections::HashMap<&str, &TableSchema> =
77        target.iter().map(|t| (t.name.as_str(), t)).collect();
78
79    // 1. Find new tables (in target but not in current)
80    for (name, schema) in &target_tables {
81        if !current_tables.contains_key(name) {
82            operations.push(SchemaOperation::CreateTable((*schema).clone()));
83        }
84    }
85
86    // 2. Find dropped tables (in current but not in target)
87    for name in current_tables.keys() {
88        if !target_tables.contains_key(name) {
89            operations.push(SchemaOperation::DropTable((*name).to_string()));
90        }
91    }
92
93    // 3. Find table modifications
94    for (name, target_table) in &target_tables {
95        if let Some(current_table) = current_tables.get(name) {
96            operations.extend(diff_table(current_table, target_table));
97        }
98    }
99
100    // 4. Order operations for safe execution
101    order_operations(operations)
102}
103
104/// Generate diff for a single table
105fn diff_table(current: &TableSchema, target: &TableSchema) -> Vec<SchemaOperation> {
106    let mut ops = Vec::new();
107    let table = &current.name;
108
109    let current_cols: std::collections::HashMap<&str, &ColumnSchema> =
110        current.columns.iter().map(|c| (c.name.as_str(), c)).collect();
111    let target_cols: std::collections::HashMap<&str, &ColumnSchema> =
112        target.columns.iter().map(|c| (c.name.as_str(), c)).collect();
113
114    // Handle renames first (check renamed_from attribute)
115    let mut renamed_from: std::collections::HashMap<&str, &str> = std::collections::HashMap::new();
116    for (name, col) in &target_cols {
117        if let Some(ref from) = col.renamed_from {
118            renamed_from.insert(from.as_str(), name);
119        }
120    }
121
122    // Handle drops (check dropped attribute)
123    for (name, col) in &target_cols {
124        if col.dropped {
125            ops.push(SchemaOperation::DropColumn {
126                table: table.clone(),
127                column: name.to_string(),
128            });
129        }
130    }
131
132    // Process renames
133    for (from, to) in &renamed_from {
134        if current_cols.contains_key(from) {
135            ops.push(SchemaOperation::RenameColumn {
136                table: table.clone(),
137                from: (*from).to_string(),
138                to: (*to).to_string(),
139            });
140        }
141    }
142
143    // New columns (not renamed, not dropped)
144    for (name, col) in &target_cols {
145        if col.dropped {
146            continue;
147        }
148
149        // Skip if this is a rename target and source exists
150        if col.renamed_from.is_some() {
151            continue;
152        }
153
154        if !current_cols.contains_key(name) {
155            ops.push(SchemaOperation::AddColumn {
156                table: table.clone(),
157                column: (*col).clone(),
158            });
159        }
160    }
161
162    // Dropped columns (in current but not in target, and not renamed)
163    for name in current_cols.keys() {
164        if !target_cols.contains_key(name) && !renamed_from.contains_key(name) {
165            // Check if any target column has dropped = true for this name
166            let explicitly_dropped = target_cols.values().any(|c| c.dropped && c.name == *name);
167
168            if !explicitly_dropped {
169                ops.push(SchemaOperation::DropColumn {
170                    table: table.clone(),
171                    column: (*name).to_string(),
172                });
173            }
174        }
175    }
176
177    // Modified columns
178    for (name, target_col) in &target_cols {
179        if target_col.dropped || target_col.renamed_from.is_some() {
180            continue;
181        }
182
183        // For renamed columns, compare with the old column
184        let current_col = if let Some(ref from) = target_col.renamed_from {
185            current_cols.get(from.as_str())
186        } else {
187            current_cols.get(name)
188        };
189
190        if let Some(current_col) = current_col {
191            if let Some(changes) = diff_column(current_col, target_col) {
192                ops.push(SchemaOperation::AlterColumn {
193                    table: table.clone(),
194                    column: name.to_string(),
195                    changes,
196                });
197            }
198        }
199    }
200
201    // Index changes
202    ops.extend(diff_indexes(table, &current.indexes, &target.indexes));
203
204    // Foreign key changes
205    ops.extend(diff_foreign_keys(table, &current.foreign_keys, &target.foreign_keys));
206
207    ops
208}
209
210/// Compare two columns and return changes if different
211fn diff_column(current: &ColumnSchema, target: &ColumnSchema) -> Option<ColumnChanges> {
212    let mut changes = ColumnChanges::default();
213
214    if current.column_type != target.column_type {
215        changes.column_type = Some(target.column_type.clone());
216    }
217
218    if current.nullable != target.nullable {
219        changes.nullable = Some(target.nullable);
220    }
221
222    if current.default != target.default {
223        changes.default = Some(target.default.clone());
224    }
225
226    if current.max_length != target.max_length {
227        changes.max_length = Some(target.max_length);
228    }
229
230    if current.unique != target.unique {
231        changes.unique = Some(target.unique);
232    }
233
234    if changes.is_empty() {
235        None
236    } else {
237        Some(changes)
238    }
239}
240
241/// Diff indexes between current and target
242fn diff_indexes(
243    table: &str,
244    current: &[IndexSchema],
245    target: &[IndexSchema],
246) -> Vec<SchemaOperation> {
247    let mut ops = Vec::new();
248
249    let current_by_name: std::collections::HashMap<&str, &IndexSchema> =
250        current.iter().map(|i| (i.name.as_str(), i)).collect();
251    let target_by_name: std::collections::HashMap<&str, &IndexSchema> =
252        target.iter().map(|i| (i.name.as_str(), i)).collect();
253
254    // New indexes
255    for (name, idx) in &target_by_name {
256        if !current_by_name.contains_key(name) {
257            ops.push(SchemaOperation::CreateIndex {
258                table: table.to_string(),
259                index: (*idx).clone(),
260            });
261        }
262    }
263
264    // Dropped indexes
265    for name in current_by_name.keys() {
266        if !target_by_name.contains_key(name) {
267            ops.push(SchemaOperation::DropIndex {
268                table: table.to_string(),
269                name: (*name).to_string(),
270            });
271        }
272    }
273
274    // Modified indexes (drop and recreate)
275    for (name, target_idx) in &target_by_name {
276        if let Some(current_idx) = current_by_name.get(name) {
277            if current_idx != target_idx {
278                ops.push(SchemaOperation::DropIndex {
279                    table: table.to_string(),
280                    name: (*name).to_string(),
281                });
282                ops.push(SchemaOperation::CreateIndex {
283                    table: table.to_string(),
284                    index: (*target_idx).clone(),
285                });
286            }
287        }
288    }
289
290    ops
291}
292
293/// Diff foreign keys between current and target
294fn diff_foreign_keys(
295    table: &str,
296    current: &[ForeignKeySchema],
297    target: &[ForeignKeySchema],
298) -> Vec<SchemaOperation> {
299    let mut ops = Vec::new();
300
301    // Use column as key since FK names might be auto-generated
302    let current_by_col: std::collections::HashMap<&str, &ForeignKeySchema> =
303        current.iter().map(|fk| (fk.column.as_str(), fk)).collect();
304    let target_by_col: std::collections::HashMap<&str, &ForeignKeySchema> =
305        target.iter().map(|fk| (fk.column.as_str(), fk)).collect();
306
307    // New foreign keys
308    for (col, fk) in &target_by_col {
309        if !current_by_col.contains_key(col) {
310            ops.push(SchemaOperation::AddForeignKey {
311                table: table.to_string(),
312                foreign_key: (*fk).clone(),
313            });
314        }
315    }
316
317    // Dropped foreign keys
318    for (col, fk) in &current_by_col {
319        if !target_by_col.contains_key(col) {
320            let name = fk.name.clone().unwrap_or_else(|| format!("fk_{table}_{col}"));
321            ops.push(SchemaOperation::DropForeignKey { table: table.to_string(), name });
322        }
323    }
324
325    // Modified foreign keys (drop and recreate)
326    for (col, target_fk) in &target_by_col {
327        if let Some(current_fk) = current_by_col.get(col) {
328            if current_fk != target_fk {
329                let name = current_fk.name.clone().unwrap_or_else(|| format!("fk_{table}_{col}"));
330                ops.push(SchemaOperation::DropForeignKey { table: table.to_string(), name });
331                ops.push(SchemaOperation::AddForeignKey {
332                    table: table.to_string(),
333                    foreign_key: (*target_fk).clone(),
334                });
335            }
336        }
337    }
338
339    ops
340}
341
342/// Order operations for safe execution
343///
344/// Order:
345/// 1. Drop foreign keys (removes dependencies)
346/// 2. Drop indexes
347/// 3. Drop columns
348/// 4. Drop tables
349/// 5. Create tables
350/// 6. Add columns
351/// 7. Rename columns
352/// 8. Alter columns
353/// 9. Create indexes
354/// 10. Add foreign keys (dependencies must exist)
355fn order_operations(mut ops: Vec<SchemaOperation>) -> Vec<SchemaOperation> {
356    ops.sort_by_key(|op| match op {
357        SchemaOperation::DropForeignKey { .. } => 0,
358        SchemaOperation::DropIndex { .. } => 1,
359        SchemaOperation::DropColumn { .. } => 2,
360        SchemaOperation::DropTable(_) => 3,
361        SchemaOperation::CreateTable(_) => 4,
362        SchemaOperation::AddColumn { .. } => 5,
363        SchemaOperation::RenameColumn { .. } => 6,
364        SchemaOperation::RenameTable { .. } => 6,
365        SchemaOperation::AlterColumn { .. } => 7,
366        SchemaOperation::CreateIndex { .. } => 8,
367        SchemaOperation::AddForeignKey { .. } => 9,
368    });
369
370    ops
371}
372
373/// Resolve a delta migration by merging with base schema
374///
375/// Takes a base schema and applies delta changes (adds, drops, renames)
376/// to produce the final schema state.
377pub fn resolve_delta(base: &TableSchema, delta: &TableSchema) -> TableSchema {
378    let mut result = base.clone();
379    result.migration_id = delta.migration_id.clone();
380
381    // Process each column in delta
382    for delta_col in &delta.columns {
383        if delta_col.dropped {
384            // Remove the column
385            result.columns.retain(|c| c.name != delta_col.name);
386            continue;
387        }
388
389        if let Some(ref from) = delta_col.renamed_from {
390            // Rename: find old column and update name
391            if let Some(col) = result.columns.iter_mut().find(|c| c.name == *from) {
392                col.name = delta_col.name.clone();
393                // Also apply any other changes from delta
394                if delta_col.max_length.is_some() {
395                    col.max_length = delta_col.max_length;
396                }
397                if delta_col.unique {
398                    col.unique = true;
399                }
400                if delta_col.indexed {
401                    col.indexed = true;
402                }
403            }
404            continue;
405        }
406
407        // Check if column already exists (modification)
408        if let Some(existing) = result.columns.iter_mut().find(|c| c.name == delta_col.name) {
409            // Update existing column
410            *existing = delta_col.clone();
411        } else {
412            // Add new column
413            result.columns.push(delta_col.clone());
414        }
415    }
416
417    // Merge indexes
418    for idx in &delta.indexes {
419        if !result.indexes.iter().any(|i| i.name == idx.name) {
420            result.indexes.push(idx.clone());
421        }
422    }
423
424    // Merge foreign keys
425    for fk in &delta.foreign_keys {
426        if !result.foreign_keys.iter().any(|f| f.column == fk.column) {
427            result.foreign_keys.push(fk.clone());
428        }
429    }
430
431    result
432}
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437
438    fn make_table(name: &str, columns: Vec<ColumnSchema>) -> TableSchema {
439        let mut table = TableSchema::new(name);
440        for col in columns {
441            if col.primary_key {
442                table.primary_key.push(col.name.clone());
443            }
444            table.columns.push(col);
445        }
446        table
447    }
448
449    #[test]
450    fn test_diff_new_table() {
451        let current: Vec<TableSchema> = vec![];
452        let target = vec![make_table(
453            "books",
454            vec![
455                ColumnSchema::new("id", ColumnType::Integer).primary_key(true),
456                ColumnSchema::new("title", ColumnType::String(Some(200))),
457            ],
458        )];
459
460        let ops = generate_diff(&current, &target);
461
462        assert_eq!(ops.len(), 1);
463        assert!(matches!(&ops[0], SchemaOperation::CreateTable(t) if t.name == "books"));
464    }
465
466    #[test]
467    fn test_diff_drop_table() {
468        let current = vec![make_table(
469            "books",
470            vec![ColumnSchema::new("id", ColumnType::Integer).primary_key(true)],
471        )];
472        let target: Vec<TableSchema> = vec![];
473
474        let ops = generate_diff(&current, &target);
475
476        assert_eq!(ops.len(), 1);
477        assert!(matches!(&ops[0], SchemaOperation::DropTable(name) if name == "books"));
478    }
479
480    #[test]
481    fn test_diff_add_column() {
482        let current = vec![make_table(
483            "books",
484            vec![ColumnSchema::new("id", ColumnType::Integer).primary_key(true)],
485        )];
486        let target = vec![make_table(
487            "books",
488            vec![
489                ColumnSchema::new("id", ColumnType::Integer).primary_key(true),
490                ColumnSchema::new("title", ColumnType::String(Some(200))),
491            ],
492        )];
493
494        let ops = generate_diff(&current, &target);
495
496        assert_eq!(ops.len(), 1);
497        assert!(matches!(
498            &ops[0],
499            SchemaOperation::AddColumn { table, column }
500            if table == "books" && column.name == "title"
501        ));
502    }
503
504    #[test]
505    fn test_diff_drop_column() {
506        let current = vec![make_table(
507            "books",
508            vec![
509                ColumnSchema::new("id", ColumnType::Integer).primary_key(true),
510                ColumnSchema::new("title", ColumnType::String(Some(200))),
511            ],
512        )];
513        let target = vec![make_table(
514            "books",
515            vec![ColumnSchema::new("id", ColumnType::Integer).primary_key(true)],
516        )];
517
518        let ops = generate_diff(&current, &target);
519
520        assert_eq!(ops.len(), 1);
521        assert!(matches!(
522            &ops[0],
523            SchemaOperation::DropColumn { table, column }
524            if table == "books" && column == "title"
525        ));
526    }
527
528    #[test]
529    fn test_diff_rename_column() {
530        let current = vec![make_table(
531            "books",
532            vec![
533                ColumnSchema::new("id", ColumnType::Integer).primary_key(true),
534                ColumnSchema::new("title", ColumnType::String(Some(200))),
535            ],
536        )];
537
538        let mut name_col = ColumnSchema::new("name", ColumnType::String(Some(200)));
539        name_col.renamed_from = Some("title".to_string());
540
541        let target = vec![make_table(
542            "books",
543            vec![ColumnSchema::new("id", ColumnType::Integer).primary_key(true), name_col],
544        )];
545
546        let ops = generate_diff(&current, &target);
547
548        assert!(ops.iter().any(|op| matches!(
549            op,
550            SchemaOperation::RenameColumn { table, from, to }
551            if table == "books" && from == "title" && to == "name"
552        )));
553    }
554
555    #[test]
556    fn test_resolve_delta_add_column() {
557        let base = make_table(
558            "books",
559            vec![ColumnSchema::new("id", ColumnType::Integer).primary_key(true)],
560        );
561
562        let mut delta = TableSchema::new("books");
563        delta.migration_id = Some("002".to_string());
564        delta.columns.push(ColumnSchema::new("title", ColumnType::String(Some(200))));
565
566        let result = resolve_delta(&base, &delta);
567
568        assert_eq!(result.columns.len(), 2);
569        assert!(result.find_column("id").is_some());
570        assert!(result.find_column("title").is_some());
571        assert_eq!(result.migration_id, Some("002".to_string()));
572    }
573
574    #[test]
575    fn test_resolve_delta_drop_column() {
576        let base = make_table(
577            "books",
578            vec![
579                ColumnSchema::new("id", ColumnType::Integer).primary_key(true),
580                ColumnSchema::new("legacy", ColumnType::String(None)),
581            ],
582        );
583
584        let mut delta = TableSchema::new("books");
585        let mut drop_col = ColumnSchema::new("legacy", ColumnType::String(None));
586        drop_col.dropped = true;
587        delta.columns.push(drop_col);
588
589        let result = resolve_delta(&base, &delta);
590
591        assert_eq!(result.columns.len(), 1);
592        assert!(result.find_column("id").is_some());
593        assert!(result.find_column("legacy").is_none());
594    }
595
596    #[test]
597    fn test_operation_ordering() {
598        let ops = vec![
599            SchemaOperation::AddColumn {
600                table: "t".to_string(),
601                column: ColumnSchema::new("c", ColumnType::Integer),
602            },
603            SchemaOperation::DropForeignKey {
604                table: "t".to_string(),
605                name: "fk".to_string(),
606            },
607            SchemaOperation::CreateTable(TableSchema::new("new")),
608            SchemaOperation::DropTable("old".to_string()),
609        ];
610
611        let ordered = order_operations(ops);
612
613        // DropForeignKey should come first
614        assert!(matches!(&ordered[0], SchemaOperation::DropForeignKey { .. }));
615        // DropTable before CreateTable
616        assert!(matches!(&ordered[1], SchemaOperation::DropTable(_)));
617        assert!(matches!(&ordered[2], SchemaOperation::CreateTable(_)));
618        // AddColumn last
619        assert!(matches!(&ordered[3], SchemaOperation::AddColumn { .. }));
620    }
621
622    #[test]
623    fn test_diff_alter_column_type() {
624        let current = vec![make_table(
625            "books",
626            vec![
627                ColumnSchema::new("id", ColumnType::Integer).primary_key(true),
628                ColumnSchema::new("price", ColumnType::Integer),
629            ],
630        )];
631        let target = vec![make_table(
632            "books",
633            vec![
634                ColumnSchema::new("id", ColumnType::Integer).primary_key(true),
635                ColumnSchema::new("price", ColumnType::Double),
636            ],
637        )];
638
639        let ops = generate_diff(&current, &target);
640
641        assert_eq!(ops.len(), 1);
642        assert!(matches!(
643            &ops[0],
644            SchemaOperation::AlterColumn { table, column, changes }
645            if table == "books" && column == "price" && changes.column_type.is_some()
646        ));
647    }
648
649    #[test]
650    fn test_diff_multiple_tables() {
651        let current: Vec<TableSchema> = vec![];
652        let target = vec![
653            make_table(
654                "authors",
655                vec![ColumnSchema::new("id", ColumnType::Integer).primary_key(true)],
656            ),
657            make_table(
658                "books",
659                vec![ColumnSchema::new("id", ColumnType::Integer).primary_key(true)],
660            ),
661        ];
662
663        let ops = generate_diff(&current, &target);
664
665        assert_eq!(ops.len(), 2);
666        let table_names: Vec<_> = ops
667            .iter()
668            .filter_map(|op| {
669                if let SchemaOperation::CreateTable(t) = op {
670                    Some(t.name.as_str())
671                } else {
672                    None
673                }
674            })
675            .collect();
676        assert!(table_names.contains(&"authors"));
677        assert!(table_names.contains(&"books"));
678    }
679
680    #[test]
681    fn test_diff_no_changes() {
682        let schema = vec![make_table(
683            "books",
684            vec![
685                ColumnSchema::new("id", ColumnType::Integer).primary_key(true),
686                ColumnSchema::new("title", ColumnType::String(Some(200))),
687            ],
688        )];
689
690        let ops = generate_diff(&schema, &schema);
691
692        assert!(ops.is_empty());
693    }
694
695    #[test]
696    fn test_resolve_delta_rename_column() {
697        let base = make_table(
698            "authors",
699            vec![
700                ColumnSchema::new("id", ColumnType::Integer).primary_key(true),
701                ColumnSchema::new("name", ColumnType::String(Some(100))),
702            ],
703        );
704
705        let mut delta = TableSchema::new("authors");
706        let mut renamed_col = ColumnSchema::new("full_name", ColumnType::String(Some(100)));
707        renamed_col.renamed_from = Some("name".to_string());
708        delta.columns.push(renamed_col);
709
710        let result = resolve_delta(&base, &delta);
711
712        assert_eq!(result.columns.len(), 2);
713        assert!(result.find_column("id").is_some());
714        assert!(result.find_column("full_name").is_some());
715        assert!(result.find_column("name").is_none());
716    }
717
718    #[test]
719    fn test_resolve_delta_modify_existing_column() {
720        let base = make_table(
721            "books",
722            vec![
723                ColumnSchema::new("id", ColumnType::Integer).primary_key(true),
724                ColumnSchema::new("title", ColumnType::String(Some(100))),
725            ],
726        );
727
728        let mut delta = TableSchema::new("books");
729        let mut modified_col = ColumnSchema::new("title", ColumnType::String(Some(500)));
730        modified_col.unique = true;
731        modified_col.max_length = Some(500);
732        delta.columns.push(modified_col);
733
734        let result = resolve_delta(&base, &delta);
735
736        assert_eq!(result.columns.len(), 2);
737        let title_col = result.find_column("title").unwrap();
738        // When column exists, it gets replaced entirely by delta
739        assert!(title_col.unique);
740        assert_eq!(title_col.column_type, ColumnType::String(Some(500)));
741    }
742
743    #[test]
744    fn test_diff_add_index() {
745        let current = vec![make_table(
746            "books",
747            vec![ColumnSchema::new("id", ColumnType::Integer).primary_key(true)],
748        )];
749
750        let mut target_table = make_table(
751            "books",
752            vec![ColumnSchema::new("id", ColumnType::Integer).primary_key(true)],
753        );
754        target_table
755            .indexes
756            .push(crate::types::IndexSchema::new("idx_books_title", vec!["title".to_string()]));
757        let target = vec![target_table];
758
759        let ops = generate_diff(&current, &target);
760
761        assert!(ops.iter().any(|op| matches!(op, SchemaOperation::CreateIndex { .. })));
762    }
763
764    #[test]
765    fn test_diff_add_foreign_key() {
766        let current = vec![make_table(
767            "books",
768            vec![
769                ColumnSchema::new("id", ColumnType::Integer).primary_key(true),
770                ColumnSchema::new("author_id", ColumnType::Integer),
771            ],
772        )];
773
774        let mut target_table = make_table(
775            "books",
776            vec![
777                ColumnSchema::new("id", ColumnType::Integer).primary_key(true),
778                ColumnSchema::new("author_id", ColumnType::Integer),
779            ],
780        );
781        target_table.foreign_keys.push(crate::types::ForeignKeySchema::new(
782            "author_id",
783            "authors",
784            "id",
785        ));
786        let target = vec![target_table];
787
788        let ops = generate_diff(&current, &target);
789
790        assert!(ops.iter().any(|op| matches!(op, SchemaOperation::AddForeignKey { .. })));
791    }
792}