toasty_cli/migration/
generate.rs1use 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#[derive(Parser, Debug)]
32pub struct GenerateCommand {
33 #[arg(short, long)]
35 name: Option<String>,
36}
37
38fn 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 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 !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 ignored_tables.insert(dropped_table.id);
89 } else {
90 let to_table = added_tables[selection - 1];
92 drop(diff);
93 hints.add_table_hint(dropped_table.id, to_table.id);
94 continue 'main; }
96 }
97 }
98
99 for item in diff.tables().iter() {
101 if let diff::TablesItem::AlterTable {
102 previous,
103 next: _,
104 columns,
105 indices,
106 } = item
107 {
108 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 ignored_columns
153 .entry(previous.id)
154 .or_default()
155 .insert(dropped_column.id);
156 } else {
157 let next_column = added_columns[selection - 1];
159 drop(diff);
160 hints.add_column_hint(dropped_column.id, next_column.id);
161 continue 'main; }
163 }
164 }
165
166 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 ignored_indices
211 .entry(previous.id)
212 .or_default()
213 .insert(dropped_index.id);
214 } else {
215 let to_index = added_indices[selection - 1];
217 drop(diff);
218 hints.add_index_hint(dropped_index.id, to_index.id);
219 continue 'main; }
221 }
222 }
223 }
224 }
225
226 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 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}