drizzle-cli 0.4.1

Command-line interface for drizzle-rs migrations
Documentation
//! `drizzle push`: applies schema changes directly, without migration files.
//!
//! Needs a database driver feature enabled at build time.

use crate::commands::overrides::{self, ConnectionOverrides, FilterArgs};
use crate::config::{Casing, Config, Dialect};
use crate::error::CliError;
use crate::output;
use drizzle_migrations::schema::Snapshot;

#[derive(clap::Args, Debug, Clone)]
pub struct PushOptions {
    /// Show all SQL statements that would be executed
    #[arg(long)]
    pub verbose: bool,

    /// Force execution without warnings (auto-approve data-loss statements)
    #[arg(long)]
    pub force: bool,

    /// Print planned SQL changes without executing them (dry run)
    #[arg(long)]
    pub explain: bool,

    /// Casing for identifiers (`camelCase` or `snake_case`)
    #[arg(long)]
    pub casing: Option<Casing>,

    /// Override dialect from config
    #[arg(long)]
    pub dialect: Option<Dialect>,

    /// Override schema path(s)
    #[arg(long, value_delimiter = ',')]
    pub schema: Option<Vec<String>>,

    #[command(flatten)]
    pub filters: FilterArgs,

    #[command(flatten)]
    pub connection: ConnectionOverrides,

    #[command(flatten)]
    pub hints: crate::commands::renames::HintArgs,
}

/// Runs `drizzle push`: introspects the database, diffs it against the
/// schema files (after applying the configured filters), and executes the
/// difference directly, without writing migration files.
///
/// `--explain` prints the planned SQL and stops; `--verbose` prints it and
/// continues. A plan that loses data (a dropped table that holds rows, a
/// dropped column that holds values) asks for confirmation unless `--force`
/// is set; see [`PushPlan::destructive`](crate::db::PushPlan::destructive).
///
/// Before planning, ambiguous renames are asked about on a terminal and
/// otherwise answered from `--hints` / `--hints-file` (see
/// [`renames`](crate::commands::renames)); `--force` does not skip them.
///
/// # Errors
///
/// Returns [`CliError`] if `db_name` does not match the config, there are no
/// credentials ([`CliError::MissingCredentials`]) or no driver for them, no
/// schema files are found or they have parse errors, introspecting or
/// planning fails, the hints are invalid ([`CliError::InvalidHints`]) or
/// leave a rename question unanswered without a terminal
/// ([`CliError::MissingHints`]), a statement fails, or the user aborts a
/// prompt or declines a destructive plan ([`CliError::Aborted`]).
pub fn run(config: &Config, db_name: Option<&str>, opts: &PushOptions) -> Result<(), CliError> {
    let db = config.database(db_name)?;

    // CLI flags override config
    let verbose = opts.verbose || db.verbose;
    let explain = opts.explain;
    let effective_casing = opts.casing.or(db.casing);
    let effective_dialect = overrides::resolve_dialect(db, opts.dialect);

    warn_unsupported_pg_filters(effective_dialect, opts);

    crate::commands::harness::print_db_header(config, db_name);

    println!("{}", output::heading("Pushing schema to database..."));
    println!();

    println!(
        "  {}: {}",
        output::label("Dialect"),
        effective_dialect.as_str()
    );

    let connection = overrides::resolve_connection(db, effective_dialect, &opts.connection)?;
    let Some(connection) = connection else {
        print_missing_credentials_help(effective_dialect);
        return Err(CliError::MissingCredentials("push"));
    };
    println!("  {}: {}", output::label("Driver"), connection.driver);

    // Parse schema files
    let parse_result = parse_schema_files(db, opts.schema.as_deref())?;

    if parse_result.tables.is_empty() && parse_result.indexes.is_empty() {
        println!(
            "{}",
            output::warning("No tables or indexes found in schema files.")
        );
        return Ok(());
    }

    println!(
        "  {} {} table(s), {} index(es)",
        output::label("Found"),
        parse_result.tables.len(),
        parse_result.indexes.len()
    );

    // Build snapshot from parsed schema (use config dialect)
    let dialect = effective_dialect.to_base();
    let mut desired_snapshot =
        Snapshot::from_parse_result(&parse_result, dialect, effective_casing);

    let filters = crate::db::SnapshotFilters {
        tables: overrides::resolve_filter_list(
            opts.filters.tables_filter.as_deref(),
            db.tables_filter.as_ref(),
        ),
        schemas: overrides::resolve_schema_filters(
            effective_dialect,
            opts.filters.schema_filters.as_deref(),
            db.schema_filter.as_ref(),
        ),
        extensions: overrides::resolve_extensions_filter(
            effective_dialect,
            opts.filters.extensions_filters.as_deref(),
            db.extensions_filters.as_deref(),
        ),
        roles: Some(db.effective_entities().roles),
    };
    crate::db::apply_snapshot_filters(&mut desired_snapshot, effective_dialect, &filters)?;

    // Compute push plan (DB snapshot -> desired snapshot). Ambiguous renames
    // are asked about (or read from hints) first; `--force` does not skip
    // them, it only approves data loss.
    let plan = crate::db::plan_push_with_renames(
        &connection,
        &desired_snapshot,
        db.breakpoints,
        &filters,
        db.migrations_table(),
        &mut |current, desired, options| {
            crate::commands::renames::resolve_for_command(current, desired, options, &opts.hints)
        },
    )?;

    if !plan.warnings.is_empty() {
        println!("{}", output::warning("Warnings:"));
        for w in &plan.warnings {
            println!("  {} {}", output::warning("-"), w);
        }
        println!();
    }

    // Print SQL plan for explain/verbose
    if explain || verbose {
        if plan.sql_statements.is_empty() {
            println!("{}", output::success("No schema changes detected."));
            return Ok(());
        }

        println!("{}", output::muted("--- Planned SQL ---"));
        println!();
        for stmt in &plan.sql_statements {
            println!("{stmt}\n");
        }
        println!("{}", output::muted("--- End SQL ---"));
        println!();
    }

    // Provide explain/dry-run output when requested
    if explain {
        return Ok(());
    }

    if plan.sql_statements.is_empty() {
        println!("{}", output::success("No schema changes detected."));
        return Ok(());
    }

    // Apply plan
    crate::db::apply_push(&connection, &plan, opts.force)?;

    println!("{}", output::success("Push complete!"));

    Ok(())
}

