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)) => format!(
1186            "match {parsed} {{ Some(value) => value, None => {} }}",
1187            default_expression(field_name, field, default)?
1188        ),
1189        (true, None) => format!("shaperail_runtime::db::require_field({parsed}, {field_name:?})?"),
1190        (false, Some(default)) if model_field_is_optional(field) => format!(
1191            "match {parsed} {{ Some(value) => Some(value), None => Some({}) }}",
1192            default_expression(field_name, field, default)?
1193        ),
1194        (false, Some(default)) => format!(
1195            "match {parsed} {{ Some(value) => value, None => {} }}",
1196            default_expression(field_name, field, default)?
1197        ),
1198        (false, None) => parsed,
1199    };
1200
1201    Ok(format!("        let {variable_name} = {expression};"))
1202}
1203
1204fn generate_update_declaration(
1205    field_name: &str,
1206    field: &FieldSchema,
1207    present_name: &str,
1208    value_name: &str,
1209) -> String {
1210    format!(
1211        "        let {present_name} = data.contains_key({field_name:?});\n        let {value_name} = shaperail_runtime::db::parse_optional_json::<{parse_type}>(data, {field_name:?})?;",
1212        parse_type = parse_type(field)
1213    )
1214}
1215
1216fn generate_filter_declaration(field_name: &str, field: &FieldSchema) -> String {
1217    let parser = match field.field_type {
1218        FieldType::Uuid => "uuid::Uuid::parse_str(text).map_err(|_| shaperail_core::ShaperailError::Internal(\"invalid uuid filter\".to_string()))",
1219        FieldType::String | FieldType::Enum | FieldType::File => "Ok(text.to_string())",
1220        FieldType::Integer => "text.parse::<i64>().map_err(|_| shaperail_core::ShaperailError::Internal(\"invalid integer filter\".to_string()))",
1221        FieldType::Number => "text.parse::<f64>().map_err(|_| shaperail_core::ShaperailError::Internal(\"invalid number filter\".to_string()))",
1222        FieldType::Boolean => "text.parse::<bool>().map_err(|_| shaperail_core::ShaperailError::Internal(\"invalid boolean filter\".to_string()))",
1223        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()))",
1224        FieldType::Date => "chrono::NaiveDate::parse_from_str(text, \"%Y-%m-%d\").map_err(|_| shaperail_core::ShaperailError::Internal(\"invalid date filter\".to_string()))",
1225        FieldType::Json => "serde_json::from_str::<serde_json::Value>(text).map_err(|_| shaperail_core::ShaperailError::Internal(\"invalid json filter\".to_string()))",
1226        FieldType::Array => "serde_json::from_str::<Vec<serde_json::Value>>(text).map_err(|_| shaperail_core::ShaperailError::Internal(\"invalid array filter\".to_string()))",
1227    };
1228
1229    format!(
1230        "        let {var} = parse_filter(filters, {field_name:?}, \"invalid_filter\", |text| {parser})?;",
1231        var = field_parameter_name(field_name)
1232    )
1233}
1234
1235fn field_parameter_name(field_name: &str) -> String {
1236    format!("filter_{}", sanitize_identifier(field_name))
1237}
1238
1239fn parameter_expression(field_name: &str, field: &FieldSchema) -> String {
1240    let var = field_parameter_name(field_name);
1241    match field.field_type {
1242        FieldType::String | FieldType::Enum | FieldType::File => format!("{var}.as_deref()"),
1243        _ => var,
1244    }
1245}
1246
1247fn select_column_sql(field_name: &str, field: &FieldSchema) -> String {
1248    let nullability = if model_field_is_optional(field) {
1249        "?"
1250    } else {
1251        "!"
1252    };
1253    let expression = match field.field_type {
1254        FieldType::Number => format!("\"{field_name}\"::DOUBLE PRECISION"),
1255        _ => format!("\"{field_name}\""),
1256    };
1257    format!(
1258        "{expression} as \"{field_name}{nullability}: {type_name}\"",
1259        type_name = query_type(field)
1260    )
1261}
1262
1263fn sortable_expression(field_name: &str, field: &FieldSchema) -> String {
1264    match field.field_type {
1265        FieldType::Json | FieldType::Array | FieldType::Uuid => format!("\"{field_name}\"::text"),
1266        FieldType::Number => format!("\"{field_name}\"::DOUBLE PRECISION"),
1267        _ => format!("\"{field_name}\""),
1268    }
1269}
1270
1271fn search_expression(fields: &[String]) -> String {
1272    fields
1273        .iter()
1274        .map(|field| format!("COALESCE(\"{field}\"::text, '')"))
1275        .collect::<Vec<_>>()
1276        .join(" || ' ' || ")
1277}
1278
1279fn sql_cast_type(field: &FieldSchema) -> String {
1280    match field.field_type {
1281        FieldType::Uuid => "uuid".to_string(),
1282        FieldType::String | FieldType::Enum | FieldType::File => "text".to_string(),
1283        FieldType::Integer => "bigint".to_string(),
1284        FieldType::Number => "double precision".to_string(),
1285        FieldType::Boolean => "boolean".to_string(),
1286        FieldType::Timestamp => "timestamptz".to_string(),
1287        FieldType::Date => "date".to_string(),
1288        FieldType::Json => "jsonb".to_string(),
1289        FieldType::Array => match field.items.as_ref().map(|i| &i.field_type) {
1290            Some(FieldType::Uuid) => "uuid[]".to_string(),
1291            Some(FieldType::Integer) => "bigint[]".to_string(),
1292            Some(FieldType::Number) => "double precision[]".to_string(),
1293            Some(FieldType::Boolean) => "boolean[]".to_string(),
1294            Some(FieldType::Timestamp) => "timestamptz[]".to_string(),
1295            Some(FieldType::Date) => "date[]".to_string(),
1296            _ => "text[]".to_string(),
1297        },
1298    }
1299}
1300
1301fn query_type(field: &FieldSchema) -> String {
1302    match field.field_type {
1303        FieldType::Uuid => "uuid::Uuid".to_string(),
1304        FieldType::String | FieldType::Enum | FieldType::File => "String".to_string(),
1305        FieldType::Integer => "i64".to_string(),
1306        FieldType::Number => "f64".to_string(),
1307        FieldType::Boolean => "bool".to_string(),
1308        FieldType::Timestamp => "chrono::DateTime<chrono::Utc>".to_string(),
1309        FieldType::Date => "chrono::NaiveDate".to_string(),
1310        FieldType::Json => "serde_json::Value".to_string(),
1311        FieldType::Array => match field.items.as_ref().map(|i| &i.field_type) {
1312            Some(FieldType::Uuid) => "Vec<uuid::Uuid>".to_string(),
1313            Some(FieldType::Integer) => "Vec<i64>".to_string(),
1314            Some(FieldType::Number) => "Vec<f64>".to_string(),
1315            Some(FieldType::Boolean) => "Vec<bool>".to_string(),
1316            Some(FieldType::Timestamp) => "Vec<chrono::DateTime<chrono::Utc>>".to_string(),
1317            Some(FieldType::Date) => "Vec<chrono::NaiveDate>".to_string(),
1318            _ => "Vec<String>".to_string(),
1319        },
1320    }
1321}
1322
1323fn parse_type(field: &FieldSchema) -> String {
1324    query_type(field)
1325}
1326
1327fn model_field_type(field: &FieldSchema) -> String {
1328    let base = query_type(field);
1329    if model_field_is_optional(field) {
1330        format!("Option<{base}>")
1331    } else {
1332        base
1333    }
1334}
1335
1336fn model_field_is_optional(field: &FieldSchema) -> bool {
1337    if field.nullable {
1338        return true;
1339    }
1340    !(field.primary || field.required || field.generated || field.default.is_some())
1341}
1342
1343fn field_is_required(field: &FieldSchema) -> bool {
1344    !model_field_is_optional(field)
1345}
1346
1347fn generated_value_expression(field: &FieldSchema) -> String {
1348    match field.field_type {
1349        FieldType::Uuid => "uuid::Uuid::new_v4()".to_string(),
1350        FieldType::Timestamp => {
1351            if model_field_is_optional(field) {
1352                "Some(chrono::Utc::now())".to_string()
1353            } else {
1354                "chrono::Utc::now()".to_string()
1355            }
1356        }
1357        FieldType::Date => {
1358            if model_field_is_optional(field) {
1359                "Some(chrono::Utc::now().date_naive())".to_string()
1360            } else {
1361                "chrono::Utc::now().date_naive()".to_string()
1362            }
1363        }
1364        _ => "Default::default()".to_string(),
1365    }
1366}
1367
1368fn default_expression(
1369    field_name: &str,
1370    field: &FieldSchema,
1371    default: &serde_json::Value,
1372) -> Result<String, String> {
1373    Ok(match field.field_type {
1374        FieldType::Uuid => format!(
1375            "parse_embedded_json::<uuid::Uuid>({field_name:?}, serde_json::json!({default}))?"
1376        ),
1377        FieldType::String | FieldType::Enum | FieldType::File => {
1378            let value = default
1379                .as_str()
1380                .ok_or_else(|| format!("Default for '{field_name}' must be a string"))?;
1381            format!("{value:?}.to_string()")
1382        }
1383        FieldType::Integer => format!(
1384            "parse_embedded_json::<i64>({field_name:?}, serde_json::json!({default}))?"
1385        ),
1386        FieldType::Number => format!(
1387            "parse_embedded_json::<f64>({field_name:?}, serde_json::json!({default}))?"
1388        ),
1389        FieldType::Boolean => default
1390            .as_bool()
1391            .ok_or_else(|| format!("Default for '{field_name}' must be a boolean"))?
1392            .to_string(),
1393        FieldType::Timestamp => format!(
1394            "parse_embedded_json::<chrono::DateTime<chrono::Utc>>({field_name:?}, serde_json::json!({default}))?"
1395        ),
1396        FieldType::Date => format!(
1397            "parse_embedded_json::<chrono::NaiveDate>({field_name:?}, serde_json::json!({default}))?"
1398        ),
1399        FieldType::Json => format!("serde_json::json!({default})"),
1400        FieldType::Array => format!(
1401            "parse_embedded_json::<{}>({field_name:?}, serde_json::json!({default}))?",
1402            query_type(field)
1403        ),
1404    })
1405}
1406
1407fn has_soft_delete(resource: &ResourceDefinition) -> bool {
1408    resource
1409        .endpoints
1410        .as_ref()
1411        .map(|endpoints| endpoints.values().any(|endpoint| endpoint.soft_delete))
1412        .unwrap_or(false)
1413}
1414
1415fn sanitize_identifier(value: &str) -> String {
1416    let mut output = String::new();
1417    for ch in value.chars() {
1418        if ch.is_ascii_alphanumeric() {
1419            output.push(ch.to_ascii_lowercase());
1420        } else {
1421            output.push('_');
1422        }
1423    }
1424
1425    if output.chars().next().is_some_and(|ch| ch.is_ascii_digit()) {
1426        output.insert(0, '_');
1427    }
1428
1429    output
1430}
1431
1432fn to_pascal_case(value: &str) -> String {
1433    value
1434        .split('_')
1435        .filter(|part| !part.is_empty())
1436        .map(|part| {
1437            let mut chars = part.chars();
1438            match chars.next() {
1439                Some(first) => {
1440                    let mut segment = String::new();
1441                    segment.extend(first.to_uppercase());
1442                    segment.push_str(chars.as_str());
1443                    segment
1444                }
1445                None => String::new(),
1446            }
1447        })
1448        .collect::<String>()
1449}
1450
1451fn indent_block(block: &str, indent: usize) -> String {
1452    if block.trim().is_empty() {
1453        return String::new();
1454    }
1455
1456    let prefix = "    ".repeat(indent);
1457    block
1458        .lines()
1459        .map(|line| {
1460            if line.is_empty() {
1461                String::new()
1462            } else {
1463                format!("{prefix}{line}")
1464            }
1465        })
1466        .collect::<Vec<_>>()
1467        .join("\n")
1468}
1469
1470#[cfg(test)]
1471mod tests {
1472    use super::*;
1473    use indexmap::IndexMap;
1474    use shaperail_core::{
1475        AuthRule, EndpointSpec, FieldSchema, HttpMethod, PaginationStyle, ResourceDefinition,
1476    };
1477
1478    fn sample_resource() -> ResourceDefinition {
1479        let mut schema = IndexMap::new();
1480        schema.insert(
1481            "id".to_string(),
1482            FieldSchema {
1483                field_type: FieldType::Uuid,
1484                primary: true,
1485                generated: true,
1486                required: false,
1487                unique: false,
1488                nullable: false,
1489                reference: None,
1490                min: None,
1491                max: None,
1492                format: None,
1493                values: None,
1494                default: None,
1495                sensitive: false,
1496                search: false,
1497                items: None,
1498                transient: false,
1499            },
1500        );
1501        schema.insert(
1502            "email".to_string(),
1503            FieldSchema {
1504                field_type: FieldType::String,
1505                primary: false,
1506                generated: false,
1507                required: true,
1508                unique: true,
1509                nullable: false,
1510                reference: None,
1511                min: None,
1512                max: None,
1513                format: None,
1514                values: None,
1515                default: None,
1516                sensitive: false,
1517                search: true,
1518                items: None,
1519                transient: false,
1520            },
1521        );
1522        schema.insert(
1523            "created_at".to_string(),
1524            FieldSchema {
1525                field_type: FieldType::Timestamp,
1526                primary: false,
1527                generated: true,
1528                required: false,
1529                unique: false,
1530                nullable: false,
1531                reference: None,
1532                min: None,
1533                max: None,
1534                format: None,
1535                values: None,
1536                default: None,
1537                sensitive: false,
1538                search: false,
1539                items: None,
1540                transient: false,
1541            },
1542        );
1543
1544        let mut endpoints = indexmap::IndexMap::new();
1545        endpoints.insert(
1546            "list".to_string(),
1547            EndpointSpec {
1548                method: Some(HttpMethod::Get),
1549                path: Some("/users".to_string()),
1550                auth: Some(AuthRule::Public),
1551                filters: Some(vec!["email".to_string()]),
1552                search: Some(vec!["email".to_string()]),
1553                pagination: Some(PaginationStyle::Cursor),
1554                sort: Some(vec!["created_at".to_string()]),
1555                ..Default::default()
1556            },
1557        );
1558
1559        ResourceDefinition {
1560            resource: "users".to_string(),
1561            version: 1,
1562            db: None,
1563            tenant_key: None,
1564            schema,
1565            endpoints: Some(endpoints),
1566            relations: None,
1567            indexes: None,
1568        }
1569    }
1570
1571    #[test]
1572    fn generates_query_as_store_module() {
1573        let resource = sample_resource();
1574        let code = generate_resource_module(&resource).unwrap();
1575
1576        assert!(code.contains("impl ResourceStore for UsersStore"));
1577        assert!(code.contains("sqlx::query_as!"));
1578        assert!(code.contains("find_all_list"));
1579    }
1580
1581    #[test]
1582    fn sensitive_field_emits_skip_serializing() {
1583        let mut resource = sample_resource();
1584        resource.schema.get_mut("email").unwrap().sensitive = true;
1585        let code = generate_resource_module(&resource).unwrap();
1586
1587        let lines: Vec<&str> = code.lines().collect();
1588        let email_idx = lines
1589            .iter()
1590            .position(|l| l.contains("pub email:"))
1591            .expect("generated module should contain `pub email:`");
1592        assert!(
1593            lines[email_idx - 1].contains("#[serde(skip_serializing)]"),
1594            "expected #[serde(skip_serializing)] above sensitive field, got: {:?}",
1595            lines[email_idx - 1]
1596        );
1597
1598        let id_idx = lines
1599            .iter()
1600            .position(|l| l.contains("pub id:"))
1601            .expect("generated module should contain `pub id:`");
1602        assert!(
1603            !lines[id_idx - 1].contains("skip_serializing"),
1604            "non-sensitive id field should not carry skip_serializing"
1605        );
1606    }
1607
1608    #[test]
1609    fn generates_registry_module() {
1610        let resource = sample_resource();
1611        let project = generate_project(&[resource]).unwrap();
1612
1613        assert!(project.mod_rs.contains("pub mod users;"));
1614        assert!(project.mod_rs.contains("build_store_registry"));
1615    }
1616
1617    #[test]
1618    fn collect_custom_handlers_returns_non_convention_endpoints_with_handler() {
1619        let mut endpoints = indexmap::IndexMap::new();
1620        endpoints.insert(
1621            "list".to_string(),
1622            EndpointSpec {
1623                method: Some(HttpMethod::Get),
1624                path: Some("/items".to_string()),
1625                handler: None,
1626                ..Default::default()
1627            },
1628        );
1629        endpoints.insert(
1630            "archive".to_string(),
1631            EndpointSpec {
1632                method: Some(HttpMethod::Post),
1633                path: Some("/items/:id/archive".to_string()),
1634                handler: Some("archive_item".to_string()),
1635                ..Default::default()
1636            },
1637        );
1638        let resource = ResourceDefinition {
1639            resource: "items".to_string(),
1640            version: 1,
1641            db: None,
1642            tenant_key: None,
1643            schema: indexmap::IndexMap::new(),
1644            endpoints: Some(endpoints),
1645            relations: None,
1646            indexes: None,
1647        };
1648        let resources = [resource];
1649        let result = collect_custom_handlers(&resources);
1650        assert_eq!(result.len(), 1);
1651        assert_eq!(result[0].0, "items");
1652        assert_eq!(result[0].1, vec![("archive", "archive_item")]);
1653    }
1654
1655    #[test]
1656    fn collect_custom_handlers_ignores_convention_endpoints() {
1657        let mut endpoints = indexmap::IndexMap::new();
1658        for action in &["list", "get", "create", "update", "delete"] {
1659            endpoints.insert(
1660                action.to_string(),
1661                EndpointSpec {
1662                    method: Some(HttpMethod::Get),
1663                    handler: Some("some_fn".to_string()),
1664                    ..Default::default()
1665                },
1666            );
1667        }
1668        let resource = ResourceDefinition {
1669            resource: "items".to_string(),
1670            version: 1,
1671            db: None,
1672            tenant_key: None,
1673            schema: indexmap::IndexMap::new(),
1674            endpoints: Some(endpoints),
1675            relations: None,
1676            indexes: None,
1677        };
1678        let resources = [resource];
1679        let result = collect_custom_handlers(&resources);
1680        assert!(
1681            result.is_empty(),
1682            "convention endpoints should not be collected"
1683        );
1684    }
1685
1686    #[test]
1687    fn generate_handler_map_fn_empty_returns_new() {
1688        let resources: Vec<ResourceDefinition> = vec![];
1689        let output = generate_handler_map_fn(&resources);
1690        assert!(output.contains("CustomHandlerMap::new()"));
1691        assert!(!output.contains("map.insert"));
1692    }
1693
1694    #[test]
1695    fn generate_handler_map_fn_with_handler_contains_insert() {
1696        let mut endpoints = indexmap::IndexMap::new();
1697        endpoints.insert(
1698            "archive".to_string(),
1699            EndpointSpec {
1700                method: Some(HttpMethod::Post),
1701                path: Some("/items/:id/archive".to_string()),
1702                handler: Some("archive_item".to_string()),
1703                ..Default::default()
1704            },
1705        );
1706        let resource = ResourceDefinition {
1707            resource: "items".to_string(),
1708            version: 1,
1709            db: None,
1710            tenant_key: None,
1711            schema: indexmap::IndexMap::new(),
1712            endpoints: Some(endpoints),
1713            relations: None,
1714            indexes: None,
1715        };
1716        let resources = [resource];
1717        let output = generate_handler_map_fn(&resources);
1718        assert!(output.contains("items_handlers::archive_item"));
1719        assert!(output.contains(r#"handler_key("items", "archive")"#));
1720    }
1721
1722    fn field(
1723        ty: FieldType,
1724        primary: bool,
1725        required: bool,
1726        nullable: bool,
1727        generated: bool,
1728        default: Option<serde_json::Value>,
1729    ) -> FieldSchema {
1730        FieldSchema {
1731            field_type: ty,
1732            primary,
1733            generated,
1734            required,
1735            unique: false,
1736            nullable,
1737            reference: None,
1738            min: None,
1739            max: None,
1740            format: None,
1741            values: None,
1742            default,
1743            sensitive: false,
1744            search: false,
1745            items: None,
1746            transient: false,
1747        }
1748    }
1749
1750    #[test]
1751    fn model_optional_for_required_only_field_is_false() {
1752        let f = field(FieldType::Integer, false, true, false, false, None);
1753        assert!(!model_field_is_optional(&f));
1754    }
1755
1756    #[test]
1757    fn model_optional_for_required_with_default_is_false() {
1758        let f = field(
1759            FieldType::Integer,
1760            false,
1761            true,
1762            false,
1763            false,
1764            Some(serde_json::json!(0)),
1765        );
1766        assert!(!model_field_is_optional(&f));
1767    }
1768
1769    #[test]
1770    fn model_optional_for_unrequired_with_default_is_false() {
1771        // The bug-report case: NOT NULL DEFAULT must produce non-Option output.
1772        let f = field(
1773            FieldType::Integer,
1774            false,
1775            false,
1776            false,
1777            false,
1778            Some(serde_json::json!(0)),
1779        );
1780        assert!(!model_field_is_optional(&f));
1781    }
1782
1783    #[test]
1784    fn model_optional_for_generated_without_required_is_false() {
1785        // created_at: { type: timestamp, generated: true }
1786        let f = field(FieldType::Timestamp, false, false, false, true, None);
1787        assert!(!model_field_is_optional(&f));
1788    }
1789
1790    #[test]
1791    fn model_optional_for_nullable_is_true() {
1792        let f = field(FieldType::String, false, false, true, false, None);
1793        assert!(model_field_is_optional(&f));
1794    }
1795
1796    #[test]
1797    fn model_optional_for_nullable_with_default_is_true() {
1798        // nullable wins, even with a default.
1799        let f = field(
1800            FieldType::String,
1801            false,
1802            false,
1803            true,
1804            false,
1805            Some(serde_json::json!("x")),
1806        );
1807        assert!(model_field_is_optional(&f));
1808    }
1809
1810    #[test]
1811    fn model_optional_for_plain_optional_field_is_true() {
1812        let f = field(FieldType::String, false, false, false, false, None);
1813        assert!(model_field_is_optional(&f));
1814    }
1815
1816    #[test]
1817    fn model_optional_for_primary_key_is_false() {
1818        let f = field(FieldType::Uuid, true, false, false, true, None);
1819        assert!(!model_field_is_optional(&f));
1820    }
1821
1822    #[test]
1823    fn generated_mod_rs_emits_pub_resources_aggregator() {
1824        let mut resource = sample_resource();
1825        // Add a controller hook so a `*_controller` module is emitted.
1826        let endpoint = resource
1827            .endpoints
1828            .as_mut()
1829            .unwrap()
1830            .get_mut("list")
1831            .unwrap();
1832        endpoint.controller = Some(shaperail_core::ControllerSpec {
1833            before: Some(shaperail_core::HookList::Single("validate".to_string())),
1834            after: None,
1835        });
1836
1837        let project = generate_project(std::slice::from_ref(&resource)).unwrap();
1838        // The mod.rs is in `project.mod_rs` (matching the existing
1839        // `assert!(project.mod_rs.contains("pub mod users;"));` assertion).
1840        assert!(
1841            project.mod_rs.contains("pub mod users_controller;"),
1842            "controller module must be `pub mod`, not `mod`. Got:\n{}",
1843            project.mod_rs
1844        );
1845        assert!(
1846            project.mod_rs.contains("#[doc(hidden)]"),
1847            "expected #[doc(hidden)] guard on resources aggregator. Got:\n{}",
1848            project.mod_rs
1849        );
1850        assert!(
1851            project.mod_rs.contains("pub mod resources"),
1852            "expected `pub mod resources` aggregator. Got:\n{}",
1853            project.mod_rs
1854        );
1855        assert!(
1856            project.mod_rs.contains("pub use super::users_controller;"),
1857            "expected re-export of users_controller in resources aggregator. Got:\n{}",
1858            project.mod_rs
1859        );
1860    }
1861}