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