Skip to main content

drizzle_cli/commands/
push.rs

1//! Push command implementation
2//!
3//! Pushes schema changes directly to the database without creating migration files.
4//! Note: This command requires database connectivity which depends on
5//! driver-specific features being enabled.
6
7use crate::commands::overrides::{self, ConnectionOverrides, FilterArgs};
8use crate::config::{Casing, Config, Dialect};
9use crate::error::CliError;
10use crate::output;
11use crate::snapshot::parse_result_to_snapshot;
12
13#[derive(clap::Args, Debug, Clone)]
14pub struct PushOptions {
15    /// Show all SQL statements that would be executed
16    #[arg(long)]
17    pub verbose: bool,
18
19    /// Force execution without warnings (auto-approve data-loss statements)
20    #[arg(long)]
21    pub force: bool,
22
23    /// Print planned SQL changes without executing them (dry run)
24    #[arg(long)]
25    pub explain: bool,
26
27    /// Casing for identifiers (`camelCase` or `snake_case`)
28    #[arg(long)]
29    pub casing: Option<Casing>,
30
31    /// Override dialect from config
32    #[arg(long)]
33    pub dialect: Option<Dialect>,
34
35    /// Override schema path(s)
36    #[arg(long, value_delimiter = ',')]
37    pub schema: Option<Vec<String>>,
38
39    #[command(flatten)]
40    pub filters: FilterArgs,
41
42    #[command(flatten)]
43    pub connection: ConnectionOverrides,
44}
45
46/// Run the push command.
47///
48/// # Errors
49///
50/// Returns [`CliError`] if the database cannot be resolved, credentials are
51/// missing or invalid, the schema files fail to parse, connecting or applying
52/// the diff to the database fails, or the user declines a destructive
53/// operation when `--force` is not set.
54pub fn run(config: &Config, db_name: Option<&str>, opts: &PushOptions) -> Result<(), CliError> {
55    let db = config.database(db_name)?;
56
57    // CLI flags override config
58    let verbose = opts.verbose || db.verbose;
59    let explain = opts.explain;
60    let effective_casing = opts.casing.or(db.casing);
61    let effective_dialect = overrides::resolve_dialect(db, opts.dialect);
62
63    warn_unsupported_pg_filters(effective_dialect, opts);
64
65    crate::commands::harness::print_db_header(config, db_name);
66
67    println!("{}", output::heading("Pushing schema to database..."));
68    println!();
69
70    println!(
71        "  {}: {}",
72        output::label("Dialect"),
73        effective_dialect.as_str()
74    );
75
76    // Get credentials
77    let credentials = overrides::resolve_credentials(db, effective_dialect, &opts.connection)?;
78    let Some(credentials) = credentials else {
79        print_missing_credentials_help(effective_dialect);
80        return Ok(());
81    };
82
83    // Parse schema files
84    let parse_result = parse_schema_files(db, opts.schema.as_deref())?;
85
86    if parse_result.tables.is_empty() && parse_result.indexes.is_empty() {
87        println!(
88            "{}",
89            output::warning("No tables or indexes found in schema files.")
90        );
91        return Ok(());
92    }
93
94    println!(
95        "  {} {} table(s), {} index(es)",
96        output::label("Found"),
97        parse_result.tables.len(),
98        parse_result.indexes.len()
99    );
100
101    // Build snapshot from parsed schema (use config dialect)
102    let dialect = effective_dialect.to_base();
103    let mut desired_snapshot = parse_result_to_snapshot(&parse_result, dialect, effective_casing);
104
105    let filters = crate::db::SnapshotFilters {
106        tables: overrides::resolve_filter_list(
107            opts.filters.tables_filter.as_deref(),
108            db.tables_filter.as_ref(),
109        ),
110        schemas: overrides::resolve_schema_filters(
111            effective_dialect,
112            opts.filters.schema_filters.as_deref(),
113            db.schema_filter.as_ref(),
114        ),
115        extensions: overrides::resolve_extensions_filter(
116            opts.filters.extensions_filters.as_deref(),
117            db.extensions_filters.as_deref(),
118        ),
119    };
120    crate::db::apply_snapshot_filters(&mut desired_snapshot, effective_dialect, &filters)?;
121
122    // Compute push plan (DB snapshot -> desired snapshot)
123    let plan = crate::db::plan_push(
124        &credentials,
125        effective_dialect,
126        &desired_snapshot,
127        db.breakpoints,
128        &filters,
129    )?;
130
131    if !plan.warnings.is_empty() {
132        println!("{}", output::warning("Warnings:"));
133        for w in &plan.warnings {
134            println!("  {} {}", output::warning("-"), w);
135        }
136        println!();
137    }
138
139    // Print SQL plan for explain/verbose
140    if explain || verbose {
141        if plan.sql_statements.is_empty() {
142            println!("{}", output::success("No schema changes detected."));
143            return Ok(());
144        }
145
146        println!("{}", output::muted("--- Planned SQL ---"));
147        println!();
148        for stmt in &plan.sql_statements {
149            println!("{stmt}\n");
150        }
151        println!("{}", output::muted("--- End SQL ---"));
152        println!();
153    }
154
155    // Provide explain/dry-run output when requested
156    if explain {
157        return Ok(());
158    }
159
160    if plan.sql_statements.is_empty() {
161        println!("{}", output::success("No schema changes detected."));
162        return Ok(());
163    }
164
165    // Apply plan
166    crate::db::apply_push(&credentials, effective_dialect, &plan, opts.force)?;
167
168    println!("{}", output::success("Push complete!"));
169
170    Ok(())
171}
172
173/// Emit warnings when `--schemaFilters` / `--extensionsFilters` are supplied
174/// against a non-postgres dialect (they'll be ignored).
175fn warn_unsupported_pg_filters(effective_dialect: Dialect, opts: &PushOptions) {
176    if effective_dialect == Dialect::Postgresql {
177        return;
178    }
179    if opts
180        .filters
181        .schema_filters
182        .as_ref()
183        .is_some_and(|v| !v.is_empty())
184    {
185        println!(
186            "{}",
187            output::warning("Ignoring --schemaFilters: only supported for postgresql")
188        );
189    }
190    if opts
191        .filters
192        .extensions_filters
193        .as_ref()
194        .is_some_and(|v| !v.is_empty())
195    {
196        println!(
197            "{}",
198            output::warning("Ignoring --extensionsFilters: only supported for postgresql")
199        );
200    }
201}
202
203/// Print a helpful message when no database credentials are configured.
204fn print_missing_credentials_help(effective_dialect: Dialect) {
205    println!("{}", output::warning("No database credentials configured."));
206    println!();
207    println!("Add credentials to your drizzle.config.toml:");
208    println!();
209    println!("  {}", output::muted("[dbCredentials]"));
210    match effective_dialect.to_base() {
211        drizzle_types::Dialect::SQLite => {
212            println!("  {}", output::muted("url = \"./dev.db\""));
213        }
214        drizzle_types::Dialect::PostgreSQL => {
215            println!(
216                "  {}",
217                output::muted("url = \"postgres://user:pass@localhost:5432/db\"")
218            );
219        }
220        drizzle_types::Dialect::MySQL => {
221            // drizzle-cli doesn't currently support MySQL end-to-end, but the base
222            // dialect type includes it, so keep the match exhaustive.
223            println!(
224                "  {}",
225                output::muted("url = \"mysql://user:pass@localhost:3306/db\"")
226            );
227        }
228    }
229    println!();
230    println!("Or use an environment variable:");
231    println!();
232    println!("  {}", output::muted("[dbCredentials]"));
233    println!("  {}", output::muted("url = { env = \"DATABASE_URL\" }"));
234}
235
236/// Resolve and parse schema files into a [`ParseResult`].
237fn parse_schema_files(
238    db: &crate::config::DatabaseConfig,
239    schema_override: Option<&[String]>,
240) -> Result<drizzle_migrations::parser::ParseResult, CliError> {
241    use drizzle_migrations::parser::SchemaParser;
242
243    let schema_files = overrides::resolve_schema_files(db, schema_override)?;
244    if schema_files.is_empty() {
245        return Err(CliError::NoSchemaFiles(overrides::resolve_schema_display(
246            db,
247            schema_override,
248        )));
249    }
250
251    println!(
252        "  {} {} schema file(s)",
253        output::label("Parsing"),
254        schema_files.len()
255    );
256
257    let mut combined_code = String::new();
258    for path in &schema_files {
259        let code = std::fs::read_to_string(path)
260            .map_err(|e| CliError::IoError(format!("Failed to read {}: {}", path.display(), e)))?;
261        combined_code.push_str(&code);
262        combined_code.push('\n');
263    }
264
265    Ok(SchemaParser::parse(&combined_code))
266}