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 crate::snapshot::parse_result_to_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::words::{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 = parse_result_to_snapshot(&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 let prefix_mode = db
149 .migrations
150 .as_ref()
151 .and_then(|m| m.prefix)
152 .map_or(PrefixMode::Timestamp, map_prefix_mode);
153
154 let next_idx = next_migration_index(&out_dir)?;
155 let migration_tag =
156 generate_migration_tag_with_mode(prefix_mode, next_idx, opts.name.as_deref());
157
158 let migration_dir =
159 write_migration_files(&out_dir, &migration_tag, &generated, effective_breakpoints)?;
160
161 if db.bundle_enabled() {
164 write_migrations_js(&out_dir)?;
165 }
166
167 println!(
168 "{}",
169 output::success(&format!("Migration generated: {migration_tag}"))
170 );
171 println!(" {}", migration_dir.display());
172
173 Ok(())
174}
175
176fn parse_schema_files(
178 db: &crate::config::DatabaseConfig,
179 schema_override: Option<&[String]>,
180) -> Result<drizzle_migrations::parser::ParseResult, CliError> {
181 use drizzle_migrations::parser::SchemaParser;
182
183 let schema_files = overrides::resolve_schema_files(db, schema_override)?;
184 if schema_files.is_empty() {
185 return Err(CliError::NoSchemaFiles(overrides::resolve_schema_display(
186 db,
187 schema_override,
188 )));
189 }
190
191 println!(
192 " {} {} schema file(s)",
193 output::label("Parsing"),
194 schema_files.len()
195 );
196
197 let mut combined_code = String::new();
198 for path in &schema_files {
199 let code = std::fs::read_to_string(path)
200 .map_err(|e| CliError::IoError(format!("Failed to read {}: {}", path.display(), e)))?;
201 combined_code.push_str(&code);
202 combined_code.push('\n');
203 }
204
205 Ok(SchemaParser::parse(&combined_code))
206}
207
208fn write_migration_files(
210 out_dir: &Path,
211 migration_tag: &str,
212 generated: &drizzle_migrations::Plan,
213 breakpoints: bool,
214) -> Result<std::path::PathBuf, CliError> {
215 let migration_dir = out_dir.join(migration_tag);
216 std::fs::create_dir_all(&migration_dir).map_err(|e| CliError::IoError(e.to_string()))?;
217
218 let migration_sql_path = migration_dir.join("migration.sql");
219 let sql_content = if breakpoints {
220 generated.statements.join("\n--> statement-breakpoint\n")
221 } else {
222 generated.statements.join("\n\n")
223 };
224 std::fs::write(&migration_sql_path, &sql_content)
225 .map_err(|e| CliError::IoError(e.to_string()))?;
226
227 let snapshot_path = migration_dir.join("snapshot.json");
228 generated
229 .snapshot
230 .save(&snapshot_path)
231 .map_err(|e| CliError::IoError(e.to_string()))?;
232
233 Ok(migration_dir)
234}
235
236fn generate_custom_migration(
238 out_dir: &Path,
239 _breakpoints: bool,
240 prefix: Option<MigrationPrefix>,
241 name: Option<String>,
242 bundle: bool,
243) -> Result<(), CliError> {
244 use drizzle_migrations::words::{PrefixMode, generate_migration_tag_with_mode};
245
246 let custom_name = name.unwrap_or_else(|| "custom".to_string());
247
248 let prefix_mode = prefix.map_or(PrefixMode::Timestamp, map_prefix_mode);
249
250 let migration_tag = generate_migration_tag_with_mode(
251 prefix_mode,
252 next_migration_index(out_dir)?,
253 Some(&custom_name),
254 );
255
256 let migration_dir = out_dir.join(&migration_tag);
258 std::fs::create_dir_all(&migration_dir).map_err(|e| CliError::IoError(e.to_string()))?;
259
260 let migration_sql_path = migration_dir.join("migration.sql");
262 let sql_content = "-- Custom SQL migration file, put your code below! --\n\n";
263 std::fs::write(&migration_sql_path, sql_content)
264 .map_err(|e| CliError::IoError(e.to_string()))?;
265
266 if bundle {
268 write_migrations_js(out_dir)?;
269 }
270
271 println!(
272 "{}",
273 output::success(&format!("Custom migration created: {migration_tag}"))
274 );
275 println!(" {}", migration_dir.display());
276 println!(
277 "{}",
278 output::label(" Edit the migration file to add your SQL statements.")
279 );
280
281 Ok(())
282}
283
284const fn map_prefix_mode(p: MigrationPrefix) -> drizzle_migrations::PrefixMode {
285 match p {
286 MigrationPrefix::Index => drizzle_migrations::PrefixMode::Index,
287 MigrationPrefix::Timestamp => drizzle_migrations::PrefixMode::Timestamp,
288 MigrationPrefix::Supabase => drizzle_migrations::PrefixMode::Supabase,
289 MigrationPrefix::Unix => drizzle_migrations::PrefixMode::Unix,
290 MigrationPrefix::None => drizzle_migrations::PrefixMode::None,
291 }
292}
293
294fn load_previous_snapshot(
296 out_dir: &Path,
297 dialect: drizzle_types::Dialect,
298) -> Result<drizzle_migrations::schema::Snapshot, CliError> {
299 use drizzle_migrations::schema::Snapshot;
300
301 if let Some(snapshot_path) = latest_v3_snapshot_path(out_dir)? {
302 return Snapshot::load(&snapshot_path, dialect)
303 .map_err(|e| CliError::IoError(e.to_string()));
304 }
305
306 Ok(Snapshot::empty(dialect))
308}
309
310fn next_migration_index(out_dir: &Path) -> Result<u32, CliError> {
311 let entries = collect_v3_migration_tags(out_dir)?;
312 let mut max_index: Option<u32> = None;
313
314 for tag in &entries {
315 let Some(prefix) = tag.split('_').next() else {
316 continue;
317 };
318
319 if prefix.len() > 10 || !prefix.chars().all(|c| c.is_ascii_digit()) {
320 continue;
321 }
322
323 if let Ok(idx) = prefix.parse::<u32>() {
324 max_index = Some(max_index.map_or(idx, |curr| curr.max(idx)));
325 }
326 }
327
328 Ok(max_index.map_or_else(
329 || u32::try_from(entries.len()).unwrap_or(u32::MAX),
330 |idx| idx.saturating_add(1),
331 ))
332}
333
334fn collect_v3_migration_tags(out_dir: &Path) -> Result<Vec<String>, CliError> {
335 if !out_dir.exists() {
336 return Ok(Vec::new());
337 }
338
339 let mut tags = Vec::new();
340 for entry in std::fs::read_dir(out_dir).map_err(|e| CliError::IoError(e.to_string()))? {
341 let entry = entry.map_err(|e| CliError::IoError(e.to_string()))?;
342 if !entry
343 .file_type()
344 .map_err(|e| CliError::IoError(e.to_string()))?
345 .is_dir()
346 {
347 continue;
348 }
349
350 let tag = entry.file_name().to_string_lossy().to_string();
351 if tag == "meta" {
352 continue;
353 }
354
355 if entry.path().join("migration.sql").exists() {
356 tags.push(tag);
357 }
358 }
359
360 tags.sort();
361 Ok(tags)
362}
363
364fn latest_v3_snapshot_path(out_dir: &Path) -> Result<Option<std::path::PathBuf>, CliError> {
365 if !out_dir.exists() {
366 return Ok(None);
367 }
368
369 let mut tags = Vec::new();
370 for entry in std::fs::read_dir(out_dir).map_err(|e| CliError::IoError(e.to_string()))? {
371 let entry = entry.map_err(|e| CliError::IoError(e.to_string()))?;
372 if !entry
373 .file_type()
374 .map_err(|e| CliError::IoError(e.to_string()))?
375 .is_dir()
376 {
377 continue;
378 }
379
380 let tag = entry.file_name().to_string_lossy().to_string();
381 if tag == "meta" {
382 continue;
383 }
384
385 let snapshot_path = entry.path().join("snapshot.json");
386 if snapshot_path.exists() {
387 tags.push((tag, snapshot_path));
388 }
389 }
390
391 tags.sort_by(|a, b| a.0.cmp(&b.0));
392 Ok(tags.pop().map(|(_, path)| path))
393}
394
395pub fn write_migrations_js(out_dir: &Path) -> Result<(), CliError> {
411 let tags = collect_v3_migration_tags(out_dir)?;
412
413 let mut content = String::new();
414 for (idx, tag) in tags.iter().enumerate() {
415 let import_name = format!("m{idx:04}");
416 let _ = writeln!(
419 content,
420 "import {import_name} from './{tag}/migration.sql';"
421 );
422 }
423
424 content.push_str("\nexport default {\n migrations: {\n");
425 for (idx, tag) in tags.iter().enumerate() {
426 let _ = writeln!(content, " \"{tag}\": m{idx:04},");
427 }
428 content.push_str(" }\n};\n");
429
430 let migrations_js_path = out_dir.join("migrations.js");
431 std::fs::write(&migrations_js_path, content).map_err(|e| CliError::IoError(e.to_string()))?;
432
433 Ok(())
434}
435
436fn generate_diff(
438 prev: &drizzle_migrations::schema::Snapshot,
439 current: &drizzle_migrations::schema::Snapshot,
440) -> Result<drizzle_migrations::Plan, CliError> {
441 drizzle_migrations::diff(prev, current).map_err(|error| match error {
442 drizzle_migrations::MigrationError::DialectMismatch => CliError::DialectMismatch,
443 drizzle_migrations::MigrationError::NoChanges => {
444 CliError::Other("No schema changes detected".to_string())
445 }
446 drizzle_migrations::MigrationError::ConfigError(_)
447 | drizzle_migrations::MigrationError::IoError(_)
448 | drizzle_migrations::MigrationError::SnapshotError(_) => {
449 CliError::MigrationError(error.to_string())
450 }
451 })
452}
453
454#[cfg(test)]
455mod tests {
456 use super::*;
457 use tempfile::tempdir;
458
459 fn touch_migration(out_dir: &Path, tag: &str) {
460 let dir = out_dir.join(tag);
461 std::fs::create_dir_all(&dir).expect("mkdir migration folder");
462 std::fs::write(dir.join("migration.sql"), "-- stub\n").expect("write migration.sql");
463 }
464
465 #[test]
466 fn migrations_js_contains_import_and_export_map_in_tag_order() {
467 let tmp = tempdir().expect("tempdir");
468 let out_dir = tmp.path();
469
470 touch_migration(out_dir, "20230331141203_first");
471 touch_migration(out_dir, "20230401091530_second");
472 touch_migration(out_dir, "20230501111111_third");
473
474 write_migrations_js(out_dir).expect("write migrations.js");
475
476 let contents =
477 std::fs::read_to_string(out_dir.join("migrations.js")).expect("read migrations.js");
478
479 assert!(
480 contents.contains("import m0000 from './20230331141203_first/migration.sql';"),
481 "first import present"
482 );
483 assert!(
484 contents.contains("import m0001 from './20230401091530_second/migration.sql';"),
485 "second import present"
486 );
487 assert!(
488 contents.contains("import m0002 from './20230501111111_third/migration.sql';"),
489 "third import present"
490 );
491 assert!(
492 contents.contains("\"20230331141203_first\": m0000,"),
493 "first map entry"
494 );
495 assert!(
496 contents.contains("\"20230401091530_second\": m0001,"),
497 "second map entry"
498 );
499 assert!(
500 contents.contains("\"20230501111111_third\": m0002,"),
501 "third map entry"
502 );
503 assert!(
504 contents.contains("export default {"),
505 "export default present"
506 );
507 }
508
509 #[test]
510 fn migrations_js_is_empty_shell_when_no_migrations_exist() {
511 let tmp = tempdir().expect("tempdir");
512 let out_dir = tmp.path();
513
514 write_migrations_js(out_dir).expect("write migrations.js");
515
516 let contents =
517 std::fs::read_to_string(out_dir.join("migrations.js")).expect("read migrations.js");
518
519 assert!(!contents.contains("import "), "no imports when empty");
520 assert!(
521 contents.contains("export default {"),
522 "export default still present"
523 );
524 assert!(
525 contents.contains("migrations: {"),
526 "migrations map still present"
527 );
528 }
529
530 #[test]
531 fn migrations_js_uses_forward_slashes_in_import_paths() {
532 let tmp = tempdir().expect("tempdir");
536 let out_dir = tmp.path();
537
538 touch_migration(out_dir, "20230331141203_first");
539
540 write_migrations_js(out_dir).expect("write migrations.js");
541
542 let contents =
543 std::fs::read_to_string(out_dir.join("migrations.js")).expect("read migrations.js");
544
545 assert!(
546 !contents.contains('\\'),
547 "import paths must use forward slashes even on Windows"
548 );
549 assert!(contents.contains("'./20230331141203_first/migration.sql'"));
550 }
551}