Skip to main content

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_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    let schema_path = args.schema.unwrap_or_else(|| cwd.join(SCHEMA_FILE_PATH));
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::Introspector;
124        use crate::commands::introspect::postgres::PostgresIntrospector;
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"
142                .to_string(),
143        ));
144    };
145
146    // Generate output
147    output::step(2, 3, "Generating schema...");
148    let schema_content = match args.format {
149        OutputFormat::Prax => format_as_prax(&db_schema, &config),
150        OutputFormat::Json => format_as_json(&db_schema)?,
151        OutputFormat::Sql => format_as_sql(&db_schema, db_type),
152    };
153
154    // Output schema
155    output::step(3, 3, "Writing output...");
156
157    if args.print {
158        output::newline();
159        output::section("Generated Schema");
160        println!("{}", schema_content);
161    } else {
162        let output_path = args.output.unwrap_or_else(|| {
163            let ext = match args.format {
164                OutputFormat::Prax => "prax",
165                OutputFormat::Json => "json",
166                OutputFormat::Sql => "sql",
167            };
168            cwd.join(format!("schema.{}", ext))
169        });
170
171        if output_path.exists() && !args.force {
172            warn(&format!("{} already exists!", output_path.display()));
173            if !output::confirm("Overwrite existing file?") {
174                output::newline();
175                output::info("Pull cancelled.");
176                return Ok(());
177            }
178        }
179
180        std::fs::write(&output_path, &schema_content)?;
181
182        output::newline();
183        success(&format!("Schema written to {}", output_path.display()));
184    }
185
186    output::newline();
187    output::section("Summary");
188    output::kv("Tables", &db_schema.tables.len().to_string());
189    output::kv("Enums", &db_schema.enums.len().to_string());
190    output::kv("Views", &db_schema.views.len().to_string());
191
192    // Show table names
193    if !db_schema.tables.is_empty() {
194        output::newline();
195        output::section("Tables Introspected");
196        for table in &db_schema.tables {
197            output::list_item(&format!("{} ({} columns)", table.name, table.columns.len()));
198        }
199    }
200
201    Ok(())
202}
203
204/// Run `prax db seed` - Seed database with initial data
205async fn run_seed(args: crate::cli::DbSeedArgs) -> CliResult<()> {
206    output::header("Database Seed");
207
208    let cwd = std::env::current_dir()?;
209    let config = load_config(&cwd)?;
210
211    // Check if seeding is allowed for this environment
212    if !args.force && !config.seed.should_seed(&args.environment) {
213        warn(&format!(
214            "Seeding is disabled for environment '{}'. Use --force to override.",
215            args.environment
216        ));
217        return Ok(());
218    }
219
220    // Find seed file - check config.seed.script first
221    let seed_path = args
222        .seed_file
223        .or_else(|| config.seed.script.clone())
224        .or_else(|| find_seed_file(&cwd, &config))
225        .ok_or_else(|| {
226            CliError::Config(
227                "Seed file not found. Create a seed file (seed.rs, seed.sql, seed.json, or seed.toml) \
228                 or specify with --seed-file".to_string()
229            )
230        })?;
231
232    if !seed_path.exists() {
233        return Err(CliError::Config(format!(
234            "Seed file not found: {}. Create a seed file or specify with --seed-file",
235            seed_path.display()
236        )));
237    }
238
239    // Get database URL
240    let database_url = get_database_url(&config)?;
241
242    output::kv("Seed file", &seed_path.display().to_string());
243    output::kv("Database", &mask_database_url(&database_url));
244    output::kv("Provider", &config.database.provider);
245    output::kv("Environment", &args.environment);
246    output::newline();
247
248    // Reset database first if requested
249    if args.reset {
250        warn("Resetting database before seeding...");
251        // TODO: Implement database reset
252        output::newline();
253    }
254
255    // Create and run seed
256    let runner = SeedRunner::new(
257        seed_path,
258        database_url,
259        config.database.provider.clone(),
260        cwd,
261    )?
262    .with_environment(&args.environment)
263    .with_reset(args.reset);
264
265    let result = runner.run().await?;
266
267    output::newline();
268    success("Database seeded successfully!");
269
270    // Show summary
271    output::newline();
272    output::section("Summary");
273    output::kv("Records affected", &result.records_affected.to_string());
274    if !result.tables_seeded.is_empty() {
275        output::kv("Tables seeded", &result.tables_seeded.join(", "));
276    }
277
278    Ok(())
279}
280
281/// Mask sensitive parts of database URL for display
282fn mask_database_url(url: &str) -> String {
283    if let Ok(parsed) = url::Url::parse(url) {
284        let mut masked = parsed.clone();
285        if parsed.password().is_some() {
286            let _ = masked.set_password(Some("****"));
287        }
288        masked.to_string()
289    } else {
290        // Not a URL format, just show first part
291        if url.len() > 30 {
292            format!("{}...", &url[..30])
293        } else {
294            url.to_string()
295        }
296    }
297}
298
299/// Run `prax db execute` - Execute raw SQL
300async fn run_execute(args: crate::cli::DbExecuteArgs) -> CliResult<()> {
301    output::header("Execute SQL");
302
303    let cwd = std::env::current_dir()?;
304    let config = load_config(&cwd)?;
305
306    // Get SQL to execute
307    let sql = if let Some(sql) = args.sql {
308        sql
309    } else if let Some(file) = args.file {
310        std::fs::read_to_string(&file)?
311    } else if args.stdin {
312        let mut sql = String::new();
313        std::io::Read::read_to_string(&mut std::io::stdin(), &mut sql)?;
314        sql
315    } else {
316        return Err(CliError::Command(
317            "Must provide SQL via --sql, --file, or --stdin".to_string(),
318        )
319        .into());
320    };
321
322    output::kv(
323        "Database",
324        config
325            .database
326            .url
327            .as_deref()
328            .unwrap_or("env(DATABASE_URL)"),
329    );
330    output::newline();
331
332    output::section("SQL");
333    output::code(&sql, "sql");
334    output::newline();
335
336    // Confirm if not forced
337    if !args.force {
338        if !output::confirm("Execute this SQL?") {
339            output::newline();
340            output::info("Execution cancelled.");
341            return Ok(());
342        }
343    }
344
345    // Execute SQL
346    output::step(1, 1, "Executing SQL...");
347    // TODO: Actually execute SQL
348
349    output::newline();
350    success("SQL executed successfully!");
351
352    Ok(())
353}
354
355// =============================================================================
356// Helper Types and Functions
357// =============================================================================
358
359#[derive(Debug)]
360struct SchemaChange {
361    description: String,
362    #[allow(dead_code)]
363    sql: String,
364    is_destructive: bool,
365}
366
367fn load_config(cwd: &PathBuf) -> CliResult<Config> {
368    let config_path = cwd.join(CONFIG_FILE_NAME);
369    if config_path.exists() {
370        Config::load(&config_path)
371    } else {
372        Ok(Config::default())
373    }
374}
375
376fn parse_schema(content: &str) -> CliResult<prax_schema::Schema> {
377    // Use validate_schema to ensure field types are properly resolved
378    // (e.g., FieldType::Model -> FieldType::Enum for enum references)
379    prax_schema::validate_schema(content)
380        .map_err(|e| CliError::Schema(format!("Failed to parse/validate schema: {}", e)))
381}
382
383fn calculate_schema_changes(_schema: &prax_schema::ast::Schema) -> CliResult<Vec<SchemaChange>> {
384    // TODO: Implement actual schema diffing
385    // For now, return empty changes
386    Ok(Vec::new())
387}