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::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 #[arg(short, long)]
22 name: Option<String>,
23}
24
25fn 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 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 !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 ignored_tables.insert(dropped_table.id);
76 } else {
77 let to_table = added_tables[selection - 1];
79 drop(diff);
80 hints.add_table_hint(dropped_table.id, to_table.id);
81 continue 'main; }
83 }
84 }
85
86 for item in diff.tables().iter() {
88 if let TablesDiffItem::AlterTable {
89 previous,
90 next: _,
91 columns,
92 indices,
93 } = item
94 {
95 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 ignored_columns
140 .entry(previous.id)
141 .or_default()
142 .insert(dropped_column.id);
143 } else {
144 let next_column = added_columns[selection - 1];
146 drop(diff);
147 hints.add_column_hint(dropped_column.id, next_column.id);
148 continue 'main; }
150 }
151 }
152
153 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 ignored_indices
198 .entry(previous.id)
199 .or_default()
200 .insert(dropped_index.id);
201 } else {
202 let to_index = added_indices[selection - 1];
204 drop(diff);
205 hints.add_index_hint(dropped_index.id, to_index.id);
206 continue 'main; }
208 }
209 }
210 }
211 }
212
213 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 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}