Skip to main content

shaperail_codegen/
rust.rs

1use shaperail_core::{EndpointSpec, FieldSchema, FieldType, HttpMethod, ResourceDefinition};
2
3pub struct GeneratedRustModule {
4    pub file_name: String,
5    pub contents: String,
6}
7
8pub struct GeneratedRustProject {
9    pub modules: Vec<GeneratedRustModule>,
10    pub mod_rs: String,
11}
12
13pub fn generate_project(resources: &[ResourceDefinition]) -> Result<GeneratedRustProject, String> {
14    let mut modules = Vec::with_capacity(resources.len());
15    for resource in resources {
16        modules.push(GeneratedRustModule {
17            file_name: format!("{}.rs", resource.resource),
18            contents: generate_resource_module(resource)?,
19        });
20    }
21
22    Ok(GeneratedRustProject {
23        modules,
24        mod_rs: generate_registry_module(resources),
25    })
26}
27
28/// Run `rustfmt --edition 2021` against the Rust source file at `path`, formatting it in place.
29///
30/// Logs to stderr and continues on any failure — never panics or returns `Err` — so that
31/// codegen keeps working in environments without `rustfmt` on `PATH`.
32/// Call only for `.rs` files; it is a no-op for other extensions.
33pub fn rustfmt_in_place(path: &std::path::Path) {
34    if path.extension().and_then(|e| e.to_str()) != Some("rs") {
35        return;
36    }
37    let result = std::process::Command::new("rustfmt")
38        .arg("--edition")
39        .arg("2021")
40        .arg(path)
41        .status();
42    match result {
43        Ok(status) if status.success() => {}
44        Ok(status) => {
45            eprintln!(
46                "warning: rustfmt exited with {:?} for {}; leaving generated file unformatted",
47                status.code(),
48                path.display()
49            );
50        }
51        Err(err) => {
52            eprintln!(
53                "warning: rustfmt not found on PATH ({}); skipping format pass for {}. \
54                 Install with `rustup component add rustfmt`.",
55                err,
56                path.display()
57            );
58        }
59    }
60}
61
62pub fn generate_resource_module(resource: &ResourceDefinition) -> Result<String, String> {
63    let context = ResourceContext::new(resource)?;
64
65    let model_fields = resource
66        .schema
67        .iter()
68        .filter(|(_, field)| field.is_persisted())
69        .map(|(name, field)| {
70            let attr = if field.sensitive {
71                "    #[serde(skip_serializing)]\n"
72            } else {
73                ""
74            };
75            format!("{attr}    pub {name}: {},", model_field_type(field))
76        })
77        .collect::<Vec<_>>()
78        .join("\n");
79
80    let list_helpers = context
81        .collection_endpoints
82        .iter()
83        .map(|endpoint| generate_list_helper(&context, endpoint))
84        .collect::<Result<Vec<_>, _>>()?
85        .join("\n\n");
86
87    let list_dispatch = if context.collection_endpoints.is_empty() {
88        "        let _ = (endpoint, filters, search, sort, page);\n        Err(shaperail_core::ShaperailError::Internal(\"No collection endpoints are available for generated list queries\".to_string()))".to_string()
89    } else {
90        let arms = context
91            .collection_endpoints
92            .iter()
93            .map(|endpoint| {
94                format!(
95                    "            {path:?} => self.{helper}(filters, search, sort, page).await,",
96                    path = endpoint.spec.path(),
97                    helper = endpoint.helper_name
98                )
99            })
100            .collect::<Vec<_>>()
101            .join("\n");
102
103        format!(
104            "        match endpoint.path() {{\n{arms}\n            _ => Err(shaperail_core::ShaperailError::Internal(format!(\"No generated list query for {{}}\", endpoint.path()))),\n        }}"
105        )
106    };
107
108    Ok(format!(
109        r###"//! Generated query module for the `{resource_name}` resource.
110//! DO NOT EDIT — this file is auto-generated by `shaperail generate`.
111
112use serde::{{Deserialize, Serialize}};
113use serde_json::{{Map, Value}};
114use shaperail_core::EndpointSpec;
115#[allow(unused_imports)]
116use shaperail_runtime::db::{{
117    async_trait, parse_embedded_json, parse_filter, parse_optional_json, require_field,
118    row_from_model, sort_direction_at, sort_field_at, FilterSet, PageRequest, ResourceRow,
119    ResourceStore, SearchParam, SortParam,
120}};
121
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct {record_name} {{
124{model_fields}
125}}
126
127pub struct {store_name} {{
128    pool: sqlx::PgPool,
129}}
130
131impl {store_name} {{
132    pub fn new(pool: sqlx::PgPool) -> Self {{
133        Self {{ pool }}
134    }}
135
136{list_helpers}
137}}
138
139#[async_trait]
140impl ResourceStore for {store_name} {{
141    fn resource_name(&self) -> &str {{
142        "{resource_name}"
143    }}
144
145    async fn find_by_id(&self, id: &uuid::Uuid) -> Result<ResourceRow, shaperail_core::ShaperailError> {{
146        let row = sqlx::query_as!(
147            {record_name},
148            r#"
149            SELECT
150                {select_columns}
151            FROM "{table_name}"
152            WHERE "{primary_key}" = $1{soft_delete_where}
153            "#,
154            id
155        )
156        .fetch_optional(&self.pool)
157        .await?
158        .ok_or(shaperail_core::ShaperailError::NotFound)?;
159
160        row_from_model(&row)
161    }}
162
163    async fn find_all(
164        &self,
165        endpoint: &EndpointSpec,
166        filters: &FilterSet,
167        search: Option<&SearchParam>,
168        sort: &SortParam,
169        page: &PageRequest,
170    ) -> Result<(Vec<ResourceRow>, Value), shaperail_core::ShaperailError> {{
171{list_dispatch}
172    }}
173
174    async fn insert(&self, data: &Map<String, Value>) -> Result<ResourceRow, shaperail_core::ShaperailError> {{
175{insert_body}
176    }}
177
178    async fn update_by_id(
179        &self,
180        id: &uuid::Uuid,
181        data: &Map<String, Value>,
182    ) -> Result<ResourceRow, shaperail_core::ShaperailError> {{
183{update_body}
184    }}
185
186    async fn soft_delete_by_id(&self, id: &uuid::Uuid) -> Result<ResourceRow, shaperail_core::ShaperailError> {{
187{soft_delete_body}
188    }}
189
190    async fn hard_delete_by_id(&self, id: &uuid::Uuid) -> Result<ResourceRow, shaperail_core::ShaperailError> {{
191{hard_delete_body}
192    }}
193}}
194"###,
195        resource_name = resource.resource,
196        record_name = context.record_name,
197        store_name = context.store_name,
198        model_fields = model_fields,
199        list_helpers = list_helpers,
200        select_columns = context.select_columns,
201        table_name = resource.resource,
202        primary_key = context.primary_key,
203        soft_delete_where = context.soft_delete_where,
204        list_dispatch = list_dispatch,
205        insert_body = generate_insert_body(&context)?,
206        update_body = generate_update_body(&context)?,
207        soft_delete_body = generate_soft_delete_body(&context),
208        hard_delete_body = generate_hard_delete_body(&context),
209    ))
210}
211
212/// Returns (resource_name, [hook_fn_names]) for each resource with native (non-WASM) controller hooks.
213fn collect_controller_hooks(resources: &[ResourceDefinition]) -> Vec<(&str, Vec<&str>)> {
214    resources
215        .iter()
216        .filter_map(|resource| {
217            let hooks: Vec<&str> = resource
218                .endpoints
219                .as_ref()
220                .map(|eps| {
221                    eps.iter()
222                        .filter_map(|(_, ep)| ep.controller.as_ref())
223                        .flat_map(|c| {
224                            c.before_names()
225                                .iter()
226                                .chain(c.after_names().iter())
227                                .map(String::as_str)
228                                .filter(|s| !s.starts_with(shaperail_core::WASM_HOOK_PREFIX))
229                        })
230                        .collect()
231                })
232                .unwrap_or_default();
233            if hooks.is_empty() {
234                None
235            } else {
236                Some((resource.resource.as_str(), hooks))
237            }
238        })
239        .collect()
240}
241
242/// The five built-in convention endpoint actions. Used by codegen and CLI to filter custom endpoints.
243pub const HANDLER_CONVENTIONS: &[&str] = &["list", "get", "create", "update", "delete"];
244
245/// Returns (resource_name, [(action, handler_fn)]) for resources with custom endpoints.
246/// Only includes non-convention endpoints that have `handler:` declared.
247fn collect_custom_handlers(resources: &[ResourceDefinition]) -> Vec<(&str, Vec<(&str, &str)>)> {
248    resources
249        .iter()
250        .filter_map(|resource| {
251            let handlers: Vec<(&str, &str)> = resource
252                .endpoints
253                .as_ref()
254                .map(|eps| {
255                    eps.iter()
256                        .filter(|(action, _)| !HANDLER_CONVENTIONS.contains(&action.as_str()))
257                        .filter_map(|(action, ep)| {
258                            ep.handler.as_deref().map(|h| (action.as_str(), h))
259                        })
260                        .collect()
261                })
262                .unwrap_or_default();
263            if handlers.is_empty() {
264                None
265            } else {
266                Some((resource.resource.as_str(), handlers))
267            }
268        })
269        .collect()
270}
271
272/// Returns all unique job names declared across all resources, sorted for determinism.
273fn collect_job_names(resources: &[ResourceDefinition]) -> Vec<String> {
274    let mut names = std::collections::BTreeSet::new();
275    for resource in resources {
276        if let Some(endpoints) = &resource.endpoints {
277            for (_, ep) in endpoints {
278                for job in ep.jobs.as_deref().unwrap_or_default() {
279                    names.insert(job.clone());
280                }
281            }
282        }
283    }
284    names.into_iter().collect()
285}
286
287/// Returns #[path] declarations for job handler modules.
288fn collect_job_path_decls(resources: &[ResourceDefinition]) -> Vec<String> {
289    collect_job_names(resources)
290        .iter()
291        .map(|name| format!("#[path = \"../jobs/{name}.rs\"]\nmod job_{name};"))
292        .collect()
293}
294
295/// Returns #[path] declarations for handler modules.
296fn collect_handler_path_decls(resources: &[ResourceDefinition]) -> Vec<String> {
297    collect_custom_handlers(resources)
298        .iter()
299        .map(|(name, _)| {
300            format!("#[path = \"../resources/{name}.handlers.rs\"]\nmod {name}_handlers;")
301        })
302        .collect()
303}
304
305/// Generates the build_job_registry() function.
306fn generate_job_registry(resources: &[ResourceDefinition]) -> String {
307    let job_names = collect_job_names(resources);
308
309    if job_names.is_empty() {
310        return r#"pub fn build_job_registry() -> shaperail_runtime::jobs::JobRegistry {
311    shaperail_runtime::jobs::JobRegistry::new()
312}"#
313        .to_string();
314    }
315
316    let inserts: Vec<String> = job_names
317        .iter()
318        .map(|name| {
319            format!(
320                r#"    handlers.insert(
321        "{name}".to_string(),
322        std::sync::Arc::new(|payload: serde_json::Value| {{
323            Box::pin(job_{name}::handle(payload))
324                as std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), shaperail_core::ShaperailError>> + Send>>
325        }}) as shaperail_runtime::jobs::JobHandler,
326    );"#
327            )
328        })
329        .collect();
330
331    format!(
332        r#"pub fn build_job_registry() -> shaperail_runtime::jobs::JobRegistry {{
333    let mut handlers: std::collections::HashMap<String, shaperail_runtime::jobs::JobHandler> =
334        std::collections::HashMap::new();
335{inserts}
336    shaperail_runtime::jobs::JobRegistry::from_handlers(handlers)
337}}"#,
338        inserts = inserts.join("\n")
339    )
340}
341
342fn generate_handler_map_fn(resources: &[ResourceDefinition]) -> String {
343    let custom = collect_custom_handlers(resources);
344
345    if custom.is_empty() {
346        return r#"pub fn build_handler_map() -> shaperail_runtime::handlers::custom::CustomHandlerMap {
347    shaperail_runtime::handlers::custom::CustomHandlerMap::new()
348}"#
349        .to_string();
350    }
351
352    let inserts: Vec<String> = custom
353        .iter()
354        .flat_map(|(res_name, handlers)| {
355            handlers.iter().map(move |(action, handler_fn)| {
356                format!(
357                    "    map.insert(\n        shaperail_runtime::handlers::custom::handler_key({res_name:?}, {action:?}),\n        std::sync::Arc::new(|req, state, res, ep| {{\n            Box::pin({res_name}_handlers::{handler_fn}(req, state, res, ep))\n        }}),\n    );"
358                )
359            })
360        })
361        .collect();
362
363    format!(
364        r#"pub fn build_handler_map() -> shaperail_runtime::handlers::custom::CustomHandlerMap {{
365    let mut map = shaperail_runtime::handlers::custom::CustomHandlerMap::new();
366{inserts}
367    map
368}}"#,
369        inserts = inserts.join("\n"),
370    )
371}
372
373fn generate_registry_module(resources: &[ResourceDefinition]) -> String {
374    let module_lines = resources
375        .iter()
376        .map(|resource| format!("pub mod {};", resource.resource))
377        .collect::<Vec<_>>()
378        .join("\n");
379
380    let registry_lines = resources
381        .iter()
382        .map(|resource| {
383            let store_name = format!("{}Store", to_pascal_case(&resource.resource));
384            format!(
385                "    stores.insert({name:?}.to_string(), std::sync::Arc::new({module}::{store_name}::new(pool.clone())));",
386                name = resource.resource,
387                module = resource.resource
388            )
389        })
390        .collect::<Vec<_>>()
391        .join("\n");
392
393    let ctrl_hooks = collect_controller_hooks(resources);
394
395    // #[path] module declarations for controller files
396    let ctrl_path_decls: Vec<String> = ctrl_hooks
397        .iter()
398        .map(|(name, _)| {
399            format!("#[path = \"../resources/{name}.controller.rs\"]\npub mod {name}_controller;")
400        })
401        .collect();
402
403    // map.register(...) calls — deduplicated
404    let mut seen_ctrl = std::collections::HashSet::new();
405    let ctrl_register_lines: Vec<String> = ctrl_hooks
406        .iter()
407        .flat_map(|(res_name, hooks)| {
408            hooks
409                .iter()
410                .filter_map(|fn_name| {
411                    let key = (*res_name, *fn_name);
412                    if seen_ctrl.insert(key) {
413                        Some(format!(
414                            "    map.register({res_name:?}, {fn_name:?}, {res_name}_controller::{fn_name});"
415                        ))
416                    } else {
417                        None
418                    }
419                })
420                .collect::<Vec<_>>()
421        })
422        .collect();
423
424    let ctrl_map_body = if ctrl_register_lines.is_empty() {
425        "    shaperail_runtime::handlers::controller::ControllerMap::new()".to_string()
426    } else {
427        let mut lines = vec![
428            "    let mut map = shaperail_runtime::handlers::controller::ControllerMap::new();"
429                .to_string(),
430        ];
431        lines.extend(ctrl_register_lines);
432        lines.push("    map".to_string());
433        lines.join("\n")
434    };
435
436    // Build the preamble: store modules, then controller path decls, then job path decls
437    let mut preamble_parts = vec![module_lines.clone()];
438    if !ctrl_path_decls.is_empty() {
439        preamble_parts.push(ctrl_path_decls.join("\n"));
440    }
441    let job_path_decls = collect_job_path_decls(resources);
442    if !job_path_decls.is_empty() {
443        preamble_parts.push(job_path_decls.join("\n"));
444    }
445    let handler_path_decls = collect_handler_path_decls(resources);
446    if !handler_path_decls.is_empty() {
447        preamble_parts.push(handler_path_decls.join("\n"));
448    }
449    let preamble = preamble_parts.join("\n\n");
450
451    let resources_aggregator = if ctrl_hooks.is_empty() {
452        String::new()
453    } else {
454        let lines: Vec<String> = ctrl_hooks
455            .iter()
456            .map(|(name, _)| format!("    pub use super::{name}_controller;"))
457            .collect();
458        format!(
459            "\n\n/// Re-exports of every controller module under one path so integration\n\
460             /// tests can reach `pub` helpers via `crate::resources::<name>_controller::*`.\n\
461             #[doc(hidden)]\n#[allow(unused_imports)]\npub mod resources {{\n{}\n}}",
462            lines.join("\n")
463        )
464    };
465
466    format!(
467        r#"#![allow(dead_code)]
468
469{preamble}{resources_aggregator}
470
471pub fn build_store_registry(pool: sqlx::PgPool) -> shaperail_runtime::db::StoreRegistry {{
472    let mut stores: std::collections::HashMap<
473        String,
474        std::sync::Arc<dyn shaperail_runtime::db::ResourceStore>,
475    > = std::collections::HashMap::new();
476{registry_lines}
477    std::sync::Arc::new(stores)
478}}
479
480pub fn build_controller_map() -> shaperail_runtime::handlers::controller::ControllerMap {{
481{ctrl_map_body}
482}}
483
484{job_registry_fn}
485
486{handler_map_fn}
487"#,
488        resources_aggregator = resources_aggregator,
489        job_registry_fn = generate_job_registry(resources),
490        handler_map_fn = generate_handler_map_fn(resources),
491    )
492}
493
494#[derive(Clone)]
495struct CollectionEndpoint<'a> {
496    spec: &'a EndpointSpec,
497    helper_name: String,
498}
499
500struct ResourceContext<'a> {
501    resource: &'a ResourceDefinition,
502    record_name: String,
503    store_name: String,
504    primary_key: String,
505    select_columns: String,
506    soft_delete_where: String,
507    collection_endpoints: Vec<CollectionEndpoint<'a>>,
508}
509
510impl<'a> ResourceContext<'a> {
511    fn new(resource: &'a ResourceDefinition) -> Result<Self, String> {
512        let primary_key = resource
513            .schema
514            .iter()
515            .find(|(_, field)| field.primary)
516            .map(|(name, _)| name.clone())
517            .unwrap_or_else(|| "id".to_string());
518
519        let select_columns = resource
520            .schema
521            .iter()
522            .filter(|(_, field)| field.is_persisted())
523            .map(|(name, field)| select_column_sql(name, field))
524            .collect::<Vec<_>>()
525            .join(",\n                ");
526
527        let collection_endpoints = resource
528            .endpoints
529            .as_ref()
530            .map(|endpoints| {
531                endpoints
532                    .iter()
533                    .filter(|(_, endpoint)| {
534                        *endpoint.method() == HttpMethod::Get && !endpoint.path().contains(":id")
535                    })
536                    .map(|(name, endpoint)| CollectionEndpoint {
537                        spec: endpoint,
538                        helper_name: format!("find_all_{}", sanitize_identifier(name)),
539                    })
540                    .collect::<Vec<_>>()
541            })
542            .unwrap_or_default();
543
544        Ok(Self {
545            resource,
546            record_name: format!("{}Record", to_pascal_case(&resource.resource)),
547            store_name: format!("{}Store", to_pascal_case(&resource.resource)),
548            primary_key,
549            select_columns,
550            soft_delete_where: if has_soft_delete(resource) {
551                " AND \"deleted_at\" IS NULL".to_string()
552            } else {
553                String::new()
554            },
555            collection_endpoints,
556        })
557    }
558}
559
560fn generate_list_helper(
561    context: &ResourceContext<'_>,
562    endpoint: &CollectionEndpoint<'_>,
563) -> Result<String, String> {
564    let filters = endpoint.spec.filters.clone().unwrap_or_default();
565    let search_fields = endpoint.spec.search.clone().unwrap_or_default();
566    let sort_fields = endpoint.spec.sort.clone().unwrap_or_default();
567
568    let filter_decls = if filters.is_empty() {
569        "        let _ = filters;".to_string()
570    } else {
571        filters
572            .iter()
573            .map(|field_name| {
574                let field = context.resource.schema.get(field_name).ok_or_else(|| {
575                    format!(
576                        "Unknown filter field '{field_name}' on resource '{}'",
577                        context.resource.resource
578                    )
579                })?;
580                Ok(generate_filter_declaration(field_name, field))
581            })
582            .collect::<Result<Vec<_>, String>>()?
583            .join("\n")
584    };
585
586    let filter_args = filters
587        .iter()
588        .map(|field_name| {
589            parameter_expression(
590                field_name,
591                context
592                    .resource
593                    .schema
594                    .get(field_name)
595                    .expect("filter field validated"),
596            )
597        })
598        .collect::<Vec<_>>();
599
600    let search_decl = if search_fields.is_empty() {
601        String::new()
602    } else {
603        "        let search_term = search.map(|value| value.term.clone());".to_string()
604    };
605    let search_param = if search_fields.is_empty() {
606        "_search"
607    } else {
608        "search"
609    };
610
611    let search_predicate = if search_fields.is_empty() {
612        String::new()
613    } else {
614        search_expression(&search_fields)
615    };
616
617    let sort_decls = if sort_fields.is_empty() {
618        "        let _ = sort;".to_string()
619    } else {
620        (0..sort_fields.len())
621            .map(|index| {
622                format!(
623                    "        let sort_field_{index} = sort_field_at(sort, {index});\n        let sort_direction_{index} = sort_direction_at(sort, {index});"
624                )
625            })
626            .collect::<Vec<_>>()
627            .join("\n")
628    };
629
630    let filter_positions = filters
631        .iter()
632        .enumerate()
633        .map(|(index, field_name)| (field_name.clone(), index + 1))
634        .collect::<Vec<_>>();
635
636    let search_position = if search_fields.is_empty() {
637        None
638    } else {
639        Some(filter_positions.len() + 1)
640    };
641
642    let cursor_position = filter_positions.len() + usize::from(search_position.is_some()) + 1;
643    let cursor_sort_positions = (0..sort_fields.len())
644        .map(|index| {
645            let base = cursor_position + 1 + (index * 2);
646            (base, base + 1)
647        })
648        .collect::<Vec<_>>();
649    let offset_sort_positions = (0..sort_fields.len())
650        .map(|index| {
651            let base =
652                filter_positions.len() + usize::from(search_position.is_some()) + 1 + (index * 2);
653            (base, base + 1)
654        })
655        .collect::<Vec<_>>();
656
657    let filter_predicates = generate_filter_predicates(context, &filter_positions)?;
658    let filter_clause = filter_predicates.join("\n");
659
660    let cursor_order_by = generate_order_by(context, &sort_fields, &cursor_sort_positions)?;
661    let offset_order_by = generate_order_by(context, &sort_fields, &offset_sort_positions)?;
662
663    let mut cursor_args = filter_args.clone();
664    if search_position.is_some() {
665        cursor_args.push("search_term.as_deref()".to_string());
666    }
667    cursor_args.push("cursor".to_string());
668    for index in 0..sort_fields.len() {
669        cursor_args.push(format!("sort_field_{index}.as_deref()"));
670        cursor_args.push(format!("sort_direction_{index}"));
671    }
672    cursor_args.push("*limit + 1".to_string());
673
674    let mut count_args = filter_args.clone();
675    if search_position.is_some() {
676        count_args.push("search_term.as_deref()".to_string());
677    }
678
679    let mut row_args = count_args.clone();
680    for index in 0..sort_fields.len() {
681        row_args.push(format!("sort_field_{index}.as_deref()"));
682        row_args.push(format!("sort_direction_{index}"));
683    }
684    row_args.push("*limit".to_string());
685    row_args.push("*offset".to_string());
686
687    let cursor_query = generate_cursor_query(
688        context,
689        &filter_clause,
690        search_position.map(|position| (position, search_predicate.as_str())),
691        cursor_position,
692        &cursor_order_by,
693        cursor_args.len(),
694        &cursor_args,
695    );
696    let offset_query = generate_offset_query(
697        context,
698        &filter_clause,
699        search_position.map(|position| (position, search_predicate.as_str())),
700        &offset_order_by,
701        row_args.len() - 1,
702        row_args.len(),
703        &count_args,
704        &row_args,
705    );
706
707    Ok(format!(
708        r###"    async fn {helper_name}(
709        &self,
710        filters: &FilterSet,
711        {search_param}: Option<&SearchParam>,
712        sort: &SortParam,
713        page: &PageRequest,
714    ) -> Result<(Vec<ResourceRow>, Value), shaperail_core::ShaperailError> {{
715{filter_decls}
716{search_decl}
717{sort_decls}
718
719        match page {{
720            PageRequest::Cursor {{ after, limit }} => {{
721                let cursor = match after {{
722                    Some(cursor_value) => Some(uuid::Uuid::parse_str(
723                        &shaperail_runtime::db::decode_cursor(cursor_value)?
724                    ).map_err(|_| shaperail_core::ShaperailError::Validation(vec![shaperail_core::FieldError {{
725                        field: "cursor".to_string(),
726                        message: "Invalid cursor value".to_string(),
727                        code: "invalid_cursor".to_string(),
728                    }}]))?),
729                    None => None,
730                }};
731{cursor_query}
732            }}
733            PageRequest::Offset {{ offset, limit }} => {{
734{offset_query}
735            }}
736        }}
737    }}"###,
738        helper_name = endpoint.helper_name,
739        search_param = search_param,
740        filter_decls = indent_block(&filter_decls, 2),
741        search_decl = indent_block(&search_decl, 2),
742        sort_decls = indent_block(&sort_decls, 2),
743        cursor_query = indent_block(&cursor_query, 4),
744        offset_query = indent_block(&offset_query, 4),
745    ))
746}
747
748fn generate_cursor_query(
749    context: &ResourceContext<'_>,
750    filter_clause: &str,
751    search_position: Option<(usize, &str)>,
752    cursor_position: usize,
753    order_by: &str,
754    limit_position: usize,
755    args: &[String],
756) -> String {
757    format!(
758        r###"                let rows = sqlx::query_as!(
759                    {record_name},
760                    r#"
761                    SELECT
762                        {select_columns}
763                    FROM "{table_name}"
764                    WHERE TRUE
765{soft_delete_clause}
766{filter_clause}{search_clause}
767                        AND (${cursor_position}::uuid IS NULL OR "{primary_key}" > ${cursor_position})
768                    ORDER BY
769{order_by}
770                    LIMIT ${limit_position}
771                    "#,
772                    {args}
773                )
774                .fetch_all(&self.pool)
775                .await?;
776
777                let has_more = rows.len() as i64 > *limit;
778                let mut result_rows = rows;
779                if has_more {{
780                    result_rows.truncate(*limit as usize);
781                }}
782
783                let data = result_rows
784                    .iter()
785                    .map(row_from_model)
786                    .collect::<Result<Vec<_>, _>>()?;
787                let cursor = if has_more {{
788                    result_rows
789                        .last()
790                        .map(|row| shaperail_runtime::db::encode_cursor(&row.{primary_key}.to_string()))
791                }} else {{
792                    None
793                }};
794
795                Ok((
796                    data,
797                    serde_json::json!({{
798                        "cursor": cursor,
799                        "has_more": has_more
800                    }})
801                ))"###,
802        record_name = context.record_name,
803        select_columns = context.select_columns,
804        table_name = context.resource.resource,
805        soft_delete_clause = if has_soft_delete(context.resource) {
806            "                        AND \"deleted_at\" IS NULL\n"
807        } else {
808            ""
809        },
810        filter_clause = if filter_clause.is_empty() {
811            String::new()
812        } else {
813            format!("{filter_clause}\n")
814        },
815        search_clause = search_position
816            .map(|(position, expression)| {
817                format!(
818                    "\n                        AND (${position}::text IS NULL OR to_tsvector('english', {expression}) @@ plainto_tsquery('english', ${position}))"
819                )
820            })
821            .unwrap_or_default(),
822        cursor_position = cursor_position,
823        primary_key = context.primary_key,
824        order_by = order_by,
825        limit_position = limit_position,
826        args = args.join(",\n                    "),
827    )
828}
829
830#[allow(clippy::too_many_arguments)]
831fn generate_offset_query(
832    context: &ResourceContext<'_>,
833    filter_clause: &str,
834    search_position: Option<(usize, &str)>,
835    order_by: &str,
836    limit_position: usize,
837    offset_position: usize,
838    count_args: &[String],
839    row_args: &[String],
840) -> String {
841    let count_macro_args = if count_args.is_empty() {
842        String::new()
843    } else {
844        format!(
845            ",\n                    {}",
846            count_args.join(",\n                    ")
847        )
848    };
849
850    format!(
851        r###"                let total = sqlx::query_scalar!(
852                    r#"
853                    SELECT COUNT(*) as "count!"
854                    FROM "{table_name}"
855                    WHERE TRUE
856{soft_delete_clause}
857{filter_clause}{search_clause}
858                    "#{count_macro_args}
859                )
860                .fetch_one(&self.pool)
861                .await?;
862
863                let rows = sqlx::query_as!(
864                    {record_name},
865                    r#"
866                    SELECT
867                        {select_columns}
868                    FROM "{table_name}"
869                    WHERE TRUE
870{soft_delete_clause}
871{filter_clause}{search_clause}
872                    ORDER BY
873{order_by}
874                    LIMIT ${limit_param}
875                    OFFSET ${offset_param}
876                    "#,
877                    {row_args}
878                )
879                .fetch_all(&self.pool)
880                .await?;
881
882                let data = rows
883                    .iter()
884                    .map(row_from_model)
885                    .collect::<Result<Vec<_>, _>>()?;
886
887                Ok((
888                    data,
889                    serde_json::json!({{
890                        "offset": offset,
891                        "limit": limit,
892                        "total": total
893                    }})
894                ))"###,
895        table_name = context.resource.resource,
896        soft_delete_clause = if has_soft_delete(context.resource) {
897            "                        AND \"deleted_at\" IS NULL\n"
898        } else {
899            ""
900        },
901        filter_clause = if filter_clause.is_empty() {
902            String::new()
903        } else {
904            format!("{filter_clause}\n")
905        },
906        search_clause = search_position
907            .map(|(position, expression)| {
908                format!(
909                    "\n                        AND (${position}::text IS NULL OR to_tsvector('english', {expression}) @@ plainto_tsquery('english', ${position}))"
910                )
911            })
912            .unwrap_or_default(),
913        count_macro_args = count_macro_args,
914        record_name = context.record_name,
915        select_columns = context.select_columns,
916        order_by = order_by,
917        limit_param = limit_position,
918        offset_param = offset_position,
919        row_args = row_args.join(",\n                    "),
920    )
921}
922
923fn generate_filter_predicates(
924    context: &ResourceContext<'_>,
925    positions: &[(String, usize)],
926) -> Result<Vec<String>, String> {
927    positions
928        .iter()
929        .map(|(field_name, position)| {
930            let field = context.resource.schema.get(field_name).ok_or_else(|| {
931                format!(
932                    "Unknown filter field '{field_name}' on resource '{}'",
933                    context.resource.resource
934                )
935            })?;
936            Ok(format!(
937                "                AND (${position}::{cast} IS NULL OR \"{field_name}\" = ${position})",
938                cast = sql_cast_type(field)
939            ))
940        })
941        .collect()
942}
943
944fn generate_order_by(
945    context: &ResourceContext<'_>,
946    sort_fields: &[String],
947    positions: &[(usize, usize)],
948) -> Result<String, String> {
949    if sort_fields.is_empty() {
950        return Ok(format!("\"{}\" ASC", context.primary_key));
951    }
952
953    let mut clauses = Vec::new();
954    for ((field_param, direction_param), field_name) in positions.iter().zip(sort_fields) {
955        for candidate in sort_fields {
956            let field = context.resource.schema.get(candidate).ok_or_else(|| {
957                format!(
958                    "Unknown sort field '{candidate}' on resource '{}'",
959                    context.resource.resource
960                )
961            })?;
962            let sort_expr = sortable_expression(candidate, field);
963            clauses.push(format!(
964                "                CASE WHEN ${field_param}::text = '{candidate}' AND ${direction_param}::text = 'asc' THEN {sort_expr} END ASC"
965            ));
966            clauses.push(format!(
967                "                CASE WHEN ${field_param}::text = '{candidate}' AND ${direction_param}::text = 'desc' THEN {sort_expr} END DESC"
968            ));
969        }
970        let _ = field_name;
971    }
972    clauses.push(format!("                \"{}\" ASC", context.primary_key));
973    Ok(clauses.join(",\n"))
974}
975
976fn generate_insert_body(context: &ResourceContext<'_>) -> Result<String, String> {
977    let mut declarations = Vec::new();
978    let mut columns = Vec::new();
979    let mut values = Vec::new();
980    let mut args = Vec::new();
981
982    for (field_name, field) in context
983        .resource
984        .schema
985        .iter()
986        .filter(|(_, f)| f.is_persisted())
987    {
988        let variable_name = sanitize_identifier(field_name);
989        declarations.push(generate_insert_declaration(
990            field_name,
991            field,
992            &variable_name,
993        )?);
994        let index = columns.len() + 1;
995        columns.push(format!("\"{field_name}\""));
996        values.push(format!("${index}"));
997        args.push(variable_name);
998    }
999
1000    Ok(format!(
1001        r###"{declarations}
1002        let row = sqlx::query_as!(
1003            {record_name},
1004            r#"
1005            INSERT INTO "{table_name}" ({columns})
1006            VALUES ({values})
1007            RETURNING
1008                {select_columns}
1009            "#,
1010            {args}
1011        )
1012        .fetch_one(&self.pool)
1013        .await?;
1014
1015        row_from_model(&row)"###,
1016        declarations = declarations.join("\n"),
1017        record_name = context.record_name,
1018        table_name = context.resource.resource,
1019        columns = columns.join(", "),
1020        values = values.join(", "),
1021        select_columns = context.select_columns,
1022        args = args.join(",\n            "),
1023    ))
1024}
1025
1026fn generate_update_body(context: &ResourceContext<'_>) -> Result<String, String> {
1027    let mut declarations = Vec::new();
1028    let mut set_clauses = Vec::new();
1029    let mut args = vec!["id".to_string()];
1030    let mut has_mutable_fields = Vec::new();
1031    let mut index = 2usize;
1032
1033    for (field_name, field) in &context.resource.schema {
1034        if field.primary || field.generated || field.transient {
1035            continue;
1036        }
1037
1038        let present_name = format!("{}_present", sanitize_identifier(field_name));
1039        let value_name = sanitize_identifier(field_name);
1040        declarations.push(generate_update_declaration(
1041            field_name,
1042            field,
1043            &present_name,
1044            &value_name,
1045        ));
1046        has_mutable_fields.push(present_name.clone());
1047        set_clauses.push(format!(
1048            "\"{field_name}\" = CASE WHEN ${present_param} THEN ${value_param} ELSE \"{field_name}\" END",
1049            present_param = index,
1050            value_param = index + 1
1051        ));
1052        args.push(present_name);
1053        args.push(value_name);
1054        index += 2;
1055    }
1056
1057    if let Some(updated_at) = context.resource.schema.get("updated_at") {
1058        if updated_at.generated && updated_at.field_type == FieldType::Timestamp {
1059            declarations.push("        let updated_at = chrono::Utc::now();".to_string());
1060            set_clauses.push(format!("\"updated_at\" = ${index}"));
1061            args.push("updated_at".to_string());
1062        }
1063    }
1064
1065    let guard = if has_mutable_fields.is_empty() {
1066        String::new()
1067    } else {
1068        format!(
1069            "        if !({}) {}",
1070            has_mutable_fields.join(" || "),
1071            r#"{
1072            return Err(shaperail_core::ShaperailError::Validation(vec![shaperail_core::FieldError {
1073                field: "body".to_string(),
1074                message: "No valid fields to update".to_string(),
1075                code: "empty_update".to_string(),
1076            }]));
1077        }"#
1078        )
1079    };
1080
1081    Ok(format!(
1082        r###"{declarations}
1083{guard}
1084        let row = sqlx::query_as!(
1085            {record_name},
1086            r#"
1087            UPDATE "{table_name}"
1088            SET {set_clauses}
1089            WHERE "{primary_key}" = $1{soft_delete_where}
1090            RETURNING
1091                {select_columns}
1092            "#,
1093            {args}
1094        )
1095        .fetch_optional(&self.pool)
1096        .await?
1097        .ok_or(shaperail_core::ShaperailError::NotFound)?;
1098
1099        row_from_model(&row)"###,
1100        declarations = declarations.join("\n"),
1101        guard = guard,
1102        record_name = context.record_name,
1103        table_name = context.resource.resource,
1104        set_clauses = set_clauses.join(", "),
1105        primary_key = context.primary_key,
1106        soft_delete_where = context.soft_delete_where,
1107        select_columns = context.select_columns,
1108        args = args.join(",\n            "),
1109    ))
1110}
1111
1112fn generate_soft_delete_body(context: &ResourceContext<'_>) -> String {
1113    if !has_soft_delete(context.resource) {
1114        return generate_hard_delete_body(context);
1115    }
1116
1117    format!(
1118        r###"        let deleted_at = chrono::Utc::now();
1119        let row = sqlx::query_as!(
1120            {record_name},
1121            r#"
1122            UPDATE "{table_name}"
1123            SET "deleted_at" = $2
1124            WHERE "{primary_key}" = $1 AND "deleted_at" IS NULL
1125            RETURNING
1126                {select_columns}
1127            "#,
1128            id,
1129            deleted_at
1130        )
1131        .fetch_optional(&self.pool)
1132        .await?
1133        .ok_or(shaperail_core::ShaperailError::NotFound)?;
1134
1135        row_from_model(&row)"###,
1136        record_name = context.record_name,
1137        table_name = context.resource.resource,
1138        primary_key = context.primary_key,
1139        select_columns = context.select_columns,
1140    )
1141}
1142
1143fn generate_hard_delete_body(context: &ResourceContext<'_>) -> String {
1144    format!(
1145        r###"        let row = sqlx::query_as!(
1146            {record_name},
1147            r#"
1148            DELETE FROM "{table_name}"
1149            WHERE "{primary_key}" = $1
1150            RETURNING
1151                {select_columns}
1152            "#,
1153            id
1154        )
1155        .fetch_optional(&self.pool)
1156        .await?
1157        .ok_or(shaperail_core::ShaperailError::NotFound)?;
1158
1159        row_from_model(&row)"###,
1160        record_name = context.record_name,
1161        table_name = context.resource.resource,
1162        primary_key = context.primary_key,
1163        select_columns = context.select_columns,
1164    )
1165}
1166
1167fn generate_insert_declaration(
1168    field_name: &str,
1169    field: &FieldSchema,
1170    variable_name: &str,
1171) -> Result<String, String> {
1172    if field.generated {
1173        return Ok(format!(
1174            "        let {variable_name} = {};",
1175            generated_value_expression(field)
1176        ));
1177    }
1178
1179    let parse_type = parse_type(field);
1180    let parsed = format!(
1181        "shaperail_runtime::db::parse_optional_json::<{parse_type}>(data, {field_name:?})?"
1182    );
1183
1184    let expression = match (field_is_required(field), field.default.as_ref()) {
1185        (true, Some(default)) => {
1186            let default_expr = default_expression(field_name, field, default)?;
1187            // For bare literal defaults (currently only Boolean), emit the
1188            // idiomatic `.unwrap_or(...)` form so codegen output is clean
1189            // under `clippy::manual_unwrap_or`. Other field types either go
1190            // through `parse_embedded_json::<…>(…)?` (whose `?` exempts the
1191            // match from the lint) or `.to_string()` (whose function call
1192            // exempts it), so the explicit `match` stays correct for those.
1193            if default_is_bare_literal(field) {
1194                format!("{parsed}.unwrap_or({default_expr})")
1195            } else {
1196                format!("match {parsed} {{ Some(value) => value, None => {default_expr} }}")
1197            }
1198        }
1199        (true, None) => format!("shaperail_runtime::db::require_field({parsed}, {field_name:?})?"),
1200        (false, Some(default)) if model_field_is_optional(field) => format!(
1201            "match {parsed} {{ Some(value) => Some(value), None => Some({}) }}",
1202            default_expression(field_name, field, default)?
1203        ),
1204        (false, Some(default)) => {
1205            let default_expr = default_expression(field_name, field, default)?;
1206            if default_is_bare_literal(field) {
1207                format!("{parsed}.unwrap_or({default_expr})")
1208            } else {
1209                format!("match {parsed} {{ Some(value) => value, None => {default_expr} }}")
1210            }
1211        }
1212        (false, None) => parsed,
1213    };
1214
1215    Ok(format!("        let {variable_name} = {expression};"))
1216}
1217
1218fn generate_update_declaration(
1219    field_name: &str,
1220    field: &FieldSchema,
1221    present_name: &str,
1222    value_name: &str,
1223) -> String {
1224    format!(
1225        "        let {present_name} = data.contains_key({field_name:?});\n        let {value_name} = shaperail_runtime::db::parse_optional_json::<{parse_type}>(data, {field_name:?})?;",
1226        parse_type = parse_type(field)
1227    )
1228}
1229
1230fn generate_filter_declaration(field_name: &str, field: &FieldSchema) -> String {
1231    let parser = match field.field_type {
1232        FieldType::Uuid => "uuid::Uuid::parse_str(text).map_err(|_| shaperail_core::ShaperailError::Internal(\"invalid uuid filter\".to_string()))",
1233        FieldType::String | FieldType::Enum | FieldType::File => "Ok(text.to_string())",
1234        FieldType::Integer => "text.parse::<i64>().map_err(|_| shaperail_core::ShaperailError::Internal(\"invalid integer filter\".to_string()))",
1235        FieldType::Number => "text.parse::<f64>().map_err(|_| shaperail_core::ShaperailError::Internal(\"invalid number filter\".to_string()))",
1236        FieldType::Boolean => "text.parse::<bool>().map_err(|_| shaperail_core::ShaperailError::Internal(\"invalid boolean filter\".to_string()))",
1237        FieldType::Timestamp => "chrono::DateTime::parse_from_rfc3339(text).map(|value| value.with_timezone(&chrono::Utc)).map_err(|_| shaperail_core::ShaperailError::Internal(\"invalid timestamp filter\".to_string()))",
1238        FieldType::Date => "chrono::NaiveDate::parse_from_str(text, \"%Y-%m-%d\").map_err(|_| shaperail_core::ShaperailError::Internal(\"invalid date filter\".to_string()))",
1239        FieldType::Json => "serde_json::from_str::<serde_json::Value>(text).map_err(|_| shaperail_core::ShaperailError::Internal(\"invalid json filter\".to_string()))",
1240        FieldType::Array => "serde_json::from_str::<Vec<serde_json::Value>>(text).map_err(|_| shaperail_core::ShaperailError::Internal(\"invalid array filter\".to_string()))",
1241    };
1242
1243    format!(
1244        "        let {var} = parse_filter(filters, {field_name:?}, \"invalid_filter\", |text| {parser})?;",
1245        var = field_parameter_name(field_name)
1246    )
1247}
1248
1249fn field_parameter_name(field_name: &str) -> String {
1250    format!("filter_{}", sanitize_identifier(field_name))
1251}
1252
1253fn parameter_expression(field_name: &str, field: &FieldSchema) -> String {
1254    let var = field_parameter_name(field_name);
1255    match field.field_type {
1256        FieldType::String | FieldType::Enum | FieldType::File => format!("{var}.as_deref()"),
1257        _ => var,
1258    }
1259}
1260
1261fn select_column_sql(field_name: &str, field: &FieldSchema) -> String {
1262    let nullability = if model_field_is_optional(field) {
1263        "?"
1264    } else {
1265        "!"
1266    };
1267    let expression = match field.field_type {
1268        FieldType::Number => format!("\"{field_name}\"::DOUBLE PRECISION"),
1269        _ => format!("\"{field_name}\""),
1270    };
1271    format!(
1272        "{expression} as \"{field_name}{nullability}: {type_name}\"",
1273        type_name = query_type(field)
1274    )
1275}
1276
1277fn sortable_expression(field_name: &str, field: &FieldSchema) -> String {
1278    match field.field_type {
1279        FieldType::Json | FieldType::Array | FieldType::Uuid => format!("\"{field_name}\"::text"),
1280        FieldType::Number => format!("\"{field_name}\"::DOUBLE PRECISION"),
1281        _ => format!("\"{field_name}\""),
1282    }
1283}
1284
1285fn search_expression(fields: &[String]) -> String {
1286    fields
1287        .iter()
1288        .map(|field| format!("COALESCE(\"{field}\"::text, '')"))
1289        .collect::<Vec<_>>()
1290        .join(" || ' ' || ")
1291}
1292
1293fn sql_cast_type(field: &FieldSchema) -> String {
1294    match field.field_type {
1295        FieldType::Uuid => "uuid".to_string(),
1296        FieldType::String | FieldType::Enum | FieldType::File => "text".to_string(),
1297        FieldType::Integer => "bigint".to_string(),
1298        FieldType::Number => "double precision".to_string(),
1299        FieldType::Boolean => "boolean".to_string(),
1300        FieldType::Timestamp => "timestamptz".to_string(),
1301        FieldType::Date => "date".to_string(),
1302        FieldType::Json => "jsonb".to_string(),
1303        FieldType::Array => match field.items.as_ref().map(|i| &i.field_type) {
1304            Some(FieldType::Uuid) => "uuid[]".to_string(),
1305            Some(FieldType::Integer) => "bigint[]".to_string(),
1306            Some(FieldType::Number) => "double precision[]".to_string(),
1307            Some(FieldType::Boolean) => "boolean[]".to_string(),
1308            Some(FieldType::Timestamp) => "timestamptz[]".to_string(),
1309            Some(FieldType::Date) => "date[]".to_string(),
1310            _ => "text[]".to_string(),
1311        },
1312    }
1313}
1314
1315fn query_type(field: &FieldSchema) -> String {
1316    match field.field_type {
1317        FieldType::Uuid => "uuid::Uuid".to_string(),
1318        FieldType::String | FieldType::Enum | FieldType::File => "String".to_string(),
1319        FieldType::Integer => "i64".to_string(),
1320        FieldType::Number => "f64".to_string(),
1321        FieldType::Boolean => "bool".to_string(),
1322        FieldType::Timestamp => "chrono::DateTime<chrono::Utc>".to_string(),
1323        FieldType::Date => "chrono::NaiveDate".to_string(),
1324        FieldType::Json => "serde_json::Value".to_string(),
1325        FieldType::Array => match field.items.as_ref().map(|i| &i.field_type) {
1326            Some(FieldType::Uuid) => "Vec<uuid::Uuid>".to_string(),
1327            Some(FieldType::Integer) => "Vec<i64>".to_string(),
1328            Some(FieldType::Number) => "Vec<f64>".to_string(),
1329            Some(FieldType::Boolean) => "Vec<bool>".to_string(),
1330            Some(FieldType::Timestamp) => "Vec<chrono::DateTime<chrono::Utc>>".to_string(),
1331            Some(FieldType::Date) => "Vec<chrono::NaiveDate>".to_string(),
1332            _ => "Vec<String>".to_string(),
1333        },
1334    }
1335}
1336
1337fn parse_type(field: &FieldSchema) -> String {
1338    query_type(field)
1339}
1340
1341fn model_field_type(field: &FieldSchema) -> String {
1342    let base = query_type(field);
1343    if model_field_is_optional(field) {
1344        format!("Option<{base}>")
1345    } else {
1346        base
1347    }
1348}
1349
1350fn model_field_is_optional(field: &FieldSchema) -> bool {
1351    if field.nullable {
1352        return true;
1353    }
1354    !(field.primary || field.required || field.generated || field.default.is_some())
1355}
1356
1357fn field_is_required(field: &FieldSchema) -> bool {
1358    !model_field_is_optional(field)
1359}
1360
1361fn generated_value_expression(field: &FieldSchema) -> String {
1362    match field.field_type {
1363        FieldType::Uuid => "uuid::Uuid::new_v4()".to_string(),
1364        FieldType::Timestamp => {
1365            if model_field_is_optional(field) {
1366                "Some(chrono::Utc::now())".to_string()
1367            } else {
1368                "chrono::Utc::now()".to_string()
1369            }
1370        }
1371        FieldType::Date => {
1372            if model_field_is_optional(field) {
1373                "Some(chrono::Utc::now().date_naive())".to_string()
1374            } else {
1375                "chrono::Utc::now().date_naive()".to_string()
1376            }
1377        }
1378        _ => "Default::default()".to_string(),
1379    }
1380}
1381
1382/// Whether `default_expression` for this field type produces a bare Rust
1383/// literal — i.e. an expression with no `?` and no function/method call.
1384///
1385/// Used by `generate_insert_declaration` to decide whether to emit the
1386/// idiomatic `.unwrap_or(default)` form (clippy-clean) or the explicit
1387/// `match` form (necessary when the default contains `?` to propagate the
1388/// outer function's error).
1389fn default_is_bare_literal(field: &FieldSchema) -> bool {
1390    matches!(field.field_type, FieldType::Boolean)
1391}
1392
1393fn default_expression(
1394    field_name: &str,
1395    field: &FieldSchema,
1396    default: &serde_json::Value,
1397) -> Result<String, String> {
1398    Ok(match field.field_type {
1399        FieldType::Uuid => format!(
1400            "parse_embedded_json::<uuid::Uuid>({field_name:?}, serde_json::json!({default}))?"
1401        ),
1402        FieldType::String | FieldType::Enum | FieldType::File => {
1403            let value = default
1404                .as_str()
1405                .ok_or_else(|| format!("Default for '{field_name}' must be a string"))?;
1406            format!("{value:?}.to_string()")
1407        }
1408        FieldType::Integer => format!(
1409            "parse_embedded_json::<i64>({field_name:?}, serde_json::json!({default}))?"
1410        ),
1411        FieldType::Number => format!(
1412            "parse_embedded_json::<f64>({field_name:?}, serde_json::json!({default}))?"
1413        ),
1414        FieldType::Boolean => default
1415            .as_bool()
1416            .ok_or_else(|| format!("Default for '{field_name}' must be a boolean"))?
1417            .to_string(),
1418        FieldType::Timestamp => format!(
1419            "parse_embedded_json::<chrono::DateTime<chrono::Utc>>({field_name:?}, serde_json::json!({default}))?"
1420        ),
1421        FieldType::Date => format!(
1422            "parse_embedded_json::<chrono::NaiveDate>({field_name:?}, serde_json::json!({default}))?"
1423        ),
1424        FieldType::Json => format!("serde_json::json!({default})"),
1425        FieldType::Array => format!(
1426            "parse_embedded_json::<{}>({field_name:?}, serde_json::json!({default}))?",
1427            query_type(field)
1428        ),
1429    })
1430}
1431
1432fn has_soft_delete(resource: &ResourceDefinition) -> bool {
1433    resource
1434        .endpoints
1435        .as_ref()
1436        .map(|endpoints| endpoints.values().any(|endpoint| endpoint.soft_delete))
1437        .unwrap_or(false)
1438}
1439
1440fn sanitize_identifier(value: &str) -> String {
1441    let mut output = String::new();
1442    for ch in value.chars() {
1443        if ch.is_ascii_alphanumeric() {
1444            output.push(ch.to_ascii_lowercase());
1445        } else {
1446            output.push('_');
1447        }
1448    }
1449
1450    if output.chars().next().is_some_and(|ch| ch.is_ascii_digit()) {
1451        output.insert(0, '_');
1452    }
1453
1454    output
1455}
1456
1457fn to_pascal_case(value: &str) -> String {
1458    value
1459        .split('_')
1460        .filter(|part| !part.is_empty())
1461        .map(|part| {
1462            let mut chars = part.chars();
1463            match chars.next() {
1464                Some(first) => {
1465                    let mut segment = String::new();
1466                    segment.extend(first.to_uppercase());
1467                    segment.push_str(chars.as_str());
1468                    segment
1469                }
1470                None => String::new(),
1471            }
1472        })
1473        .collect::<String>()
1474}
1475
1476fn indent_block(block: &str, indent: usize) -> String {
1477    if block.trim().is_empty() {
1478        return String::new();
1479    }
1480
1481    let prefix = "    ".repeat(indent);
1482    block
1483        .lines()
1484        .map(|line| {
1485            if line.is_empty() {
1486                String::new()
1487            } else {
1488                format!("{prefix}{line}")
1489            }
1490        })
1491        .collect::<Vec<_>>()
1492        .join("\n")
1493}
1494
1495#[cfg(test)]
1496mod tests {
1497    use super::*;
1498    use indexmap::IndexMap;
1499    use shaperail_core::{
1500        AuthRule, EndpointSpec, FieldSchema, HttpMethod, PaginationStyle, ResourceDefinition,
1501    };
1502
1503    fn sample_resource() -> ResourceDefinition {
1504        let mut schema = IndexMap::new();
1505        schema.insert(
1506            "id".to_string(),
1507            FieldSchema {
1508                field_type: FieldType::Uuid,
1509                primary: true,
1510                generated: true,
1511                required: false,
1512                unique: false,
1513                nullable: false,
1514                reference: None,
1515                min: None,
1516                max: None,
1517                format: None,
1518                values: None,
1519                default: None,
1520                sensitive: false,
1521                search: false,
1522                items: None,
1523                transient: false,
1524            },
1525        );
1526        schema.insert(
1527            "email".to_string(),
1528            FieldSchema {
1529                field_type: FieldType::String,
1530                primary: false,
1531                generated: false,
1532                required: true,
1533                unique: true,
1534                nullable: false,
1535                reference: None,
1536                min: None,
1537                max: None,
1538                format: None,
1539                values: None,
1540                default: None,
1541                sensitive: false,
1542                search: true,
1543                items: None,
1544                transient: false,
1545            },
1546        );
1547        schema.insert(
1548            "created_at".to_string(),
1549            FieldSchema {
1550                field_type: FieldType::Timestamp,
1551                primary: false,
1552                generated: true,
1553                required: false,
1554                unique: false,
1555                nullable: false,
1556                reference: None,
1557                min: None,
1558                max: None,
1559                format: None,
1560                values: None,
1561                default: None,
1562                sensitive: false,
1563                search: false,
1564                items: None,
1565                transient: false,
1566            },
1567        );
1568
1569        let mut endpoints = indexmap::IndexMap::new();
1570        endpoints.insert(
1571            "list".to_string(),
1572            EndpointSpec {
1573                method: Some(HttpMethod::Get),
1574                path: Some("/users".to_string()),
1575                auth: Some(AuthRule::Public),
1576                filters: Some(vec!["email".to_string()]),
1577                search: Some(vec!["email".to_string()]),
1578                pagination: Some(PaginationStyle::Cursor),
1579                sort: Some(vec!["created_at".to_string()]),
1580                ..Default::default()
1581            },
1582        );
1583
1584        ResourceDefinition {
1585            resource: "users".to_string(),
1586            version: 1,
1587            db: None,
1588            tenant_key: None,
1589            schema,
1590            endpoints: Some(endpoints),
1591            relations: None,
1592            indexes: None,
1593        }
1594    }
1595
1596    #[test]
1597    fn generates_query_as_store_module() {
1598        let resource = sample_resource();
1599        let code = generate_resource_module(&resource).unwrap();
1600
1601        assert!(code.contains("impl ResourceStore for UsersStore"));
1602        assert!(code.contains("sqlx::query_as!"));
1603        assert!(code.contains("find_all_list"));
1604    }
1605
1606    #[test]
1607    fn sensitive_field_emits_skip_serializing() {
1608        let mut resource = sample_resource();
1609        resource.schema.get_mut("email").unwrap().sensitive = true;
1610        let code = generate_resource_module(&resource).unwrap();
1611
1612        let lines: Vec<&str> = code.lines().collect();
1613        let email_idx = lines
1614            .iter()
1615            .position(|l| l.contains("pub email:"))
1616            .expect("generated module should contain `pub email:`");
1617        assert!(
1618            lines[email_idx - 1].contains("#[serde(skip_serializing)]"),
1619            "expected #[serde(skip_serializing)] above sensitive field, got: {:?}",
1620            lines[email_idx - 1]
1621        );
1622
1623        let id_idx = lines
1624            .iter()
1625            .position(|l| l.contains("pub id:"))
1626            .expect("generated module should contain `pub id:`");
1627        assert!(
1628            !lines[id_idx - 1].contains("skip_serializing"),
1629            "non-sensitive id field should not carry skip_serializing"
1630        );
1631    }
1632
1633    #[test]
1634    fn generates_registry_module() {
1635        let resource = sample_resource();
1636        let project = generate_project(&[resource]).unwrap();
1637
1638        assert!(project.mod_rs.contains("pub mod users;"));
1639        assert!(project.mod_rs.contains("build_store_registry"));
1640    }
1641
1642    #[test]
1643    fn boolean_default_emits_unwrap_or_form() {
1644        // Regression test for clippy::manual_unwrap_or in generated code:
1645        // a Boolean field with a literal default should expand to
1646        // `parse_optional_json(...)?.unwrap_or(true)` rather than the
1647        // explicit `match ... { Some(v) => v, None => true }` form, which
1648        // clippy 1.94 flags by default.
1649        let mut resource = sample_resource();
1650        resource.schema.insert(
1651            "is_active".to_string(),
1652            FieldSchema {
1653                field_type: FieldType::Boolean,
1654                primary: false,
1655                generated: false,
1656                required: true,
1657                unique: false,
1658                nullable: false,
1659                reference: None,
1660                min: None,
1661                max: None,
1662                format: None,
1663                values: None,
1664                default: Some(serde_json::json!(true)),
1665                sensitive: false,
1666                search: false,
1667                items: None,
1668                transient: false,
1669            },
1670        );
1671
1672        let code = generate_resource_module(&resource).unwrap();
1673
1674        assert!(
1675            code.contains(".unwrap_or(true)"),
1676            "boolean default should emit `.unwrap_or(true)` form; got:\n{code}"
1677        );
1678        assert!(
1679            !code.contains("Some(value) => value, None => true"),
1680            "boolean default should NOT keep the manual match form; got:\n{code}"
1681        );
1682    }
1683
1684    #[test]
1685    fn string_default_keeps_match_form() {
1686        // String defaults expand to `"x".to_string()`, which carries a method
1687        // call. Clippy's `manual_unwrap_or` does not flag the match form when
1688        // the None arm is a method call (it would suggest `or_fun_call`
1689        // instead, which is a separate concern). Keep the match form so we
1690        // don't introduce eager allocation in the hot path.
1691        let mut resource = sample_resource();
1692        resource.schema.insert(
1693            "tier".to_string(),
1694            FieldSchema {
1695                field_type: FieldType::String,
1696                primary: false,
1697                generated: false,
1698                required: true,
1699                unique: false,
1700                nullable: false,
1701                reference: None,
1702                min: None,
1703                max: None,
1704                format: None,
1705                values: None,
1706                default: Some(serde_json::json!("standard")),
1707                sensitive: false,
1708                search: false,
1709                items: None,
1710                transient: false,
1711            },
1712        );
1713
1714        let code = generate_resource_module(&resource).unwrap();
1715        assert!(
1716            code.contains("Some(value) => value, None => \"standard\".to_string()"),
1717            "string default should keep the explicit match form; got:\n{code}"
1718        );
1719    }
1720
1721    #[test]
1722    fn collect_custom_handlers_returns_non_convention_endpoints_with_handler() {
1723        let mut endpoints = indexmap::IndexMap::new();
1724        endpoints.insert(
1725            "list".to_string(),
1726            EndpointSpec {
1727                method: Some(HttpMethod::Get),
1728                path: Some("/items".to_string()),
1729                handler: None,
1730                ..Default::default()
1731            },
1732        );
1733        endpoints.insert(
1734            "archive".to_string(),
1735            EndpointSpec {
1736                method: Some(HttpMethod::Post),
1737                path: Some("/items/:id/archive".to_string()),
1738                handler: Some("archive_item".to_string()),
1739                ..Default::default()
1740            },
1741        );
1742        let resource = ResourceDefinition {
1743            resource: "items".to_string(),
1744            version: 1,
1745            db: None,
1746            tenant_key: None,
1747            schema: indexmap::IndexMap::new(),
1748            endpoints: Some(endpoints),
1749            relations: None,
1750            indexes: None,
1751        };
1752        let resources = [resource];
1753        let result = collect_custom_handlers(&resources);
1754        assert_eq!(result.len(), 1);
1755        assert_eq!(result[0].0, "items");
1756        assert_eq!(result[0].1, vec![("archive", "archive_item")]);
1757    }
1758
1759    #[test]
1760    fn collect_custom_handlers_ignores_convention_endpoints() {
1761        let mut endpoints = indexmap::IndexMap::new();
1762        for action in &["list", "get", "create", "update", "delete"] {
1763            endpoints.insert(
1764                action.to_string(),
1765                EndpointSpec {
1766                    method: Some(HttpMethod::Get),
1767                    handler: Some("some_fn".to_string()),
1768                    ..Default::default()
1769                },
1770            );
1771        }
1772        let resource = ResourceDefinition {
1773            resource: "items".to_string(),
1774            version: 1,
1775            db: None,
1776            tenant_key: None,
1777            schema: indexmap::IndexMap::new(),
1778            endpoints: Some(endpoints),
1779            relations: None,
1780            indexes: None,
1781        };
1782        let resources = [resource];
1783        let result = collect_custom_handlers(&resources);
1784        assert!(
1785            result.is_empty(),
1786            "convention endpoints should not be collected"
1787        );
1788    }
1789
1790    #[test]
1791    fn generate_handler_map_fn_empty_returns_new() {
1792        let resources: Vec<ResourceDefinition> = vec![];
1793        let output = generate_handler_map_fn(&resources);
1794        assert!(output.contains("CustomHandlerMap::new()"));
1795        assert!(!output.contains("map.insert"));
1796    }
1797
1798    #[test]
1799    fn generate_handler_map_fn_with_handler_contains_insert() {
1800        let mut endpoints = indexmap::IndexMap::new();
1801        endpoints.insert(
1802            "archive".to_string(),
1803            EndpointSpec {
1804                method: Some(HttpMethod::Post),
1805                path: Some("/items/:id/archive".to_string()),
1806                handler: Some("archive_item".to_string()),
1807                ..Default::default()
1808            },
1809        );
1810        let resource = ResourceDefinition {
1811            resource: "items".to_string(),
1812            version: 1,
1813            db: None,
1814            tenant_key: None,
1815            schema: indexmap::IndexMap::new(),
1816            endpoints: Some(endpoints),
1817            relations: None,
1818            indexes: None,
1819        };
1820        let resources = [resource];
1821        let output = generate_handler_map_fn(&resources);
1822        assert!(output.contains("items_handlers::archive_item"));
1823        assert!(output.contains(r#"handler_key("items", "archive")"#));
1824    }
1825
1826    fn field(
1827        ty: FieldType,
1828        primary: bool,
1829        required: bool,
1830        nullable: bool,
1831        generated: bool,
1832        default: Option<serde_json::Value>,
1833    ) -> FieldSchema {
1834        FieldSchema {
1835            field_type: ty,
1836            primary,
1837            generated,
1838            required,
1839            unique: false,
1840            nullable,
1841            reference: None,
1842            min: None,
1843            max: None,
1844            format: None,
1845            values: None,
1846            default,
1847            sensitive: false,
1848            search: false,
1849            items: None,
1850            transient: false,
1851        }
1852    }
1853
1854    #[test]
1855    fn model_optional_for_required_only_field_is_false() {
1856        let f = field(FieldType::Integer, false, true, false, false, None);
1857        assert!(!model_field_is_optional(&f));
1858    }
1859
1860    #[test]
1861    fn model_optional_for_required_with_default_is_false() {
1862        let f = field(
1863            FieldType::Integer,
1864            false,
1865            true,
1866            false,
1867            false,
1868            Some(serde_json::json!(0)),
1869        );
1870        assert!(!model_field_is_optional(&f));
1871    }
1872
1873    #[test]
1874    fn model_optional_for_unrequired_with_default_is_false() {
1875        // The bug-report case: NOT NULL DEFAULT must produce non-Option output.
1876        let f = field(
1877            FieldType::Integer,
1878            false,
1879            false,
1880            false,
1881            false,
1882            Some(serde_json::json!(0)),
1883        );
1884        assert!(!model_field_is_optional(&f));
1885    }
1886
1887    #[test]
1888    fn model_optional_for_generated_without_required_is_false() {
1889        // created_at: { type: timestamp, generated: true }
1890        let f = field(FieldType::Timestamp, false, false, false, true, None);
1891        assert!(!model_field_is_optional(&f));
1892    }
1893
1894    #[test]
1895    fn model_optional_for_nullable_is_true() {
1896        let f = field(FieldType::String, false, false, true, false, None);
1897        assert!(model_field_is_optional(&f));
1898    }
1899
1900    #[test]
1901    fn model_optional_for_nullable_with_default_is_true() {
1902        // nullable wins, even with a default.
1903        let f = field(
1904            FieldType::String,
1905            false,
1906            false,
1907            true,
1908            false,
1909            Some(serde_json::json!("x")),
1910        );
1911        assert!(model_field_is_optional(&f));
1912    }
1913
1914    #[test]
1915    fn model_optional_for_plain_optional_field_is_true() {
1916        let f = field(FieldType::String, false, false, false, false, None);
1917        assert!(model_field_is_optional(&f));
1918    }
1919
1920    #[test]
1921    fn model_optional_for_primary_key_is_false() {
1922        let f = field(FieldType::Uuid, true, false, false, true, None);
1923        assert!(!model_field_is_optional(&f));
1924    }
1925
1926    #[test]
1927    fn generated_mod_rs_emits_pub_resources_aggregator() {
1928        let mut resource = sample_resource();
1929        // Add a controller hook so a `*_controller` module is emitted.
1930        let endpoint = resource
1931            .endpoints
1932            .as_mut()
1933            .unwrap()
1934            .get_mut("list")
1935            .unwrap();
1936        endpoint.controller = Some(shaperail_core::ControllerSpec {
1937            before: Some(shaperail_core::HookList::Single("validate".to_string())),
1938            after: None,
1939        });
1940
1941        let project = generate_project(std::slice::from_ref(&resource)).unwrap();
1942        // The mod.rs is in `project.mod_rs` (matching the existing
1943        // `assert!(project.mod_rs.contains("pub mod users;"));` assertion).
1944        assert!(
1945            project.mod_rs.contains("pub mod users_controller;"),
1946            "controller module must be `pub mod`, not `mod`. Got:\n{}",
1947            project.mod_rs
1948        );
1949        assert!(
1950            project.mod_rs.contains("#[doc(hidden)]"),
1951            "expected #[doc(hidden)] guard on resources aggregator. Got:\n{}",
1952            project.mod_rs
1953        );
1954        assert!(
1955            project.mod_rs.contains("pub mod resources"),
1956            "expected `pub mod resources` aggregator. Got:\n{}",
1957            project.mod_rs
1958        );
1959        assert!(
1960            project.mod_rs.contains("pub use super::users_controller;"),
1961            "expected re-export of users_controller in resources aggregator. Got:\n{}",
1962            project.mod_rs
1963        );
1964    }
1965}