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