toasty_cli/migration/
generate.rs

1use super::{HistoryFile, HistoryFileMigration, SnapshotFile};
2use crate::{Config, theme::dialoguer_theme};
3use anyhow::Result;
4use clap::Parser;
5use console::style;
6use dialoguer::Select;
7use rand::RngExt;
8use std::collections::{HashMap, HashSet};
9use std::fs;
10use toasty::{
11    Db,
12    schema::db::{
13        ColumnId, ColumnsDiffItem, IndexId, IndicesDiffItem, Migration, RenameHints, Schema,
14        SchemaDiff, TableId, TablesDiffItem,
15    },
16};
17
18/// Generates a new SQL migration from the current schema diff.
19///
20/// Compares the current database schema (as registered on the [`Db`]) against
21/// the most recent snapshot. If there are differences, generates a SQL
22/// migration file, writes a new snapshot, and updates the history file.
23///
24/// When the diff contains dropped-and-added tables, columns, or indices, the
25/// command interactively asks whether these are renames rather than
26/// drop-then-create pairs.
27///
28/// If no schema changes are detected, the command exits without creating any
29/// files.
30#[derive(Parser, Debug)]
31pub struct GenerateCommand {
32    /// Name for the migration
33    #[arg(short, long)]
34    name: Option<String>,
35}
36
37/// Collects rename hints by interactively asking the user about potential renames
38fn collect_rename_hints(previous_schema: &Schema, schema: &Schema) -> Result<RenameHints> {
39    let mut hints = RenameHints::default();
40    let mut ignored_tables = HashSet::<TableId>::new();
41    let mut ignored_columns = HashMap::<TableId, HashSet<ColumnId>>::new();
42    let mut ignored_indices = HashMap::<TableId, HashSet<IndexId>>::new();
43
44    'main: loop {
45        let diff = SchemaDiff::from(previous_schema, schema, &hints);
46
47        // Check for table renames
48        let dropped_tables: Vec<_> = diff
49            .tables()
50            .iter()
51            .filter_map(|item| match item {
52                TablesDiffItem::DropTable(table) if !ignored_tables.contains(&table.id) => {
53                    Some(*table)
54                }
55                _ => None,
56            })
57            .collect();
58
59        let added_tables: Vec<_> = diff
60            .tables()
61            .iter()
62            .filter_map(|item| match item {
63                TablesDiffItem::CreateTable(table) => Some(*table),
64                _ => None,
65            })
66            .collect();
67
68        // If there are both dropped and added tables, ask about potential renames
69        if !dropped_tables.is_empty() && !added_tables.is_empty() {
70            for dropped_table in &dropped_tables {
71                let mut options = vec![format!("  Drop \"{}\" ✖", dropped_table.name)];
72                for added_table in &added_tables {
73                    options.push(format!(
74                        "  Rename \"{}\" → \"{}\"",
75                        dropped_table.name, added_table.name
76                    ));
77                }
78
79                let selection = Select::with_theme(&dialoguer_theme())
80                    .with_prompt(format!("  Table \"{}\" is missing", dropped_table.name))
81                    .items(&options)
82                    .default(0)
83                    .interact()?;
84
85                if selection == 0 {
86                    // User confirmed it was dropped
87                    ignored_tables.insert(dropped_table.id);
88                } else {
89                    // User indicated a rename (selection - 1 maps to added_tables index)
90                    let to_table = added_tables[selection - 1];
91                    drop(diff);
92                    hints.add_table_hint(dropped_table.id, to_table.id);
93                    continue 'main; // Regenerate diff with new hint
94                }
95            }
96        }
97
98        // Check for column and index renames within altered tables
99        for item in diff.tables().iter() {
100            if let TablesDiffItem::AlterTable {
101                previous,
102                next: _,
103                columns,
104                indices,
105            } = item
106            {
107                // Handle column renames
108                let dropped_columns: Vec<_> = columns
109                    .iter()
110                    .filter_map(|item| match item {
111                        ColumnsDiffItem::DropColumn(column)
112                            if !ignored_columns
113                                .get(&previous.id)
114                                .is_some_and(|set| set.contains(&column.id)) =>
115                        {
116                            Some(*column)
117                        }
118                        _ => None,
119                    })
120                    .collect();
121
122                let added_columns: Vec<_> = columns
123                    .iter()
124                    .filter_map(|item| match item {
125                        ColumnsDiffItem::AddColumn(column) => Some(*column),
126                        _ => None,
127                    })
128                    .collect();
129
130                if !dropped_columns.is_empty() && !added_columns.is_empty() {
131                    for dropped_column in &dropped_columns {
132                        let mut options = vec![format!("  Drop \"{}\" ✖", dropped_column.name)];
133                        for added_column in &added_columns {
134                            options.push(format!(
135                                "  Rename \"{}\" → \"{}\"",
136                                dropped_column.name, added_column.name
137                            ));
138                        }
139
140                        let selection = Select::with_theme(&dialoguer_theme())
141                            .with_prompt(format!(
142                                "  Column \"{}\".\"{}\" is missing",
143                                previous.name, dropped_column.name
144                            ))
145                            .items(&options)
146                            .default(0)
147                            .interact()?;
148
149                        if selection == 0 {
150                            // User confirmed it was dropped
151                            ignored_columns
152                                .entry(previous.id)
153                                .or_default()
154                                .insert(dropped_column.id);
155                        } else {
156                            // User indicated a rename
157                            let next_column = added_columns[selection - 1];
158                            drop(diff);
159                            hints.add_column_hint(dropped_column.id, next_column.id);
160                            continue 'main; // Regenerate diff with new hint
161                        }
162                    }
163                }
164
165                // Handle index renames
166                let dropped_indices: Vec<_> = indices
167                    .iter()
168                    .filter_map(|item| match item {
169                        IndicesDiffItem::DropIndex(index)
170                            if !ignored_indices
171                                .get(&previous.id)
172                                .is_some_and(|set| set.contains(&index.id)) =>
173                        {
174                            Some(*index)
175                        }
176                        _ => None,
177                    })
178                    .collect();
179
180                let added_indices: Vec<_> = indices
181                    .iter()
182                    .filter_map(|item| match item {
183                        IndicesDiffItem::CreateIndex(index) => Some(*index),
184                        _ => None,
185                    })
186                    .collect();
187
188                if !dropped_indices.is_empty() && !added_indices.is_empty() {
189                    for dropped_index in &dropped_indices {
190                        let mut options = vec![format!("  Drop \"{}\" ✖", dropped_index.name)];
191                        for added_index in &added_indices {
192                            options.push(format!(
193                                "  Rename \"{}\" → \"{}\"",
194                                dropped_index.name, added_index.name
195                            ));
196                        }
197
198                        let selection = Select::with_theme(&dialoguer_theme())
199                            .with_prompt(format!(
200                                "  Index \"{}\".\"{}\" is missing",
201                                previous.name, dropped_index.name
202                            ))
203                            .items(&options)
204                            .default(0)
205                            .interact()?;
206
207                        if selection == 0 {
208                            // User confirmed it was dropped
209                            ignored_indices
210                                .entry(previous.id)
211                                .or_default()
212                                .insert(dropped_index.id);
213                        } else {
214                            // User indicated a rename
215                            let to_index = added_indices[selection - 1];
216                            drop(diff);
217                            hints.add_index_hint(dropped_index.id, to_index.id);
218                            continue 'main; // Regenerate diff with new hint
219                        }
220                    }
221                }
222            }
223        }
224
225        // No more potential renames to ask about
226        break;
227    }
228
229    Ok(hints)
230}
231
232impl GenerateCommand {
233    pub(crate) fn run(self, db: &Db, config: &Config) -> Result<()> {
234        println!();
235        println!(
236            "  {}",
237            style("Generate Migration").cyan().bold().underlined()
238        );
239        println!();
240
241        let history_path = config.migration.get_history_file_path();
242
243        fs::create_dir_all(config.migration.get_migrations_dir())?;
244        fs::create_dir_all(config.migration.get_snapshots_dir())?;
245        fs::create_dir_all(history_path.parent().unwrap())?;
246
247        let mut history = HistoryFile::load_or_default(&history_path)?;
248
249        let previous_snapshot = history
250            .migrations()
251            .last()
252            .map(|f| {
253                SnapshotFile::load(config.migration.get_snapshots_dir().join(&f.snapshot_name))
254            })
255            .transpose()?;
256        let previous_schema = previous_snapshot
257            .map(|snapshot| snapshot.schema)
258            .unwrap_or_else(Schema::default);
259
260        let schema = toasty::schema::db::Schema::clone(&db.schema().db);
261
262        let rename_hints = collect_rename_hints(&previous_schema, &schema)?;
263        let diff = SchemaDiff::from(&previous_schema, &schema, &rename_hints);
264
265        if diff.is_empty() {
266            println!(
267                "  {}",
268                style("The current schema matches the previous snapshot. No migration needed.")
269                    .magenta()
270                    .dim()
271            );
272            println!();
273            return Ok(());
274        }
275
276        let snapshot = SnapshotFile::new(schema.clone());
277        let migration_number = history.next_migration_number();
278        let snapshot_name = format!("{:04}_snapshot.toml", migration_number);
279        let snapshot_path = config.migration.get_snapshots_dir().join(&snapshot_name);
280
281        let migration_name = format!(
282            "{:04}_{}.sql",
283            migration_number,
284            self.name.as_deref().unwrap_or("migration")
285        );
286        let migration_path = config.migration.get_migrations_dir().join(&migration_name);
287
288        let migration = db.driver().generate_migration(&diff);
289
290        history.add_migration(HistoryFileMigration {
291            // Some databases only supported signed 64-bit integers.
292            id: rand::rng().random_range(0..i64::MAX) as u64,
293            name: migration_name.clone(),
294            snapshot_name: snapshot_name.clone(),
295            checksum: None,
296        });
297
298        let Migration::Sql(sql) = migration;
299        std::fs::write(migration_path, sql)?;
300        println!(
301            "  {} {}",
302            style("✓").green().bold(),
303            style(format!("Created migration file: {}", migration_name)).dim()
304        );
305
306        snapshot.save(&snapshot_path)?;
307        println!(
308            "  {} {}",
309            style("✓").green().bold(),
310            style(format!("Created snapshot: {}", snapshot_name)).dim()
311        );
312
313        history.save(&history_path)?;
314        println!(
315            "  {} {}",
316            style("✓").green().bold(),
317            style("Updated migration history").dim()
318        );
319
320        println!();
321        println!(
322            "  {}",
323            style(format!(
324                "Migration '{}' generated successfully",
325                migration_name
326            ))
327            .green()
328            .bold()
329        );
330        println!();
331
332        Ok(())
333    }
334}