Skip to main content

toasty_cli/migration/
generate.rs

1use crate::{Config, theme::dialoguer_theme};
2use anyhow::Result;
3use clap::Parser;
4use console::style;
5use dialoguer::Select;
6use hashbrown::{HashMap, HashSet};
7use rand::RngExt;
8use std::fs;
9use toasty::migration::{self, History, HistoryEntry, Snapshot};
10use toasty::{
11    Db,
12    schema::{
13        db::{ColumnId, IndexId, Migration, Schema, TableId},
14        diff,
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<diff::RenameHints> {
39    let mut hints = diff::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 = diff::Schema::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                diff::Table::Drop(table) if !ignored_tables.contains(&table.id) => Some(*table),
53                _ => None,
54            })
55            .collect();
56
57        let added_tables: Vec<_> = diff
58            .tables()
59            .iter()
60            .filter_map(|item| match item {
61                diff::Table::Create(table) => Some(*table),
62                _ => None,
63            })
64            .collect();
65
66        // If there are both dropped and added tables, ask about potential renames
67        if !dropped_tables.is_empty() && !added_tables.is_empty() {
68            for dropped_table in &dropped_tables {
69                let mut options = vec![format!("  Drop \"{}\" ✖", dropped_table.name)];
70                for added_table in &added_tables {
71                    options.push(format!(
72                        "  Rename \"{}\" → \"{}\"",
73                        dropped_table.name, added_table.name
74                    ));
75                }
76
77                let selection = Select::with_theme(&dialoguer_theme())
78                    .with_prompt(format!("  Table \"{}\" is missing", dropped_table.name))
79                    .items(&options)
80                    .default(0)
81                    .interact()?;
82
83                if selection == 0 {
84                    // User confirmed it was dropped
85                    ignored_tables.insert(dropped_table.id);
86                } else {
87                    // User indicated a rename (selection - 1 maps to added_tables index)
88                    let to_table = added_tables[selection - 1];
89                    drop(diff);
90                    hints.add_table_hint(dropped_table.id, to_table.id);
91                    continue 'main; // Regenerate diff with new hint
92                }
93            }
94        }
95
96        // Check for column and index renames within altered tables
97        for item in diff.tables().iter() {
98            if let diff::Table::Alter {
99                previous,
100                next: _,
101                columns,
102                indices,
103            } = item
104            {
105                // Handle column renames
106                let dropped_columns: Vec<_> = columns
107                    .iter()
108                    .filter_map(|item| match item {
109                        diff::Column::Drop(column)
110                            if !ignored_columns
111                                .get(&previous.id)
112                                .is_some_and(|set| set.contains(&column.id)) =>
113                        {
114                            Some(*column)
115                        }
116                        _ => None,
117                    })
118                    .collect();
119
120                let added_columns: Vec<_> = columns
121                    .iter()
122                    .filter_map(|item| match item {
123                        diff::Column::Add(column) => Some(*column),
124                        _ => None,
125                    })
126                    .collect();
127
128                if !dropped_columns.is_empty() && !added_columns.is_empty() {
129                    for dropped_column in &dropped_columns {
130                        let mut options = vec![format!("  Drop \"{}\" ✖", dropped_column.name)];
131                        for added_column in &added_columns {
132                            options.push(format!(
133                                "  Rename \"{}\" → \"{}\"",
134                                dropped_column.name, added_column.name
135                            ));
136                        }
137
138                        let selection = Select::with_theme(&dialoguer_theme())
139                            .with_prompt(format!(
140                                "  Column \"{}\".\"{}\" is missing",
141                                previous.name, dropped_column.name
142                            ))
143                            .items(&options)
144                            .default(0)
145                            .interact()?;
146
147                        if selection == 0 {
148                            // User confirmed it was dropped
149                            ignored_columns
150                                .entry(previous.id)
151                                .or_default()
152                                .insert(dropped_column.id);
153                        } else {
154                            // User indicated a rename
155                            let next_column = added_columns[selection - 1];
156                            drop(diff);
157                            hints.add_column_hint(dropped_column.id, next_column.id);
158                            continue 'main; // Regenerate diff with new hint
159                        }
160                    }
161                }
162
163                // Handle index renames
164                let dropped_indices: Vec<_> = indices
165                    .iter()
166                    .filter_map(|item| match item {
167                        diff::Index::Drop(index)
168                            if !ignored_indices
169                                .get(&previous.id)
170                                .is_some_and(|set| set.contains(&index.id)) =>
171                        {
172                            Some(*index)
173                        }
174                        _ => None,
175                    })
176                    .collect();
177
178                let added_indices: Vec<_> = indices
179                    .iter()
180                    .filter_map(|item| match item {
181                        diff::Index::Create(index) => Some(*index),
182                        _ => None,
183                    })
184                    .collect();
185
186                if !dropped_indices.is_empty() && !added_indices.is_empty() {
187                    for dropped_index in &dropped_indices {
188                        let mut options = vec![format!("  Drop \"{}\" ✖", dropped_index.name)];
189                        for added_index in &added_indices {
190                            options.push(format!(
191                                "  Rename \"{}\" → \"{}\"",
192                                dropped_index.name, added_index.name
193                            ));
194                        }
195
196                        let selection = Select::with_theme(&dialoguer_theme())
197                            .with_prompt(format!(
198                                "  Index \"{}\".\"{}\" is missing",
199                                previous.name, dropped_index.name
200                            ))
201                            .items(&options)
202                            .default(0)
203                            .interact()?;
204
205                        if selection == 0 {
206                            // User confirmed it was dropped
207                            ignored_indices
208                                .entry(previous.id)
209                                .or_default()
210                                .insert(dropped_index.id);
211                        } else {
212                            // User indicated a rename
213                            let to_index = added_indices[selection - 1];
214                            drop(diff);
215                            hints.add_index_hint(dropped_index.id, to_index.id);
216                            continue 'main; // Regenerate diff with new hint
217                        }
218                    }
219                }
220            }
221        }
222
223        // No more potential renames to ask about
224        break;
225    }
226
227    Ok(hints)
228}
229
230impl GenerateCommand {
231    pub(crate) fn run(self, db: &Db, config: &Config) -> Result<()> {
232        println!();
233        println!(
234            "  {}",
235            style("Generate Migration").cyan().bold().underlined()
236        );
237        println!();
238
239        let history_path = config.migration.get_history_file_path();
240
241        fs::create_dir_all(config.migration.get_migrations_dir())?;
242        fs::create_dir_all(config.migration.get_snapshots_dir())?;
243        fs::create_dir_all(history_path.parent().unwrap())?;
244
245        let mut history = History::load_or_default(&history_path)?;
246
247        let previous_snapshot = history
248            .entries()
249            .last()
250            .map(|f| Snapshot::load(config.migration.get_snapshots_dir().join(&f.snapshot_name)))
251            .transpose()?;
252        let previous_schema = previous_snapshot
253            .map(|snapshot| snapshot.schema)
254            .unwrap_or_else(Schema::default);
255
256        let schema = toasty::schema::db::Schema::clone(&db.schema().db);
257
258        let rename_hints = collect_rename_hints(&previous_schema, &schema)?;
259        let Some(generated) =
260            migration::generate(db.driver(), &previous_schema, &schema, &rename_hints)
261        else {
262            println!(
263                "  {}",
264                style("The current schema matches the previous snapshot. No migration needed.")
265                    .magenta()
266                    .dim()
267            );
268            println!();
269            return Ok(());
270        };
271
272        let migration_prefix = match config.migration.prefix_style {
273            crate::MigrationPrefixStyle::Sequential => {
274                format!("{:04}", history.next_migration_number())
275            }
276            crate::MigrationPrefixStyle::Timestamp => {
277                jiff::Timestamp::now().strftime("%Y%m%d_%H%M%S").to_string()
278            }
279        };
280        let snapshot_name = format!("{:04}_snapshot.toml", &migration_prefix);
281        let snapshot_path = config.migration.get_snapshots_dir().join(&snapshot_name);
282
283        let migration_name = format!(
284            "{:04}_{}.sql",
285            &migration_prefix,
286            self.name.as_deref().unwrap_or("migration")
287        );
288        let migration_path = config.migration.get_migrations_dir().join(&migration_name);
289
290        history.add_entry(HistoryEntry {
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 = generated.migration;
299        let Migration::Sql(sql) = migration;
300        std::fs::write(&migration_path, format!("{sql}\n"))?;
301        println!(
302            "  {} {}",
303            style("✓").green().bold(),
304            style(format!("Created migration file: {}", migration_name)).dim()
305        );
306
307        generated.snapshot.save(&snapshot_path)?;
308        println!(
309            "  {} {}",
310            style("✓").green().bold(),
311            style(format!("Created snapshot: {}", snapshot_name)).dim()
312        );
313
314        history.save(&history_path)?;
315        println!(
316            "  {} {}",
317            style("✓").green().bold(),
318            style("Updated migration history").dim()
319        );
320
321        println!();
322        println!(
323            "  {}",
324            style(format!(
325                "Migration '{}' generated successfully",
326                migration_name
327            ))
328            .green()
329            .bold()
330        );
331        println!();
332
333        Ok(())
334    }
335}