Skip to main content

prax_cli/commands/
generate.rs

1//! `prax generate` command - Generate Rust client code from schema.
2
3use std::collections::{HashMap, HashSet};
4use std::path::{Path, PathBuf};
5
6use crate::cli::GenerateArgs;
7use crate::config::{CONFIG_FILE_NAME, Config, SCHEMA_FILE_PATH};
8use crate::error::{CliError, CliResult};
9use crate::output::{self, success};
10
11/// Run the generate command
12pub async fn run(args: GenerateArgs) -> CliResult<()> {
13    output::header("Generate Prax Client");
14
15    let cwd = std::env::current_dir()?;
16
17    // Load config
18    let config_path = cwd.join(CONFIG_FILE_NAME);
19    let config = if config_path.exists() {
20        Config::load(&config_path)?
21    } else {
22        Config::default()
23    };
24
25    // Resolve schema path
26    let schema_path = args
27        .schema
28        .clone()
29        .unwrap_or_else(|| cwd.join(SCHEMA_FILE_PATH));
30    if !schema_path.exists() {
31        return Err(CliError::Config(format!(
32            "Schema file not found: {}",
33            schema_path.display()
34        )));
35    }
36
37    // Resolve output directory
38    let output_dir = args
39        .output
40        .clone()
41        .unwrap_or_else(|| PathBuf::from(&config.generator.output));
42
43    output::kv("Schema", &schema_path.display().to_string());
44    output::kv("Output", &output_dir.display().to_string());
45    output::newline();
46
47    output::step(1, 4, "Reading schema...");
48
49    // Parse schema
50    let schema_content = std::fs::read_to_string(&schema_path)?;
51    let schema = parse_schema(&schema_content)?;
52
53    output::step(2, 4, "Validating schema...");
54
55    // Validate schema
56    validate_schema(&schema)?;
57
58    output::step(3, 4, "Generating code...");
59
60    // Create output directory
61    std::fs::create_dir_all(&output_dir)?;
62
63    // Generate code
64    let generated_files = generate_code(&schema, &output_dir, &args, &config)?;
65
66    output::step(4, 4, "Writing files...");
67
68    // Print generated files
69    output::newline();
70    output::section("Generated files");
71
72    for file in &generated_files {
73        let relative_path = file
74            .strip_prefix(&cwd)
75            .unwrap_or(file)
76            .display()
77            .to_string();
78        output::list_item(&relative_path);
79    }
80
81    output::newline();
82    success(&format!(
83        "Generated {} files in {:.2}s",
84        generated_files.len(),
85        0.0 // TODO: Add timing
86    ));
87
88    Ok(())
89}
90
91/// Parse and validate the schema file
92fn parse_schema(content: &str) -> CliResult<prax_schema::Schema> {
93    // Use validate_schema to ensure field types are properly resolved
94    // (e.g., FieldType::Model -> FieldType::Enum for enum references)
95    prax_schema::validate_schema(content)
96        .map_err(|e| CliError::Schema(format!("Failed to parse/validate schema: {}", e)))
97}
98
99/// Validate the schema (now a no-op since parse_schema does validation)
100fn validate_schema(_schema: &prax_schema::Schema) -> CliResult<()> {
101    // Validation is now done in parse_schema via validate_schema()
102    Ok(())
103}
104
105/// Generate code from the schema
106fn generate_code(
107    schema: &prax_schema::ast::Schema,
108    output_dir: &Path,
109    args: &GenerateArgs,
110    config: &Config,
111) -> CliResult<Vec<PathBuf>> {
112    let mut generated_files = Vec::new();
113
114    // Determine which features to generate
115    let features = if !args.features.is_empty() {
116        args.features.clone()
117    } else {
118        config
119            .generator
120            .features
121            .clone()
122            .unwrap_or_else(|| vec!["client".to_string()])
123    };
124
125    // Build relation graph for cycle detection
126    let relation_graph = build_relation_graph(schema);
127
128    // Generate main client module
129    let client_path = output_dir.join("mod.rs");
130    let client_code = generate_client_module(schema, &features)?;
131    std::fs::write(&client_path, client_code)?;
132    generated_files.push(client_path);
133
134    // Generate model modules
135    for model in schema.models.values() {
136        let model_path = output_dir.join(format!("{}.rs", to_snake_case(model.name())));
137        let model_code = generate_model_module(model, &features, &relation_graph)?;
138        std::fs::write(&model_path, model_code)?;
139        generated_files.push(model_path);
140    }
141
142    // Generate enum modules
143    for enum_def in schema.enums.values() {
144        let enum_path = output_dir.join(format!("{}.rs", to_snake_case(enum_def.name())));
145        let enum_code = generate_enum_module(enum_def)?;
146        std::fs::write(&enum_path, enum_code)?;
147        generated_files.push(enum_path);
148    }
149
150    // Generate type definitions
151    let types_path = output_dir.join("types.rs");
152    let types_code = generate_types_module(schema)?;
153    std::fs::write(&types_path, types_code)?;
154    generated_files.push(types_path);
155
156    // Generate filters
157    let filters_path = output_dir.join("filters.rs");
158    let filters_code = generate_filters_module(schema)?;
159    std::fs::write(&filters_path, filters_code)?;
160    generated_files.push(filters_path);
161
162    Ok(generated_files)
163}
164
165/// Build a graph of model relations for cycle detection.
166/// Returns a map from model name to the set of model names it references
167/// (non-list relations only, since Vec<T> doesn't cause infinite size).
168fn build_relation_graph(schema: &prax_schema::ast::Schema) -> HashMap<String, HashSet<String>> {
169    let mut graph: HashMap<String, HashSet<String>> = HashMap::new();
170
171    for model in schema.models.values() {
172        let entry = graph.entry(model.name().to_string()).or_default();
173        for field in model.fields.values() {
174            if let prax_schema::ast::FieldType::Model(ref target) = field.field_type
175                && !field.is_list()
176            {
177                entry.insert(target.to_string());
178            }
179        }
180    }
181
182    graph
183}
184
185/// Check if a non-list relation field from `source_model` to `target_model`
186/// participates in a cycle (i.e. target_model can reach source_model through
187/// non-list relations). If so, the field must be wrapped in Box<T>.
188fn needs_boxing(
189    source_model: &str,
190    target_model: &str,
191    graph: &HashMap<String, HashSet<String>>,
192) -> bool {
193    let mut visited = HashSet::new();
194    let mut stack = vec![target_model.to_string()];
195
196    while let Some(current) = stack.pop() {
197        if current == source_model {
198            return true;
199        }
200        if !visited.insert(current.clone()) {
201            continue;
202        }
203        if let Some(neighbors) = graph.get(&current) {
204            for neighbor in neighbors {
205                stack.push(neighbor.clone());
206            }
207        }
208    }
209
210    false
211}
212
213/// Generate the main client module
214fn generate_client_module(
215    schema: &prax_schema::ast::Schema,
216    _features: &[String],
217) -> CliResult<String> {
218    let mut code = String::new();
219
220    code.push_str("//! Auto-generated by Prax - DO NOT EDIT\n");
221    code.push_str("//!\n");
222    code.push_str("//! This module contains the generated Prax client.\n\n");
223
224    // Module declarations
225    code.push_str("pub mod types;\n");
226    code.push_str("pub mod filters;\n\n");
227
228    for model in schema.models.values() {
229        code.push_str(&format!("pub mod {};\n", to_snake_case(model.name())));
230    }
231
232    for enum_def in schema.enums.values() {
233        code.push_str(&format!("pub mod {};\n", to_snake_case(enum_def.name())));
234    }
235
236    code.push('\n');
237
238    // Re-exports
239    code.push_str("#[allow(unused_imports)]\npub use types::*;\n");
240    code.push_str("#[allow(unused_imports)]\npub use filters::*;\n\n");
241
242    for model in schema.models.values() {
243        code.push_str(&format!(
244            "#[allow(unused_imports)]\npub use {}::{};\n",
245            to_snake_case(model.name()),
246            model.name()
247        ));
248    }
249
250    for enum_def in schema.enums.values() {
251        code.push_str(&format!(
252            "#[allow(unused_imports)]\npub use {}::{};\n",
253            to_snake_case(enum_def.name()),
254            enum_def.name()
255        ));
256    }
257
258    code.push('\n');
259
260    // Client struct with Clone bound and derive
261    code.push_str("#[allow(dead_code)]\n");
262    code.push_str("/// The Prax database client\n");
263    code.push_str("#[derive(Clone)]\n");
264    code.push_str("pub struct PraxClient<E: prax_query::QueryEngine> {\n");
265    code.push_str("    engine: E,\n");
266    code.push_str("}\n\n");
267
268    code.push_str("impl<E: prax_query::QueryEngine> PraxClient<E> {\n");
269    code.push_str("    /// Create a new Prax client with the given query engine\n");
270    code.push_str("    pub fn new(engine: E) -> Self {\n");
271    code.push_str("        Self { engine }\n");
272    code.push_str("    }\n\n");
273
274    code.push_str("    /// Borrow the underlying engine. Useful when composing\n");
275    code.push_str("    /// per-model operations directly or running raw SQL.\n");
276    code.push_str("    pub fn engine(&self) -> &E {\n");
277    code.push_str("        &self.engine\n");
278    code.push_str("    }\n\n");
279
280    code.push_str("    /// Execute a typed raw SQL query, decoding each returned\n");
281    code.push_str("    /// row as `T`. Mirrors `prax_orm::PraxClient::query_raw`.\n");
282    code.push_str("    ///\n");
283    code.push_str("    /// The typed per-model API covers the common CRUD cases;\n");
284    code.push_str("    /// use this for window functions, vendor-specific\n");
285    code.push_str("    /// extensions, CTEs, JOIN-driven row shapes, and aggregates\n");
286    code.push_str("    /// that the fluent builder doesn't model yet. `T` must\n");
287    code.push_str("    /// implement both `Model` (for the table association) and\n");
288    code.push_str("    /// `FromRow` (for row decoding); the generator emits both\n");
289    code.push_str("    /// impls on every model in this client.\n");
290    code.push_str("    pub async fn query_raw<T>(&self, sql: prax_query::raw::Sql)\n");
291    code.push_str("        -> prax_query::error::QueryResult<Vec<T>>\n");
292    code.push_str("    where\n");
293    code.push_str(
294        "        T: prax_query::traits::Model + prax_query::row::FromRow + Send + 'static,\n",
295    );
296    code.push_str("    {\n");
297    code.push_str("        let (s, p) = sql.build();\n");
298    code.push_str("        self.engine.query_many::<T>(&s, p).await\n");
299    code.push_str("    }\n\n");
300
301    code.push_str("    /// Execute a raw statement that doesn't return rows\n");
302    code.push_str("    /// (INSERT / UPDATE / DELETE / DDL). Returns the\n");
303    code.push_str("    /// driver-reported affected-row count. Mirrors\n");
304    code.push_str("    /// `prax_orm::PraxClient::execute_raw`.\n");
305    code.push_str("    pub async fn execute_raw(&self, sql: prax_query::raw::Sql)\n");
306    code.push_str("        -> prax_query::error::QueryResult<u64>\n");
307    code.push_str("    {\n");
308    code.push_str("        let (s, p) = sql.build();\n");
309    code.push_str("        self.engine.execute_raw(&s, p).await\n");
310    code.push_str("    }\n\n");
311
312    for model in schema.models.values() {
313        let snake_name = to_snake_case(model.name());
314        code.push_str(&format!("    /// Access {} operations\n", model.name()));
315        code.push_str(&format!(
316            "    pub fn {}(&self) -> {}::Client<E> {{\n",
317            snake_name, snake_name,
318        ));
319        code.push_str(&format!(
320            "        {}::Client::new(self.engine.clone())\n",
321            snake_name,
322        ));
323        code.push_str("    }\n\n");
324    }
325
326    code.push_str("}\n");
327
328    Ok(code)
329}
330
331/// Generate a model module
332fn generate_model_module(
333    model: &prax_schema::ast::Model,
334    features: &[String],
335    relation_graph: &HashMap<String, HashSet<String>>,
336) -> CliResult<String> {
337    let mut code = String::new();
338
339    code.push_str(&format!(
340        "//! Auto-generated module for {} model\n\n",
341        model.name()
342    ));
343
344    // Import sibling types for relation fields
345    code.push_str("#[allow(unused_imports)]\n");
346    code.push_str("use super::*;\n");
347    code.push_str("#[allow(unused_imports)]\n");
348    code.push_str("use prax_query::traits::Model;\n\n");
349
350    // Derive macros based on features
351    let mut derives = vec!["Debug", "Clone"];
352    if features.contains(&"serde".to_string()) {
353        derives.push("serde::Serialize");
354        derives.push("serde::Deserialize");
355    }
356
357    // Model struct
358    code.push_str("#[allow(dead_code)]\n");
359    code.push_str(&format!("#[derive({})]\n", derives.join(", ")));
360    code.push_str(&format!("pub struct {} {{\n", model.name()));
361
362    for field in model.fields.values() {
363        let field_name = to_field_ident(field.name());
364
365        // Add serde rename if mapped
366        if let Some(attr) = field.get_attribute("map")
367            && features.contains(&"serde".to_string())
368            && let Some(value) = attr.first_arg().and_then(|v| v.as_string())
369        {
370            code.push_str(&format!("    #[serde(rename = \"{}\")]\n", value));
371        }
372
373        let rust_type = field_type_to_rust_with_boxing(
374            &field.field_type,
375            field.modifier,
376            model.name(),
377            relation_graph,
378        );
379        code.push_str(&format!("    pub {}: {},\n", field_name, rust_type));
380    }
381
382    code.push_str("}\n\n");
383
384    // Model trait implementation
385    let table_name = model.table_name();
386    let id_fields: Vec<&str> = model.id_fields().iter().map(|f| f.name()).collect();
387    let scalar_columns: Vec<String> = model
388        .scalar_fields()
389        .iter()
390        .map(|f| {
391            // Use @map name if present, otherwise snake_case the field name
392            f.get_attribute("map")
393                .and_then(|a| a.first_arg())
394                .and_then(|v| v.as_string())
395                .map(|s| s.to_string())
396                .unwrap_or_else(|| to_snake_case(f.name()))
397        })
398        .collect();
399
400    code.push_str(&format!("impl Model for {} {{\n", model.name()));
401    code.push_str(&format!(
402        "    const MODEL_NAME: &'static str = \"{}\";\n",
403        model.name()
404    ));
405    code.push_str(&format!(
406        "    const TABLE_NAME: &'static str = \"{}\";\n",
407        table_name
408    ));
409    code.push_str(&format!(
410        "    const PRIMARY_KEY: &'static [&'static str] = &[{}];\n",
411        id_fields
412            .iter()
413            .map(|f| format!("\"{}\"", to_snake_case(f)))
414            .collect::<Vec<_>>()
415            .join(", ")
416    ));
417    code.push_str(&format!(
418        "    const COLUMNS: &'static [&'static str] = &[{}];\n",
419        scalar_columns
420            .iter()
421            .map(|c| format!("\"{}\"", c))
422            .collect::<Vec<_>>()
423            .join(", ")
424    ));
425    code.push_str("}\n\n");
426
427    // FromRow — required to decode rows back into the model when an
428    // operation is run. Mirrors the emission in
429    // `prax-codegen/src/generators/derive_from_row.rs`: scalar fields
430    // decode via `FromColumn`; relation fields default-init and are
431    // filled later by the relation executor on the `.include` path.
432    code.push_str(&format!(
433        "impl prax_query::row::FromRow for {} {{\n",
434        model.name()
435    ));
436    code.push_str(
437        "    fn from_row(row: &impl prax_query::row::RowRef)\n        -> Result<Self, prax_query::row::RowError>\n    {\n",
438    );
439    code.push_str("        Ok(Self {\n");
440    for field in model.fields.values() {
441        let field_name = to_field_ident(field.name());
442        let rust_type = field_type_to_rust_with_boxing(
443            &field.field_type,
444            field.modifier,
445            model.name(),
446            relation_graph,
447        );
448        if field.is_relation() {
449            code.push_str(&format!(
450                "            {}: ::core::default::Default::default(),\n",
451                field_name
452            ));
453        } else {
454            let column = field
455                .get_attribute("map")
456                .and_then(|a| a.first_arg())
457                .and_then(|v| v.as_string())
458                .map(|s| s.to_string())
459                .unwrap_or_else(|| field_name.clone());
460            code.push_str(&format!(
461                "            {}: <{} as prax_query::row::FromColumn>::from_column(row, \"{}\")?,\n",
462                field_name, rust_type, column
463            ));
464        }
465    }
466    code.push_str("        })\n");
467    code.push_str("    }\n");
468    code.push_str("}\n\n");
469
470    // ModelWithPk — required by composite-key handling and by operations
471    // that need to extract the primary key from a model instance (e.g.
472    // upsert, nested writes). Mirrors
473    // `prax-codegen/src/generators/derive_model_with_pk.rs`.
474    code.push_str(&format!(
475        "impl prax_query::traits::ModelWithPk for {} {{\n",
476        model.name()
477    ));
478    code.push_str("    fn pk_value(&self) -> prax_query::filter::FilterValue {\n");
479    let id_field_objs: Vec<_> = model.id_fields();
480    if id_field_objs.len() == 1 {
481        let f = id_field_objs[0];
482        code.push_str(&format!(
483            "        <{} as prax_query::filter::ToFilterValue>::to_filter_value(&self.{})\n",
484            field_type_to_rust_with_boxing(&f.field_type, f.modifier, model.name(), relation_graph),
485            to_field_ident(f.name())
486        ));
487    } else if id_field_objs.is_empty() {
488        code.push_str("        prax_query::filter::FilterValue::Null\n");
489    } else {
490        code.push_str("        prax_query::filter::FilterValue::List(vec![\n");
491        for f in &id_field_objs {
492            code.push_str(&format!(
493                "            <{} as prax_query::filter::ToFilterValue>::to_filter_value(&self.{}),\n",
494                field_type_to_rust_with_boxing(&f.field_type, f.modifier, model.name(), relation_graph),
495                to_field_ident(f.name())
496            ));
497        }
498        code.push_str("        ])\n");
499    }
500    code.push_str("    }\n\n");
501
502    code.push_str(
503        "    fn get_column_value(&self, column: &str)\n        -> ::core::option::Option<prax_query::filter::FilterValue>\n    {\n",
504    );
505    code.push_str("        match column {\n");
506    for field in model.scalar_fields() {
507        // Rust field identifier — escape reserved keywords.
508        let field_name = to_field_ident(field.name());
509        // SQL column name — use raw snake_case (no r# prefix) because the
510        // @map override or fallback feeds a string literal passed to
511        // `FromColumn::from_column(row, "...")`, not an identifier.
512        let column = field
513            .get_attribute("map")
514            .and_then(|a| a.first_arg())
515            .and_then(|v| v.as_string())
516            .map(|s| s.to_string())
517            .unwrap_or_else(|| to_snake_case(field.name()));
518        let rust_type = field_type_to_rust_with_boxing(
519            &field.field_type,
520            field.modifier,
521            model.name(),
522            relation_graph,
523        );
524        code.push_str(&format!(
525            "            \"{}\" => ::core::option::Option::Some(\n                <{} as prax_query::filter::ToFilterValue>::to_filter_value(&self.{})\n            ),\n",
526            column, rust_type, field_name
527        ));
528    }
529    code.push_str("            _ => ::core::option::Option::None,\n");
530    code.push_str("        }\n");
531    code.push_str("    }\n");
532    code.push_str("}\n\n");
533
534    // Per-model `Client<E>` (named `Client`, not `{Model}Operations`, so
535    // `prax::client!(Foo, Bar, ...)` can find `foo::Client::new(...)`
536    // and `bar::Client::new(...)` by snake-cased module path — matching
537    // the shape emitted by `#[derive(Model)]`).
538    code.push_str("#[allow(dead_code)]\n");
539    code.push_str(&format!("/// Operations for the {} model\n", model.name()));
540    code.push_str("pub struct Client<E: prax_query::QueryEngine> {\n");
541    code.push_str("    engine: E,\n");
542    code.push_str("}\n\n");
543
544    code.push_str("impl<E: prax_query::QueryEngine> Client<E> {\n");
545    code.push_str("    pub fn new(engine: E) -> Self {\n");
546    code.push_str("        Self { engine }\n");
547    code.push_str("    }\n\n");
548
549    let model_ty = model.name();
550    let crud_methods: &[(&str, &str, &str)] = &[
551        ("find_many", "FindManyOperation", "Find many records"),
552        ("find_unique", "FindUniqueOperation", "Find a unique record"),
553        (
554            "find_first",
555            "FindFirstOperation",
556            "Find the first matching record",
557        ),
558        ("create", "CreateOperation", "Create a new record"),
559        (
560            "create_many",
561            "CreateManyOperation",
562            "Create many records in one operation",
563        ),
564        ("update", "UpdateOperation", "Update a record"),
565        (
566            "update_many",
567            "UpdateManyOperation",
568            "Update many records matching a filter",
569        ),
570        ("upsert", "UpsertOperation", "Insert or update a record"),
571        ("delete", "DeleteOperation", "Delete a record"),
572        (
573            "delete_many",
574            "DeleteManyOperation",
575            "Delete many records matching a filter",
576        ),
577        ("count", "CountOperation", "Count records"),
578    ];
579    for (method, op_ty, doc) in crud_methods {
580        code.push_str(&format!("    /// {}\n", doc));
581        code.push_str(&format!(
582            "    pub fn {}(&self) -> prax_query::operations::{}<E, {}> {{\n",
583            method, op_ty, model_ty,
584        ));
585        code.push_str(&format!(
586            "        prax_query::operations::{}::new(self.engine.clone())\n",
587            op_ty,
588        ));
589        code.push_str("    }\n\n");
590    }
591
592    code.push_str("}\n");
593
594    Ok(code)
595}
596
597/// Generate an enum module
598fn generate_enum_module(enum_def: &prax_schema::ast::Enum) -> CliResult<String> {
599    let mut code = String::new();
600
601    code.push_str(&format!(
602        "//! Auto-generated module for {} enum\n\n",
603        enum_def.name()
604    ));
605
606    code.push_str("#[allow(dead_code)]\n");
607    code.push_str(
608        "#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]\n",
609    );
610    code.push_str(&format!("pub enum {} {{\n", enum_def.name()));
611
612    for variant in &enum_def.variants {
613        let raw_name = variant.name();
614        let pascal_name = to_pascal_case(raw_name);
615
616        // Check for explicit @map attribute first
617        if let Some(attr) = variant.attributes.iter().find(|a| a.is("map"))
618            && let Some(value) = attr.first_arg().and_then(|v| v.as_string())
619        {
620            code.push_str(&format!("    #[serde(rename = \"{}\")]\n", value));
621            code.push_str(&format!("    {},\n", pascal_name));
622            continue;
623        }
624
625        // If variant name differs from PascalCase form, add serde rename
626        if raw_name != pascal_name {
627            code.push_str(&format!("    #[serde(rename = \"{}\")]\n", raw_name));
628        }
629        code.push_str(&format!("    {},\n", pascal_name));
630    }
631
632    code.push_str("}\n\n");
633
634    // Display implementation for SQL serialization
635    code.push_str(&format!(
636        "impl std::fmt::Display for {} {{\n",
637        enum_def.name()
638    ));
639    code.push_str("    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n");
640    code.push_str("        match self {\n");
641    for variant in &enum_def.variants {
642        let raw_name = variant.name();
643        let pascal_name = to_pascal_case(raw_name);
644        let db_value = variant.db_value();
645        code.push_str(&format!(
646            "            Self::{} => write!(f, \"{}\"),\n",
647            pascal_name, db_value
648        ));
649    }
650    code.push_str("        }\n");
651    code.push_str("    }\n");
652    code.push_str("}\n\n");
653
654    // Default implementation
655    if let Some(default_variant) = enum_def.variants.first() {
656        let pascal_name = to_pascal_case(default_variant.name());
657        code.push_str(&format!("impl Default for {} {{\n", enum_def.name()));
658        code.push_str(&format!(
659            "    fn default() -> Self {{\n        Self::{}\n    }}\n",
660            pascal_name
661        ));
662        code.push_str("}\n\n");
663    }
664
665    // Round-trip helper: parse the DB string form back into an enum variant.
666    // Used by the `FromColumn` impls below and callable directly by
667    // consumers that need to deserialize a raw string payload.
668    code.push_str(&format!(
669        "impl std::str::FromStr for {} {{\n",
670        enum_def.name()
671    ));
672    code.push_str("    type Err = prax_query::row::RowError;\n");
673    code.push_str("    fn from_str(s: &str) -> Result<Self, Self::Err> {\n");
674    code.push_str("        match s {\n");
675    for variant in &enum_def.variants {
676        let raw_name = variant.name();
677        let pascal_name = to_pascal_case(raw_name);
678        let db_value = variant.db_value();
679        code.push_str(&format!(
680            "            \"{}\" => Ok(Self::{}),\n",
681            db_value, pascal_name
682        ));
683    }
684    code.push_str(&format!(
685        "            _ => Err(prax_query::row::RowError::TypeConversion {{\n                column: String::new(),\n                message: format!(\"unknown {} variant: {{}}\", s),\n            }}),\n",
686        enum_def.name()
687    ));
688    code.push_str("        }\n");
689    code.push_str("    }\n");
690    code.push_str("}\n\n");
691
692    // `FromColumn` — decode a string column back into the enum. Required
693    // for `find_many` / `find_unique` / any query that returns rows with
694    // this enum as a field type.
695    code.push_str(&format!(
696        "impl prax_query::row::FromColumn for {} {{\n",
697        enum_def.name()
698    ));
699    code.push_str(
700        "    fn from_column(row: &impl prax_query::row::RowRef, column: &str)\n        -> Result<Self, prax_query::row::RowError>\n    {\n",
701    );
702    code.push_str("        let raw = row.get_string(column)?;\n");
703    code.push_str("        <Self as std::str::FromStr>::from_str(&raw).map_err(|e| {\n");
704    code.push_str("            let msg = match &e {\n");
705    code.push_str(
706        "                prax_query::row::RowError::TypeConversion { message, .. } => message.clone(),\n",
707    );
708    code.push_str("                other => other.to_string(),\n");
709    code.push_str("            };\n");
710    code.push_str("            prax_query::row::RowError::TypeConversion {\n");
711    code.push_str("                column: column.to_string(),\n");
712    code.push_str("                message: msg,\n");
713    code.push_str("            }\n");
714    code.push_str("        })\n");
715    code.push_str("    }\n");
716    code.push_str("}\n\n");
717
718    // `Option<Enum>` handled by the blanket `impl<T: FromColumn>
719    // FromColumn for Option<T>` in prax-query — no per-enum Option impl
720    // needed here (the orphan rule would forbid it on the consumer side
721    // anyway).
722
723    // `ToFilterValue` — encode the enum as a string FilterValue so that
724    // `where` clauses / `ModelWithPk::pk_value` / nested writes can send
725    // it as a bind parameter.
726    code.push_str(&format!(
727        "impl prax_query::filter::ToFilterValue for {} {{\n",
728        enum_def.name()
729    ));
730    code.push_str("    fn to_filter_value(&self) -> prax_query::filter::FilterValue {\n");
731    code.push_str("        prax_query::filter::FilterValue::String(self.to_string())\n");
732    code.push_str("    }\n");
733    code.push_str("}\n");
734
735    Ok(code)
736}
737
738/// Generate types module
739fn generate_types_module(schema: &prax_schema::ast::Schema) -> CliResult<String> {
740    let mut code = String::new();
741
742    code.push_str("//! Common type definitions\n\n");
743    code.push_str("#[allow(unused_imports)]\npub use chrono::{DateTime, Utc};\n");
744    code.push_str("#[allow(unused_imports)]\npub use uuid::Uuid;\n");
745    code.push_str("#[allow(unused_imports)]\npub use serde_json::Value as Json;\n");
746    code.push('\n');
747
748    // Add any custom types from composite types
749    for composite in schema.types.values() {
750        code.push_str("#[allow(dead_code)]\n");
751        code.push_str("#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]\n");
752        code.push_str(&format!("pub struct {} {{\n", composite.name()));
753        for field in composite.fields.values() {
754            let rust_type = field_type_to_rust(&field.field_type, field.modifier);
755            let field_name = to_field_ident(field.name());
756            code.push_str(&format!("    pub {}: {},\n", field_name, rust_type));
757        }
758        code.push_str("}\n\n");
759    }
760
761    Ok(code)
762}
763
764/// Generate filters module
765fn generate_filters_module(schema: &prax_schema::ast::Schema) -> CliResult<String> {
766    let mut code = String::new();
767
768    code.push_str("//! Filter types for queries\n\n");
769    code.push_str("#[allow(unused_imports)]\n");
770    code.push_str("use prax_query::filter::{Filter, ScalarFilter};\n");
771
772    // Collect all enum types referenced by model scalar fields
773    let mut referenced_enums = HashSet::new();
774    for model in schema.models.values() {
775        for field in model.fields.values() {
776            if !field.is_relation()
777                && let prax_schema::ast::FieldType::Enum(ref name) = field.field_type
778            {
779                referenced_enums.insert(name.to_string());
780            }
781        }
782    }
783
784    // Import enum types
785    for enum_name in &referenced_enums {
786        code.push_str(&format!(
787            "#[allow(unused_imports)]\nuse super::{}::{};\n",
788            to_snake_case(enum_name),
789            enum_name
790        ));
791    }
792
793    code.push('\n');
794
795    for model in schema.models.values() {
796        // Where input
797        code.push_str("#[allow(dead_code)]\n");
798        code.push_str(&format!("/// Filter input for {} queries\n", model.name()));
799        code.push_str("#[derive(Debug, Default, Clone)]\n");
800        code.push_str(&format!("pub struct {}WhereInput {{\n", model.name()));
801
802        for field in model.fields.values() {
803            if !field.is_relation() {
804                let filter_type = field_to_filter_type(&field.field_type);
805                let field_name = to_field_ident(field.name());
806                code.push_str(&format!(
807                    "    pub {}: Option<{}>,\n",
808                    field_name, filter_type
809                ));
810            }
811        }
812
813        code.push_str("    pub and: Option<Vec<Self>>,\n");
814        code.push_str("    pub or: Option<Vec<Self>>,\n");
815        code.push_str("    pub not: Option<Box<Self>>,\n");
816        code.push_str("}\n\n");
817
818        // OrderBy input
819        code.push_str("#[allow(dead_code)]\n");
820        code.push_str(&format!(
821            "/// Order by input for {} queries\n",
822            model.name()
823        ));
824        code.push_str("#[derive(Debug, Default, Clone)]\n");
825        code.push_str(&format!("pub struct {}OrderByInput {{\n", model.name()));
826
827        for field in model.fields.values() {
828            if !field.is_relation() {
829                let field_name = to_field_ident(field.name());
830                code.push_str(&format!(
831                    "    pub {}: Option<prax_query::SortOrder>,\n",
832                    field_name
833                ));
834            }
835        }
836
837        code.push_str("}\n\n");
838    }
839
840    Ok(code)
841}
842
843/// Convert a field type to Rust type (basic, without boxing)
844fn field_type_to_rust(
845    field_type: &prax_schema::ast::FieldType,
846    modifier: prax_schema::ast::TypeModifier,
847) -> String {
848    use prax_schema::ast::{FieldType, ScalarType, TypeModifier};
849
850    let base_type = match field_type {
851        FieldType::Scalar(scalar) => match scalar {
852            ScalarType::Int => "i32".to_string(),
853            ScalarType::BigInt => "i64".to_string(),
854            ScalarType::Float => "f64".to_string(),
855            ScalarType::String => "String".to_string(),
856            ScalarType::Boolean => "bool".to_string(),
857            ScalarType::DateTime => "chrono::DateTime<chrono::Utc>".to_string(),
858            ScalarType::Date => "chrono::NaiveDate".to_string(),
859            ScalarType::Time => "chrono::NaiveTime".to_string(),
860            ScalarType::Json => "serde_json::Value".to_string(),
861            ScalarType::Bytes => "Vec<u8>".to_string(),
862            ScalarType::Decimal => "rust_decimal::Decimal".to_string(),
863            ScalarType::Uuid => "uuid::Uuid".to_string(),
864            ScalarType::Cuid => "String".to_string(),
865            ScalarType::Cuid2 => "String".to_string(),
866            ScalarType::NanoId => "String".to_string(),
867            ScalarType::Ulid => "String".to_string(),
868            ScalarType::Vector(_) | ScalarType::HalfVector(_) => "Vec<f32>".to_string(),
869            ScalarType::SparseVector(_) => "Vec<(u32, f32)>".to_string(),
870            ScalarType::Bit(_) => "Vec<u8>".to_string(),
871        },
872        FieldType::Model(name) => name.to_string(),
873        FieldType::Enum(name) => name.to_string(),
874        FieldType::Composite(name) => name.to_string(),
875        FieldType::Unsupported(_) => "serde_json::Value".to_string(),
876    };
877
878    match modifier {
879        TypeModifier::Optional | TypeModifier::OptionalList => format!("Option<{}>", base_type),
880        TypeModifier::List => format!("Vec<{}>", base_type),
881        TypeModifier::Required => base_type,
882    }
883}
884
885/// Convert a field type to Rust type with Box<T> wrapping for cyclic relations.
886fn field_type_to_rust_with_boxing(
887    field_type: &prax_schema::ast::FieldType,
888    modifier: prax_schema::ast::TypeModifier,
889    source_model: &str,
890    relation_graph: &HashMap<String, HashSet<String>>,
891) -> String {
892    use prax_schema::ast::{FieldType, TypeModifier};
893
894    // For model references (non-list), check if boxing is needed to break cycles.
895    // Non-list relations are always emitted as `Option<T>` regardless of the
896    // schema's required/optional modifier: the relation is "not loaded" until
897    // `.include` populates it, so the Rust struct must allow representing the
898    // un-included state. This also gives the field a `Default::default()`
899    // (== `None`), which `FromRow` relies on to construct a row that hasn't
900    // been join-decoded yet. The schema-level required-ness is a database
901    // constraint enforced by FK + NOT NULL, not a Rust struct invariant.
902    if let FieldType::Model(target) = field_type
903        && !matches!(modifier, TypeModifier::List)
904    {
905        let should_box = needs_boxing(source_model, target, relation_graph);
906        let base = target.to_string();
907        return if should_box {
908            format!("Option<Box<{}>>", base)
909        } else {
910            format!("Option<{}>", base)
911        };
912    }
913
914    // Fallback to basic conversion for non-cyclic fields
915    field_type_to_rust(field_type, modifier)
916}
917
918/// Convert a field type to filter type
919fn field_to_filter_type(field_type: &prax_schema::ast::FieldType) -> String {
920    use prax_schema::ast::{FieldType, ScalarType};
921
922    match field_type {
923        FieldType::Scalar(scalar) => match scalar {
924            ScalarType::Int | ScalarType::BigInt => "ScalarFilter<i64>".to_string(),
925            ScalarType::Float | ScalarType::Decimal => "ScalarFilter<f64>".to_string(),
926            ScalarType::String
927            | ScalarType::Uuid
928            | ScalarType::Cuid
929            | ScalarType::Cuid2
930            | ScalarType::NanoId
931            | ScalarType::Ulid => "ScalarFilter<String>".to_string(),
932            ScalarType::Boolean => "ScalarFilter<bool>".to_string(),
933            ScalarType::DateTime => "ScalarFilter<chrono::DateTime<chrono::Utc>>".to_string(),
934            ScalarType::Date => "ScalarFilter<chrono::NaiveDate>".to_string(),
935            ScalarType::Time => "ScalarFilter<chrono::NaiveTime>".to_string(),
936            ScalarType::Json => "ScalarFilter<serde_json::Value>".to_string(),
937            ScalarType::Bytes => "ScalarFilter<Vec<u8>>".to_string(),
938            // pgvector scalars. Only `VectorFilter` exists in prax-pgvector
939            // today (for `vector(N)` and `halfvec(N)`); sparse and bit
940            // columns fall back to the raw element type under
941            // `ScalarFilter` until dedicated filter types ship upstream.
942            // Fully qualify the path so the generated filters.rs compiles
943            // without requiring the consumer to add a `use` statement.
944            ScalarType::Vector(_) | ScalarType::HalfVector(_) => {
945                "prax_pgvector::filter::VectorFilter".to_string()
946            }
947            ScalarType::SparseVector(_) => "ScalarFilter<Vec<(u32, f32)>>".to_string(),
948            ScalarType::Bit(_) => "ScalarFilter<Vec<u8>>".to_string(),
949        },
950        FieldType::Enum(name) => format!("ScalarFilter<{}>", name),
951        _ => "Filter".to_string(),
952    }
953}
954
955/// Convert PascalCase to snake_case
956fn to_snake_case(name: &str) -> String {
957    let mut result = String::new();
958    for (i, c) in name.chars().enumerate() {
959        if c.is_uppercase() {
960            if i > 0 {
961                result.push('_');
962            }
963            result.push(c.to_lowercase().next().unwrap());
964        } else {
965            result.push(c);
966        }
967    }
968    result
969}
970
971/// Snake-case a name and escape it as a raw identifier if the result
972/// collides with a Rust reserved keyword. Use for any emitted Rust field
973/// or variable name; plain column-name strings (serde rename values, SQL
974/// column lookups) still use `to_snake_case` directly.
975///
976/// Schemas with columns literally named `type`, `match`, `use`, `loop`,
977/// etc. (common in Prisma — documents, notifications, email_verification
978/// all have a `type` column) otherwise produce code like `pub type: …`
979/// that fails to parse. This function emits `r#type` instead.
980///
981/// The four keywords Rust forbids as raw identifiers (`crate`, `self`,
982/// `Self`, `super`) are intentionally not escaped; a column literally
983/// named `self` would still fail to compile, which is the correct
984/// behavior (the schema should be fixed).
985fn to_field_ident(name: &str) -> String {
986    let snake = to_snake_case(name);
987    if is_rust_keyword(&snake) {
988        format!("r#{}", snake)
989    } else {
990        snake
991    }
992}
993
994fn is_rust_keyword(s: &str) -> bool {
995    matches!(
996        s,
997        "abstract"
998            | "as"
999            | "async"
1000            | "await"
1001            | "become"
1002            | "box"
1003            | "break"
1004            | "const"
1005            | "continue"
1006            | "do"
1007            | "dyn"
1008            | "else"
1009            | "enum"
1010            | "extern"
1011            | "false"
1012            | "final"
1013            | "fn"
1014            | "for"
1015            | "gen"
1016            | "if"
1017            | "impl"
1018            | "in"
1019            | "let"
1020            | "loop"
1021            | "macro"
1022            | "match"
1023            | "mod"
1024            | "move"
1025            | "mut"
1026            | "override"
1027            | "priv"
1028            | "pub"
1029            | "ref"
1030            | "return"
1031            | "static"
1032            | "struct"
1033            | "trait"
1034            | "true"
1035            | "try"
1036            | "type"
1037            | "typeof"
1038            | "unsafe"
1039            | "unsized"
1040            | "use"
1041            | "virtual"
1042            | "where"
1043            | "while"
1044            | "yield"
1045    )
1046}
1047
1048/// Convert snake_case, SCREAMING_SNAKE_CASE, or any other casing to PascalCase.
1049fn to_pascal_case(name: &str) -> String {
1050    if name.is_empty() {
1051        return String::new();
1052    }
1053
1054    // If already PascalCase (starts with uppercase, contains lowercase), return as-is
1055    let first = name.chars().next().unwrap();
1056    if first.is_uppercase() && name.chars().any(|c| c.is_lowercase()) && !name.contains('_') {
1057        return name.to_string();
1058    }
1059
1060    // Split on underscores and capitalize each segment
1061    name.split('_')
1062        .filter(|s| !s.is_empty())
1063        .map(|segment| {
1064            let mut chars = segment.chars();
1065            match chars.next() {
1066                None => String::new(),
1067                Some(first) => {
1068                    let rest: String = chars.collect();
1069                    format!("{}{}", first.to_uppercase(), rest.to_lowercase())
1070                }
1071            }
1072        })
1073        .collect()
1074}
1075
1076#[cfg(test)]
1077mod tests {
1078    use super::*;
1079
1080    #[test]
1081    fn test_to_snake_case() {
1082        assert_eq!(to_snake_case("BoardMember"), "board_member");
1083        assert_eq!(to_snake_case("User"), "user");
1084        assert_eq!(to_snake_case("JiraImportConfig"), "jira_import_config");
1085    }
1086
1087    #[test]
1088    fn test_to_pascal_case_from_snake() {
1089        assert_eq!(to_pascal_case("card_created"), "CardCreated");
1090        assert_eq!(to_pascal_case("branch_deleted"), "BranchDeleted");
1091        assert_eq!(to_pascal_case("pr_merged"), "PrMerged");
1092    }
1093
1094    #[test]
1095    fn test_to_pascal_case_from_screaming() {
1096        assert_eq!(to_pascal_case("CARD_CREATED"), "CardCreated");
1097        assert_eq!(to_pascal_case("PR_MERGED"), "PrMerged");
1098    }
1099
1100    #[test]
1101    fn test_to_pascal_case_already_pascal() {
1102        assert_eq!(to_pascal_case("Admin"), "Admin");
1103        assert_eq!(to_pascal_case("SuperAdmin"), "SuperAdmin");
1104        assert_eq!(to_pascal_case("Low"), "Low");
1105    }
1106
1107    #[test]
1108    fn test_to_pascal_case_single_word() {
1109        assert_eq!(to_pascal_case("active"), "Active");
1110        assert_eq!(to_pascal_case("ACTIVE"), "Active");
1111    }
1112
1113    #[test]
1114    fn test_needs_boxing_direct_cycle() {
1115        let mut graph = HashMap::new();
1116        graph.insert(
1117            "Board".to_string(),
1118            HashSet::from(["JiraConfig".to_string()]),
1119        );
1120        graph.insert(
1121            "JiraConfig".to_string(),
1122            HashSet::from(["Board".to_string()]),
1123        );
1124
1125        assert!(needs_boxing("Board", "JiraConfig", &graph));
1126        assert!(needs_boxing("JiraConfig", "Board", &graph));
1127    }
1128
1129    #[test]
1130    fn test_needs_boxing_no_cycle() {
1131        let mut graph = HashMap::new();
1132        graph.insert("Post".to_string(), HashSet::from(["User".to_string()]));
1133        graph.insert("User".to_string(), HashSet::new());
1134
1135        assert!(!needs_boxing("Post", "User", &graph));
1136    }
1137
1138    #[test]
1139    fn test_needs_boxing_indirect_cycle() {
1140        let mut graph = HashMap::new();
1141        graph.insert("A".to_string(), HashSet::from(["B".to_string()]));
1142        graph.insert("B".to_string(), HashSet::from(["C".to_string()]));
1143        graph.insert("C".to_string(), HashSet::from(["A".to_string()]));
1144
1145        assert!(needs_boxing("A", "B", &graph));
1146        assert!(needs_boxing("B", "C", &graph));
1147        assert!(needs_boxing("C", "A", &graph));
1148    }
1149}