Skip to main content

drizzle_cli/commands/
push.rs

1//! `drizzle push`: applies schema changes directly, without migration files.
2//!
3//! Needs a database driver feature enabled at build time.
4
5use crate::commands::overrides::{self, ConnectionOverrides, FilterArgs};
6use crate::config::{Casing, Config, Dialect};
7use crate::error::CliError;
8use crate::output;
9use drizzle_migrations::schema::Snapshot;
10
11#[derive(clap::Args, Debug, Clone)]
12pub struct PushOptions {
13    /// Show all SQL statements that would be executed
14    #[arg(long)]
15    pub verbose: bool,
16
17    /// Force execution without warnings (auto-approve data-loss statements)
18    #[arg(long)]
19    pub force: bool,
20
21    /// Print planned SQL changes without executing them (dry run)
22    #[arg(long)]
23    pub explain: bool,
24
25    /// Casing for identifiers (`camelCase` or `snake_case`)
26    #[arg(long)]
27    pub casing: Option<Casing>,
28
29    /// Override dialect from config
30    #[arg(long)]
31    pub dialect: Option<Dialect>,
32
33    /// Override schema path(s)
34    #[arg(long, value_delimiter = ',')]
35    pub schema: Option<Vec<String>>,
36
37    #[command(flatten)]
38    pub filters: FilterArgs,
39
40    #[command(flatten)]
41    pub connection: ConnectionOverrides,
42
43    #[command(flatten)]
44    pub hints: crate::commands::renames::HintArgs,
45}
46
47/// Runs `drizzle push`: introspects the database, diffs it against the
48/// schema files (after applying the configured filters), and executes the
49/// difference directly, without writing migration files.
50///
51/// `--explain` prints the planned SQL and stops; `--verbose` prints it and
52/// continues. A plan that loses data (a dropped table that holds rows, a
53/// dropped column that holds values) asks for confirmation unless `--force`
54/// is set; see [`PushPlan::destructive`](crate::db::PushPlan::destructive).
55///
56/// Before planning, ambiguous renames are asked about on a terminal and
57/// otherwise answered from `--hints` / `--hints-file` (see
58/// [`renames`](crate::commands::renames)); `--force` does not skip them.
59///
60/// # Errors
61///
62/// Returns [`CliError`] if `db_name` does not match the config, there are no
63/// credentials ([`CliError::MissingCredentials`]) or no driver for them, no
64/// schema files are found or they have parse errors, introspecting or
65/// planning fails, the hints are invalid ([`CliError::InvalidHints`]) or
66/// leave a rename question unanswered without a terminal
67/// ([`CliError::MissingHints`]), a statement fails, or the user aborts a
68/// prompt or declines a destructive plan ([`CliError::Aborted`]).
69pub fn run(config: &Config, db_name: Option<&str>, opts: &PushOptions) -> Result<(), CliError> {
70    let db = config.database(db_name)?;
71
72    // CLI flags override config
73    let verbose = opts.verbose || db.verbose;
74    let explain = opts.explain;
75    let effective_casing = opts.casing.or(db.casing);
76    let effective_dialect = overrides::resolve_dialect(db, opts.dialect);
77
78    warn_unsupported_pg_filters(effective_dialect, opts);
79
80    crate::commands::harness::print_db_header(config, db_name);
81
82    println!("{}", output::heading("Pushing schema to database..."));
83    println!();
84
85    println!(
86        "  {}: {}",
87        output::label("Dialect"),
88        effective_dialect.as_str()
89    );
90
91    let connection = overrides::resolve_connection(db, effective_dialect, &opts.connection)?;
92    let Some(connection) = connection else {
93        print_missing_credentials_help(effective_dialect);
94        return Err(CliError::MissingCredentials("push"));
95    };
96    println!("  {}: {}", output::label("Driver"), connection.driver);
97
98    // Parse schema files
99    let parse_result = parse_schema_files(db, opts.schema.as_deref())?;
100
101    if parse_result.tables.is_empty() && parse_result.indexes.is_empty() {
102        println!(
103            "{}",
104            output::warning("No tables or indexes found in schema files.")
105        );
106        return Ok(());
107    }
108
109    println!(
110        "  {} {} table(s), {} index(es)",
111        output::label("Found"),
112        parse_result.tables.len(),
113        parse_result.indexes.len()
114    );
115
116    // Build snapshot from parsed schema (use config dialect)
117    let dialect = effective_dialect.to_base();
118    let mut desired_snapshot =
119        Snapshot::from_parse_result(&parse_result, dialect, effective_casing);
120
121    let filters = crate::db::SnapshotFilters {
122        tables: overrides::resolve_filter_list(
123            opts.filters.tables_filter.as_deref(),
124            db.tables_filter.as_ref(),
125        ),
126        schemas: overrides::resolve_schema_filters(
127            effective_dialect,
128            opts.filters.schema_filters.as_deref(),
129            db.schema_filter.as_ref(),
130        ),
131        extensions: overrides::resolve_extensions_filter(
132            effective_dialect,
133            opts.filters.extensions_filters.as_deref(),
134            db.extensions_filters.as_deref(),
135        ),
136        roles: Some(db.effective_entities().roles),
137    };
138    crate::db::apply_snapshot_filters(&mut desired_snapshot, effective_dialect, &filters)?;
139
140    // Compute push plan (DB snapshot -> desired snapshot). Ambiguous renames
141    // are asked about (or read from hints) first; `--force` does not skip
142    // them, it only approves data loss.
143    let plan = crate::db::plan_push_with_renames(
144        &connection,
145        &desired_snapshot,
146        db.breakpoints,
147        &filters,
148        db.migrations_table(),
149        &mut |current, desired, options| {
150            crate::commands::renames::resolve_for_command(current, desired, options, &opts.hints)
151        },
152    )?;
153
154    if !plan.warnings.is_empty() {
155        println!("{}", output::warning("Warnings:"));
156        for w in &plan.warnings {
157            println!("  {} {}", output::warning("-"), w);
158        }
159        println!();
160    }
161
162    // Print SQL plan for explain/verbose
163    if explain || verbose {
164        if plan.sql_statements.is_empty() {
165            println!("{}", output::success("No schema changes detected."));
166            return Ok(());
167        }
168
169        println!("{}", output::muted("--- Planned SQL ---"));
170        println!();
171        for stmt in &plan.sql_statements {
172            println!("{stmt}\n");
173        }
174        println!("{}", output::muted("--- End SQL ---"));
175        println!();
176    }
177
178    // Provide explain/dry-run output when requested
179    if explain {
180        return Ok(());
181    }
182
183    if plan.sql_statements.is_empty() {
184        println!("{}", output::success("No schema changes detected."));
185        return Ok(());
186    }
187
188    // Apply plan
189    crate::db::apply_push(&connection, &plan, opts.force)?;
190
191    println!("{}", output::success("Push complete!"));
192
193    Ok(())
194}
195
196/// Emit warnings when `--schemaFilters` / `--extensionsFilters` are supplied
197/// against a non-postgres dialect (they'll be ignored).
198fn warn_unsupported_pg_filters(effective_dialect: Dialect, opts: &PushOptions) {
199    if effective_dialect == Dialect::Postgresql {
200        return;
201    }
202    if opts
203        .filters
204        .schema_filters
205        .as_ref()
206        .is_some_and(|v| !v.is_empty())
207    {
208        println!(
209            "{}",
210            output::warning("Ignoring --schemaFilters: only supported for postgresql")
211        );
212    }
213    if opts
214        .filters
215        .extensions_filters
216        .as_ref()
217        .is_some_and(|v| !v.is_empty())
218    {
219        println!(
220            "{}",
221            output::warning("Ignoring --extensionsFilters: only supported for postgresql")
222        );
223    }
224}
225
226/// Print how to configure credentials; the caller reports the failure.
227fn print_missing_credentials_help(effective_dialect: Dialect) {
228    println!("Add credentials to your drizzle.config.toml:");
229    println!();
230    println!("  {}", output::muted("[dbCredentials]"));
231    match effective_dialect.to_base() {
232        drizzle_types::Dialect::SQLite => {
233            println!("  {}", output::muted("url = \"./dev.db\""));
234        }
235        drizzle_types::Dialect::PostgreSQL => {
236            println!(
237                "  {}",
238                output::muted("url = \"postgres://user:pass@localhost:5432/db\"")
239            );
240        }
241        drizzle_types::Dialect::MySQL => {
242            println!(
243                "  {}",
244                output::muted("url = \"mysql://user:pass@localhost:3306/db\"")
245            );
246        }
247    }
248    println!();
249    println!("Or use an environment variable:");
250    println!();
251    println!("  {}", output::muted("[dbCredentials]"));
252    println!("  {}", output::muted("url = { env = \"DATABASE_URL\" }"));
253}
254
255/// Resolve and parse schema files into a [`ParseResult`].
256fn parse_schema_files(
257    db: &crate::config::DatabaseConfig,
258    schema_override: Option<&[String]>,
259) -> Result<drizzle_migrations::parser::ParseResult, CliError> {
260    use drizzle_migrations::parser::SchemaParser;
261
262    let schema_files = overrides::resolve_schema_files(db, schema_override)?;
263    if schema_files.is_empty() {
264        return Err(CliError::NoSchemaFiles(overrides::resolve_schema_display(
265            db,
266            schema_override,
267        )));
268    }
269
270    println!(
271        "  {} {} schema file(s)",
272        output::label("Parsing"),
273        schema_files.len()
274    );
275
276    let mut combined_code = String::new();
277    for path in &schema_files {
278        let code = std::fs::read_to_string(path)
279            .map_err(|e| CliError::IoError(format!("Failed to read {}: {}", path.display(), e)))?;
280        combined_code.push_str(&code);
281        combined_code.push('\n');
282    }
283
284    let parse_result = SchemaParser::parse(&combined_code);
285    crate::snapshot::surface_parse_diagnostics(&parse_result)?;
286    Ok(parse_result)
287}