1use std::fmt::Write;
4use std::path::{Path, PathBuf};
5
6use crate::commands::overrides;
7use crate::config::{Casing, Config, Dialect, Driver, MigrationPrefix};
8use crate::error::CliError;
9use crate::output;
10use drizzle_migrations::schema::Snapshot;
11
12#[derive(clap::Args, Debug, Clone)]
13pub struct GenerateOptions {
14 #[arg(short, long)]
16 pub name: Option<String>,
17
18 #[arg(long)]
20 pub custom: bool,
21
22 #[arg(long)]
24 pub casing: Option<Casing>,
25
26 #[arg(long)]
28 pub dialect: Option<Dialect>,
29
30 #[arg(long)]
32 pub driver: Option<Driver>,
33
34 #[arg(long, value_delimiter = ',')]
36 pub schema: Option<Vec<String>>,
37
38 #[arg(long)]
40 pub out: Option<std::path::PathBuf>,
41
42 #[arg(long)]
44 pub breakpoints: Option<bool>,
45
46 #[arg(long)]
49 pub ignore_conflicts: bool,
50
51 #[arg(long, conflicts_with_all = ["ignore_conflicts", "custom"])]
56 pub fix_conflicts: bool,
57
58 #[command(flatten)]
59 pub hints: crate::commands::renames::HintArgs,
60}
61
62pub fn run(config: &Config, db_name: Option<&str>, opts: GenerateOptions) -> Result<(), CliError> {
93 use drizzle_migrations::naming::{PrefixMode, generate_migration_tag_with_mode};
94
95 let db = config.database(db_name)?;
96
97 let effective_casing = opts.casing.or(db.casing);
99 let effective_dialect = overrides::resolve_dialect(db, opts.dialect);
100 let effective_driver = overrides::resolve_driver(db, effective_dialect, opts.driver)?;
101 let effective_breakpoints = opts.breakpoints.unwrap_or(db.breakpoints);
102 let out_dir = opts
103 .out
104 .clone()
105 .unwrap_or_else(|| db.migrations_dir().to_path_buf());
106
107 crate::commands::harness::print_db_header(config, db_name);
108
109 println!("{}", output::heading("Generating migration..."));
110
111 println!(
112 " {}: {}",
113 output::label("Dialect"),
114 effective_dialect.as_str()
115 );
116 if let Some(driver) = effective_driver {
117 println!(" {}: {}", output::label("Driver"), driver);
118 }
119
120 std::fs::create_dir_all(&out_dir).map_err(|e| CliError::IoError(e.to_string()))?;
122
123 let legacy_journal_path = out_dir.join("meta").join("_journal.json");
124 if legacy_journal_path.exists() {
125 return Err(CliError::Other(
126 "Detected old drizzle-kit migration folders. Upgrade them before generating new migrations."
127 .to_string(),
128 ));
129 }
130
131 if opts.custom {
133 let bundle = db.bundle_enabled();
134 return generate_custom_migration(
135 &out_dir,
136 effective_breakpoints,
137 db.migrations.as_ref().and_then(|m| m.prefix),
138 opts.name,
139 bundle,
140 );
141 }
142
143 let parse_result = parse_schema_files(db, opts.schema.as_deref())?;
145
146 if parse_result.tables.is_empty() && parse_result.indexes.is_empty() {
147 println!(
148 "{}",
149 output::warning("No tables or indexes found in schema files.")
150 );
151 return Ok(());
152 }
153
154 println!(
155 " {} {} table(s), {} index(es)",
156 output::label("Found"),
157 parse_result.tables.len(),
158 parse_result.indexes.len()
159 );
160
161 let dialect = effective_dialect.to_base();
163
164 let current_snapshot = Snapshot::from_parse_result(&parse_result, dialect, effective_casing);
166
167 let mut set_aside = None;
171 if opts.fix_conflicts {
172 let folders =
173 drizzle_migrations::history::conflict_fix(&out_dir, dialect).map_err(history_error)?;
174 if !folders.is_empty() {
175 println!(
176 "{}",
177 output::warning(
178 "Deleting conflicting migrations (reset any database that ran them):"
179 )
180 );
181 for folder in &folders {
182 println!(" {folder}");
183 }
184 set_aside = Some(SetAside::new(&out_dir, folders)?);
185 }
186 }
187
188 let merge_base =
191 drizzle_migrations::history::load_merge_base(&out_dir, dialect, opts.ignore_conflicts)
192 .map_err(history_error)?;
193
194 let options = crate::commands::renames::resolve_for_command(
196 &merge_base.snapshot,
197 ¤t_snapshot,
198 drizzle_migrations::DiffOptions::new(),
199 &opts.hints,
200 )?;
201 let mut generated = generate_diff(&merge_base.snapshot, ¤t_snapshot, &options)?;
202 if let Some(prev_ids) = merge_base.prev_ids {
203 println!(
204 " {} {} migration branches",
205 output::label("Merging"),
206 prev_ids.len()
207 );
208 generated.snapshot.set_prev_ids(prev_ids);
209 }
210
211 if generated.is_empty() {
212 println!("{}", output::warning("No schema changes detected 😴"));
213 if let Some(set_aside) = set_aside {
214 set_aside.delete()?;
215 if db.bundle_enabled() {
216 write_migrations_js(&out_dir)?;
217 }
218 }
219 return Ok(());
220 }
221
222 println!(
223 " {} {} SQL statement(s)",
224 output::label("Generated"),
225 generated.statements.len()
226 );
227
228 if !generated.warnings.is_empty() {
230 println!("{}", output::warning("Warnings:"));
231 for warning in &generated.warnings {
232 println!(" {} {}", output::warning("-"), warning);
233 }
234 println!();
235 }
236
237 let prefix_mode = db
238 .migrations
239 .as_ref()
240 .and_then(|m| m.prefix)
241 .map_or(PrefixMode::Timestamp, map_prefix_mode);
242
243 let next_idx = next_migration_index(&out_dir)?;
244 let migration_tag =
245 generate_migration_tag_with_mode(prefix_mode, next_idx, opts.name.as_deref());
246
247 let migration_dir =
248 write_migration_files(&out_dir, &migration_tag, &generated, effective_breakpoints)?;
249 if let Some(set_aside) = set_aside {
250 set_aside.delete()?;
251 }
252
253 if db.bundle_enabled() {
256 write_migrations_js(&out_dir)?;
257 }
258
259 println!(
260 "{}",
261 output::success(&format!("Migration generated: {migration_tag}"))
262 );
263 println!(" {}", migration_dir.display());
264
265 Ok(())
266}
267
268fn parse_schema_files(
270 db: &crate::config::DatabaseConfig,
271 schema_override: Option<&[String]>,
272) -> Result<drizzle_migrations::parser::ParseResult, CliError> {
273 use drizzle_migrations::parser::SchemaParser;
274
275 let schema_files = overrides::resolve_schema_files(db, schema_override)?;
276 if schema_files.is_empty() {
277 return Err(CliError::NoSchemaFiles(overrides::resolve_schema_display(
278 db,
279 schema_override,
280 )));
281 }
282
283 println!(
284 " {} {} schema file(s)",
285 output::label("Parsing"),
286 schema_files.len()
287 );
288
289 let mut combined_code = String::new();
290 for path in &schema_files {
291 let code = std::fs::read_to_string(path)
292 .map_err(|e| CliError::IoError(format!("Failed to read {}: {}", path.display(), e)))?;
293 combined_code.push_str(&code);
294 combined_code.push('\n');
295 }
296
297 let parse_result = SchemaParser::parse(&combined_code);
298 crate::snapshot::surface_parse_diagnostics(&parse_result)?;
299 Ok(parse_result)
300}
301
302fn write_migration_files(
304 out_dir: &Path,
305 migration_tag: &str,
306 generated: &drizzle_migrations::Plan,
307 breakpoints: bool,
308) -> Result<std::path::PathBuf, CliError> {
309 let sql_content = if breakpoints {
310 generated.statements.join("\n--> statement-breakpoint\n")
311 } else {
312 generated.statements.join("\n\n")
313 };
314
315 drizzle_migrations::writer::publish_migration_directory(out_dir, migration_tag, |folder| {
316 std::fs::write(folder.join("migration.sql"), &sql_content)
317 .map_err(|error| drizzle_migrations::MigrationError::IoError(error.to_string()))?;
318 generated
319 .snapshot
320 .save(&folder.join("snapshot.json"))
321 .map_err(|error| drizzle_migrations::MigrationError::SnapshotError(error.to_string()))
322 })
323 .map_err(map_migration_error)
324}
325
326fn history_error(error: drizzle_migrations::history::HistoryError) -> CliError {
327 match error {
328 drizzle_migrations::history::HistoryError::Io(error) => {
329 CliError::IoError(error.to_string())
330 }
331 conflicts => CliError::MigrationError(conflicts.to_string()),
332 }
333}
334
335struct SetAside {
339 out_dir: PathBuf,
340 stash: PathBuf,
341 folders: Vec<String>,
342}
343
344impl SetAside {
345 fn new(out_dir: &Path, folders: Vec<String>) -> Result<Self, CliError> {
346 let io = |e: std::io::Error| CliError::IoError(e.to_string());
347 let out_dir = std::fs::canonicalize(out_dir).map_err(io)?;
348 let name = out_dir
349 .file_name()
350 .map_or_else(|| "migrations".into(), |name| name.to_string_lossy());
351 let stash = out_dir.with_file_name(format!(".{name}.fix-conflicts"));
352 if stash.exists() {
353 return Err(CliError::IoError(format!(
354 "{} is left from an earlier run; move its folders back into {} or delete it",
355 stash.display(),
356 out_dir.display()
357 )));
358 }
359 std::fs::create_dir(&stash).map_err(io)?;
360 let mut set_aside = Self {
361 out_dir,
362 stash,
363 folders: Vec::with_capacity(folders.len()),
364 };
365 for folder in folders {
366 std::fs::rename(
367 set_aside.out_dir.join(&folder),
368 set_aside.stash.join(&folder),
369 )
370 .map_err(io)?;
371 set_aside.folders.push(folder);
372 }
373 Ok(set_aside)
374 }
375
376 fn delete(mut self) -> Result<(), CliError> {
377 self.folders.clear();
378 std::fs::remove_dir_all(&self.stash).map_err(|e| CliError::IoError(e.to_string()))
379 }
380}
381
382impl Drop for SetAside {
383 fn drop(&mut self) {
384 if self.folders.is_empty() {
385 return;
386 }
387 for folder in &self.folders {
388 if let Err(error) = std::fs::rename(self.stash.join(folder), self.out_dir.join(folder))
389 {
390 eprintln!(
391 "{}",
392 output::warning(&format!(
393 "Could not move {folder} back from {}: {error}",
394 self.stash.display()
395 ))
396 );
397 return;
398 }
399 }
400 let _ = std::fs::remove_dir(&self.stash);
401 }
402}
403
404fn generate_custom_migration(
406 out_dir: &Path,
407 _breakpoints: bool,
408 prefix: Option<MigrationPrefix>,
409 name: Option<String>,
410 bundle: bool,
411) -> Result<(), CliError> {
412 use drizzle_migrations::naming::{PrefixMode, generate_migration_tag_with_mode};
413
414 let custom_name = name.unwrap_or_else(|| "custom".to_string());
415
416 let prefix_mode = prefix.map_or(PrefixMode::Timestamp, map_prefix_mode);
417
418 let migration_tag = generate_migration_tag_with_mode(
419 prefix_mode,
420 next_migration_index(out_dir)?,
421 Some(&custom_name),
422 );
423
424 let sql_content = "-- Custom SQL migration file, put your code below! --\n\n";
425 let migration_dir = drizzle_migrations::writer::publish_migration_directory(
426 out_dir,
427 &migration_tag,
428 |folder| {
429 std::fs::write(folder.join("migration.sql"), sql_content)
430 .map_err(|error| drizzle_migrations::MigrationError::IoError(error.to_string()))
431 },
432 )
433 .map_err(map_migration_error)?;
434
435 if bundle {
437 write_migrations_js(out_dir)?;
438 }
439
440 println!(
441 "{}",
442 output::success(&format!("Custom migration created: {migration_tag}"))
443 );
444 println!(" {}", migration_dir.display());
445 println!(
446 "{}",
447 output::label(" Edit the migration file to add your SQL statements.")
448 );
449
450 Ok(())
451}
452
453const fn map_prefix_mode(p: MigrationPrefix) -> drizzle_migrations::PrefixMode {
454 match p {
455 MigrationPrefix::Index => drizzle_migrations::PrefixMode::Index,
456 MigrationPrefix::Timestamp => drizzle_migrations::PrefixMode::Timestamp,
457 MigrationPrefix::Supabase => drizzle_migrations::PrefixMode::Supabase,
458 MigrationPrefix::Unix => drizzle_migrations::PrefixMode::Unix,
459 MigrationPrefix::None => drizzle_migrations::PrefixMode::None,
460 }
461}
462
463fn next_migration_index(out_dir: &Path) -> Result<u32, CliError> {
464 let entries = collect_v3_migration_tags(out_dir)?;
465 let mut max_index: Option<u32> = None;
466
467 for tag in &entries {
468 let Some(prefix) = tag.split('_').next() else {
469 continue;
470 };
471
472 if prefix.len() > 5 || !prefix.chars().all(|c| c.is_ascii_digit()) {
475 continue;
476 }
477
478 if let Ok(idx) = prefix.parse::<u32>() {
479 max_index = Some(max_index.map_or(idx, |curr| curr.max(idx)));
480 }
481 }
482
483 Ok(max_index.map_or_else(
484 || u32::try_from(entries.len()).unwrap_or(u32::MAX),
485 |idx| idx.saturating_add(1),
486 ))
487}
488
489fn collect_v3_migration_tags(out_dir: &Path) -> Result<Vec<String>, CliError> {
490 if !out_dir.exists() {
491 return Ok(Vec::new());
492 }
493
494 let mut tags = Vec::new();
495 for entry in std::fs::read_dir(out_dir).map_err(|e| CliError::IoError(e.to_string()))? {
496 let entry = entry.map_err(|e| CliError::IoError(e.to_string()))?;
497 if !entry
498 .file_type()
499 .map_err(|e| CliError::IoError(e.to_string()))?
500 .is_dir()
501 {
502 continue;
503 }
504
505 let tag = entry.file_name().to_string_lossy().to_string();
506 if tag == "meta" {
507 continue;
508 }
509
510 if entry.path().join("migration.sql").exists() {
511 tags.push(tag);
512 }
513 }
514
515 tags.sort();
516 Ok(tags)
517}
518
519pub fn write_migrations_js(out_dir: &Path) -> Result<(), CliError> {
535 let tags = collect_v3_migration_tags(out_dir)?;
536
537 let mut content = String::new();
538 for (idx, tag) in tags.iter().enumerate() {
539 let import_name = format!("m{idx:04}");
540 let _ = writeln!(
543 content,
544 "import {import_name} from './{tag}/migration.sql';"
545 );
546 }
547
548 content.push_str("\nexport default {\n migrations: {\n");
549 for (idx, tag) in tags.iter().enumerate() {
550 let _ = writeln!(content, " \"{tag}\": m{idx:04},");
551 }
552 content.push_str(" }\n};\n");
553
554 let migrations_js_path = out_dir.join("migrations.js");
555 std::fs::write(&migrations_js_path, content).map_err(|e| CliError::IoError(e.to_string()))?;
556
557 Ok(())
558}
559
560fn map_migration_error(error: drizzle_migrations::MigrationError) -> CliError {
562 match error {
563 drizzle_migrations::MigrationError::DialectMismatch => CliError::DialectMismatch,
564 drizzle_migrations::MigrationError::NoChanges => {
565 CliError::Other("No schema changes detected".to_string())
566 }
567 drizzle_migrations::MigrationError::UnansweredRenames(questions) => {
568 CliError::MissingHints(super::renames::missing_hints_report(&questions))
569 }
570 drizzle_migrations::MigrationError::ConfigError(_)
571 | drizzle_migrations::MigrationError::IoError(_)
572 | drizzle_migrations::MigrationError::SnapshotError(_) => {
573 CliError::MigrationError(error.to_string())
574 }
575 }
576}
577
578fn generate_diff(
580 prev: &drizzle_migrations::schema::Snapshot,
581 current: &drizzle_migrations::schema::Snapshot,
582 options: &drizzle_migrations::DiffOptions,
583) -> Result<drizzle_migrations::Plan, CliError> {
584 drizzle_migrations::diff_with(prev, current, options).map_err(map_migration_error)
585}
586
587#[cfg(test)]
588mod tests {
589 use super::*;
590 use tempfile::tempdir;
591
592 fn touch_migration(out_dir: &Path, tag: &str) {
593 let dir = out_dir.join(tag);
594 std::fs::create_dir_all(&dir).expect("mkdir migration folder");
595 std::fs::write(dir.join("migration.sql"), "-- stub\n").expect("write migration.sql");
596 }
597
598 #[test]
599 fn custom_migration_rejects_unsafe_name_before_writing() {
600 let tmp = tempdir().expect("tempdir");
601 let error = generate_custom_migration(
602 tmp.path(),
603 false,
604 Some(MigrationPrefix::None),
605 Some("../escape".to_string()),
606 false,
607 )
608 .expect_err("unsafe name must fail");
609
610 assert!(matches!(error, CliError::MigrationError(_)));
611 assert_eq!(
612 std::fs::read_dir(tmp.path()).expect("read output").count(),
613 0
614 );
615 }
616
617 #[test]
618 fn custom_migration_is_published_as_complete_directory() {
619 let tmp = tempdir().expect("tempdir");
620 generate_custom_migration(
621 tmp.path(),
622 false,
623 Some(MigrationPrefix::None),
624 Some("manual_change".to_string()),
625 false,
626 )
627 .expect("generate custom migration");
628
629 let migration = tmp.path().join("manual_change");
630 assert!(migration.join("migration.sql").is_file());
631 assert_eq!(
632 std::fs::read_dir(tmp.path()).expect("read output").count(),
633 1
634 );
635 }
636
637 #[test]
638 fn migrations_js_contains_import_and_export_map_in_tag_order() {
639 let tmp = tempdir().expect("tempdir");
640 let out_dir = tmp.path();
641
642 touch_migration(out_dir, "20230331141203_first");
643 touch_migration(out_dir, "20230401091530_second");
644 touch_migration(out_dir, "20230501111111_third");
645
646 write_migrations_js(out_dir).expect("write migrations.js");
647
648 let contents =
649 std::fs::read_to_string(out_dir.join("migrations.js")).expect("read migrations.js");
650
651 assert!(
652 contents.contains("import m0000 from './20230331141203_first/migration.sql';"),
653 "first import present"
654 );
655 assert!(
656 contents.contains("import m0001 from './20230401091530_second/migration.sql';"),
657 "second import present"
658 );
659 assert!(
660 contents.contains("import m0002 from './20230501111111_third/migration.sql';"),
661 "third import present"
662 );
663 assert!(
664 contents.contains("\"20230331141203_first\": m0000,"),
665 "first map entry"
666 );
667 assert!(
668 contents.contains("\"20230401091530_second\": m0001,"),
669 "second map entry"
670 );
671 assert!(
672 contents.contains("\"20230501111111_third\": m0002,"),
673 "third map entry"
674 );
675 assert!(
676 contents.contains("export default {"),
677 "export default present"
678 );
679 }
680
681 #[test]
682 fn migrations_js_is_empty_shell_when_no_migrations_exist() {
683 let tmp = tempdir().expect("tempdir");
684 let out_dir = tmp.path();
685
686 write_migrations_js(out_dir).expect("write migrations.js");
687
688 let contents =
689 std::fs::read_to_string(out_dir.join("migrations.js")).expect("read migrations.js");
690
691 assert!(!contents.contains("import "), "no imports when empty");
692 assert!(
693 contents.contains("export default {"),
694 "export default still present"
695 );
696 assert!(
697 contents.contains("migrations: {"),
698 "migrations map still present"
699 );
700 }
701
702 #[test]
703 fn migrations_js_uses_forward_slashes_in_import_paths() {
704 let tmp = tempdir().expect("tempdir");
708 let out_dir = tmp.path();
709
710 touch_migration(out_dir, "20230331141203_first");
711
712 write_migrations_js(out_dir).expect("write migrations.js");
713
714 let contents =
715 std::fs::read_to_string(out_dir.join("migrations.js")).expect("read migrations.js");
716
717 assert!(
718 !contents.contains('\\'),
719 "import paths must use forward slashes even on Windows"
720 );
721 assert!(contents.contains("'./20230331141203_first/migration.sql'"));
722 }
723}