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