/// Emit warnings when `--schemaFilters` / `--extensionsFilters` are supplied
/// against a non-postgres dialect (they'll be ignored).
fn warn_unsupported_pg_filters(effective_dialect: Dialect, opts: &PushOptions) {
    if effective_dialect == Dialect::Postgresql {
        return;
    }
    if opts
        .filters
        .schema_filters
        .as_ref()
        .is_some_and(|v| !v.is_empty())
    {
        println!(
            "{}",
            output::warning("Ignoring --schemaFilters: only supported for postgresql")
        );
    }
    if opts
        .filters
        .extensions_filters
        .as_ref()
        .is_some_and(|v| !v.is_empty())
    {
        println!(
            "{}",
            output::warning("Ignoring --extensionsFilters: only supported for postgresql")
        );
    }
}

/// Print how to configure credentials; the caller reports the failure.
fn print_missing_credentials_help(effective_dialect: Dialect) {
    println!("Add credentials to your drizzle.config.toml:");
    println!();
    println!("  {}", output::muted("[dbCredentials]"));
    match effective_dialect.to_base() {
        drizzle_types::Dialect::SQLite => {
            println!("  {}", output::muted("url = \"./dev.db\""));
        }
        drizzle_types::Dialect::PostgreSQL => {
            println!(
                "  {}",
                output::muted("url = \"postgres://user:pass@localhost:5432/db\"")
            );
        }
        drizzle_types::Dialect::MySQL => {
            println!(
                "  {}",
                output::muted("url = \"mysql://user:pass@localhost:3306/db\"")
            );
        }
    }
    println!();
    println!("Or use an environment variable:");
    println!();
    println!("  {}", output::muted("[dbCredentials]"));
    println!("  {}", output::muted("url = { env = \"DATABASE_URL\" }"));
}

/// Resolve and parse schema files into a [`ParseResult`].
fn parse_schema_files(
    db: &crate::config::DatabaseConfig,
    schema_override: Option<&[String]>,
) -> Result<drizzle_migrations::parser::ParseResult, CliError> {
    use drizzle_migrations::parser::SchemaParser;

    let schema_files = overrides::resolve_schema_files(db, schema_override)?;
    if schema_files.is_empty() {
        return Err(CliError::NoSchemaFiles(overrides::resolve_schema_display(
            db,
            schema_override,
        )));
    }

    println!(
        "  {} {} schema file(s)",
        output::label("Parsing"),
        schema_files.len()
    );

    let mut combined_code = String::new();
    for path in &schema_files {
        let code = std::fs::read_to_string(path)
            .map_err(|e| CliError::IoError(format!("Failed to read {}: {}", path.display(), e)))?;
        combined_code.push_str(&code);
        combined_code.push('\n');
    }

    let parse_result = SchemaParser::parse(&combined_code);
    crate::snapshot::surface_parse_diagnostics(&parse_result)?;
    Ok(parse_result)
}