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