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 ([`CliError::Aborted`]).
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    let connection = overrides::resolve_connection(db, effective_dialect, &opts.connection)?;
77    let Some(connection) = connection else {
78        print_missing_credentials_help(effective_dialect);
79        return Err(CliError::MissingCredentials("push"));
80    };
81    println!("  {}: {}", output::label("Driver"), connection.driver);
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            effective_dialect,
118            opts.filters.extensions_filters.as_deref(),
119            db.extensions_filters.as_deref(),
120        ),
121        roles: Some(db.effective_entities().roles),
122    };
123    crate::db::apply_snapshot_filters(&mut desired_snapshot, effective_dialect, &filters)?;
124
125    // Compute push plan (DB snapshot -> desired snapshot)
126    let plan = crate::db::plan_push(
127        &connection,
128        &desired_snapshot,
129        db.breakpoints,
130        &filters,
131        db.migrations_table(),
132    )?;
133
134    if !plan.warnings.is_empty() {
135        println!("{}", output::warning("Warnings:"));
136        for w in &plan.warnings {
137            println!("  {} {}", output::warning("-"), w);
138        }
139        println!();
140    }
141
142    // Print SQL plan for explain/verbose
143    if explain || verbose {
144        if plan.sql_statements.is_empty() {
145            println!("{}", output::success("No schema changes detected."));
146            return Ok(());
147        }
148
149        println!("{}", output::muted("--- Planned SQL ---"));
150        println!();
151        for stmt in &plan.sql_statements {
152            println!("{stmt}\n");
153        }
154        println!("{}", output::muted("--- End SQL ---"));
155        println!();
156    }
157
158    // Provide explain/dry-run output when requested
159    if explain {
160        return Ok(());
161    }
162
163    if plan.sql_statements.is_empty() {
164        println!("{}", output::success("No schema changes detected."));
165        return Ok(());
166    }
167
168    // Apply plan
169    crate::db::apply_push(&connection, &plan, opts.force)?;
170
171    println!("{}", output::success("Push complete!"));
172
173    Ok(())
174}
175
176/// Emit warnings when `--schemaFilters` / `--extensionsFilters` are supplied
177/// against a non-postgres dialect (they'll be ignored).
178fn warn_unsupported_pg_filters(effective_dialect: Dialect, opts: &PushOptions) {
179    if effective_dialect == Dialect::Postgresql {
180        return;
181    }
182    if opts
183        .filters
184        .schema_filters
185        .as_ref()
186        .is_some_and(|v| !v.is_empty())
187    {
188        println!(
189            "{}",
190            output::warning("Ignoring --schemaFilters: only supported for postgresql")
191        );
192    }
193    if opts
194        .filters
195        .extensions_filters
196        .as_ref()
197        .is_some_and(|v| !v.is_empty())
198    {
199        println!(
200            "{}",
201            output::warning("Ignoring --extensionsFilters: only supported for postgresql")
202        );
203    }
204}
205
206/// Print how to configure credentials; the caller reports the failure.
207fn print_missing_credentials_help(effective_dialect: Dialect) {
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}