prax_cli/commands/
db.rs

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