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