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    write_formatted(&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        write_formatted(&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        write_formatted(&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    write_formatted(&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    write_formatted(&filters_path, &filters_code)?;
160    generated_files.push(filters_path);
161
162    Ok(generated_files)
163}
164
165/// Pretty-print Rust source via `prettyplease` before writing, so
166/// `cargo fmt --check` in consumer repos can run without a
167/// `rustfmt.toml` exclusion for generated code. `prettyplease`
168/// produces byte-identical output across rustfmt versions —
169/// deliberately unlike `cargo fmt` — which is what we want for
170/// codegen: the emitter's output must not drift based on whichever
171/// rustfmt happens to be on the developer's PATH.
172///
173/// If the emitted string does not parse as a Rust file
174/// (shouldn't happen in practice — `generate_*_module` functions
175/// are exercised by the workspace's own tests), fall back to the
176/// raw string with a warning rather than blowing up. The fallback
177/// keeps `prax generate` unblocked while surfacing the formatter
178/// contract violation for whoever added the offending generator.
179fn write_formatted(path: &Path, code: &str) -> CliResult<()> {
180    let formatted = match syn::parse_file(code) {
181        Ok(file) => prettyplease::unparse(&file),
182        Err(e) => {
183            output::warn(&format!(
184                "generated code at {} did not parse; writing unformatted. \
185                 This is a codegen bug: {}",
186                path.display(),
187                e
188            ));
189            code.to_string()
190        }
191    };
192    std::fs::write(path, formatted)?;
193    Ok(())
194}
195
196/// Build a graph of model relations for cycle detection.
197/// Returns a map from model name to the set of model names it references
198/// (non-list relations only, since Vec<T> doesn't cause infinite size).
199fn build_relation_graph(schema: &prax_schema::ast::Schema) -> HashMap<String, HashSet<String>> {
200    let mut graph: HashMap<String, HashSet<String>> = HashMap::new();
201
202    for model in schema.models.values() {
203        let entry = graph.entry(model.name().to_string()).or_default();
204        for field in model.fields.values() {
205            if let prax_schema::ast::FieldType::Model(ref target) = field.field_type
206                && !field.is_list()
207            {
208                entry.insert(target.to_string());
209            }
210        }
211    }
212
213    graph
214}
215
216/// Check if a non-list relation field from `source_model` to `target_model`
217/// participates in a cycle (i.e. target_model can reach source_model through
218/// non-list relations). If so, the field must be wrapped in Box<T>.
219fn needs_boxing(
220    source_model: &str,
221    target_model: &str,
222    graph: &HashMap<String, HashSet<String>>,
223) -> bool {
224    let mut visited = HashSet::new();
225    let mut stack = vec![target_model.to_string()];
226
227    while let Some(current) = stack.pop() {
228        if current == source_model {
229            return true;
230        }
231        if !visited.insert(current.clone()) {
232            continue;
233        }
234        if let Some(neighbors) = graph.get(&current) {
235            for neighbor in neighbors {
236                stack.push(neighbor.clone());
237            }
238        }
239    }
240
241    false
242}
243
244/// Generate the main client module
245fn generate_client_module(
246    schema: &prax_schema::ast::Schema,
247    _features: &[String],
248) -> CliResult<String> {
249    let mut code = String::new();
250
251    code.push_str("//! Auto-generated by Prax - DO NOT EDIT\n");
252    code.push_str("//!\n");
253    code.push_str("//! This module contains the generated Prax client.\n\n");
254    // Generated code is a superset of the schema's shape; any given
255    // consumer only touches a fraction of it, and the codegen favors
256    // explicit form over what clippy would write. Silence the lints
257    // at the module root so downstream crates don't have to sprinkle
258    // allows at every unused accessor / derivable impl / shadowed
259    // identifier.
260    code.push_str("#![allow(dead_code)]\n");
261    code.push_str("#![allow(clippy::derivable_impls)]\n");
262    code.push_str("#![allow(clippy::needless_update)]\n");
263    code.push_str("#![allow(clippy::too_many_arguments)]\n\n");
264
265    // Module declarations
266    code.push_str("pub mod types;\n");
267    code.push_str("pub mod filters;\n\n");
268
269    for model in schema.models.values() {
270        code.push_str(&format!("pub mod {};\n", to_snake_case(model.name())));
271    }
272
273    for enum_def in schema.enums.values() {
274        code.push_str(&format!("pub mod {};\n", to_snake_case(enum_def.name())));
275    }
276
277    code.push('\n');
278
279    // Re-exports
280    code.push_str("#[allow(unused_imports)]\npub use types::*;\n");
281    code.push_str("#[allow(unused_imports)]\npub use filters::*;\n\n");
282
283    for model in schema.models.values() {
284        code.push_str(&format!(
285            "#[allow(unused_imports)]\npub use {}::{};\n",
286            to_snake_case(model.name()),
287            model.name()
288        ));
289    }
290
291    for enum_def in schema.enums.values() {
292        code.push_str(&format!(
293            "#[allow(unused_imports)]\npub use {}::{};\n",
294            to_snake_case(enum_def.name()),
295            enum_def.name()
296        ));
297    }
298
299    code.push('\n');
300
301    // Client struct with Clone bound and derive
302    code.push_str("#[allow(dead_code)]\n");
303    code.push_str("/// The Prax database client\n");
304    code.push_str("#[derive(Clone)]\n");
305    code.push_str("pub struct PraxClient<E: prax_query::QueryEngine> {\n");
306    code.push_str("    engine: E,\n");
307    code.push_str("}\n\n");
308
309    code.push_str("impl<E: prax_query::QueryEngine> PraxClient<E> {\n");
310    code.push_str("    /// Create a new Prax client with the given query engine\n");
311    code.push_str("    pub fn new(engine: E) -> Self {\n");
312    code.push_str("        Self { engine }\n");
313    code.push_str("    }\n\n");
314
315    code.push_str("    /// Borrow the underlying engine. Useful when composing\n");
316    code.push_str("    /// per-model operations directly or running raw SQL.\n");
317    code.push_str("    pub fn engine(&self) -> &E {\n");
318    code.push_str("        &self.engine\n");
319    code.push_str("    }\n\n");
320
321    code.push_str("    /// Execute a typed raw SQL query, decoding each returned\n");
322    code.push_str("    /// row as `T`. Mirrors `prax_orm::PraxClient::query_raw`.\n");
323    code.push_str("    ///\n");
324    code.push_str("    /// The typed per-model API covers the common CRUD cases;\n");
325    code.push_str("    /// use this for window functions, vendor-specific\n");
326    code.push_str("    /// extensions, CTEs, JOIN-driven row shapes, and aggregates\n");
327    code.push_str("    /// that the fluent builder doesn't model yet. `T` must\n");
328    code.push_str("    /// implement both `Model` (for the table association) and\n");
329    code.push_str("    /// `FromRow` (for row decoding); the generator emits both\n");
330    code.push_str("    /// impls on every model in this client.\n");
331    code.push_str("    pub async fn query_raw<T>(&self, sql: prax_query::raw::Sql)\n");
332    code.push_str("        -> prax_query::error::QueryResult<Vec<T>>\n");
333    code.push_str("    where\n");
334    code.push_str(
335        "        T: prax_query::traits::Model + prax_query::row::FromRow + Send + 'static,\n",
336    );
337    code.push_str("    {\n");
338    code.push_str("        let (s, p) = sql.build();\n");
339    code.push_str("        self.engine.query_many::<T>(&s, p).await\n");
340    code.push_str("    }\n\n");
341
342    code.push_str("    /// Execute a raw statement that doesn't return rows\n");
343    code.push_str("    /// (INSERT / UPDATE / DELETE / DDL). Returns the\n");
344    code.push_str("    /// driver-reported affected-row count. Mirrors\n");
345    code.push_str("    /// `prax_orm::PraxClient::execute_raw`.\n");
346    code.push_str("    pub async fn execute_raw(&self, sql: prax_query::raw::Sql)\n");
347    code.push_str("        -> prax_query::error::QueryResult<u64>\n");
348    code.push_str("    {\n");
349    code.push_str("        let (s, p) = sql.build();\n");
350    code.push_str("        self.engine.execute_raw(&s, p).await\n");
351    code.push_str("    }\n\n");
352
353    code.push_str("    /// Run a closure inside a transaction. The closure receives\n");
354    code.push_str("    /// a fresh `PraxClient<E>` wrapping the transaction's engine;\n");
355    code.push_str("    /// every query issued through it participates in the same\n");
356    code.push_str("    /// transactional scope. Commits on `Ok`, rolls back on\n");
357    code.push_str("    /// `Err`. Mirrors `prax_orm::PraxClient::transaction`.\n");
358    code.push_str("    ///\n");
359    code.push_str("    /// Nested `transaction()` calls on the same engine currently\n");
360    code.push_str("    /// return `QueryError::internal(...)` until dialect-aware\n");
361    code.push_str("    /// SAVEPOINT support lands.\n");
362    code.push_str("    pub async fn transaction<R, Fut, F>(&self, f: F)\n");
363    code.push_str("        -> prax_query::error::QueryResult<R>\n");
364    code.push_str("    where\n");
365    code.push_str("        F: FnOnce(PraxClient<E>) -> Fut + Send + 'static,\n");
366    code.push_str("        Fut: ::core::future::Future<Output = prax_query::error::QueryResult<R>> + Send + 'static,\n");
367    code.push_str("        R: Send + 'static,\n");
368    code.push_str("    {\n");
369    code.push_str("        self.engine\n");
370    code.push_str("            .transaction(move |tx_engine| async move { f(PraxClient::new(tx_engine)).await })\n");
371    code.push_str("            .await\n");
372    code.push_str("    }\n\n");
373
374    for model in schema.models.values() {
375        let snake_name = to_snake_case(model.name());
376        code.push_str(&format!("    /// Access {} operations\n", model.name()));
377        code.push_str(&format!(
378            "    pub fn {}(&self) -> {}::Client<E> {{\n",
379            snake_name, snake_name,
380        ));
381        code.push_str(&format!(
382            "        {}::Client::new(self.engine.clone())\n",
383            snake_name,
384        ));
385        code.push_str("    }\n\n");
386    }
387
388    code.push_str("}\n");
389
390    Ok(code)
391}
392
393/// Generate a model module
394fn generate_model_module(
395    model: &prax_schema::ast::Model,
396    features: &[String],
397    relation_graph: &HashMap<String, HashSet<String>>,
398) -> CliResult<String> {
399    let mut code = String::new();
400
401    code.push_str(&format!(
402        "//! Auto-generated module for {} model\n\n",
403        model.name()
404    ));
405
406    // Import sibling types for relation fields
407    code.push_str("#[allow(unused_imports)]\n");
408    code.push_str("use super::*;\n");
409    code.push_str("#[allow(unused_imports)]\n");
410    code.push_str("use prax_query::traits::Model;\n\n");
411
412    // Derive macros based on features
413    let mut derives = vec!["Debug", "Clone"];
414    if features.contains(&"serde".to_string()) {
415        derives.push("serde::Serialize");
416        derives.push("serde::Deserialize");
417    }
418
419    // Model struct
420    code.push_str("#[allow(dead_code)]\n");
421    code.push_str(&format!("#[derive({})]\n", derives.join(", ")));
422    code.push_str(&format!("pub struct {} {{\n", model.name()));
423
424    for field in model.fields.values() {
425        let field_name = to_field_ident(field.name());
426
427        // Add serde rename if mapped
428        if let Some(attr) = field.get_attribute("map")
429            && features.contains(&"serde".to_string())
430            && let Some(value) = attr.first_arg().and_then(|v| v.as_string())
431        {
432            code.push_str(&format!("    #[serde(rename = \"{}\")]\n", value));
433        }
434
435        let rust_type = field_type_to_rust_with_boxing(
436            &field.field_type,
437            field.modifier,
438            model.name(),
439            relation_graph,
440        );
441        code.push_str(&format!("    pub {}: {},\n", field_name, rust_type));
442    }
443
444    code.push_str("}\n\n");
445
446    // Model trait implementation
447    let table_name = model.table_name();
448    let id_fields: Vec<&str> = model.id_fields().iter().map(|f| f.name()).collect();
449    let scalar_columns: Vec<String> = model
450        .scalar_fields()
451        .iter()
452        .map(|f| {
453            // Use @map name if present, otherwise snake_case the field name
454            f.get_attribute("map")
455                .and_then(|a| a.first_arg())
456                .and_then(|v| v.as_string())
457                .map(|s| s.to_string())
458                .unwrap_or_else(|| to_snake_case(f.name()))
459        })
460        .collect();
461
462    code.push_str(&format!("impl Model for {} {{\n", model.name()));
463    code.push_str(&format!(
464        "    const MODEL_NAME: &'static str = \"{}\";\n",
465        model.name()
466    ));
467    code.push_str(&format!(
468        "    const TABLE_NAME: &'static str = \"{}\";\n",
469        table_name
470    ));
471    code.push_str(&format!(
472        "    const PRIMARY_KEY: &'static [&'static str] = &[{}];\n",
473        id_fields
474            .iter()
475            .map(|f| format!("\"{}\"", to_snake_case(f)))
476            .collect::<Vec<_>>()
477            .join(", ")
478    ));
479    code.push_str(&format!(
480        "    const COLUMNS: &'static [&'static str] = &[{}];\n",
481        scalar_columns
482            .iter()
483            .map(|c| format!("\"{}\"", c))
484            .collect::<Vec<_>>()
485            .join(", ")
486    ));
487    code.push_str("}\n\n");
488
489    // FromRow — required to decode rows back into the model when an
490    // operation is run. Mirrors the emission in
491    // `prax-codegen/src/generators/derive_from_row.rs`: scalar fields
492    // decode via `FromColumn`; relation fields default-init and are
493    // filled later by the relation executor on the `.include` path.
494    code.push_str(&format!(
495        "impl prax_query::row::FromRow for {} {{\n",
496        model.name()
497    ));
498    code.push_str(
499        "    fn from_row(row: &impl prax_query::row::RowRef)\n        -> Result<Self, prax_query::row::RowError>\n    {\n",
500    );
501    code.push_str("        Ok(Self {\n");
502    for field in model.fields.values() {
503        let field_name = to_field_ident(field.name());
504        let rust_type = field_type_to_rust_with_boxing(
505            &field.field_type,
506            field.modifier,
507            model.name(),
508            relation_graph,
509        );
510        if field.is_relation() {
511            code.push_str(&format!(
512                "            {}: ::core::default::Default::default(),\n",
513                field_name
514            ));
515        } else {
516            let column = field
517                .get_attribute("map")
518                .and_then(|a| a.first_arg())
519                .and_then(|v| v.as_string())
520                .map(|s| s.to_string())
521                .unwrap_or_else(|| field_name.clone());
522            code.push_str(&format!(
523                "            {}: <{} as prax_query::row::FromColumn>::from_column(row, \"{}\")?,\n",
524                field_name, rust_type, column
525            ));
526        }
527    }
528    code.push_str("        })\n");
529    code.push_str("    }\n");
530    code.push_str("}\n\n");
531
532    // ModelWithPk — required by composite-key handling and by operations
533    // that need to extract the primary key from a model instance (e.g.
534    // upsert, nested writes). Mirrors
535    // `prax-codegen/src/generators/derive_model_with_pk.rs`.
536    code.push_str(&format!(
537        "impl prax_query::traits::ModelWithPk for {} {{\n",
538        model.name()
539    ));
540    code.push_str("    fn pk_value(&self) -> prax_query::filter::FilterValue {\n");
541    let id_field_objs: Vec<_> = model.id_fields();
542    if id_field_objs.len() == 1 {
543        let f = id_field_objs[0];
544        code.push_str(&format!(
545            "        <{} as prax_query::filter::ToFilterValue>::to_filter_value(&self.{})\n",
546            field_type_to_rust_with_boxing(&f.field_type, f.modifier, model.name(), relation_graph),
547            to_field_ident(f.name())
548        ));
549    } else if id_field_objs.is_empty() {
550        code.push_str("        prax_query::filter::FilterValue::Null\n");
551    } else {
552        code.push_str("        prax_query::filter::FilterValue::List(vec![\n");
553        for f in &id_field_objs {
554            code.push_str(&format!(
555                "            <{} as prax_query::filter::ToFilterValue>::to_filter_value(&self.{}),\n",
556                field_type_to_rust_with_boxing(&f.field_type, f.modifier, model.name(), relation_graph),
557                to_field_ident(f.name())
558            ));
559        }
560        code.push_str("        ])\n");
561    }
562    code.push_str("    }\n\n");
563
564    code.push_str(
565        "    fn get_column_value(&self, column: &str)\n        -> ::core::option::Option<prax_query::filter::FilterValue>\n    {\n",
566    );
567    code.push_str("        match column {\n");
568    for field in model.scalar_fields() {
569        // Rust field identifier — escape reserved keywords.
570        let field_name = to_field_ident(field.name());
571        // SQL column name — use raw snake_case (no r# prefix) because the
572        // @map override or fallback feeds a string literal passed to
573        // `FromColumn::from_column(row, "...")`, not an identifier.
574        let column = field
575            .get_attribute("map")
576            .and_then(|a| a.first_arg())
577            .and_then(|v| v.as_string())
578            .map(|s| s.to_string())
579            .unwrap_or_else(|| to_snake_case(field.name()));
580        let rust_type = field_type_to_rust_with_boxing(
581            &field.field_type,
582            field.modifier,
583            model.name(),
584            relation_graph,
585        );
586        code.push_str(&format!(
587            "            \"{}\" => ::core::option::Option::Some(\n                <{} as prax_query::filter::ToFilterValue>::to_filter_value(&self.{})\n            ),\n",
588            column, rust_type, field_name
589        ));
590    }
591    code.push_str("            _ => ::core::option::Option::None,\n");
592    code.push_str("        }\n");
593    code.push_str("    }\n");
594    code.push_str("}\n\n");
595
596    // ModelRelationLoader — uniformity requirement for
597    // `FindManyOperation` / `FindUniqueOperation` / `FindFirstOperation`
598    // bounds. Schema-generated models don't currently expose the
599    // relation-accessor surface that the derive path does (no
600    // `super::super::<module>::<field>::Relation` markers), so every
601    // `.include(...)` attempt on a schema-generated model errors at
602    // runtime rather than at compile time. That's acceptable for now —
603    // plain `find_unique()` / `find_many()` without `.include()` work,
604    // which is what every current consumer of the schema-gen path uses.
605    // Adding real relation loading here is tracked as a follow-up.
606    code.push_str(&format!(
607        "impl<E: prax_query::traits::QueryEngine>\n    prax_query::traits::ModelRelationLoader<E>\n    for {}\n{{\n",
608        model.name()
609    ));
610    code.push_str("    fn load_relation<'a>(\n");
611    code.push_str("        _engine: &'a E,\n");
612    code.push_str("        _parents: &'a mut [Self],\n");
613    code.push_str("        spec: &'a prax_query::relations::IncludeSpec,\n");
614    code.push_str(
615        "    ) -> prax_query::traits::BoxFuture<'a, prax_query::error::QueryResult<()>> {\n",
616    );
617    code.push_str("        let name = spec.relation_name.clone();\n");
618    code.push_str(&format!("        let model_name = \"{}\";\n", model.name()));
619    code.push_str("        Box::pin(async move {\n");
620    code.push_str("            Err(prax_query::error::QueryError::internal(format!(\n");
621    code.push_str("                \"relation '{}' on schema-generated model '{}' is not wired for .include() — use query_raw for JOINs\",\n");
622    code.push_str("                name,\n");
623    code.push_str("                model_name,\n");
624    code.push_str("            )))\n");
625    code.push_str("        })\n");
626    code.push_str("    }\n");
627    code.push_str("}\n\n");
628
629    // Per-model `Client<E>` (named `Client`, not `{Model}Operations`, so
630    // `prax::client!(Foo, Bar, ...)` can find `foo::Client::new(...)`
631    // and `bar::Client::new(...)` by snake-cased module path — matching
632    // the shape emitted by `#[derive(Model)]`).
633    code.push_str("#[allow(dead_code)]\n");
634    code.push_str(&format!("/// Operations for the {} model\n", model.name()));
635    code.push_str("pub struct Client<E: prax_query::QueryEngine> {\n");
636    code.push_str("    engine: E,\n");
637    code.push_str("}\n\n");
638
639    code.push_str("impl<E: prax_query::QueryEngine> Client<E> {\n");
640    code.push_str("    pub fn new(engine: E) -> Self {\n");
641    code.push_str("        Self { engine }\n");
642    code.push_str("    }\n\n");
643
644    let model_ty = model.name();
645    let crud_methods: &[(&str, &str, &str)] = &[
646        ("find_many", "FindManyOperation", "Find many records"),
647        ("find_unique", "FindUniqueOperation", "Find a unique record"),
648        (
649            "find_first",
650            "FindFirstOperation",
651            "Find the first matching record",
652        ),
653        ("create", "CreateOperation", "Create a new record"),
654        (
655            "create_many",
656            "CreateManyOperation",
657            "Create many records in one operation",
658        ),
659        ("update", "UpdateOperation", "Update a record"),
660        (
661            "update_many",
662            "UpdateManyOperation",
663            "Update many records matching a filter",
664        ),
665        ("upsert", "UpsertOperation", "Insert or update a record"),
666        ("delete", "DeleteOperation", "Delete a record"),
667        (
668            "delete_many",
669            "DeleteManyOperation",
670            "Delete many records matching a filter",
671        ),
672        ("count", "CountOperation", "Count records"),
673    ];
674    for (method, op_ty, doc) in crud_methods {
675        code.push_str(&format!("    /// {}\n", doc));
676        code.push_str(&format!(
677            "    pub fn {}(&self) -> prax_query::operations::{}<E, {}> {{\n",
678            method, op_ty, model_ty,
679        ));
680        code.push_str(&format!(
681            "        prax_query::operations::{}::new(self.engine.clone())\n",
682            op_ty,
683        ));
684        code.push_str("    }\n\n");
685    }
686
687    code.push_str("}\n");
688
689    Ok(code)
690}
691
692/// Generate an enum module
693fn generate_enum_module(enum_def: &prax_schema::ast::Enum) -> CliResult<String> {
694    let mut code = String::new();
695
696    code.push_str(&format!(
697        "//! Auto-generated module for {} enum\n\n",
698        enum_def.name()
699    ));
700
701    code.push_str("#[allow(dead_code)]\n");
702    code.push_str(
703        "#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]\n",
704    );
705    code.push_str(&format!("pub enum {} {{\n", enum_def.name()));
706
707    for variant in &enum_def.variants {
708        let raw_name = variant.name();
709        let pascal_name = to_pascal_case(raw_name);
710
711        // Check for explicit @map attribute first
712        if let Some(attr) = variant.attributes.iter().find(|a| a.is("map"))
713            && let Some(value) = attr.first_arg().and_then(|v| v.as_string())
714        {
715            code.push_str(&format!("    #[serde(rename = \"{}\")]\n", value));
716            code.push_str(&format!("    {},\n", pascal_name));
717            continue;
718        }
719
720        // If variant name differs from PascalCase form, add serde rename
721        if raw_name != pascal_name {
722            code.push_str(&format!("    #[serde(rename = \"{}\")]\n", raw_name));
723        }
724        code.push_str(&format!("    {},\n", pascal_name));
725    }
726
727    code.push_str("}\n\n");
728
729    // Display implementation for SQL serialization
730    code.push_str(&format!(
731        "impl std::fmt::Display for {} {{\n",
732        enum_def.name()
733    ));
734    code.push_str("    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n");
735    code.push_str("        match self {\n");
736    for variant in &enum_def.variants {
737        let raw_name = variant.name();
738        let pascal_name = to_pascal_case(raw_name);
739        let db_value = variant.db_value();
740        code.push_str(&format!(
741            "            Self::{} => write!(f, \"{}\"),\n",
742            pascal_name, db_value
743        ));
744    }
745    code.push_str("        }\n");
746    code.push_str("    }\n");
747    code.push_str("}\n\n");
748
749    // Default implementation
750    if let Some(default_variant) = enum_def.variants.first() {
751        let pascal_name = to_pascal_case(default_variant.name());
752        code.push_str(&format!("impl Default for {} {{\n", enum_def.name()));
753        code.push_str(&format!(
754            "    fn default() -> Self {{\n        Self::{}\n    }}\n",
755            pascal_name
756        ));
757        code.push_str("}\n\n");
758    }
759
760    // Round-trip helper: parse the DB string form back into an enum variant.
761    // Used by the `FromColumn` impls below and callable directly by
762    // consumers that need to deserialize a raw string payload.
763    code.push_str(&format!(
764        "impl std::str::FromStr for {} {{\n",
765        enum_def.name()
766    ));
767    code.push_str("    type Err = prax_query::row::RowError;\n");
768    code.push_str("    fn from_str(s: &str) -> Result<Self, Self::Err> {\n");
769    code.push_str("        match s {\n");
770    for variant in &enum_def.variants {
771        let raw_name = variant.name();
772        let pascal_name = to_pascal_case(raw_name);
773        let db_value = variant.db_value();
774        code.push_str(&format!(
775            "            \"{}\" => Ok(Self::{}),\n",
776            db_value, pascal_name
777        ));
778    }
779    code.push_str(&format!(
780        "            _ => Err(prax_query::row::RowError::TypeConversion {{\n                column: String::new(),\n                message: format!(\"unknown {} variant: {{}}\", s),\n            }}),\n",
781        enum_def.name()
782    ));
783    code.push_str("        }\n");
784    code.push_str("    }\n");
785    code.push_str("}\n\n");
786
787    // `FromColumn` — decode a string column back into the enum. Required
788    // for `find_many` / `find_unique` / any query that returns rows with
789    // this enum as a field type.
790    code.push_str(&format!(
791        "impl prax_query::row::FromColumn for {} {{\n",
792        enum_def.name()
793    ));
794    code.push_str(
795        "    fn from_column(row: &impl prax_query::row::RowRef, column: &str)\n        -> Result<Self, prax_query::row::RowError>\n    {\n",
796    );
797    code.push_str("        let raw = row.get_string(column)?;\n");
798    code.push_str("        <Self as std::str::FromStr>::from_str(&raw).map_err(|e| {\n");
799    code.push_str("            let msg = match &e {\n");
800    code.push_str(
801        "                prax_query::row::RowError::TypeConversion { message, .. } => message.clone(),\n",
802    );
803    code.push_str("                other => other.to_string(),\n");
804    code.push_str("            };\n");
805    code.push_str("            prax_query::row::RowError::TypeConversion {\n");
806    code.push_str("                column: column.to_string(),\n");
807    code.push_str("                message: msg,\n");
808    code.push_str("            }\n");
809    code.push_str("        })\n");
810    code.push_str("    }\n");
811    code.push_str("}\n\n");
812
813    // `Option<Enum>` handled by the blanket `impl<T: FromColumn>
814    // FromColumn for Option<T>` in prax-query — no per-enum Option impl
815    // needed here (the orphan rule would forbid it on the consumer side
816    // anyway).
817
818    // `ToFilterValue` — encode the enum as a string FilterValue so that
819    // `where` clauses / `ModelWithPk::pk_value` / nested writes can send
820    // it as a bind parameter.
821    code.push_str(&format!(
822        "impl prax_query::filter::ToFilterValue for {} {{\n",
823        enum_def.name()
824    ));
825    code.push_str("    fn to_filter_value(&self) -> prax_query::filter::FilterValue {\n");
826    code.push_str("        prax_query::filter::FilterValue::String(self.to_string())\n");
827    code.push_str("    }\n");
828    code.push_str("}\n");
829
830    Ok(code)
831}
832
833/// Generate types module
834fn generate_types_module(schema: &prax_schema::ast::Schema) -> CliResult<String> {
835    let mut code = String::new();
836
837    code.push_str("//! Common type definitions\n\n");
838    code.push_str("#[allow(unused_imports)]\npub use chrono::{DateTime, Utc};\n");
839    code.push_str("#[allow(unused_imports)]\npub use uuid::Uuid;\n");
840    code.push_str("#[allow(unused_imports)]\npub use serde_json::Value as Json;\n");
841    code.push('\n');
842
843    // Add any custom types from composite types
844    for composite in schema.types.values() {
845        code.push_str("#[allow(dead_code)]\n");
846        code.push_str("#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]\n");
847        code.push_str(&format!("pub struct {} {{\n", composite.name()));
848        for field in composite.fields.values() {
849            let rust_type = field_type_to_rust(&field.field_type, field.modifier);
850            let field_name = to_field_ident(field.name());
851            code.push_str(&format!("    pub {}: {},\n", field_name, rust_type));
852        }
853        code.push_str("}\n\n");
854    }
855
856    Ok(code)
857}
858
859/// Generate filters module
860fn generate_filters_module(schema: &prax_schema::ast::Schema) -> CliResult<String> {
861    let mut code = String::new();
862
863    code.push_str("//! Filter types for queries\n\n");
864    code.push_str("#[allow(unused_imports)]\n");
865    code.push_str("use prax_query::filter::{Filter, ScalarFilter};\n");
866
867    // Collect all enum types referenced by model scalar fields
868    let mut referenced_enums = HashSet::new();
869    for model in schema.models.values() {
870        for field in model.fields.values() {
871            if !field.is_relation()
872                && let prax_schema::ast::FieldType::Enum(ref name) = field.field_type
873            {
874                referenced_enums.insert(name.to_string());
875            }
876        }
877    }
878
879    // Import enum types
880    for enum_name in &referenced_enums {
881        code.push_str(&format!(
882            "#[allow(unused_imports)]\nuse super::{}::{};\n",
883            to_snake_case(enum_name),
884            enum_name
885        ));
886    }
887
888    code.push('\n');
889
890    for model in schema.models.values() {
891        // Where input
892        code.push_str("#[allow(dead_code)]\n");
893        code.push_str(&format!("/// Filter input for {} queries\n", model.name()));
894        code.push_str("#[derive(Debug, Default, Clone)]\n");
895        code.push_str(&format!("pub struct {}WhereInput {{\n", model.name()));
896
897        for field in model.fields.values() {
898            if !field.is_relation() {
899                let filter_type = field_to_filter_type(&field.field_type);
900                let field_name = to_field_ident(field.name());
901                code.push_str(&format!(
902                    "    pub {}: Option<{}>,\n",
903                    field_name, filter_type
904                ));
905            }
906        }
907
908        code.push_str("    pub and: Option<Vec<Self>>,\n");
909        code.push_str("    pub or: Option<Vec<Self>>,\n");
910        code.push_str("    pub not: Option<Box<Self>>,\n");
911        code.push_str("}\n\n");
912
913        // OrderBy input
914        code.push_str("#[allow(dead_code)]\n");
915        code.push_str(&format!(
916            "/// Order by input for {} queries\n",
917            model.name()
918        ));
919        code.push_str("#[derive(Debug, Default, Clone)]\n");
920        code.push_str(&format!("pub struct {}OrderByInput {{\n", model.name()));
921
922        for field in model.fields.values() {
923            if !field.is_relation() {
924                let field_name = to_field_ident(field.name());
925                code.push_str(&format!(
926                    "    pub {}: Option<prax_query::SortOrder>,\n",
927                    field_name
928                ));
929            }
930        }
931
932        code.push_str("}\n\n");
933    }
934
935    Ok(code)
936}
937
938/// Convert a field type to Rust type (basic, without boxing)
939fn field_type_to_rust(
940    field_type: &prax_schema::ast::FieldType,
941    modifier: prax_schema::ast::TypeModifier,
942) -> String {
943    use prax_schema::ast::{FieldType, ScalarType, TypeModifier};
944
945    let base_type = match field_type {
946        FieldType::Scalar(scalar) => match scalar {
947            ScalarType::Int => "i32".to_string(),
948            ScalarType::BigInt => "i64".to_string(),
949            ScalarType::Float => "f64".to_string(),
950            ScalarType::String => "String".to_string(),
951            ScalarType::Boolean => "bool".to_string(),
952            ScalarType::DateTime => "chrono::DateTime<chrono::Utc>".to_string(),
953            ScalarType::Date => "chrono::NaiveDate".to_string(),
954            ScalarType::Time => "chrono::NaiveTime".to_string(),
955            ScalarType::Json => "serde_json::Value".to_string(),
956            ScalarType::Bytes => "Vec<u8>".to_string(),
957            ScalarType::Decimal => "rust_decimal::Decimal".to_string(),
958            ScalarType::Uuid => "uuid::Uuid".to_string(),
959            ScalarType::Cuid => "String".to_string(),
960            ScalarType::Cuid2 => "String".to_string(),
961            ScalarType::NanoId => "String".to_string(),
962            ScalarType::Ulid => "String".to_string(),
963            ScalarType::Vector(_) | ScalarType::HalfVector(_) => "Vec<f32>".to_string(),
964            ScalarType::SparseVector(_) => "Vec<(u32, f32)>".to_string(),
965            ScalarType::Bit(_) => "Vec<u8>".to_string(),
966        },
967        FieldType::Model(name) => name.to_string(),
968        FieldType::Enum(name) => name.to_string(),
969        FieldType::Composite(name) => name.to_string(),
970        FieldType::Unsupported(_) => "serde_json::Value".to_string(),
971    };
972
973    match modifier {
974        TypeModifier::Optional | TypeModifier::OptionalList => format!("Option<{}>", base_type),
975        TypeModifier::List => format!("Vec<{}>", base_type),
976        TypeModifier::Required => base_type,
977    }
978}
979
980/// Convert a field type to Rust type with Box<T> wrapping for cyclic relations.
981fn field_type_to_rust_with_boxing(
982    field_type: &prax_schema::ast::FieldType,
983    modifier: prax_schema::ast::TypeModifier,
984    source_model: &str,
985    relation_graph: &HashMap<String, HashSet<String>>,
986) -> String {
987    use prax_schema::ast::{FieldType, TypeModifier};
988
989    // For model references (non-list), check if boxing is needed to break cycles.
990    // Non-list relations are always emitted as `Option<T>` regardless of the
991    // schema's required/optional modifier: the relation is "not loaded" until
992    // `.include` populates it, so the Rust struct must allow representing the
993    // un-included state. This also gives the field a `Default::default()`
994    // (== `None`), which `FromRow` relies on to construct a row that hasn't
995    // been join-decoded yet. The schema-level required-ness is a database
996    // constraint enforced by FK + NOT NULL, not a Rust struct invariant.
997    if let FieldType::Model(target) = field_type
998        && !matches!(modifier, TypeModifier::List)
999    {
1000        let should_box = needs_boxing(source_model, target, relation_graph);
1001        let base = target.to_string();
1002        return if should_box {
1003            format!("Option<Box<{}>>", base)
1004        } else {
1005            format!("Option<{}>", base)
1006        };
1007    }
1008
1009    // Fallback to basic conversion for non-cyclic fields
1010    field_type_to_rust(field_type, modifier)
1011}
1012
1013/// Convert a field type to filter type
1014fn field_to_filter_type(field_type: &prax_schema::ast::FieldType) -> String {
1015    use prax_schema::ast::{FieldType, ScalarType};
1016
1017    match field_type {
1018        FieldType::Scalar(scalar) => match scalar {
1019            ScalarType::Int | ScalarType::BigInt => "ScalarFilter<i64>".to_string(),
1020            ScalarType::Float | ScalarType::Decimal => "ScalarFilter<f64>".to_string(),
1021            ScalarType::String
1022            | ScalarType::Uuid
1023            | ScalarType::Cuid
1024            | ScalarType::Cuid2
1025            | ScalarType::NanoId
1026            | ScalarType::Ulid => "ScalarFilter<String>".to_string(),
1027            ScalarType::Boolean => "ScalarFilter<bool>".to_string(),
1028            ScalarType::DateTime => "ScalarFilter<chrono::DateTime<chrono::Utc>>".to_string(),
1029            ScalarType::Date => "ScalarFilter<chrono::NaiveDate>".to_string(),
1030            ScalarType::Time => "ScalarFilter<chrono::NaiveTime>".to_string(),
1031            ScalarType::Json => "ScalarFilter<serde_json::Value>".to_string(),
1032            ScalarType::Bytes => "ScalarFilter<Vec<u8>>".to_string(),
1033            // pgvector scalars. Only `VectorFilter` exists in prax-pgvector
1034            // today (for `vector(N)` and `halfvec(N)`); sparse and bit
1035            // columns fall back to the raw element type under
1036            // `ScalarFilter` until dedicated filter types ship upstream.
1037            // Fully qualify the path so the generated filters.rs compiles
1038            // without requiring the consumer to add a `use` statement.
1039            ScalarType::Vector(_) | ScalarType::HalfVector(_) => {
1040                "prax_pgvector::filter::VectorFilter".to_string()
1041            }
1042            ScalarType::SparseVector(_) => "ScalarFilter<Vec<(u32, f32)>>".to_string(),
1043            ScalarType::Bit(_) => "ScalarFilter<Vec<u8>>".to_string(),
1044        },
1045        FieldType::Enum(name) => format!("ScalarFilter<{}>", name),
1046        _ => "Filter".to_string(),
1047    }
1048}
1049
1050/// Convert PascalCase to snake_case
1051fn to_snake_case(name: &str) -> String {
1052    let mut result = String::new();
1053    for (i, c) in name.chars().enumerate() {
1054        if c.is_uppercase() {
1055            if i > 0 {
1056                result.push('_');
1057            }
1058            result.push(c.to_lowercase().next().unwrap());
1059        } else {
1060            result.push(c);
1061        }
1062    }
1063    result
1064}
1065
1066/// Snake-case a name and escape it as a raw identifier if the result
1067/// collides with a Rust reserved keyword. Use for any emitted Rust field
1068/// or variable name; plain column-name strings (serde rename values, SQL
1069/// column lookups) still use `to_snake_case` directly.
1070///
1071/// Schemas with columns literally named `type`, `match`, `use`, `loop`,
1072/// etc. (common in Prisma — documents, notifications, email_verification
1073/// all have a `type` column) otherwise produce code like `pub type: …`
1074/// that fails to parse. This function emits `r#type` instead.
1075///
1076/// The four keywords Rust forbids as raw identifiers (`crate`, `self`,
1077/// `Self`, `super`) are intentionally not escaped; a column literally
1078/// named `self` would still fail to compile, which is the correct
1079/// behavior (the schema should be fixed).
1080fn to_field_ident(name: &str) -> String {
1081    let snake = to_snake_case(name);
1082    if is_rust_keyword(&snake) {
1083        format!("r#{}", snake)
1084    } else {
1085        snake
1086    }
1087}
1088
1089fn is_rust_keyword(s: &str) -> bool {
1090    matches!(
1091        s,
1092        "abstract"
1093            | "as"
1094            | "async"
1095            | "await"
1096            | "become"
1097            | "box"
1098            | "break"
1099            | "const"
1100            | "continue"
1101            | "do"
1102            | "dyn"
1103            | "else"
1104            | "enum"
1105            | "extern"
1106            | "false"
1107            | "final"
1108            | "fn"
1109            | "for"
1110            | "gen"
1111            | "if"
1112            | "impl"
1113            | "in"
1114            | "let"
1115            | "loop"
1116            | "macro"
1117            | "match"
1118            | "mod"
1119            | "move"
1120            | "mut"
1121            | "override"
1122            | "priv"
1123            | "pub"
1124            | "ref"
1125            | "return"
1126            | "static"
1127            | "struct"
1128            | "trait"
1129            | "true"
1130            | "try"
1131            | "type"
1132            | "typeof"
1133            | "unsafe"
1134            | "unsized"
1135            | "use"
1136            | "virtual"
1137            | "where"
1138            | "while"
1139            | "yield"
1140    )
1141}
1142
1143/// Convert snake_case, SCREAMING_SNAKE_CASE, or any other casing to PascalCase.
1144fn to_pascal_case(name: &str) -> String {
1145    if name.is_empty() {
1146        return String::new();
1147    }
1148
1149    // If already PascalCase (starts with uppercase, contains lowercase), return as-is
1150    let first = name.chars().next().unwrap();
1151    if first.is_uppercase() && name.chars().any(|c| c.is_lowercase()) && !name.contains('_') {
1152        return name.to_string();
1153    }
1154
1155    // Split on underscores and capitalize each segment
1156    name.split('_')
1157        .filter(|s| !s.is_empty())
1158        .map(|segment| {
1159            let mut chars = segment.chars();
1160            match chars.next() {
1161                None => String::new(),
1162                Some(first) => {
1163                    let rest: String = chars.collect();
1164                    format!("{}{}", first.to_uppercase(), rest.to_lowercase())
1165                }
1166            }
1167        })
1168        .collect()
1169}
1170
1171#[cfg(test)]
1172mod tests {
1173    use super::*;
1174
1175    #[test]
1176    fn test_to_snake_case() {
1177        assert_eq!(to_snake_case("BoardMember"), "board_member");
1178        assert_eq!(to_snake_case("User"), "user");
1179        assert_eq!(to_snake_case("JiraImportConfig"), "jira_import_config");
1180    }
1181
1182    #[test]
1183    fn test_to_pascal_case_from_snake() {
1184        assert_eq!(to_pascal_case("card_created"), "CardCreated");
1185        assert_eq!(to_pascal_case("branch_deleted"), "BranchDeleted");
1186        assert_eq!(to_pascal_case("pr_merged"), "PrMerged");
1187    }
1188
1189    #[test]
1190    fn test_to_pascal_case_from_screaming() {
1191        assert_eq!(to_pascal_case("CARD_CREATED"), "CardCreated");
1192        assert_eq!(to_pascal_case("PR_MERGED"), "PrMerged");
1193    }
1194
1195    #[test]
1196    fn test_to_pascal_case_already_pascal() {
1197        assert_eq!(to_pascal_case("Admin"), "Admin");
1198        assert_eq!(to_pascal_case("SuperAdmin"), "SuperAdmin");
1199        assert_eq!(to_pascal_case("Low"), "Low");
1200    }
1201
1202    #[test]
1203    fn test_to_pascal_case_single_word() {
1204        assert_eq!(to_pascal_case("active"), "Active");
1205        assert_eq!(to_pascal_case("ACTIVE"), "Active");
1206    }
1207
1208    #[test]
1209    fn test_needs_boxing_direct_cycle() {
1210        let mut graph = HashMap::new();
1211        graph.insert(
1212            "Board".to_string(),
1213            HashSet::from(["JiraConfig".to_string()]),
1214        );
1215        graph.insert(
1216            "JiraConfig".to_string(),
1217            HashSet::from(["Board".to_string()]),
1218        );
1219
1220        assert!(needs_boxing("Board", "JiraConfig", &graph));
1221        assert!(needs_boxing("JiraConfig", "Board", &graph));
1222    }
1223
1224    #[test]
1225    fn test_needs_boxing_no_cycle() {
1226        let mut graph = HashMap::new();
1227        graph.insert("Post".to_string(), HashSet::from(["User".to_string()]));
1228        graph.insert("User".to_string(), HashSet::new());
1229
1230        assert!(!needs_boxing("Post", "User", &graph));
1231    }
1232
1233    #[test]
1234    fn test_needs_boxing_indirect_cycle() {
1235        let mut graph = HashMap::new();
1236        graph.insert("A".to_string(), HashSet::from(["B".to_string()]));
1237        graph.insert("B".to_string(), HashSet::from(["C".to_string()]));
1238        graph.insert("C".to_string(), HashSet::from(["A".to_string()]));
1239
1240        assert!(needs_boxing("A", "B", &graph));
1241        assert!(needs_boxing("B", "C", &graph));
1242        assert!(needs_boxing("C", "A", &graph));
1243    }
1244}