1use std::fmt::Write;
4use std::path::Path;
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 #[command(flatten)]
52 pub hints: crate::commands::renames::HintArgs,
53}
54
55pub fn run(config: &Config, db_name: Option<&str>, opts: GenerateOptions) -> Result<(), CliError> {
85 use drizzle_migrations::naming::{PrefixMode, generate_migration_tag_with_mode};
86
87 let db = config.database(db_name)?;
88
89 let effective_casing = opts.casing.or(db.casing);
91 let effective_dialect = overrides::resolve_dialect(db, opts.dialect);
92 let effective_driver = overrides::resolve_driver(db, effective_dialect, opts.driver)?;
93 let effective_breakpoints = opts.breakpoints.unwrap_or(db.breakpoints);
94 let out_dir = opts
95 .out
96 .clone()
97 .unwrap_or_else(|| db.migrations_dir().to_path_buf());
98
99 crate::commands::harness::print_db_header(config, db_name);
100
101 println!("{}", output::heading("Generating migration..."));
102
103 println!(
104 " {}: {}",
105 output::label("Dialect"),
106 effective_dialect.as_str()
107 );
108 if let Some(driver) = effective_driver {
109 println!(" {}: {}", output::label("Driver"), driver);
110 }
111
112 std::fs::create_dir_all(&out_dir).map_err(|e| CliError::IoError(e.to_string()))?;
114
115 let legacy_journal_path = out_dir.join("meta").join("_journal.json");
116 if legacy_journal_path.exists() {
117 return Err(CliError::Other(
118 "Detected old drizzle-kit migration folders. Upgrade them before generating new migrations."
119 .to_string(),
120 ));
121 }
122
123 if opts.custom {
125 let bundle = db.bundle_enabled();
126 return generate_custom_migration(
127 &out_dir,
128 effective_breakpoints,
129 db.migrations.as_ref().and_then(|m| m.prefix),
130 opts.name,
131 bundle,
132 );
133 }
134
135 let parse_result = parse_schema_files(db, opts.schema.as_deref())?;
137
138 if parse_result.tables.is_empty() && parse_result.indexes.is_empty() {
139 println!(
140 "{}",
141 output::warning("No tables or indexes found in schema files.")
142 );
143 return Ok(());
144 }
145
146 println!(
147 " {} {} table(s), {} index(es)",
148 output::label("Found"),
149 parse_result.tables.len(),
150 parse_result.indexes.len()
151 );
152
153 let dialect = effective_dialect.to_base();
155
156 let current_snapshot = Snapshot::from_parse_result(&parse_result, dialect, effective_casing);
158
159 let merge_base =
162 drizzle_migrations::history::load_merge_base(&out_dir, dialect, opts.ignore_conflicts)
163 .map_err(|error| match error {
164 drizzle_migrations::history::HistoryError::Io(error) => {
165 CliError::IoError(error.to_string())
166 }
167 conflicts => CliError::MigrationError(conflicts.to_string()),
168 })?;
169
170 let options = crate::commands::renames::resolve_for_command(
172 &merge_base.snapshot,
173 ¤t_snapshot,
174 drizzle_migrations::DiffOptions::new(),
175 &opts.hints,
176 )?;
177 let mut generated = generate_diff(&merge_base.snapshot, ¤t_snapshot, &options)?;
178 if let Some(prev_ids) = merge_base.prev_ids {
179 println!(
180 " {} {} migration branches",
181 output::label("Merging"),
182 prev_ids.len()
183 );
184 generated.snapshot.set_prev_ids(prev_ids);
185 }
186
187 if generated.is_empty() {
188 println!("{}", output::warning("No schema changes detected 😴"));
189 return Ok(());
190 }
191
192 println!(
193 " {} {} SQL statement(s)",
194 output::label("Generated"),
195 generated.statements.len()
196 );
197
198 if !generated.warnings.is_empty() {
200 println!("{}", output::warning("Warnings:"));
201 for warning in &generated.warnings {
202 println!(" {} {}", output::warning("-"), warning);
203 }
204 println!();
205 }
206
207 let prefix_mode = db
208 .migrations
209 .as_ref()
210 .and_then(|m| m.prefix)
211 .map_or(PrefixMode::Timestamp, map_prefix_mode);
212
213 let next_idx = next_migration_index(&out_dir)?;
214 let migration_tag =
215 generate_migration_tag_with_mode(prefix_mode, next_idx, opts.name.as_deref());
216
217 let migration_dir =
218 write_migration_files(&out_dir, &migration_tag, &generated, effective_breakpoints)?;
219
220 if db.bundle_enabled() {
223 write_migrations_js(&out_dir)?;
224 }
225
226 println!(
227 "{}",
228 output::success(&format!("Migration generated: {migration_tag}"))
229 );
230 println!(" {}", migration_dir.display());
231
232 Ok(())
233}
234
235fn parse_schema_files(
237 db: &crate::config::DatabaseConfig,
238 schema_override: Option<&[String]>,
239) -> Result<drizzle_migrations::parser::ParseResult, CliError> {
240 use drizzle_migrations::parser::SchemaParser;
241
242 let schema_files = overrides::resolve_schema_files(db, schema_override)?;
243 if schema_files.is_empty() {
244 return Err(CliError::NoSchemaFiles(overrides::resolve_schema_display(
245 db,
246 schema_override,
247 )));
248 }
249
250 println!(
251 " {} {} schema file(s)",
252 output::label("Parsing"),
253 schema_files.len()
254 );
255
256 let mut combined_code = String::new();
257 for path in &schema_files {
258 let code = std::fs::read_to_string(path)
259 .map_err(|e| CliError::IoError(format!("Failed to read {}: {}", path.display(), e)))?;
260 combined_code.push_str(&code);
261 combined_code.push('\n');
262 }
263
264 let parse_result = SchemaParser::parse(&combined_code);
265 crate::snapshot::surface_parse_diagnostics(&parse_result)?;
266 Ok(parse_result)
267}
268
269fn write_migration_files(
271 out_dir: &Path,
272 migration_tag: &str,
273 generated: &drizzle_migrations::Plan,
274 breakpoints: bool,
275) -> Result<std::path::PathBuf, CliError> {
276 let sql_content = if breakpoints {
277 generated.statements.join("\n--> statement-breakpoint\n")
278 } else {
279 generated.statements.join("\n\n")
280 };
281
282 drizzle_migrations::writer::publish_migration_directory(out_dir, migration_tag, |folder| {
283 std::fs::write(folder.join("migration.sql"), &sql_content)
284 .map_err(|error| drizzle_migrations::MigrationError::IoError(error.to_string()))?;
285 generated
286 .snapshot
287 .save(&folder.join("snapshot.json"))
288 .map_err(|error| drizzle_migrations::MigrationError::SnapshotError(error.to_string()))
289 })
290 .map_err(map_migration_error)
291}
292
293fn generate_custom_migration(
295 out_dir: &Path,
296 _breakpoints: bool,
297 prefix: Option<MigrationPrefix>,
298 name: Option<String>,
299 bundle: bool,
300) -> Result<(), CliError> {
301 use drizzle_migrations::naming::{PrefixMode, generate_migration_tag_with_mode};
302
303 let custom_name = name.unwrap_or_else(|| "custom".to_string());
304
305 let prefix_mode = prefix.map_or(PrefixMode::Timestamp, map_prefix_mode);
306
307 let migration_tag = generate_migration_tag_with_mode(
308 prefix_mode,
309 next_migration_index(out_dir)?,
310 Some(&custom_name),
311 );
312
313 let sql_content = "-- Custom SQL migration file, put your code below! --\n\n";
314 let migration_dir = drizzle_migrations::writer::publish_migration_directory(
315 out_dir,
316 &migration_tag,
317 |folder| {
318 std::fs::write(folder.join("migration.sql"), sql_content)
319 .map_err(|error| drizzle_migrations::MigrationError::IoError(error.to_string()))
320 },
321 )
322 .map_err(map_migration_error)?;
323
324 if bundle {
326 write_migrations_js(out_dir)?;
327 }
328
329 println!(
330 "{}",
331 output::success(&format!("Custom migration created: {migration_tag}"))
332 );
333 println!(" {}", migration_dir.display());
334 println!(
335 "{}",
336 output::label(" Edit the migration file to add your SQL statements.")
337 );
338
339 Ok(())
340}
341
342const fn map_prefix_mode(p: MigrationPrefix) -> drizzle_migrations::PrefixMode {
343 match p {
344 MigrationPrefix::Index => drizzle_migrations::PrefixMode::Index,
345 MigrationPrefix::Timestamp => drizzle_migrations::PrefixMode::Timestamp,
346 MigrationPrefix::Supabase => drizzle_migrations::PrefixMode::Supabase,
347 MigrationPrefix::Unix => drizzle_migrations::PrefixMode::Unix,
348 MigrationPrefix::None => drizzle_migrations::PrefixMode::None,
349 }
350}
351
352fn next_migration_index(out_dir: &Path) -> Result<u32, CliError> {
353 let entries = collect_v3_migration_tags(out_dir)?;
354 let mut max_index: Option<u32> = None;
355
356 for tag in &entries {
357 let Some(prefix) = tag.split('_').next() else {
358 continue;
359 };
360
361 if prefix.len() > 5 || !prefix.chars().all(|c| c.is_ascii_digit()) {
364 continue;
365 }
366
367 if let Ok(idx) = prefix.parse::<u32>() {
368 max_index = Some(max_index.map_or(idx, |curr| curr.max(idx)));
369 }
370 }
371
372 Ok(max_index.map_or_else(
373 || u32::try_from(entries.len()).unwrap_or(u32::MAX),
374 |idx| idx.saturating_add(1),
375 ))
376}
377
378fn collect_v3_migration_tags(out_dir: &Path) -> Result<Vec<String>, CliError> {
379 if !out_dir.exists() {
380 return Ok(Vec::new());
381 }
382
383 let mut tags = Vec::new();
384 for entry in std::fs::read_dir(out_dir).map_err(|e| CliError::IoError(e.to_string()))? {
385 let entry = entry.map_err(|e| CliError::IoError(e.to_string()))?;
386 if !entry
387 .file_type()
388 .map_err(|e| CliError::IoError(e.to_string()))?
389 .is_dir()
390 {
391 continue;
392 }
393
394 let tag = entry.file_name().to_string_lossy().to_string();
395 if tag == "meta" {
396 continue;
397 }
398
399 if entry.path().join("migration.sql").exists() {
400 tags.push(tag);
401 }
402 }
403
404 tags.sort();
405 Ok(tags)
406}
407
408pub fn write_migrations_js(out_dir: &Path) -> Result<(), CliError> {
424 let tags = collect_v3_migration_tags(out_dir)?;
425
426 let mut content = String::new();
427 for (idx, tag) in tags.iter().enumerate() {
428 let import_name = format!("m{idx:04}");
429 let _ = writeln!(
432 content,
433 "import {import_name} from './{tag}/migration.sql';"
434 );
435 }
436
437 content.push_str("\nexport default {\n migrations: {\n");
438 for (idx, tag) in tags.iter().enumerate() {
439 let _ = writeln!(content, " \"{tag}\": m{idx:04},");
440 }
441 content.push_str(" }\n};\n");
442
443 let migrations_js_path = out_dir.join("migrations.js");
444 std::fs::write(&migrations_js_path, content).map_err(|e| CliError::IoError(e.to_string()))?;
445
446 Ok(())
447}
448
449fn map_migration_error(error: drizzle_migrations::MigrationError) -> CliError {
451 match error {
452 drizzle_migrations::MigrationError::DialectMismatch => CliError::DialectMismatch,
453 drizzle_migrations::MigrationError::NoChanges => {
454 CliError::Other("No schema changes detected".to_string())
455 }
456 drizzle_migrations::MigrationError::ConfigError(_)
457 | drizzle_migrations::MigrationError::IoError(_)
458 | drizzle_migrations::MigrationError::SnapshotError(_) => {
459 CliError::MigrationError(error.to_string())
460 }
461 }
462}
463
464fn generate_diff(
466 prev: &drizzle_migrations::schema::Snapshot,
467 current: &drizzle_migrations::schema::Snapshot,
468 options: &drizzle_migrations::DiffOptions,
469) -> Result<drizzle_migrations::Plan, CliError> {
470 drizzle_migrations::diff_with(prev, current, options).map_err(map_migration_error)
471}
472
473#[cfg(test)]
474mod tests {
475 use super::*;
476 use tempfile::tempdir;
477
478 fn touch_migration(out_dir: &Path, tag: &str) {
479 let dir = out_dir.join(tag);
480 std::fs::create_dir_all(&dir).expect("mkdir migration folder");
481 std::fs::write(dir.join("migration.sql"), "-- stub\n").expect("write migration.sql");
482 }
483
484 #[test]
485 fn custom_migration_rejects_unsafe_name_before_writing() {
486 let tmp = tempdir().expect("tempdir");
487 let error = generate_custom_migration(
488 tmp.path(),
489 false,
490 Some(MigrationPrefix::None),
491 Some("../escape".to_string()),
492 false,
493 )
494 .expect_err("unsafe name must fail");
495
496 assert!(matches!(error, CliError::MigrationError(_)));
497 assert_eq!(
498 std::fs::read_dir(tmp.path()).expect("read output").count(),
499 0
500 );
501 }
502
503 #[test]
504 fn custom_migration_is_published_as_complete_directory() {
505 let tmp = tempdir().expect("tempdir");
506 generate_custom_migration(
507 tmp.path(),
508 false,
509 Some(MigrationPrefix::None),
510 Some("manual_change".to_string()),
511 false,
512 )
513 .expect("generate custom migration");
514
515 let migration = tmp.path().join("manual_change");
516 assert!(migration.join("migration.sql").is_file());
517 assert_eq!(
518 std::fs::read_dir(tmp.path()).expect("read output").count(),
519 1
520 );
521 }
522
523 #[test]
524 fn migrations_js_contains_import_and_export_map_in_tag_order() {
525 let tmp = tempdir().expect("tempdir");
526 let out_dir = tmp.path();
527
528 touch_migration(out_dir, "20230331141203_first");
529 touch_migration(out_dir, "20230401091530_second");
530 touch_migration(out_dir, "20230501111111_third");
531
532 write_migrations_js(out_dir).expect("write migrations.js");
533
534 let contents =
535 std::fs::read_to_string(out_dir.join("migrations.js")).expect("read migrations.js");
536
537 assert!(
538 contents.contains("import m0000 from './20230331141203_first/migration.sql';"),
539 "first import present"
540 );
541 assert!(
542 contents.contains("import m0001 from './20230401091530_second/migration.sql';"),
543 "second import present"
544 );
545 assert!(
546 contents.contains("import m0002 from './20230501111111_third/migration.sql';"),
547 "third import present"
548 );
549 assert!(
550 contents.contains("\"20230331141203_first\": m0000,"),
551 "first map entry"
552 );
553 assert!(
554 contents.contains("\"20230401091530_second\": m0001,"),
555 "second map entry"
556 );
557 assert!(
558 contents.contains("\"20230501111111_third\": m0002,"),
559 "third map entry"
560 );
561 assert!(
562 contents.contains("export default {"),
563 "export default present"
564 );
565 }
566
567 #[test]
568 fn migrations_js_is_empty_shell_when_no_migrations_exist() {
569 let tmp = tempdir().expect("tempdir");
570 let out_dir = tmp.path();
571
572 write_migrations_js(out_dir).expect("write migrations.js");
573
574 let contents =
575 std::fs::read_to_string(out_dir.join("migrations.js")).expect("read migrations.js");
576
577 assert!(!contents.contains("import "), "no imports when empty");
578 assert!(
579 contents.contains("export default {"),
580 "export default still present"
581 );
582 assert!(
583 contents.contains("migrations: {"),
584 "migrations map still present"
585 );
586 }
587
588 #[test]
589 fn migrations_js_uses_forward_slashes_in_import_paths() {
590 let tmp = tempdir().expect("tempdir");
594 let out_dir = tmp.path();
595
596 touch_migration(out_dir, "20230331141203_first");
597
598 write_migrations_js(out_dir).expect("write migrations.js");
599
600 let contents =
601 std::fs::read_to_string(out_dir.join("migrations.js")).expect("read migrations.js");
602
603 assert!(
604 !contents.contains('\\'),
605 "import paths must use forward slashes even on Windows"
606 );
607 assert!(contents.contains("'./20230331141203_first/migration.sql'"));
608 }
609}