Skip to main content

rust_ef/migration/
engine.rs

1//! `MigrationEngine` — model snapshot diffing and migration generation.
2
3use super::ddl_expr;
4use super::dialect::MigrationDialect;
5use super::diff::{
6    columns_structurally_equal, diff_foreign_keys, diff_indexes, fk_reference_for_property,
7};
8use super::schema_change::SchemaChange;
9use super::types::{
10    Migration, ModelSnapshot, SnapshotCheck, SnapshotColumn, SnapshotEntityType,
11    SnapshotForeignKey, SnapshotIndex, SnapshotIndexColumn, SnapshotTableOptions,
12};
13use crate::error::EFResult;
14use crate::metadata::EntityTypeMeta;
15use std::collections::{HashMap, HashSet};
16
17/// The migration engine — compares old and new model snapshots to generate
18/// migration SQL.
19pub struct MigrationEngine {
20    pub(crate) dialect: MigrationDialect,
21}
22
23impl MigrationEngine {
24    pub fn new(dialect: MigrationDialect) -> Self {
25        Self { dialect }
26    }
27
28    /// Generates a migration by diffing the current model against a snapshot.
29    pub fn generate(
30        &self,
31        name: &str,
32        current: &[EntityTypeMeta],
33        previous_snapshot: &Option<ModelSnapshot>,
34    ) -> EFResult<Migration> {
35        let current_snapshot = self.create_snapshot("__current__", current);
36
37        let changes = match previous_snapshot {
38            Some(prev) => self.diff(prev, &current_snapshot),
39            None => self.initial_create_with_fks(&current_snapshot),
40        };
41
42        let up_sql = self.generate_up_sql(&changes);
43        let down_sql = self.generate_down_sql(&changes);
44
45        Ok(Migration {
46            id: name.to_string(),
47            description: name.to_string(),
48            up_sql,
49            down_sql,
50        })
51    }
52
53    /// Generates a migration directly from model snapshots.
54    ///
55    /// Unlike [`Self::generate`] this preserves snapshot-only details that
56    /// cannot be reconstructed from [`EntityTypeMeta`] (e.g. rendered
57    /// partial-index predicates). Used by `rust-ef migration add`, which reads
58    /// a snapshot file.
59    pub fn generate_from_snapshot(
60        &self,
61        name: &str,
62        current: &ModelSnapshot,
63        previous_snapshot: &Option<ModelSnapshot>,
64    ) -> EFResult<Migration> {
65        let changes = match previous_snapshot {
66            Some(prev) => self.diff(prev, current),
67            None => self.initial_create_with_fks(current),
68        };
69        Ok(Migration {
70            id: name.to_string(),
71            description: name.to_string(),
72            up_sql: self.generate_up_sql(&changes),
73            down_sql: self.generate_down_sql(&changes),
74        })
75    }
76
77    /// Creates a snapshot from current entity type metadata.
78    pub fn create_snapshot(
79        &self,
80        migration_id: &str,
81        entity_types: &[EntityTypeMeta],
82    ) -> ModelSnapshot {
83        let types = entity_types
84            .iter()
85            .map(|et| {
86                // Maps a Rust field name or a source column name to the final
87                // (possibly renamed) database column name.
88                let resolve_column = |name: &str| -> String {
89                    et.properties
90                        .iter()
91                        .find(|p| p.column_name == name)
92                        .or_else(|| et.properties.iter().find(|p| p.field_name == name))
93                        .map(|p| p.column_name.to_string())
94                        .unwrap_or_else(|| name.to_string())
95                };
96
97                // Explicit single-column foreign keys (scalar `#[foreign_key]`)
98                // take precedence over navigation-derived ones.
99                let explicit_single: HashMap<String, &crate::metadata::ForeignKeyMeta> = et
100                    .foreign_keys
101                    .iter()
102                    .filter(|fk| fk.columns.len() == 1 && fk.referenced_columns.len() == 1)
103                    .map(|fk| (resolve_column(&fk.columns[0]), fk))
104                    .collect();
105
106                let foreign_keys = et
107                    .foreign_keys
108                    .iter()
109                    .filter(|fk| !(fk.columns.len() == 1 && fk.referenced_columns.len() == 1))
110                    .map(|fk| SnapshotForeignKey {
111                        name: fk
112                            .name
113                            .as_ref()
114                            .map(|n| n.to_string())
115                            .unwrap_or_else(|| fk.derive_name(et.table_name.as_ref())),
116                        columns: fk.columns.iter().map(|c| resolve_column(c)).collect(),
117                        referenced_table: fk.referenced_table.to_string(),
118                        referenced_columns: fk
119                            .referenced_columns
120                            .iter()
121                            .map(|c| c.to_string())
122                            .collect(),
123                        on_delete: fk.on_delete.as_ref().map(|c| c.to_string()),
124                    })
125                    .collect();
126
127                let indexes = et
128                    .indexes
129                    .iter()
130                    .map(|idx| SnapshotIndex {
131                        name: idx
132                            .name
133                            .as_ref()
134                            .map(|n| n.to_string())
135                            .unwrap_or_else(|| idx.derive_name(et.table_name.as_ref())),
136                        columns: idx
137                            .columns
138                            .iter()
139                            .map(|c| SnapshotIndexColumn {
140                                value: match c {
141                                    crate::metadata::IndexColumn::Column(name) => {
142                                        resolve_column(name)
143                                    }
144                                    crate::metadata::IndexColumn::Expression(_) => {
145                                        c.as_str().to_string()
146                                    }
147                                },
148                                is_expression: c.is_expression(),
149                            })
150                            .collect(),
151                        is_unique: idx.is_unique,
152                        filter: idx
153                            .filter
154                            .as_ref()
155                            .map(|f| ddl_expr::render_filter(f, self.dialect)),
156                        include: idx.include.iter().map(|c| c.to_string()).collect(),
157                    })
158                    .collect();
159
160                let checks = et
161                    .checks
162                    .iter()
163                    .map(|c| SnapshotCheck {
164                        name: c.name.to_string(),
165                        expression: c.expression.to_string(),
166                    })
167                    .collect();
168
169                let table_options = SnapshotTableOptions {
170                    engine: et.table_options.engine.as_ref().map(|s| s.to_string()),
171                    charset: et.table_options.charset.as_ref().map(|s| s.to_string()),
172                    collate: et.table_options.collate.as_ref().map(|s| s.to_string()),
173                };
174
175                SnapshotEntityType {
176                    type_name: et.type_name.to_string(),
177                    table_name: et.table_name.to_string(),
178                    columns: et
179                        .properties
180                        .iter()
181                        .filter(|p| !p.is_not_mapped)
182                        .map(|p| {
183                            let (nav_table, nav_col, nav_on_delete) =
184                                fk_reference_for_property(et, p.column_name.as_ref());
185                            let (fk_table, fk_col, fk_on_delete) =
186                                match explicit_single.get(p.column_name.as_ref()) {
187                                    Some(fk) => (
188                                        Some(fk.referenced_table.to_string()),
189                                        Some(fk.referenced_columns[0].to_string()),
190                                        // The scalar FK wins for the target, but
191                                        // `#[on_delete]` on the navigation still
192                                        // supplies the delete behavior.
193                                        fk.on_delete
194                                            .as_ref()
195                                            .map(|c| c.to_string())
196                                            .or(nav_on_delete),
197                                    ),
198                                    None => (nav_table, nav_col, nav_on_delete),
199                                };
200                            // A Fluent `has_foreign_key` (single column) or a
201                            // navigation also makes this a FK even without a
202                            // `#[foreign_key]` attribute.
203                            let is_foreign_key = p.is_foreign_key || fk_table.is_some();
204                            SnapshotColumn {
205                                field_name: p.field_name.to_string(),
206                                column_name: p.column_name.to_string(),
207                                type_name: p.type_name.to_string(),
208                                is_primary_key: p.is_primary_key,
209                                is_required: p.is_required,
210                                is_foreign_key,
211                                max_length: p.max_length,
212                                is_auto_increment: p.is_auto_increment,
213                                is_sequence: p.is_sequence,
214                                sequence_name: p.sequence_name.as_ref().map(|s| s.to_string()),
215                                fk_referenced_table: fk_table,
216                                fk_referenced_column: fk_col,
217                                has_index: p.has_index,
218                                is_unique: p.is_unique,
219                                fk_on_delete,
220                                default_value: p.default_value.as_ref().map(|s| s.to_string()),
221                                column_type: p.column_type.as_ref().map(|s| s.to_string()),
222                                precision: p.precision,
223                                scale: p.scale,
224                                is_fixed_length: p.is_fixed_length,
225                                collation: p.collation.as_ref().map(|s| s.to_string()),
226                                comment: p.comment.as_ref().map(|s| s.to_string()),
227                                computed_sql: p.computed_sql.as_ref().map(|s| s.to_string()),
228                                computed_stored: p.computed_stored,
229                            }
230                        })
231                        .collect(),
232                    indexes,
233                    foreign_keys,
234                    checks,
235                    table_options,
236                    is_view: et.is_view,
237                    is_keyless: et.is_keyless,
238                    comment: et.comment.as_ref().map(|s| s.to_string()),
239                }
240            })
241            .collect();
242
243        ModelSnapshot {
244            migration_id: migration_id.to_string(),
245            entity_types: types,
246        }
247    }
248
249    // -----------------------------------------------------------------------
250    // Diffing
251    // -----------------------------------------------------------------------
252
253    pub(crate) fn initial_create(&self, current: &ModelSnapshot) -> Vec<SchemaChange> {
254        current
255            .entity_types
256            .iter()
257            .filter(|et| !et.is_view)
258            .map(|et| SchemaChange::CreateTable {
259                table: et.table_name.clone(),
260                columns: et.columns.clone(),
261                checks: et.checks.clone(),
262                table_options: et.table_options.clone(),
263                foreign_keys: et.foreign_keys.clone(),
264                comment: et.comment.clone(),
265            })
266            .collect()
267    }
268
269    fn diff(&self, old: &ModelSnapshot, new: &ModelSnapshot) -> Vec<SchemaChange> {
270        let mut changes = Vec::new();
271
272        // Build lookup maps by table name
273        // Views are excluded from table DDL/diffing entirely.
274        let old_tables: HashMap<&str, &SnapshotEntityType> = old
275            .entity_types
276            .iter()
277            .filter(|e| !e.is_view)
278            .map(|e| (e.table_name.as_str(), e))
279            .collect();
280        let new_tables: HashMap<&str, &SnapshotEntityType> = new
281            .entity_types
282            .iter()
283            .filter(|e| !e.is_view)
284            .map(|e| (e.table_name.as_str(), e))
285            .collect();
286
287        let old_names: HashSet<&str> = old_tables.keys().copied().collect();
288        let new_names: HashSet<&str> = new_tables.keys().copied().collect();
289
290        // Dropped tables
291        for name in old_names.difference(&new_names) {
292            changes.push(SchemaChange::DropTable {
293                table: name.to_string(),
294            });
295        }
296
297        // New tables
298        for name in new_names.difference(&old_names) {
299            let et = new_tables[name];
300            changes.push(SchemaChange::CreateTable {
301                table: et.table_name.clone(),
302                columns: et.columns.clone(),
303                checks: et.checks.clone(),
304                table_options: et.table_options.clone(),
305                foreign_keys: et.foreign_keys.clone(),
306                comment: et.comment.clone(),
307            });
308            // SQLite cannot ALTER TABLE ADD CONSTRAINT, so its foreign keys are
309            // emitted inline in CREATE TABLE instead.
310            if self.dialect != MigrationDialect::Sqlite {
311                Self::append_create_table_fks(&mut changes, &et.table_name, &et.columns);
312                Self::append_composite_fks(&mut changes, et);
313            }
314            Self::append_create_table_indexes(&mut changes, &et.table_name, &et.columns);
315            Self::append_declared_indexes(&mut changes, et);
316        }
317
318        // Tables present in both — compare columns
319        for name in old_names.intersection(&new_names) {
320            let old_et = old_tables[name];
321            let new_et = new_tables[name];
322            let table = &old_et.table_name;
323
324            let old_cols: HashMap<&str, &SnapshotColumn> = old_et
325                .columns
326                .iter()
327                .map(|c| (c.column_name.as_str(), c))
328                .collect();
329            let new_cols: HashMap<&str, &SnapshotColumn> = new_et
330                .columns
331                .iter()
332                .map(|c| (c.column_name.as_str(), c))
333                .collect();
334
335            let old_col_names: HashSet<&str> = old_cols.keys().copied().collect();
336            let new_col_names: HashSet<&str> = new_cols.keys().copied().collect();
337
338            // Added columns (with indexes if configured)
339            for col_name in new_col_names.difference(&old_col_names) {
340                let col = new_cols[col_name];
341                changes.push(SchemaChange::AddColumn {
342                    table: table.clone(),
343                    column: (*col).clone(),
344                });
345                changes.extend(diff_indexes(table, &SnapshotColumn::default(), col));
346            }
347
348            // Foreign keys (after add-column, before drop-column)
349            changes.extend(diff_foreign_keys(table, old_et, new_et));
350            changes.extend(super::diff::diff_composite_foreign_keys(
351                table, old_et, new_et,
352            ));
353
354            // Declarative indexes / CHECK constraints / table options.
355            changes.extend(super::diff::diff_declared_indexes(table, old_et, new_et));
356            changes.extend(super::diff::diff_checks(table, old_et, new_et));
357            changes.extend(super::diff::diff_table_comment(table, old_et, new_et));
358            changes.extend(super::diff::diff_table_options(table, old_et, new_et));
359
360            // Dropped columns
361            for col_name in old_col_names.difference(&new_col_names) {
362                changes.push(SchemaChange::DropColumn {
363                    table: table.clone(),
364                    column_name: col_name.to_string(),
365                });
366            }
367
368            // Altered columns and index changes (present in both)
369            for col_name in old_col_names.intersection(&new_col_names) {
370                let old_col = old_cols[col_name];
371                let new_col = new_cols[col_name];
372
373                // Index state changes → CreateIndex/DropIndex
374                changes.extend(diff_indexes(table, old_col, new_col));
375
376                // Structural changes (excluding index fields) → AlterColumn
377                if !columns_structurally_equal(old_col, new_col) {
378                    changes.push(SchemaChange::AlterColumn {
379                        table: table.clone(),
380                        column_name: col_name.to_string(),
381                        old: Box::new((*old_col).clone()),
382                        new: Box::new((*new_col).clone()),
383                    });
384                }
385            }
386        }
387
388        changes
389    }
390
391    pub(super) fn append_create_table_fks(
392        changes: &mut Vec<SchemaChange>,
393        table: &str,
394        columns: &[SnapshotColumn],
395    ) {
396        for col in columns {
397            if let Some((ref_table, ref_col)) = super::diff::fk_target(col) {
398                changes.push(SchemaChange::AddForeignKey {
399                    table: table.to_string(),
400                    column: col.column_name.clone(),
401                    referenced_table: ref_table,
402                    referenced_column: ref_col,
403                    on_delete: col.fk_on_delete.clone(),
404                });
405            }
406        }
407    }
408
409    pub(super) fn append_create_table_indexes(
410        changes: &mut Vec<SchemaChange>,
411        table: &str,
412        columns: &[SnapshotColumn],
413    ) {
414        for col in columns {
415            if col.is_unique {
416                changes.push(SchemaChange::CreateIndex {
417                    table: table.to_string(),
418                    column: col.column_name.clone(),
419                    is_unique: true,
420                });
421            } else if col.has_index {
422                changes.push(SchemaChange::CreateIndex {
423                    table: table.to_string(),
424                    column: col.column_name.clone(),
425                    is_unique: false,
426                });
427            }
428        }
429    }
430
431    /// Appends `CreateDeclaredIndex` changes for an entity type's declarative
432    /// indexes (composite / partial / expression).
433    pub(super) fn append_declared_indexes(
434        changes: &mut Vec<SchemaChange>,
435        et: &SnapshotEntityType,
436    ) {
437        for index in &et.indexes {
438            changes.push(SchemaChange::CreateDeclaredIndex {
439                table: et.table_name.clone(),
440                index: index.clone(),
441            });
442        }
443    }
444
445    /// Appends `AddCompositeForeignKey` changes for multi-column foreign keys.
446    pub(super) fn append_composite_fks(changes: &mut Vec<SchemaChange>, et: &SnapshotEntityType) {
447        for fk in &et.foreign_keys {
448            changes.push(SchemaChange::AddCompositeForeignKey {
449                table: et.table_name.clone(),
450                foreign_key: fk.clone(),
451            });
452        }
453    }
454}