prax_cli/commands/
db.rs

1//! `prax db` commands - Direct database operations.
2
3use std::path::PathBuf;
4
5use crate::cli::DbArgs;
6use crate::commands::seed::{find_seed_file, get_database_url, SeedRunner};
7use crate::config::{Config, CONFIG_FILE_NAME, SCHEMA_FILE_NAME};
8use crate::error::{CliError, CliResult};
9use crate::output::{self, success, warn};
10
11/// Run the db command
12pub async fn run(args: DbArgs) -> CliResult<()> {
13    match args.command {
14        crate::cli::DbSubcommand::Push(push_args) => run_push(push_args).await,
15        crate::cli::DbSubcommand::Pull(pull_args) => run_pull(pull_args).await,
16        crate::cli::DbSubcommand::Seed(seed_args) => run_seed(seed_args).await,
17        crate::cli::DbSubcommand::Execute(exec_args) => run_execute(exec_args).await,
18    }
19}
20
21/// Run `prax db push` - Push schema to database without migrations
22async fn run_push(args: crate::cli::DbPushArgs) -> CliResult<()> {
23    output::header("Database Push");
24
25    let cwd = std::env::current_dir()?;
26    let config = load_config(&cwd)?;
27    let schema_path = args.schema.unwrap_or_else(|| cwd.join(SCHEMA_FILE_NAME));
28
29    output::kv("Schema", &schema_path.display().to_string());
30    output::kv("Database", config.database.url.as_deref().unwrap_or("env(DATABASE_URL)"));
31    output::newline();
32
33    // Parse schema
34    output::step(1, 4, "Parsing schema...");
35    let schema_content = std::fs::read_to_string(&schema_path)?;
36    let schema = parse_schema(&schema_content)?;
37
38    // Introspect database
39    output::step(2, 4, "Introspecting database...");
40    // TODO: Get current database state
41
42    // Calculate changes
43    output::step(3, 4, "Calculating changes...");
44    let changes = calculate_schema_changes(&schema)?;
45
46    if changes.is_empty() {
47        output::newline();
48        success("Database is already in sync with schema!");
49        return Ok(());
50    }
51
52    // Check for destructive changes
53    let destructive = changes.iter().any(|c| c.is_destructive);
54    if destructive && !args.accept_data_loss && !args.force {
55        output::newline();
56        warn("This push would cause data loss!");
57        output::section("Destructive changes");
58        for change in changes.iter().filter(|c| c.is_destructive) {
59            output::list_item(&format!("⚠️  {}", change.description));
60        }
61        output::newline();
62        output::info("Use --accept-data-loss to proceed, or --force to skip confirmation.");
63        return Ok(());
64    }
65
66    // Apply changes
67    output::step(4, 4, "Applying changes...");
68    for change in &changes {
69        output::list_item(&change.description);
70        // TODO: Execute SQL
71    }
72
73    output::newline();
74    success(&format!("Applied {} changes to database!", changes.len()));
75
76    Ok(())
77}
78
79/// Run `prax db pull` - Introspect database and generate schema
80async fn run_pull(args: crate::cli::DbPullArgs) -> CliResult<()> {
81    output::header("Database Pull");
82
83    let cwd = std::env::current_dir()?;
84    let config = load_config(&cwd)?;
85
86    output::kv("Database", config.database.url.as_deref().unwrap_or("env(DATABASE_URL)"));
87    output::newline();
88
89    // Introspect database
90    output::step(1, 3, "Introspecting database...");
91    let schema = introspect_database(&config).await?;
92
93    // Generate schema file
94    output::step(2, 3, "Generating schema...");
95    let schema_content = generate_schema_file(&schema)?;
96
97    // Write schema
98    output::step(3, 3, "Writing schema file...");
99    let output_path = args.output.unwrap_or_else(|| cwd.join(SCHEMA_FILE_NAME));
100
101    if output_path.exists() && !args.force {
102        warn(&format!("{} already exists!", output_path.display()));
103        if !output::confirm("Overwrite existing schema?") {
104            output::newline();
105            output::info("Pull cancelled.");
106            return Ok(());
107        }
108    }
109
110    std::fs::write(&output_path, &schema_content)?;
111
112    output::newline();
113    success(&format!(
114        "Schema written to {}",
115        output_path.display()
116    ));
117
118    output::newline();
119    output::section("Introspected");
120    output::kv("Models", &schema.models.len().to_string());
121    output::kv("Enums", &schema.enums.len().to_string());
122
123    Ok(())
124}
125
126/// Run `prax db seed` - Seed database with initial data
127async fn run_seed(args: crate::cli::DbSeedArgs) -> CliResult<()> {
128    output::header("Database Seed");
129
130    let cwd = std::env::current_dir()?;
131    let config = load_config(&cwd)?;
132
133    // Check if seeding is allowed for this environment
134    if !args.force && !config.seed.should_seed(&args.environment) {
135        warn(&format!(
136            "Seeding is disabled for environment '{}'. Use --force to override.",
137            args.environment
138        ));
139        return Ok(());
140    }
141
142    // Find seed file - check config.seed.script first
143    let seed_path = args
144        .seed_file
145        .or_else(|| config.seed.script.clone())
146        .or_else(|| find_seed_file(&cwd, &config))
147        .ok_or_else(|| {
148            CliError::Config(
149                "Seed file not found. Create a seed file (seed.rs, seed.sql, seed.json, or seed.toml) \
150                 or specify with --seed-file".to_string()
151            )
152        })?;
153
154    if !seed_path.exists() {
155        return Err(CliError::Config(format!(
156            "Seed file not found: {}. Create a seed file or specify with --seed-file",
157            seed_path.display()
158        )));
159    }
160
161    // Get database URL
162    let database_url = get_database_url(&config)?;
163
164    output::kv("Seed file", &seed_path.display().to_string());
165    output::kv("Database", &mask_database_url(&database_url));
166    output::kv("Provider", &config.database.provider);
167    output::kv("Environment", &args.environment);
168    output::newline();
169
170    // Reset database first if requested
171    if args.reset {
172        warn("Resetting database before seeding...");
173        // TODO: Implement database reset
174        output::newline();
175    }
176
177    // Create and run seed
178    let runner = SeedRunner::new(
179        seed_path,
180        database_url,
181        config.database.provider.clone(),
182        cwd,
183    )?
184    .with_environment(&args.environment)
185    .with_reset(args.reset);
186
187    let result = runner.run().await?;
188
189    output::newline();
190    success("Database seeded successfully!");
191
192    // Show summary
193    output::newline();
194    output::section("Summary");
195    output::kv("Records affected", &result.records_affected.to_string());
196    if !result.tables_seeded.is_empty() {
197        output::kv("Tables seeded", &result.tables_seeded.join(", "));
198    }
199
200    Ok(())
201}
202
203/// Mask sensitive parts of database URL for display
204fn mask_database_url(url: &str) -> String {
205    if let Ok(parsed) = url::Url::parse(url) {
206        let mut masked = parsed.clone();
207        if parsed.password().is_some() {
208            let _ = masked.set_password(Some("****"));
209        }
210        masked.to_string()
211    } else {
212        // Not a URL format, just show first part
213        if url.len() > 30 {
214            format!("{}...", &url[..30])
215        } else {
216            url.to_string()
217        }
218    }
219}
220
221/// Run `prax db execute` - Execute raw SQL
222async fn run_execute(args: crate::cli::DbExecuteArgs) -> CliResult<()> {
223    output::header("Execute SQL");
224
225    let cwd = std::env::current_dir()?;
226    let config = load_config(&cwd)?;
227
228    // Get SQL to execute
229    let sql = if let Some(sql) = args.sql {
230        sql
231    } else if let Some(file) = args.file {
232        std::fs::read_to_string(&file)?
233    } else if args.stdin {
234        let mut sql = String::new();
235        std::io::Read::read_to_string(&mut std::io::stdin(), &mut sql)?;
236        sql
237    } else {
238        return Err(CliError::Command(
239            "Must provide SQL via --sql, --file, or --stdin".to_string()
240        ).into());
241    };
242
243    output::kv("Database", config.database.url.as_deref().unwrap_or("env(DATABASE_URL)"));
244    output::newline();
245
246    output::section("SQL");
247    output::code(&sql, "sql");
248    output::newline();
249
250    // Confirm if not forced
251    if !args.force {
252        if !output::confirm("Execute this SQL?") {
253            output::newline();
254            output::info("Execution cancelled.");
255            return Ok(());
256        }
257    }
258
259    // Execute SQL
260    output::step(1, 1, "Executing SQL...");
261    // TODO: Actually execute SQL
262
263    output::newline();
264    success("SQL executed successfully!");
265
266    Ok(())
267}
268
269// =============================================================================
270// Helper Types and Functions
271// =============================================================================
272
273#[derive(Debug)]
274struct SchemaChange {
275    description: String,
276    #[allow(dead_code)]
277    sql: String,
278    is_destructive: bool,
279}
280
281fn load_config(cwd: &PathBuf) -> CliResult<Config> {
282    let config_path = cwd.join(CONFIG_FILE_NAME);
283    if config_path.exists() {
284        Config::load(&config_path)
285    } else {
286        Ok(Config::default())
287    }
288}
289
290fn parse_schema(content: &str) -> CliResult<prax_schema::Schema> {
291    prax_schema::parse_schema(content)
292        .map_err(|e| CliError::Schema(format!("Failed to parse schema: {}", e)))
293}
294
295fn calculate_schema_changes(
296    _schema: &prax_schema::ast::Schema,
297) -> CliResult<Vec<SchemaChange>> {
298    // TODO: Implement actual schema diffing
299    // For now, return empty changes
300    Ok(Vec::new())
301}
302
303async fn introspect_database(_config: &Config) -> CliResult<prax_schema::ast::Schema> {
304    // TODO: Implement actual database introspection
305    // For now, return an empty schema
306    Ok(prax_schema::ast::Schema::default())
307}
308
309fn generate_schema_file(schema: &prax_schema::ast::Schema) -> CliResult<String> {
310    use prax_schema::ast::{FieldType, ScalarType, TypeModifier};
311
312    let mut output = String::new();
313
314    output.push_str("// Generated by `prax db pull`\n");
315    output.push_str("// Edit this file to customize your schema\n\n");
316
317    output.push_str("datasource db {\n");
318    output.push_str("    provider = \"postgresql\"\n");
319    output.push_str("    url      = env(\"DATABASE_URL\")\n");
320    output.push_str("}\n\n");
321
322    output.push_str("generator client {\n");
323    output.push_str("    provider = \"prax-client-rust\"\n");
324    output.push_str("    output   = \"./src/generated\"\n");
325    output.push_str("}\n\n");
326
327    // Generate models
328    for model in schema.models.values() {
329        output.push_str(&format!("model {} {{\n", model.name()));
330        for field in model.fields.values() {
331            let field_type = format_field_type(&field.field_type, field.modifier);
332            output.push_str(&format!("    {} {}\n", field.name(), field_type));
333        }
334        output.push_str("}\n\n");
335    }
336
337    // Generate enums
338    for enum_def in schema.enums.values() {
339        output.push_str(&format!("enum {} {{\n", enum_def.name()));
340        for variant in &enum_def.variants {
341            output.push_str(&format!("    {}\n", variant.name()));
342        }
343        output.push_str("}\n\n");
344    }
345
346    return Ok(output);
347
348    fn format_field_type(field_type: &FieldType, modifier: TypeModifier) -> String {
349        let base = match field_type {
350            FieldType::Scalar(scalar) => match scalar {
351                ScalarType::Int => "Int",
352                ScalarType::BigInt => "BigInt",
353                ScalarType::Float => "Float",
354                ScalarType::String => "String",
355                ScalarType::Boolean => "Boolean",
356                ScalarType::DateTime => "DateTime",
357                ScalarType::Date => "Date",
358                ScalarType::Time => "Time",
359                ScalarType::Json => "Json",
360                ScalarType::Bytes => "Bytes",
361                ScalarType::Decimal => "Decimal",
362                ScalarType::Uuid => "Uuid",
363                ScalarType::Cuid => "Cuid",
364                ScalarType::Cuid2 => "Cuid2",
365                ScalarType::NanoId => "NanoId",
366                ScalarType::Ulid => "Ulid",
367            }
368            .to_string(),
369            FieldType::Model(name) => name.to_string(),
370            FieldType::Enum(name) => name.to_string(),
371            FieldType::Composite(name) => name.to_string(),
372            FieldType::Unsupported(name) => format!("Unsupported(\"{}\")", name),
373        };
374
375        match modifier {
376            TypeModifier::Optional => format!("{}?", base),
377            TypeModifier::List => format!("{}[]", base),
378            TypeModifier::OptionalList => format!("{}[]?", base),
379            TypeModifier::Required => base,
380        }
381    }
382}