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
94    output::step(1, 3, "Introspecting database...");
95
96    #[cfg(feature = "postgres")]
97    let db_schema = {
98        use crate::commands::introspect::Introspector;
99        use crate::commands::introspect::postgres::PostgresIntrospector;
100
101        if config.database.provider.to_lowercase().contains("postgres") {
102            let introspector = PostgresIntrospector::new(database_url.clone());
103            introspector.introspect(&options).await?
104        } else {
105            return Err(CliError::Config(format!(
106                "Introspection (`prax db pull`) currently supports PostgreSQL only; provider \
107                 '{}' is not supported yet.",
108                config.database.provider
109            )));
110        }
111    };
112
113    #[cfg(not(feature = "postgres"))]
114    let db_schema = {
115        return Err(CliError::Config(
116            "Introspection (`prax db pull`) currently supports PostgreSQL only and requires \
117             the `postgres` feature: recompile with --features postgres."
118                .to_string(),
119        ));
120    };
121
122    // Generate output
123    output::step(2, 3, "Generating schema...");
124    let schema_content = match args.format {
125        OutputFormat::Prax => format_as_prax(&db_schema, &config),
126        OutputFormat::Json => format_as_json(&db_schema)?,
127        OutputFormat::Sql => format_as_sql(&db_schema, db_type),
128    };
129
130    // Output schema
131    output::step(3, 3, "Writing output...");
132
133    if args.print {
134        output::newline();
135        output::section("Generated Schema");
136        println!("{}", schema_content);
137    } else {
138        let output_path = args.output.unwrap_or_else(|| {
139            let ext = match args.format {
140                OutputFormat::Prax => "prax",
141                OutputFormat::Json => "json",
142                OutputFormat::Sql => "sql",
143            };
144            cwd.join(format!("schema.{}", ext))
145        });
146
147        if output_path.exists() && !args.force {
148            warn(&format!("{} already exists!", output_path.display()));
149            if !output::confirm("Overwrite existing file?") {
150                output::newline();
151                output::info("Pull cancelled.");
152                return Ok(());
153            }
154        }
155
156        std::fs::write(&output_path, &schema_content)?;
157
158        output::newline();
159        success(&format!("Schema written to {}", output_path.display()));
160    }
161
162    output::newline();
163    output::section("Summary");
164    output::kv("Tables", &db_schema.tables.len().to_string());
165    output::kv("Enums", &db_schema.enums.len().to_string());
166    output::kv("Views", &db_schema.views.len().to_string());
167
168    // Show table names
169    if !db_schema.tables.is_empty() {
170        output::newline();
171        output::section("Tables Introspected");
172        for table in &db_schema.tables {
173            output::list_item(&format!("{} ({} columns)", table.name, table.columns.len()));
174        }
175    }
176
177    Ok(())
178}
179
180/// Run `prax db seed` - Seed database with initial data
181async fn run_seed(args: crate::cli::DbSeedArgs) -> CliResult<()> {
182    output::header("Database Seed");
183
184    let cwd = std::env::current_dir()?;
185    let config = load_config(&cwd)?;
186
187    // Check if seeding is allowed for this environment
188    if !args.force && !config.seed.should_seed(&args.environment) {
189        warn(&format!(
190            "Seeding is disabled for environment '{}'. Use --force to override.",
191            args.environment
192        ));
193        return Ok(());
194    }
195
196    // Find seed file - check config.seed.script first
197    let seed_path = args
198        .seed_file
199        .or_else(|| config.seed.script.clone())
200        .or_else(|| find_seed_file(&cwd, &config))
201        .ok_or_else(|| {
202            CliError::Config(
203                "Seed file not found. Create a seed file (seed.rs, seed.sql, seed.json, or seed.toml) \
204                 or specify with --seed-file".to_string()
205            )
206        })?;
207
208    if !seed_path.exists() {
209        return Err(CliError::Config(format!(
210            "Seed file not found: {}. Create a seed file or specify with --seed-file",
211            seed_path.display()
212        )));
213    }
214
215    // Get database URL
216    let database_url = get_database_url(&config)?;
217
218    output::kv("Seed file", &seed_path.display().to_string());
219    output::kv("Database", &mask_database_url(&database_url));
220    output::kv("Provider", &config.database.provider);
221    output::kv("Environment", &args.environment);
222    output::newline();
223
224    // Create and run seed
225    let runner = SeedRunner::new(
226        seed_path,
227        database_url,
228        config.database.provider.clone(),
229        cwd,
230    )?
231    .with_environment(&args.environment)
232    .with_reset(args.reset);
233
234    let result = runner.run().await?;
235
236    output::newline();
237    success("Database seeded successfully!");
238
239    // Show summary
240    output::newline();
241    output::section("Summary");
242    output::kv("Records affected", &result.records_affected.to_string());
243    if !result.tables_seeded.is_empty() {
244        output::kv("Tables seeded", &result.tables_seeded.join(", "));
245    }
246
247    Ok(())
248}
249
250/// Mask sensitive parts of database URL for display
251fn mask_database_url(url: &str) -> String {
252    if let Ok(parsed) = url::Url::parse(url) {
253        let mut masked = parsed.clone();
254        if parsed.password().is_some() {
255            let _ = masked.set_password(Some("****"));
256        }
257        masked.to_string()
258    } else {
259        // Not a URL format, just show first part
260        if url.len() > 30 {
261            format!("{}...", &url[..30])
262        } else {
263            url.to_string()
264        }
265    }
266}
267
268/// Run `prax db execute` - Execute raw SQL
269async fn run_execute(args: crate::cli::DbExecuteArgs) -> CliResult<()> {
270    output::header("Execute SQL");
271
272    let cwd = std::env::current_dir()?;
273    let config = load_config(&cwd)?;
274
275    // Get SQL to execute
276    let sql = if let Some(sql) = args.sql {
277        sql
278    } else if let Some(file) = args.file {
279        std::fs::read_to_string(&file)?
280    } else if args.stdin {
281        let mut sql = String::new();
282        std::io::Read::read_to_string(&mut std::io::stdin(), &mut sql)?;
283        sql
284    } else {
285        return Err(CliError::Command(
286            "Must provide SQL via --sql, --file, or --stdin".to_string(),
287        ));
288    };
289
290    output::kv(
291        "Database",
292        config
293            .database
294            .url
295            .as_deref()
296            .unwrap_or("env(DATABASE_URL)"),
297    );
298    output::newline();
299
300    output::section("SQL");
301    output::code(&sql, "sql");
302    output::newline();
303
304    // The SQL shell-out helpers in seed.rs (execute_postgres_sql /
305    // execute_mysql_sql / execute_sqlite_sql) are private to that module, so
306    // there is no execution path available here. Fail honestly instead of
307    // printing a success message for SQL that never ran.
308    Err(CliError::Command(
309        "`prax db execute` is not yet implemented: the CLI has no SQL execution path wired \
310         up yet. Run this SQL with your database's native client (psql, mysql, sqlite3)."
311            .to_string(),
312    ))
313}
314
315// =============================================================================
316// Helper Types and Functions
317// =============================================================================
318
319fn load_config(cwd: &Path) -> CliResult<Config> {
320    let config_path = cwd.join(CONFIG_FILE_NAME);
321    if config_path.exists() {
322        Config::load(&config_path)
323    } else {
324        Ok(Config::default())
325    }
326}