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    for model in schema.models.values() {
275        let snake_name = to_snake_case(model.name());
276        code.push_str(&format!("    /// Access {} operations\n", model.name()));
277        code.push_str(&format!(
278            "    pub fn {}(&self) -> {}::Client<E> {{\n",
279            snake_name, snake_name,
280        ));
281        code.push_str(&format!(
282            "        {}::Client::new(self.engine.clone())\n",
283            snake_name,
284        ));
285        code.push_str("    }\n\n");
286    }
287
288    code.push_str("}\n");
289
290    Ok(code)
291}
292
293/// Generate a model module
294fn generate_model_module(
295    model: &prax_schema::ast::Model,
296    features: &[String],
297    relation_graph: &HashMap<String, HashSet<String>>,
298) -> CliResult<String> {
299    let mut code = String::new();
300
301    code.push_str(&format!(
302        "//! Auto-generated module for {} model\n\n",
303        model.name()
304    ));
305
306    // Import sibling types for relation fields
307    code.push_str("#[allow(unused_imports)]\n");
308    code.push_str("use super::*;\n");
309    code.push_str("#[allow(unused_imports)]\n");
310    code.push_str("use prax_query::traits::Model;\n\n");
311
312    // Derive macros based on features
313    let mut derives = vec!["Debug", "Clone"];
314    if features.contains(&"serde".to_string()) {
315        derives.push("serde::Serialize");
316        derives.push("serde::Deserialize");
317    }
318
319    // Model struct
320    code.push_str("#[allow(dead_code)]\n");
321    code.push_str(&format!("#[derive({})]\n", derives.join(", ")));
322    code.push_str(&format!("pub struct {} {{\n", model.name()));
323
324    for field in model.fields.values() {
325        let field_name = to_snake_case(field.name());
326
327        // Add serde rename if mapped
328        if let Some(attr) = field.get_attribute("map")
329            && features.contains(&"serde".to_string())
330            && let Some(value) = attr.first_arg().and_then(|v| v.as_string())
331        {
332            code.push_str(&format!("    #[serde(rename = \"{}\")]\n", value));
333        }
334
335        let rust_type = field_type_to_rust_with_boxing(
336            &field.field_type,
337            field.modifier,
338            model.name(),
339            relation_graph,
340        );
341        code.push_str(&format!("    pub {}: {},\n", field_name, rust_type));
342    }
343
344    code.push_str("}\n\n");
345
346    // Model trait implementation
347    let table_name = model.table_name();
348    let id_fields: Vec<&str> = model.id_fields().iter().map(|f| f.name()).collect();
349    let scalar_columns: Vec<String> = model
350        .scalar_fields()
351        .iter()
352        .map(|f| {
353            // Use @map name if present, otherwise snake_case the field name
354            f.get_attribute("map")
355                .and_then(|a| a.first_arg())
356                .and_then(|v| v.as_string())
357                .map(|s| s.to_string())
358                .unwrap_or_else(|| to_snake_case(f.name()))
359        })
360        .collect();
361
362    code.push_str(&format!("impl Model for {} {{\n", model.name()));
363    code.push_str(&format!(
364        "    const MODEL_NAME: &'static str = \"{}\";\n",
365        model.name()
366    ));
367    code.push_str(&format!(
368        "    const TABLE_NAME: &'static str = \"{}\";\n",
369        table_name
370    ));
371    code.push_str(&format!(
372        "    const PRIMARY_KEY: &'static [&'static str] = &[{}];\n",
373        id_fields
374            .iter()
375            .map(|f| format!("\"{}\"", to_snake_case(f)))
376            .collect::<Vec<_>>()
377            .join(", ")
378    ));
379    code.push_str(&format!(
380        "    const COLUMNS: &'static [&'static str] = &[{}];\n",
381        scalar_columns
382            .iter()
383            .map(|c| format!("\"{}\"", c))
384            .collect::<Vec<_>>()
385            .join(", ")
386    ));
387    code.push_str("}\n\n");
388
389    // FromRow — required to decode rows back into the model when an
390    // operation is run. Mirrors the emission in
391    // `prax-codegen/src/generators/derive_from_row.rs`: scalar fields
392    // decode via `FromColumn`; relation fields default-init and are
393    // filled later by the relation executor on the `.include` path.
394    code.push_str(&format!(
395        "impl prax_query::row::FromRow for {} {{\n",
396        model.name()
397    ));
398    code.push_str(
399        "    fn from_row(row: &impl prax_query::row::RowRef)\n        -> Result<Self, prax_query::row::RowError>\n    {\n",
400    );
401    code.push_str("        Ok(Self {\n");
402    for field in model.fields.values() {
403        let field_name = to_snake_case(field.name());
404        let rust_type = field_type_to_rust_with_boxing(
405            &field.field_type,
406            field.modifier,
407            model.name(),
408            relation_graph,
409        );
410        if field.is_relation() {
411            code.push_str(&format!(
412                "            {}: ::core::default::Default::default(),\n",
413                field_name
414            ));
415        } else {
416            let column = field
417                .get_attribute("map")
418                .and_then(|a| a.first_arg())
419                .and_then(|v| v.as_string())
420                .map(|s| s.to_string())
421                .unwrap_or_else(|| field_name.clone());
422            code.push_str(&format!(
423                "            {}: <{} as prax_query::row::FromColumn>::from_column(row, \"{}\")?,\n",
424                field_name, rust_type, column
425            ));
426        }
427    }
428    code.push_str("        })\n");
429    code.push_str("    }\n");
430    code.push_str("}\n\n");
431
432    // ModelWithPk — required by composite-key handling and by operations
433    // that need to extract the primary key from a model instance (e.g.
434    // upsert, nested writes). Mirrors
435    // `prax-codegen/src/generators/derive_model_with_pk.rs`.
436    code.push_str(&format!(
437        "impl prax_query::traits::ModelWithPk for {} {{\n",
438        model.name()
439    ));
440    code.push_str("    fn pk_value(&self) -> prax_query::filter::FilterValue {\n");
441    let id_field_objs: Vec<_> = model.id_fields();
442    if id_field_objs.len() == 1 {
443        let f = id_field_objs[0];
444        code.push_str(&format!(
445            "        <{} as prax_query::filter::ToFilterValue>::to_filter_value(&self.{})\n",
446            field_type_to_rust_with_boxing(&f.field_type, f.modifier, model.name(), relation_graph),
447            to_snake_case(f.name())
448        ));
449    } else if id_field_objs.is_empty() {
450        code.push_str("        prax_query::filter::FilterValue::Null\n");
451    } else {
452        code.push_str("        prax_query::filter::FilterValue::List(vec![\n");
453        for f in &id_field_objs {
454            code.push_str(&format!(
455                "            <{} as prax_query::filter::ToFilterValue>::to_filter_value(&self.{}),\n",
456                field_type_to_rust_with_boxing(&f.field_type, f.modifier, model.name(), relation_graph),
457                to_snake_case(f.name())
458            ));
459        }
460        code.push_str("        ])\n");
461    }
462    code.push_str("    }\n\n");
463
464    code.push_str(
465        "    fn get_column_value(&self, column: &str)\n        -> ::core::option::Option<prax_query::filter::FilterValue>\n    {\n",
466    );
467    code.push_str("        match column {\n");
468    for field in model.scalar_fields() {
469        let field_name = to_snake_case(field.name());
470        let column = field
471            .get_attribute("map")
472            .and_then(|a| a.first_arg())
473            .and_then(|v| v.as_string())
474            .map(|s| s.to_string())
475            .unwrap_or_else(|| field_name.clone());
476        let rust_type = field_type_to_rust_with_boxing(
477            &field.field_type,
478            field.modifier,
479            model.name(),
480            relation_graph,
481        );
482        code.push_str(&format!(
483            "            \"{}\" => ::core::option::Option::Some(\n                <{} as prax_query::filter::ToFilterValue>::to_filter_value(&self.{})\n            ),\n",
484            column, rust_type, field_name
485        ));
486    }
487    code.push_str("            _ => ::core::option::Option::None,\n");
488    code.push_str("        }\n");
489    code.push_str("    }\n");
490    code.push_str("}\n\n");
491
492    // Per-model `Client<E>` (named `Client`, not `{Model}Operations`, so
493    // `prax::client!(Foo, Bar, ...)` can find `foo::Client::new(...)`
494    // and `bar::Client::new(...)` by snake-cased module path — matching
495    // the shape emitted by `#[derive(Model)]`).
496    code.push_str("#[allow(dead_code)]\n");
497    code.push_str(&format!("/// Operations for the {} model\n", model.name()));
498    code.push_str("pub struct Client<E: prax_query::QueryEngine> {\n");
499    code.push_str("    engine: E,\n");
500    code.push_str("}\n\n");
501
502    code.push_str("impl<E: prax_query::QueryEngine> Client<E> {\n");
503    code.push_str("    pub fn new(engine: E) -> Self {\n");
504    code.push_str("        Self { engine }\n");
505    code.push_str("    }\n\n");
506
507    let model_ty = model.name();
508    let crud_methods: &[(&str, &str, &str)] = &[
509        ("find_many", "FindManyOperation", "Find many records"),
510        ("find_unique", "FindUniqueOperation", "Find a unique record"),
511        (
512            "find_first",
513            "FindFirstOperation",
514            "Find the first matching record",
515        ),
516        ("create", "CreateOperation", "Create a new record"),
517        (
518            "create_many",
519            "CreateManyOperation",
520            "Create many records in one operation",
521        ),
522        ("update", "UpdateOperation", "Update a record"),
523        (
524            "update_many",
525            "UpdateManyOperation",
526            "Update many records matching a filter",
527        ),
528        ("upsert", "UpsertOperation", "Insert or update a record"),
529        ("delete", "DeleteOperation", "Delete a record"),
530        (
531            "delete_many",
532            "DeleteManyOperation",
533            "Delete many records matching a filter",
534        ),
535        ("count", "CountOperation", "Count records"),
536    ];
537    for (method, op_ty, doc) in crud_methods {
538        code.push_str(&format!("    /// {}\n", doc));
539        code.push_str(&format!(
540            "    pub fn {}(&self) -> prax_query::operations::{}<E, {}> {{\n",
541            method, op_ty, model_ty,
542        ));
543        code.push_str(&format!(
544            "        prax_query::operations::{}::new(self.engine.clone())\n",
545            op_ty,
546        ));
547        code.push_str("    }\n\n");
548    }
549
550    code.push_str("}\n");
551
552    Ok(code)
553}
554
555/// Generate an enum module
556fn generate_enum_module(enum_def: &prax_schema::ast::Enum) -> CliResult<String> {
557    let mut code = String::new();
558
559    code.push_str(&format!(
560        "//! Auto-generated module for {} enum\n\n",
561        enum_def.name()
562    ));
563
564    code.push_str("#[allow(dead_code)]\n");
565    code.push_str(
566        "#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]\n",
567    );
568    code.push_str(&format!("pub enum {} {{\n", enum_def.name()));
569
570    for variant in &enum_def.variants {
571        let raw_name = variant.name();
572        let pascal_name = to_pascal_case(raw_name);
573
574        // Check for explicit @map attribute first
575        if let Some(attr) = variant.attributes.iter().find(|a| a.is("map"))
576            && let Some(value) = attr.first_arg().and_then(|v| v.as_string())
577        {
578            code.push_str(&format!("    #[serde(rename = \"{}\")]\n", value));
579            code.push_str(&format!("    {},\n", pascal_name));
580            continue;
581        }
582
583        // If variant name differs from PascalCase form, add serde rename
584        if raw_name != pascal_name {
585            code.push_str(&format!("    #[serde(rename = \"{}\")]\n", raw_name));
586        }
587        code.push_str(&format!("    {},\n", pascal_name));
588    }
589
590    code.push_str("}\n\n");
591
592    // Display implementation for SQL serialization
593    code.push_str(&format!(
594        "impl std::fmt::Display for {} {{\n",
595        enum_def.name()
596    ));
597    code.push_str("    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n");
598    code.push_str("        match self {\n");
599    for variant in &enum_def.variants {
600        let raw_name = variant.name();
601        let pascal_name = to_pascal_case(raw_name);
602        let db_value = variant.db_value();
603        code.push_str(&format!(
604            "            Self::{} => write!(f, \"{}\"),\n",
605            pascal_name, db_value
606        ));
607    }
608    code.push_str("        }\n");
609    code.push_str("    }\n");
610    code.push_str("}\n\n");
611
612    // Default implementation
613    if let Some(default_variant) = enum_def.variants.first() {
614        let pascal_name = to_pascal_case(default_variant.name());
615        code.push_str(&format!("impl Default for {} {{\n", enum_def.name()));
616        code.push_str(&format!(
617            "    fn default() -> Self {{\n        Self::{}\n    }}\n",
618            pascal_name
619        ));
620        code.push_str("}\n");
621    }
622
623    Ok(code)
624}
625
626/// Generate types module
627fn generate_types_module(schema: &prax_schema::ast::Schema) -> CliResult<String> {
628    let mut code = String::new();
629
630    code.push_str("//! Common type definitions\n\n");
631    code.push_str("#[allow(unused_imports)]\npub use chrono::{DateTime, Utc};\n");
632    code.push_str("#[allow(unused_imports)]\npub use uuid::Uuid;\n");
633    code.push_str("#[allow(unused_imports)]\npub use serde_json::Value as Json;\n");
634    code.push('\n');
635
636    // Add any custom types from composite types
637    for composite in schema.types.values() {
638        code.push_str("#[allow(dead_code)]\n");
639        code.push_str("#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]\n");
640        code.push_str(&format!("pub struct {} {{\n", composite.name()));
641        for field in composite.fields.values() {
642            let rust_type = field_type_to_rust(&field.field_type, field.modifier);
643            let field_name = to_snake_case(field.name());
644            code.push_str(&format!("    pub {}: {},\n", field_name, rust_type));
645        }
646        code.push_str("}\n\n");
647    }
648
649    Ok(code)
650}
651
652/// Generate filters module
653fn generate_filters_module(schema: &prax_schema::ast::Schema) -> CliResult<String> {
654    let mut code = String::new();
655
656    code.push_str("//! Filter types for queries\n\n");
657    code.push_str("#[allow(unused_imports)]\n");
658    code.push_str("use prax_query::filter::{Filter, ScalarFilter};\n");
659
660    // Collect all enum types referenced by model scalar fields
661    let mut referenced_enums = HashSet::new();
662    for model in schema.models.values() {
663        for field in model.fields.values() {
664            if !field.is_relation()
665                && let prax_schema::ast::FieldType::Enum(ref name) = field.field_type
666            {
667                referenced_enums.insert(name.to_string());
668            }
669        }
670    }
671
672    // Import enum types
673    for enum_name in &referenced_enums {
674        code.push_str(&format!(
675            "#[allow(unused_imports)]\nuse super::{}::{};\n",
676            to_snake_case(enum_name),
677            enum_name
678        ));
679    }
680
681    code.push('\n');
682
683    for model in schema.models.values() {
684        // Where input
685        code.push_str("#[allow(dead_code)]\n");
686        code.push_str(&format!("/// Filter input for {} queries\n", model.name()));
687        code.push_str("#[derive(Debug, Default, Clone)]\n");
688        code.push_str(&format!("pub struct {}WhereInput {{\n", model.name()));
689
690        for field in model.fields.values() {
691            if !field.is_relation() {
692                let filter_type = field_to_filter_type(&field.field_type);
693                let field_name = to_snake_case(field.name());
694                code.push_str(&format!(
695                    "    pub {}: Option<{}>,\n",
696                    field_name, filter_type
697                ));
698            }
699        }
700
701        code.push_str("    pub and: Option<Vec<Self>>,\n");
702        code.push_str("    pub or: Option<Vec<Self>>,\n");
703        code.push_str("    pub not: Option<Box<Self>>,\n");
704        code.push_str("}\n\n");
705
706        // OrderBy input
707        code.push_str("#[allow(dead_code)]\n");
708        code.push_str(&format!(
709            "/// Order by input for {} queries\n",
710            model.name()
711        ));
712        code.push_str("#[derive(Debug, Default, Clone)]\n");
713        code.push_str(&format!("pub struct {}OrderByInput {{\n", model.name()));
714
715        for field in model.fields.values() {
716            if !field.is_relation() {
717                let field_name = to_snake_case(field.name());
718                code.push_str(&format!(
719                    "    pub {}: Option<prax_query::SortOrder>,\n",
720                    field_name
721                ));
722            }
723        }
724
725        code.push_str("}\n\n");
726    }
727
728    Ok(code)
729}
730
731/// Convert a field type to Rust type (basic, without boxing)
732fn field_type_to_rust(
733    field_type: &prax_schema::ast::FieldType,
734    modifier: prax_schema::ast::TypeModifier,
735) -> String {
736    use prax_schema::ast::{FieldType, ScalarType, TypeModifier};
737
738    let base_type = match field_type {
739        FieldType::Scalar(scalar) => match scalar {
740            ScalarType::Int => "i32".to_string(),
741            ScalarType::BigInt => "i64".to_string(),
742            ScalarType::Float => "f64".to_string(),
743            ScalarType::String => "String".to_string(),
744            ScalarType::Boolean => "bool".to_string(),
745            ScalarType::DateTime => "chrono::DateTime<chrono::Utc>".to_string(),
746            ScalarType::Date => "chrono::NaiveDate".to_string(),
747            ScalarType::Time => "chrono::NaiveTime".to_string(),
748            ScalarType::Json => "serde_json::Value".to_string(),
749            ScalarType::Bytes => "Vec<u8>".to_string(),
750            ScalarType::Decimal => "rust_decimal::Decimal".to_string(),
751            ScalarType::Uuid => "uuid::Uuid".to_string(),
752            ScalarType::Cuid => "String".to_string(),
753            ScalarType::Cuid2 => "String".to_string(),
754            ScalarType::NanoId => "String".to_string(),
755            ScalarType::Ulid => "String".to_string(),
756            ScalarType::Vector(_) | ScalarType::HalfVector(_) => "Vec<f32>".to_string(),
757            ScalarType::SparseVector(_) => "Vec<(u32, f32)>".to_string(),
758            ScalarType::Bit(_) => "Vec<u8>".to_string(),
759        },
760        FieldType::Model(name) => name.to_string(),
761        FieldType::Enum(name) => name.to_string(),
762        FieldType::Composite(name) => name.to_string(),
763        FieldType::Unsupported(_) => "serde_json::Value".to_string(),
764    };
765
766    match modifier {
767        TypeModifier::Optional | TypeModifier::OptionalList => format!("Option<{}>", base_type),
768        TypeModifier::List => format!("Vec<{}>", base_type),
769        TypeModifier::Required => base_type,
770    }
771}
772
773/// Convert a field type to Rust type with Box<T> wrapping for cyclic relations.
774fn field_type_to_rust_with_boxing(
775    field_type: &prax_schema::ast::FieldType,
776    modifier: prax_schema::ast::TypeModifier,
777    source_model: &str,
778    relation_graph: &HashMap<String, HashSet<String>>,
779) -> String {
780    use prax_schema::ast::{FieldType, TypeModifier};
781
782    // For model references (non-list), check if boxing is needed to break cycles.
783    // Non-list relations are always emitted as `Option<T>` regardless of the
784    // schema's required/optional modifier: the relation is "not loaded" until
785    // `.include` populates it, so the Rust struct must allow representing the
786    // un-included state. This also gives the field a `Default::default()`
787    // (== `None`), which `FromRow` relies on to construct a row that hasn't
788    // been join-decoded yet. The schema-level required-ness is a database
789    // constraint enforced by FK + NOT NULL, not a Rust struct invariant.
790    if let FieldType::Model(target) = field_type
791        && !matches!(modifier, TypeModifier::List)
792    {
793        let should_box = needs_boxing(source_model, target, relation_graph);
794        let base = target.to_string();
795        return if should_box {
796            format!("Option<Box<{}>>", base)
797        } else {
798            format!("Option<{}>", base)
799        };
800    }
801
802    // Fallback to basic conversion for non-cyclic fields
803    field_type_to_rust(field_type, modifier)
804}
805
806/// Convert a field type to filter type
807fn field_to_filter_type(field_type: &prax_schema::ast::FieldType) -> String {
808    use prax_schema::ast::{FieldType, ScalarType};
809
810    match field_type {
811        FieldType::Scalar(scalar) => match scalar {
812            ScalarType::Int | ScalarType::BigInt => "ScalarFilter<i64>".to_string(),
813            ScalarType::Float | ScalarType::Decimal => "ScalarFilter<f64>".to_string(),
814            ScalarType::String
815            | ScalarType::Uuid
816            | ScalarType::Cuid
817            | ScalarType::Cuid2
818            | ScalarType::NanoId
819            | ScalarType::Ulid => "ScalarFilter<String>".to_string(),
820            ScalarType::Boolean => "ScalarFilter<bool>".to_string(),
821            ScalarType::DateTime => "ScalarFilter<chrono::DateTime<chrono::Utc>>".to_string(),
822            ScalarType::Date => "ScalarFilter<chrono::NaiveDate>".to_string(),
823            ScalarType::Time => "ScalarFilter<chrono::NaiveTime>".to_string(),
824            ScalarType::Json => "ScalarFilter<serde_json::Value>".to_string(),
825            ScalarType::Bytes => "ScalarFilter<Vec<u8>>".to_string(),
826            // pgvector scalars. Only `VectorFilter` exists in prax-pgvector
827            // today (for `vector(N)` and `halfvec(N)`); sparse and bit
828            // columns fall back to the raw element type under
829            // `ScalarFilter` until dedicated filter types ship upstream.
830            // Fully qualify the path so the generated filters.rs compiles
831            // without requiring the consumer to add a `use` statement.
832            ScalarType::Vector(_) | ScalarType::HalfVector(_) => {
833                "prax_pgvector::filter::VectorFilter".to_string()
834            }
835            ScalarType::SparseVector(_) => "ScalarFilter<Vec<(u32, f32)>>".to_string(),
836            ScalarType::Bit(_) => "ScalarFilter<Vec<u8>>".to_string(),
837        },
838        FieldType::Enum(name) => format!("ScalarFilter<{}>", name),
839        _ => "Filter".to_string(),
840    }
841}
842
843/// Convert PascalCase to snake_case
844fn to_snake_case(name: &str) -> String {
845    let mut result = String::new();
846    for (i, c) in name.chars().enumerate() {
847        if c.is_uppercase() {
848            if i > 0 {
849                result.push('_');
850            }
851            result.push(c.to_lowercase().next().unwrap());
852        } else {
853            result.push(c);
854        }
855    }
856    result
857}
858
859/// Convert snake_case, SCREAMING_SNAKE_CASE, or any other casing to PascalCase.
860fn to_pascal_case(name: &str) -> String {
861    if name.is_empty() {
862        return String::new();
863    }
864
865    // If already PascalCase (starts with uppercase, contains lowercase), return as-is
866    let first = name.chars().next().unwrap();
867    if first.is_uppercase() && name.chars().any(|c| c.is_lowercase()) && !name.contains('_') {
868        return name.to_string();
869    }
870
871    // Split on underscores and capitalize each segment
872    name.split('_')
873        .filter(|s| !s.is_empty())
874        .map(|segment| {
875            let mut chars = segment.chars();
876            match chars.next() {
877                None => String::new(),
878                Some(first) => {
879                    let rest: String = chars.collect();
880                    format!("{}{}", first.to_uppercase(), rest.to_lowercase())
881                }
882            }
883        })
884        .collect()
885}
886
887#[cfg(test)]
888mod tests {
889    use super::*;
890
891    #[test]
892    fn test_to_snake_case() {
893        assert_eq!(to_snake_case("BoardMember"), "board_member");
894        assert_eq!(to_snake_case("User"), "user");
895        assert_eq!(to_snake_case("JiraImportConfig"), "jira_import_config");
896    }
897
898    #[test]
899    fn test_to_pascal_case_from_snake() {
900        assert_eq!(to_pascal_case("card_created"), "CardCreated");
901        assert_eq!(to_pascal_case("branch_deleted"), "BranchDeleted");
902        assert_eq!(to_pascal_case("pr_merged"), "PrMerged");
903    }
904
905    #[test]
906    fn test_to_pascal_case_from_screaming() {
907        assert_eq!(to_pascal_case("CARD_CREATED"), "CardCreated");
908        assert_eq!(to_pascal_case("PR_MERGED"), "PrMerged");
909    }
910
911    #[test]
912    fn test_to_pascal_case_already_pascal() {
913        assert_eq!(to_pascal_case("Admin"), "Admin");
914        assert_eq!(to_pascal_case("SuperAdmin"), "SuperAdmin");
915        assert_eq!(to_pascal_case("Low"), "Low");
916    }
917
918    #[test]
919    fn test_to_pascal_case_single_word() {
920        assert_eq!(to_pascal_case("active"), "Active");
921        assert_eq!(to_pascal_case("ACTIVE"), "Active");
922    }
923
924    #[test]
925    fn test_needs_boxing_direct_cycle() {
926        let mut graph = HashMap::new();
927        graph.insert(
928            "Board".to_string(),
929            HashSet::from(["JiraConfig".to_string()]),
930        );
931        graph.insert(
932            "JiraConfig".to_string(),
933            HashSet::from(["Board".to_string()]),
934        );
935
936        assert!(needs_boxing("Board", "JiraConfig", &graph));
937        assert!(needs_boxing("JiraConfig", "Board", &graph));
938    }
939
940    #[test]
941    fn test_needs_boxing_no_cycle() {
942        let mut graph = HashMap::new();
943        graph.insert("Post".to_string(), HashSet::from(["User".to_string()]));
944        graph.insert("User".to_string(), HashSet::new());
945
946        assert!(!needs_boxing("Post", "User", &graph));
947    }
948
949    #[test]
950    fn test_needs_boxing_indirect_cycle() {
951        let mut graph = HashMap::new();
952        graph.insert("A".to_string(), HashSet::from(["B".to_string()]));
953        graph.insert("B".to_string(), HashSet::from(["C".to_string()]));
954        graph.insert("C".to_string(), HashSet::from(["A".to_string()]));
955
956        assert!(needs_boxing("A", "B", &graph));
957        assert!(needs_boxing("B", "C", &graph));
958        assert!(needs_boxing("C", "A", &graph));
959    }
960}