Skip to main content

prax_cli/commands/
db.rs

1//! `prax db` commands - Direct database operations.
2
3use std::path::Path;
4
5use crate::cli::{DbArgs, OutputFormat};
6use crate::commands::introspect::{
7    IntrospectionOptions, format_as_json, format_as_prax, format_as_sql, get_database_type,
8};
9use crate::commands::seed::{SeedRunner, find_seed_file, get_database_url};
10use crate::config::{CONFIG_FILE_NAME, Config, SCHEMA_FILE_PATH};
11use crate::error::{CliError, CliResult};
12use crate::output::{self, success, warn};
13
14/// Run the db command
15pub async fn run(args: DbArgs) -> CliResult<()> {
16    match args.command {
17        crate::cli::DbSubcommand::Push(push_args) => run_push(push_args).await,
18        crate::cli::DbSubcommand::Pull(pull_args) => run_pull(pull_args).await,
19        crate::cli::DbSubcommand::Seed(seed_args) => run_seed(seed_args).await,
20        crate::cli::DbSubcommand::Execute(exec_args) => run_execute(exec_args).await,
21    }
22}
23
24/// Run `prax db push` - Push schema to database without migrations
25async fn run_push(args: crate::cli::DbPushArgs) -> CliResult<()> {
26    output::header("Database Push");
27
28    let cwd = std::env::current_dir()?;
29    let config = load_config(&cwd)?;
30
31    let display_path = args
32        .schema
33        .as_deref()
34        .map(|p| p.display().to_string())
35        .unwrap_or_else(|| SCHEMA_FILE_PATH.to_string());
36    output::kv("Schema", &display_path);
37    output::kv(
38        "Database",
39        config
40            .database
41            .url
42            .as_deref()
43            .unwrap_or("env(DATABASE_URL)"),
44    );
45    output::newline();
46
47    // Parse schema (still validates the schema file before failing)
48    output::step(1, 1, "Parsing schema...");
49    crate::schema_loader::load_schema(args.schema.as_deref())?;
50
51    // Pushing requires diffing the parsed schema against the introspected
52    // database state and executing the resulting SQL. The CLI has no
53    // introspected database state to feed prax-migrate's differ here, so
54    // fail honestly instead of claiming the database is in sync.
55    Err(CliError::Command(
56        "`prax db push` is not yet implemented: computing schema changes requires database \
57         introspection and diffing, which are not available yet. Use \
58         `prax migrate dev --create-only` to generate migration SQL, then apply it with an \
59         external tool (psql, mysql, sqlite3)."
60            .to_string(),
61    ))
62}
63
64/// Run `prax db pull` - Introspect database and generate schema
65async fn run_pull(args: crate::cli::DbPullArgs) -> CliResult<()> {
66    output::header("Database Pull (Introspection)");
67
68    let cwd = std::env::current_dir()?;
69    let config = load_config(&cwd)?;
70
71    // Get database URL
72    let database_url = get_database_url(&config)?;
73    let db_type = get_database_type(&config.database.provider)?;
74
75    output::kv("Provider", &config.database.provider);
76    output::kv("Database", &mask_database_url(&database_url));
77    if let Some(ref schema) = args.schema {
78        output::kv("Schema", schema);
79    }
80    output::newline();
81
82    // Build introspection options
83    let options = IntrospectionOptions {
84        schema: args.schema.clone(),
85        include_views: args.include_views,
86        include_materialized_views: args.include_materialized_views,
87        table_filter: args.tables.clone(),
88        exclude_pattern: args.exclude.clone(),
89        include_comments: args.comments,
90        sample_size: args.sample_size,
91    };
92
93    // Introspect database. Dispatch matches the provider against the canonical
94    // allow-list in `get_database_type` (postgres/postgresql/pg, mysql/mariadb,
95    // sqlite/sqlite3, mssql/sqlserver/sql_server). This is stricter than the
96    // previous `provider.contains("postgres")` substring check: a non-canonical
97    // provider string that used to match loosely now errors as unsupported.
98    output::step(1, 3, "Introspecting database...");
99
100    let db_schema = crate::commands::introspect::introspect_database(
101        &config.database.provider,
102        &database_url,
103        &options,
104    )
105    .await?;
106
107    // Generate output
108    output::step(2, 3, "Generating schema...");
109    let schema_content = match args.format {
110        OutputFormat::Prax => format_as_prax(&db_schema, &config),
111        OutputFormat::Json => format_as_json(&db_schema)?,
112        OutputFormat::Sql => format_as_sql(&db_schema, db_type),
113    };
114
115    // Output schema
116    output::step(3, 3, "Writing output...");
117
118    if args.print {
119        output::newline();
120        output::section("Generated Schema");
121        println!("{}", schema_content);
122    } else {
123        let output_path = args.output.unwrap_or_else(|| {
124            let ext = match args.format {
125                OutputFormat::Prax => "prax",
126                OutputFormat::Json => "json",
127                OutputFormat::Sql => "sql",
128            };
129            cwd.join(format!("schema.{}", ext))
130        });
131
132        if output_path.exists() && !args.force {
133            warn(&format!("{} already exists!", output_path.display()));
134            if !output::confirm("Overwrite existing file?") {
135                output::newline();
136                output::info("Pull cancelled.");
137                return Ok(());
138            }
139        }
140
141        std::fs::write(&output_path, &schema_content)?;
142
143        output::newline();
144        success(&format!("Schema written to {}", output_path.display()));
145    }
146
147    output::newline();
148    output::section("Summary");
149    output::kv("Tables", &db_schema.tables.len().to_string());
150    output::kv("Enums", &db_schema.enums.len().to_string());
151    output::kv("Views", &db_schema.views.len().to_string());
152
153    // Show table names
154    if !db_schema.tables.is_empty() {
155        output::newline();
156        output::section("Tables Introspected");
157        for table in &db_schema.tables {
158            output::list_item(&format!("{} ({} columns)", table.name, table.columns.len()));
159        }
160    }
161
162    Ok(())
163}
164
165/// Run `prax db seed` - Seed database with initial data
166async fn run_seed(args: crate::cli::DbSeedArgs) -> CliResult<()> {
167    output::header("Database Seed");
168
169    let cwd = std::env::current_dir()?;
170    let config = load_config(&cwd)?;
171
172    // Check if seeding is allowed for this environment
173    if !args.force && !config.seed.should_seed(&args.environment) {
174        warn(&format!(
175            "Seeding is disabled for environment '{}'. Use --force to override.",
176            args.environment
177        ));
178        return Ok(());
179    }
180
181    // Find seed file - check config.seed.script first
182    let seed_path = args
183        .seed_file
184        .or_else(|| config.seed.script.clone())
185        .or_else(|| find_seed_file(&cwd, &config))
186        .ok_or_else(|| {
187            CliError::Config(
188                "Seed file not found. Create a seed file (seed.rs, seed.sql, seed.json, or seed.toml) \
189                 or specify with --seed-file".to_string()
190            )
191        })?;
192
193    if !seed_path.exists() {
194        return Err(CliError::Config(format!(
195            "Seed file not found: {}. Create a seed file or specify with --seed-file",
196            seed_path.display()
197        )));
198    }
199
200    // Get database URL
201    let database_url = get_database_url(&config)?;
202
203    output::kv("Seed file", &seed_path.display().to_string());
204    output::kv("Database", &mask_database_url(&database_url));
205    output::kv("Provider", &config.database.provider);
206    output::kv("Environment", &args.environment);
207    output::newline();
208
209    // Create and run seed
210    let runner = SeedRunner::new(
211        seed_path,
212        database_url,
213        config.database.provider.clone(),
214        cwd,
215    )?
216    .with_environment(&args.environment)
217    .with_reset(args.reset);
218
219    let result = runner.run().await?;
220
221    output::newline();
222    success("Database seeded successfully!");
223
224    // Show summary
225    output::newline();
226    output::section("Summary");
227    output::kv("Records affected", &result.records_affected.to_string());
228    if !result.tables_seeded.is_empty() {
229        output::kv("Tables seeded", &result.tables_seeded.join(", "));
230    }
231
232    Ok(())
233}
234
235/// Mask sensitive parts of database URL for display
236fn mask_database_url(url: &str) -> String {
237    if let Ok(parsed) = url::Url::parse(url) {
238        let mut masked = parsed.clone();
239        if parsed.password().is_some() {
240            let _ = masked.set_password(Some("****"));
241        }
242        masked.to_string()
243    } else {
244        // Not a URL format, just show first part
245        if url.len() > 30 {
246            format!("{}...", &url[..30])
247        } else {
248            url.to_string()
249        }
250    }
251}
252
253/// Run `prax db execute` - Execute raw SQL
254async fn run_execute(args: crate::cli::DbExecuteArgs) -> CliResult<()> {
255    output::header("Execute SQL");
256
257    let cwd = std::env::current_dir()?;
258    let config = load_config(&cwd)?;
259
260    // Get SQL to execute
261    let sql = if let Some(sql) = args.sql {
262        sql
263    } else if let Some(file) = args.file {
264        std::fs::read_to_string(&file)?
265    } else if args.stdin {
266        let mut sql = String::new();
267        std::io::Read::read_to_string(&mut std::io::stdin(), &mut sql)?;
268        sql
269    } else {
270        return Err(CliError::Command(
271            "Must provide SQL via --sql, --file, or --stdin".to_string(),
272        ));
273    };
274
275    output::kv(
276        "Database",
277        config
278            .database
279            .url
280            .as_deref()
281            .unwrap_or("env(DATABASE_URL)"),
282    );
283    output::newline();
284
285    output::section("SQL");
286    output::code(&sql, "sql");
287    output::newline();
288
289    // The SQL shell-out helpers in seed.rs (execute_postgres_sql /
290    // execute_mysql_sql / execute_sqlite_sql) are private to that module, so
291    // there is no execution path available here. Fail honestly instead of
292    // printing a success message for SQL that never ran.
293    Err(CliError::Command(
294        "`prax db execute` is not yet implemented: the CLI has no SQL execution path wired \
295         up yet. Run this SQL with your database's native client (psql, mysql, sqlite3)."
296            .to_string(),
297    ))
298}
299
300// =============================================================================
301// Helper Types and Functions
302// =============================================================================
303
304fn load_config(cwd: &Path) -> CliResult<Config> {
305    let config_path = cwd.join(CONFIG_FILE_NAME);
306    if config_path.exists() {
307        Config::load(&config_path)
308    } else {
309        Ok(Config::default())
310    }
311}