Skip to main content

drizzle_cli/commands/
generate.rs

1//! `drizzle generate`: writes a migration folder from schema changes.
2
3use 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    /// Migration name (optional, auto-generated if not provided)
15    #[arg(short, long)]
16    pub name: Option<String>,
17
18    /// Create a custom (empty) migration file for manual SQL
19    #[arg(long)]
20    pub custom: bool,
21
22    /// Casing for generated identifiers (`camelCase` or `snake_case`)
23    #[arg(long)]
24    pub casing: Option<Casing>,
25
26    /// Override dialect from config
27    #[arg(long)]
28    pub dialect: Option<Dialect>,
29
30    /// Override driver from config
31    #[arg(long)]
32    pub driver: Option<Driver>,
33
34    /// Override schema path(s)
35    #[arg(long, value_delimiter = ',')]
36    pub schema: Option<Vec<String>>,
37
38    /// Override output directory
39    #[arg(long)]
40    pub out: Option<std::path::PathBuf>,
41
42    /// Override breakpoints setting
43    #[arg(long)]
44    pub breakpoints: Option<bool>,
45
46    /// When merged migration branches change the same objects, diff against
47    /// the newest snapshot instead of failing
48    #[arg(long)]
49    pub ignore_conflicts: bool,
50
51    #[command(flatten)]
52    pub hints: crate::commands::renames::HintArgs,
53}
54
55/// Runs `drizzle generate`: diffs the schema files against the newest
56/// `snapshot.json` in the migrations folder and writes
57/// `<out>/<tag>/migration.sql` plus `snapshot.json`.
58///
59/// After a git merge leaves several open heads in the snapshot history, the
60/// diff runs against the merged state of every branch instead (see
61/// [`drizzle_migrations::history::load_merge_base`]) and the new snapshot's
62/// `prevIds` lists every head, as drizzle-kit does.
63///
64/// Prints a note and writes nothing when the schema has no tables or indexes
65/// or nothing changed. With `--custom`, writes a `migration.sql` holding only
66/// a comment and no snapshot, so the next diff ignores it. Also rewrites
67/// `migrations.js` when bundling is enabled. Does not connect to the
68/// database.
69///
70/// Ambiguous renames are asked about on a terminal and otherwise answered
71/// from `--hints` / `--hints-file`; renames are never inferred (see
72/// [`renames`](crate::commands::renames)).
73///
74/// # Errors
75///
76/// Returns [`CliError`] if `db_name` does not match the config, the folder
77/// still uses the legacy `meta/_journal.json` layout (run `drizzle up`), no
78/// schema files are found or they have parse errors, merged migration
79/// branches conflict (unless `--ignore-conflicts`), the hints are invalid
80/// ([`CliError::InvalidHints`]) or leave a rename question unanswered without
81/// a terminal ([`CliError::MissingHints`]), a prompt is aborted, the diff
82/// fails, or the migration folder cannot be written (including when its tag
83/// already exists).
84pub 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    // CLI flag overrides config
90    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    // Create output directories if they don't exist
113    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    // Handle custom migration (empty migration file for manual SQL)
124    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    // Parse schema files
136    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    // Get dialect from config
154    let dialect = effective_dialect.to_base();
155
156    // Build current snapshot from parsed schema (use config dialect, not parser-detected)
157    let current_snapshot = Snapshot::from_parse_result(&parse_result, dialect, effective_casing);
158
159    // The schema the existing migrations produce: the newest snapshot, or
160    // every branch's changes merged after a git merge.
161    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    // Ask (or read hints) about ambiguous renames, then diff with the answers.
171    let options = crate::commands::renames::resolve_for_command(
172        &merge_base.snapshot,
173        &current_snapshot,
174        drizzle_migrations::DiffOptions::new(),
175        &opts.hints,
176    )?;
177    let mut generated = generate_diff(&merge_base.snapshot, &current_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    // Data-loss and review notes from the diff, as push prints them.
199    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    // Regenerate {out_dir}/migrations.js bundle index when enabled.
221    // Auto-enabled for driver = durable-sqlite (see `DatabaseConfig::bundle_enabled`).
222    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
235/// Resolve and parse schema files.
236fn 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
269/// Write migration.sql and snapshot.json to `{out_dir}/{tag}/`.
270fn 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
293/// Generate an empty custom migration for manual SQL
294fn 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    // Regenerate {out_dir}/migrations.js bundle index when enabled.
325    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        // Index prefixes are short (`0000`); longer digit runs are timestamp
362        // (14), unix (10), or millisecond (13) prefixes, not indexes.
363        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
408/// Write a `migrations.js` bundle index at the root of the migrations output
409/// folder.
410///
411/// Mirrors `drizzle-kit`'s `bundle: true` output. JS bundlers (Metro for
412/// Expo/React Native, Cloudflare Workers' bundler for Durable Objects `SQLite`)
413/// require static `import` statements to embed SQL text into the final JS
414/// bundle; this file is the entry point.
415///
416/// Rust-only consumers can ignore it — our [`drizzle_migrations::MigrationDir`]
417/// loader reads the `migration.sql` files directly.
418///
419/// # Errors
420///
421/// Returns [`CliError`] if the migrations directory cannot be enumerated or if
422/// writing the `migrations.js` file fails.
423pub 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        // Forward slashes work in JS import specifiers on every platform,
430        // including Windows — they are URL-style paths, not filesystem paths.
431        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
449/// Map migration infrastructure errors to CLI errors.
450fn 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
464/// Generate diff between two snapshots
465fn 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        // JS import specifiers use URL-style paths (always forward slashes),
591        // regardless of host filesystem separator. This guards against a
592        // Windows-specific regression that upstream shipped in beta.22.
593        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}