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
48    output::step(1, 4, "Parsing schema...");
49    let loaded = crate::schema_loader::load_schema(args.schema.as_deref())?;
50    let schema = loaded.schema;
51
52    // Introspect database
53    output::step(2, 4, "Introspecting database...");
54    // TODO: Get current database state
55
56    // Calculate changes
57    output::step(3, 4, "Calculating changes...");
58    let changes = calculate_schema_changes(&schema)?;
59
60    if changes.is_empty() {
61        output::newline();
62        success("Database is already in sync with schema!");
63        return Ok(());
64    }
65
66    // Check for destructive changes
67    let destructive = changes.iter().any(|c| c.is_destructive);
68    if destructive && !args.accept_data_loss && !args.force {
69        output::newline();
70        warn("This push would cause data loss!");
71        output::section("Destructive changes");
72        for change in changes.iter().filter(|c| c.is_destructive) {
73            output::list_item(&format!("⚠️  {}", change.description));
74        }
75        output::newline();
76        output::info("Use --accept-data-loss to proceed, or --force to skip confirmation.");
77        return Ok(());
78    }
79
80    // Apply changes
81    output::step(4, 4, "Applying changes...");
82    for change in &changes {
83        output::list_item(&change.description);
84        // TODO: Execute SQL
85    }
86
87    output::newline();
88    success(&format!("Applied {} changes to database!", changes.len()));
89
90    Ok(())
91}
92
93/// Run `prax db pull` - Introspect database and generate schema
94async fn run_pull(args: crate::cli::DbPullArgs) -> CliResult<()> {
95    output::header("Database Pull (Introspection)");
96
97    let cwd = std::env::current_dir()?;
98    let config = load_config(&cwd)?;
99
100    // Get database URL
101    let database_url = get_database_url(&config)?;
102    let db_type = get_database_type(&config.database.provider)?;
103
104    output::kv("Provider", &config.database.provider);
105    output::kv("Database", &mask_database_url(&database_url));
106    if let Some(ref schema) = args.schema {
107        output::kv("Schema", schema);
108    }
109    output::newline();
110
111    // Build introspection options
112    let options = IntrospectionOptions {
113        schema: args.schema.clone(),
114        include_views: args.include_views,
115        include_materialized_views: args.include_materialized_views,
116        table_filter: args.tables.clone(),
117        exclude_pattern: args.exclude.clone(),
118        include_comments: args.comments,
119        sample_size: args.sample_size,
120    };
121
122    // Introspect database
123    output::step(1, 3, "Introspecting database...");
124
125    #[cfg(feature = "postgres")]
126    let db_schema = {
127        use crate::commands::introspect::Introspector;
128        use crate::commands::introspect::postgres::PostgresIntrospector;
129
130        if config.database.provider.to_lowercase().contains("postgres") {
131            let introspector = PostgresIntrospector::new(database_url.clone());
132            introspector.introspect(&options).await?
133        } else {
134            return Err(CliError::Config(format!(
135                "Introspection for {} requires the corresponding feature. Compile with --features {}",
136                config.database.provider,
137                config.database.provider.to_lowercase()
138            )));
139        }
140    };
141
142    #[cfg(not(feature = "postgres"))]
143    let db_schema = {
144        return Err(CliError::Config(
145            "No database driver enabled. Compile with --features postgres, mysql, sqlite, or mssql"
146                .to_string(),
147        ));
148    };
149
150    // Generate output
151    output::step(2, 3, "Generating schema...");
152    let schema_content = match args.format {
153        OutputFormat::Prax => format_as_prax(&db_schema, &config),
154        OutputFormat::Json => format_as_json(&db_schema)?,
155        OutputFormat::Sql => format_as_sql(&db_schema, db_type),
156    };
157
158    // Output schema
159    output::step(3, 3, "Writing output...");
160
161    if args.print {
162        output::newline();
163        output::section("Generated Schema");
164        println!("{}", schema_content);
165    } else {
166        let output_path = args.output.unwrap_or_else(|| {
167            let ext = match args.format {
168                OutputFormat::Prax => "prax",
169                OutputFormat::Json => "json",
170                OutputFormat::Sql => "sql",
171            };
172            cwd.join(format!("schema.{}", ext))
173        });
174
175        if output_path.exists() && !args.force {
176            warn(&format!("{} already exists!", output_path.display()));
177            if !output::confirm("Overwrite existing file?") {
178                output::newline();
179                output::info("Pull cancelled.");
180                return Ok(());
181            }
182        }
183
184        std::fs::write(&output_path, &schema_content)?;
185
186        output::newline();
187        success(&format!("Schema written to {}", output_path.display()));
188    }
189
190    output::newline();
191    output::section("Summary");
192    output::kv("Tables", &db_schema.tables.len().to_string());
193    output::kv("Enums", &db_schema.enums.len().to_string());
194    output::kv("Views", &db_schema.views.len().to_string());
195
196    // Show table names
197    if !db_schema.tables.is_empty() {
198        output::newline();
199        output::section("Tables Introspected");
200        for table in &db_schema.tables {
201            output::list_item(&format!("{} ({} columns)", table.name, table.columns.len()));
202        }
203    }
204
205    Ok(())
206}
207
208/// Run `prax db seed` - Seed database with initial data
209async fn run_seed(args: crate::cli::DbSeedArgs) -> CliResult<()> {
210    output::header("Database Seed");
211
212    let cwd = std::env::current_dir()?;
213    let config = load_config(&cwd)?;
214
215    // Check if seeding is allowed for this environment
216    if !args.force && !config.seed.should_seed(&args.environment) {
217        warn(&format!(
218            "Seeding is disabled for environment '{}'. Use --force to override.",
219            args.environment
220        ));
221        return Ok(());
222    }
223
224    // Find seed file - check config.seed.script first
225    let seed_path = args
226        .seed_file
227        .or_else(|| config.seed.script.clone())
228        .or_else(|| find_seed_file(&cwd, &config))
229        .ok_or_else(|| {
230            CliError::Config(
231                "Seed file not found. Create a seed file (seed.rs, seed.sql, seed.json, or seed.toml) \
232                 or specify with --seed-file".to_string()
233            )
234        })?;
235
236    if !seed_path.exists() {
237        return Err(CliError::Config(format!(
238            "Seed file not found: {}. Create a seed file or specify with --seed-file",
239            seed_path.display()
240        )));
241    }
242
243    // Get database URL
244    let database_url = get_database_url(&config)?;
245
246    output::kv("Seed file", &seed_path.display().to_string());
247    output::kv("Database", &mask_database_url(&database_url));
248    output::kv("Provider", &config.database.provider);
249    output::kv("Environment", &args.environment);
250    output::newline();
251
252    // Reset database first if requested
253    if args.reset {
254        warn("Resetting database before seeding...");
255        // TODO: Implement database reset
256        output::newline();
257    }
258
259    // Create and run seed
260    let runner = SeedRunner::new(
261        seed_path,
262        database_url,
263        config.database.provider.clone(),
264        cwd,
265    )?
266    .with_environment(&args.environment)
267    .with_reset(args.reset);
268
269    let result = runner.run().await?;
270
271    output::newline();
272    success("Database seeded successfully!");
273
274    // Show summary
275    output::newline();
276    output::section("Summary");
277    output::kv("Records affected", &result.records_affected.to_string());
278    if !result.tables_seeded.is_empty() {
279        output::kv("Tables seeded", &result.tables_seeded.join(", "));
280    }
281
282    Ok(())
283}
284
285/// Mask sensitive parts of database URL for display
286fn mask_database_url(url: &str) -> String {
287    if let Ok(parsed) = url::Url::parse(url) {
288        let mut masked = parsed.clone();
289        if parsed.password().is_some() {
290            let _ = masked.set_password(Some("****"));
291        }
292        masked.to_string()
293    } else {
294        // Not a URL format, just show first part
295        if url.len() > 30 {
296            format!("{}...", &url[..30])
297        } else {
298            url.to_string()
299        }
300    }
301}
302
303/// Run `prax db execute` - Execute raw SQL
304async fn run_execute(args: crate::cli::DbExecuteArgs) -> CliResult<()> {
305    output::header("Execute SQL");
306
307    let cwd = std::env::current_dir()?;
308    let config = load_config(&cwd)?;
309
310    // Get SQL to execute
311    let sql = if let Some(sql) = args.sql {
312        sql
313    } else if let Some(file) = args.file {
314        std::fs::read_to_string(&file)?
315    } else if args.stdin {
316        let mut sql = String::new();
317        std::io::Read::read_to_string(&mut std::io::stdin(), &mut sql)?;
318        sql
319    } else {
320        return Err(CliError::Command(
321            "Must provide SQL via --sql, --file, or --stdin".to_string(),
322        ));
323    };
324
325    output::kv(
326        "Database",
327        config
328            .database
329            .url
330            .as_deref()
331            .unwrap_or("env(DATABASE_URL)"),
332    );
333    output::newline();
334
335    output::section("SQL");
336    output::code(&sql, "sql");
337    output::newline();
338
339    // Confirm if not forced
340    if !args.force && !output::confirm("Execute this SQL?") {
341        output::newline();
342        output::info("Execution cancelled.");
343        return Ok(());
344    }
345
346    // Execute SQL
347    output::step(1, 1, "Executing SQL...");
348    // TODO: Actually execute SQL
349
350    output::newline();
351    success("SQL executed successfully!");
352
353    Ok(())
354}
355
356// =============================================================================
357// Helper Types and Functions
358// =============================================================================
359
360#[derive(Debug)]
361struct SchemaChange {
362    description: String,
363    #[allow(dead_code)]
364    sql: String,
365    is_destructive: bool,
366}
367
368fn load_config(cwd: &Path) -> CliResult<Config> {
369    let config_path = cwd.join(CONFIG_FILE_NAME);
370    if config_path.exists() {
371        Config::load(&config_path)
372    } else {
373        Ok(Config::default())
374    }
375}
376
377fn calculate_schema_changes(_schema: &prax_schema::ast::Schema) -> CliResult<Vec<SchemaChange>> {
378    // TODO: Implement actual schema diffing
379    // For now, return empty changes
380    Ok(Vec::new())
381}