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