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 drizzle_migrations::schema::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 =
104        Snapshot::from_parse_result(&parse_result, dialect, effective_casing);
105
106    let filters = crate::db::SnapshotFilters {
107        tables: overrides::resolve_filter_list(
108            opts.filters.tables_filter.as_deref(),
109            db.tables_filter.as_ref(),
110        ),
111        schemas: overrides::resolve_schema_filters(
112            effective_dialect,
113            opts.filters.schema_filters.as_deref(),
114            db.schema_filter.as_ref(),
115        ),
116        extensions: overrides::resolve_extensions_filter(
117            opts.filters.extensions_filters.as_deref(),
118            db.extensions_filters.as_deref(),
119        ),
120    };
121    crate::db::apply_snapshot_filters(&mut desired_snapshot, effective_dialect, &filters)?;
122
123    // Compute push plan (DB snapshot -> desired snapshot)
124    let plan = crate::db::plan_push(
125        &credentials,
126        effective_dialect,
127        &desired_snapshot,
128        db.breakpoints,
129        &filters,
130    )?;
131
132    if !plan.warnings.is_empty() {
133        println!("{}", output::warning("Warnings:"));
134        for w in &plan.warnings {
135            println!("  {} {}", output::warning("-"), w);
136        }
137        println!();
138    }
139
140    // Print SQL plan for explain/verbose
141    if explain || verbose {
142        if plan.sql_statements.is_empty() {
143            println!("{}", output::success("No schema changes detected."));
144            return Ok(());
145        }
146
147        println!("{}", output::muted("--- Planned SQL ---"));
148        println!();
149        for stmt in &plan.sql_statements {
150            println!("{stmt}\n");
151        }
152        println!("{}", output::muted("--- End SQL ---"));
153        println!();
154    }
155
156    // Provide explain/dry-run output when requested
157    if explain {
158        return Ok(());
159    }
160
161    if plan.sql_statements.is_empty() {
162        println!("{}", output::success("No schema changes detected."));
163        return Ok(());
164    }
165
166    // Apply plan
167    crate::db::apply_push(&credentials, effective_dialect, &plan, opts.force)?;
168
169    println!("{}", output::success("Push complete!"));
170
171    Ok(())
172}
173
174/// Emit warnings when `--schemaFilters` / `--extensionsFilters` are supplied
175/// against a non-postgres dialect (they'll be ignored).
176fn warn_unsupported_pg_filters(effective_dialect: Dialect, opts: &PushOptions) {
177    if effective_dialect == Dialect::Postgresql {
178        return;
179    }
180    if opts
181        .filters
182        .schema_filters
183        .as_ref()
184        .is_some_and(|v| !v.is_empty())
185    {
186        println!(
187            "{}",
188            output::warning("Ignoring --schemaFilters: only supported for postgresql")
189        );
190    }
191    if opts
192        .filters
193        .extensions_filters
194        .as_ref()
195        .is_some_and(|v| !v.is_empty())
196    {
197        println!(
198            "{}",
199            output::warning("Ignoring --extensionsFilters: only supported for postgresql")
200        );
201    }
202}
203
204/// Print a helpful message when no database credentials are configured.
205fn print_missing_credentials_help(effective_dialect: Dialect) {
206    println!("{}", output::warning("No database credentials configured."));
207    println!();
208    println!("Add credentials to your drizzle.config.toml:");
209    println!();
210    println!("  {}", output::muted("[dbCredentials]"));
211    match effective_dialect.to_base() {
212        drizzle_types::Dialect::SQLite => {
213            println!("  {}", output::muted("url = \"./dev.db\""));
214        }
215        drizzle_types::Dialect::PostgreSQL => {
216            println!(
217                "  {}",
218                output::muted("url = \"postgres://user:pass@localhost:5432/db\"")
219            );
220        }
221        drizzle_types::Dialect::MySQL => {
222            // drizzle-cli doesn't currently support MySQL end-to-end, but the base
223            // dialect type includes it, so keep the match exhaustive.
224            println!(
225                "  {}",
226                output::muted("url = \"mysql://user:pass@localhost:3306/db\"")
227            );
228        }
229    }
230    println!();
231    println!("Or use an environment variable:");
232    println!();
233    println!("  {}", output::muted("[dbCredentials]"));
234    println!("  {}", output::muted("url = { env = \"DATABASE_URL\" }"));
235}
236
237/// Resolve and parse schema files into a [`ParseResult`].
238fn parse_schema_files(
239    db: &crate::config::DatabaseConfig,
240    schema_override: Option<&[String]>,
241) -> Result<drizzle_migrations::parser::ParseResult, CliError> {
242    use drizzle_migrations::parser::SchemaParser;
243
244    let schema_files = overrides::resolve_schema_files(db, schema_override)?;
245    if schema_files.is_empty() {
246        return Err(CliError::NoSchemaFiles(overrides::resolve_schema_display(
247            db,
248            schema_override,
249        )));
250    }
251
252    println!(
253        "  {} {} schema file(s)",
254        output::label("Parsing"),
255        schema_files.len()
256    );
257
258    let mut combined_code = String::new();
259    for path in &schema_files {
260        let code = std::fs::read_to_string(path)
261            .map_err(|e| CliError::IoError(format!("Failed to read {}: {}", path.display(), e)))?;
262        combined_code.push_str(&code);
263        combined_code.push('\n');
264    }
265
266    let parse_result = SchemaParser::parse(&combined_code);
267    crate::snapshot::surface_parse_diagnostics(&parse_result)?;
268    Ok(parse_result)
269}