toasty_cli/migration/
generate.rs1use 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#[derive(Parser, Debug)]
31pub struct GenerateCommand {
32 #[arg(short, long)]
34 name: Option<String>,
35}
36
37fn 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 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 !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 ignored_tables.insert(dropped_table.id);
86 } else {
87 let to_table = added_tables[selection - 1];
89 drop(diff);
90 hints.add_table_hint(dropped_table.id, to_table.id);
91 continue 'main; }
93 }
94 }
95
96 for item in diff.tables().iter() {
98 if let diff::Table::Alter {
99 previous,
100 next: _,
101 columns,
102 indices,
103 } = item
104 {
105 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 ignored_columns
150 .entry(previous.id)
151 .or_default()
152 .insert(dropped_column.id);
153 } else {
154 let next_column = added_columns[selection - 1];
156 drop(diff);
157 hints.add_column_hint(dropped_column.id, next_column.id);
158 continue 'main; }
160 }
161 }
162
163 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 ignored_indices
208 .entry(previous.id)
209 .or_default()
210 .insert(dropped_index.id);
211 } else {
212 let to_index = added_indices[selection - 1];
214 drop(diff);
215 hints.add_index_hint(dropped_index.id, to_index.id);
216 continue 'main; }
218 }
219 }
220 }
221 }
222
223 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 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}