Skip to main content

drizzle_cli/commands/
generate.rs

1//! Generate command implementation
2//!
3//! Generates migration files from schema changes.
4
5use 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    /// Migration name (optional, auto-generated if not provided)
17    #[arg(short, long)]
18    pub name: Option<String>,
19
20    /// Create a custom (empty) migration file for manual SQL
21    #[arg(long)]
22    pub custom: bool,
23
24    /// Casing for generated identifiers (`camelCase` or `snake_case`)
25    #[arg(long)]
26    pub casing: Option<Casing>,
27
28    /// Override dialect from config
29    #[arg(long)]
30    pub dialect: Option<Dialect>,
31
32    /// Override driver from config
33    #[arg(long)]
34    pub driver: Option<Driver>,
35
36    /// Override schema path(s)
37    #[arg(long, value_delimiter = ',')]
38    pub schema: Option<Vec<String>>,
39
40    /// Override output directory
41    #[arg(long)]
42    pub out: Option<std::path::PathBuf>,
43
44    /// Override breakpoints setting
45    #[arg(long)]
46    pub breakpoints: Option<bool>,
47}
48
49/// Run the generate command.
50///
51/// # Errors
52///
53/// Returns [`CliError`] if the requested database cannot be resolved, the
54/// schema files fail to parse, snapshot/diff generation fails, or writing the
55/// new migration and journal files to disk fails.
56pub 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    // CLI flag overrides config
62    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    // Create output directories if they don't exist
85    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    // Handle custom migration (empty migration file for manual SQL)
96    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    // Parse schema files
108    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    // Get dialect from config
126    let dialect = effective_dialect.to_base();
127
128    // Build current snapshot from parsed schema (use config dialect, not parser-detected)
129    let current_snapshot = Snapshot::from_parse_result(&parse_result, dialect, effective_casing);
130
131    // Load previous snapshot if exists
132    let prev_snapshot = load_previous_snapshot(&out_dir, dialect)?;
133
134    // Generate diff
135    let generated = generate_diff(&prev_snapshot, &current_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    // Data-loss and review notes from the diff, as push prints them.
149    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    // Regenerate {out_dir}/migrations.js bundle index when enabled.
171    // Auto-enabled for driver = durable-sqlite (see `DatabaseConfig::bundle_enabled`).
172    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
185/// Resolve and parse schema files.
186fn 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
219/// Write migration.sql and snapshot.json to `{out_dir}/{tag}/`.
220fn 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
243/// Generate an empty custom migration for manual SQL
244fn 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    // Regenerate {out_dir}/migrations.js bundle index when enabled.
275    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
302/// Load the previous snapshot from the migration directory
303fn 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    // No previous snapshot, return empty
315    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        // Index prefixes are short (`0000`); longer digit runs are timestamp
328        // (14), unix (10), or millisecond (13) prefixes, not indexes.
329        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
405/// Write a `migrations.js` bundle index at the root of the migrations output
406/// folder.
407///
408/// Mirrors `drizzle-kit`'s `bundle: true` output. JS bundlers (Metro for
409/// Expo/React Native, Cloudflare Workers' bundler for Durable Objects `SQLite`)
410/// require static `import` statements to embed SQL text into the final JS
411/// bundle; this file is the entry point.
412///
413/// Rust-only consumers can ignore it — our [`drizzle_migrations::MigrationDir`]
414/// loader reads the `migration.sql` files directly.
415///
416/// # Errors
417///
418/// Returns [`CliError`] if the migrations directory cannot be enumerated or if
419/// writing the `migrations.js` file fails.
420pub 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        // Forward slashes work in JS import specifiers on every platform,
427        // including Windows — they are URL-style paths, not filesystem paths.
428        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
446/// Map migration infrastructure errors to CLI errors.
447fn 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
461/// Generate diff between two snapshots
462fn 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        // JS import specifiers use URL-style paths (always forward slashes),
587        // regardless of host filesystem separator. This guards against a
588        // Windows-specific regression that upstream shipped in beta.22.
589        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}