toasty_cli/migration/
generate.rs1use 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#[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<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 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 !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 ignored_tables.insert(dropped_table.id);
88 } else {
89 let to_table = added_tables[selection - 1];
91 drop(diff);
92 hints.add_table_hint(dropped_table.id, to_table.id);
93 continue 'main; }
95 }
96 }
97
98 for item in diff.tables().iter() {
100 if let TablesDiffItem::AlterTable {
101 previous,
102 next: _,
103 columns,
104 indices,
105 } = item
106 {
107 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 ignored_columns
152 .entry(previous.id)
153 .or_default()
154 .insert(dropped_column.id);
155 } else {
156 let next_column = added_columns[selection - 1];
158 drop(diff);
159 hints.add_column_hint(dropped_column.id, next_column.id);
160 continue 'main; }
162 }
163 }
164
165 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 ignored_indices
210 .entry(previous.id)
211 .or_default()
212 .insert(dropped_index.id);
213 } else {
214 let to_index = added_indices[selection - 1];
216 drop(diff);
217 hints.add_index_hint(dropped_index.id, to_index.id);
218 continue 'main; }
220 }
221 }
222 }
223 }
224
225 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 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}