Skip to main content

toasty_cli/migration/
generate.rs

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