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                            let before = c
225                                .before
226                                .as_deref()
227                                .filter(|s| !s.starts_with(shaperail_core::WASM_HOOK_PREFIX));
228                            let after = c
229                                .after
230                                .as_deref()
231                                .filter(|s| !s.starts_with(shaperail_core::WASM_HOOK_PREFIX));
232                            [before, after].into_iter().flatten()
233                        })
234                        .collect()
235                })
236                .unwrap_or_default();
237            if hooks.is_empty() {
238                None
239            } else {
240                Some((resource.resource.as_str(), hooks))
241            }
242        })
243        .collect()
244}
245
246/// The five built-in convention endpoint actions. Used by codegen and CLI to filter custom endpoints.
247pub const HANDLER_CONVENTIONS: &[&str] = &["list", "get", "create", "update", "delete"];
248
249/// Returns (resource_name, [(action, handler_fn)]) for resources with custom endpoints.
250/// Only includes non-convention endpoints that have `handler:` declared.
251fn collect_custom_handlers(resources: &[ResourceDefinition]) -> Vec<(&str, Vec<(&str, &str)>)> {
252    resources
253        .iter()
254        .filter_map(|resource| {
255            let handlers: Vec<(&str, &str)> = resource
256                .endpoints
257                .as_ref()
258                .map(|eps| {
259                    eps.iter()
260                        .filter(|(action, _)| !HANDLER_CONVENTIONS.contains(&action.as_str()))
261                        .filter_map(|(action, ep)| {
262                            ep.handler.as_deref().map(|h| (action.as_str(), h))
263                        })
264                        .collect()
265                })
266                .unwrap_or_default();
267            if handlers.is_empty() {
268                None
269            } else {
270                Some((resource.resource.as_str(), handlers))
271            }
272        })
273        .collect()
274}
275
276/// Returns all unique job names declared across all resources, sorted for determinism.
277fn collect_job_names(resources: &[ResourceDefinition]) -> Vec<String> {
278    let mut names = std::collections::BTreeSet::new();
279    for resource in resources {
280        if let Some(endpoints) = &resource.endpoints {
281            for (_, ep) in endpoints {
282                for job in ep.jobs.as_deref().unwrap_or_default() {
283                    names.insert(job.clone());
284                }
285            }
286        }
287    }
288    names.into_iter().collect()
289}
290
291/// Returns #[path] declarations for job handler modules.
292fn collect_job_path_decls(resources: &[ResourceDefinition]) -> Vec<String> {
293    collect_job_names(resources)
294        .iter()
295        .map(|name| format!("#[path = \"../jobs/{name}.rs\"]\nmod job_{name};"))
296        .collect()
297}
298
299/// Returns #[path] declarations for handler modules.
300fn collect_handler_path_decls(resources: &[ResourceDefinition]) -> Vec<String> {
301    collect_custom_handlers(resources)
302        .iter()
303        .map(|(name, _)| {
304            format!("#[path = \"../resources/{name}.handlers.rs\"]\nmod {name}_handlers;")
305        })
306        .collect()
307}
308
309/// Generates the build_job_registry() function.
310fn generate_job_registry(resources: &[ResourceDefinition]) -> String {
311    let job_names = collect_job_names(resources);
312
313    if job_names.is_empty() {
314        return r#"pub fn build_job_registry() -> shaperail_runtime::jobs::JobRegistry {
315    shaperail_runtime::jobs::JobRegistry::new()
316}"#
317        .to_string();
318    }
319
320    let inserts: Vec<String> = job_names
321        .iter()
322        .map(|name| {
323            format!(
324                r#"    handlers.insert(
325        "{name}".to_string(),
326        std::sync::Arc::new(|payload: serde_json::Value| {{
327            Box::pin(job_{name}::handle(payload))
328                as std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), shaperail_core::ShaperailError>> + Send>>
329        }}) as shaperail_runtime::jobs::JobHandler,
330    );"#
331            )
332        })
333        .collect();
334
335    format!(
336        r#"pub fn build_job_registry() -> shaperail_runtime::jobs::JobRegistry {{
337    let mut handlers: std::collections::HashMap<String, shaperail_runtime::jobs::JobHandler> =
338        std::collections::HashMap::new();
339{inserts}
340    shaperail_runtime::jobs::JobRegistry::from_handlers(handlers)
341}}"#,
342        inserts = inserts.join("\n")
343    )
344}
345
346fn generate_handler_map_fn(resources: &[ResourceDefinition]) -> String {
347    let custom = collect_custom_handlers(resources);
348
349    if custom.is_empty() {
350        return r#"pub fn build_handler_map() -> shaperail_runtime::handlers::custom::CustomHandlerMap {
351    shaperail_runtime::handlers::custom::CustomHandlerMap::new()
352}"#
353        .to_string();
354    }
355
356    let inserts: Vec<String> = custom
357        .iter()
358        .flat_map(|(res_name, handlers)| {
359            handlers.iter().map(move |(action, handler_fn)| {
360                format!(
361                    "    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    );"
362                )
363            })
364        })
365        .collect();
366
367    format!(
368        r#"pub fn build_handler_map() -> shaperail_runtime::handlers::custom::CustomHandlerMap {{
369    let mut map = shaperail_runtime::handlers::custom::CustomHandlerMap::new();
370{inserts}
371    map
372}}"#,
373        inserts = inserts.join("\n"),
374    )
375}
376
377fn generate_registry_module(resources: &[ResourceDefinition]) -> String {
378    let module_lines = resources
379        .iter()
380        .map(|resource| format!("pub mod {};", resource.resource))
381        .collect::<Vec<_>>()
382        .join("\n");
383
384    let registry_lines = resources
385        .iter()
386        .map(|resource| {
387            let store_name = format!("{}Store", to_pascal_case(&resource.resource));
388            format!(
389                "    stores.insert({name:?}.to_string(), std::sync::Arc::new({module}::{store_name}::new(pool.clone())));",
390                name = resource.resource,
391                module = resource.resource
392            )
393        })
394        .collect::<Vec<_>>()
395        .join("\n");
396
397    let ctrl_hooks = collect_controller_hooks(resources);
398
399    // #[path] module declarations for controller files
400    let ctrl_path_decls: Vec<String> = ctrl_hooks
401        .iter()
402        .map(|(name, _)| {
403            format!("#[path = \"../resources/{name}.controller.rs\"]\nmod {name}_controller;")
404        })
405        .collect();
406
407    // map.register(...) calls — deduplicated
408    let mut seen_ctrl = std::collections::HashSet::new();
409    let ctrl_register_lines: Vec<String> = ctrl_hooks
410        .iter()
411        .flat_map(|(res_name, hooks)| {
412            hooks
413                .iter()
414                .filter_map(|fn_name| {
415                    let key = (*res_name, *fn_name);
416                    if seen_ctrl.insert(key) {
417                        Some(format!(
418                            "    map.register({res_name:?}, {fn_name:?}, {res_name}_controller::{fn_name});"
419                        ))
420                    } else {
421                        None
422                    }
423                })
424                .collect::<Vec<_>>()
425        })
426        .collect();
427
428    let ctrl_map_body = if ctrl_register_lines.is_empty() {
429        "    shaperail_runtime::handlers::controller::ControllerMap::new()".to_string()
430    } else {
431        let mut lines = vec![
432            "    let mut map = shaperail_runtime::handlers::controller::ControllerMap::new();"
433                .to_string(),
434        ];
435        lines.extend(ctrl_register_lines);
436        lines.push("    map".to_string());
437        lines.join("\n")
438    };
439
440    // Build the preamble: store modules, then controller path decls, then job path decls
441    let mut preamble_parts = vec![module_lines.clone()];
442    if !ctrl_path_decls.is_empty() {
443        preamble_parts.push(ctrl_path_decls.join("\n"));
444    }
445    let job_path_decls = collect_job_path_decls(resources);
446    if !job_path_decls.is_empty() {
447        preamble_parts.push(job_path_decls.join("\n"));
448    }
449    let handler_path_decls = collect_handler_path_decls(resources);
450    if !handler_path_decls.is_empty() {
451        preamble_parts.push(handler_path_decls.join("\n"));
452    }
453    let preamble = preamble_parts.join("\n\n");
454
455    format!(
456        r#"#![allow(dead_code)]
457
458{preamble}
459
460pub fn build_store_registry(pool: sqlx::PgPool) -> shaperail_runtime::db::StoreRegistry {{
461    let mut stores: std::collections::HashMap<
462        String,
463        std::sync::Arc<dyn shaperail_runtime::db::ResourceStore>,
464    > = std::collections::HashMap::new();
465{registry_lines}
466    std::sync::Arc::new(stores)
467}}
468
469pub fn build_controller_map() -> shaperail_runtime::handlers::controller::ControllerMap {{
470{ctrl_map_body}
471}}
472
473{job_registry_fn}
474
475{handler_map_fn}
476"#,
477        job_registry_fn = generate_job_registry(resources),
478        handler_map_fn = generate_handler_map_fn(resources),
479    )
480}
481
482#[derive(Clone)]
483struct CollectionEndpoint<'a> {
484    spec: &'a EndpointSpec,
485    helper_name: String,
486}
487
488struct ResourceContext<'a> {
489    resource: &'a ResourceDefinition,
490    record_name: String,
491    store_name: String,
492    primary_key: String,
493    select_columns: String,
494    soft_delete_where: String,
495    collection_endpoints: Vec<CollectionEndpoint<'a>>,
496}
497
498impl<'a> ResourceContext<'a> {
499    fn new(resource: &'a ResourceDefinition) -> Result<Self, String> {
500        let primary_key = resource
501            .schema
502            .iter()
503            .find(|(_, field)| field.primary)
504            .map(|(name, _)| name.clone())
505            .unwrap_or_else(|| "id".to_string());
506
507        let select_columns = resource
508            .schema
509            .iter()
510            .filter(|(_, field)| field.is_persisted())
511            .map(|(name, field)| select_column_sql(name, field))
512            .collect::<Vec<_>>()
513            .join(",\n                ");
514
515        let collection_endpoints = resource
516            .endpoints
517            .as_ref()
518            .map(|endpoints| {
519                endpoints
520                    .iter()
521                    .filter(|(_, endpoint)| {
522                        *endpoint.method() == HttpMethod::Get && !endpoint.path().contains(":id")
523                    })
524                    .map(|(name, endpoint)| CollectionEndpoint {
525                        spec: endpoint,
526                        helper_name: format!("find_all_{}", sanitize_identifier(name)),
527                    })
528                    .collect::<Vec<_>>()
529            })
530            .unwrap_or_default();
531
532        Ok(Self {
533            resource,
534            record_name: format!("{}Record", to_pascal_case(&resource.resource)),
535            store_name: format!("{}Store", to_pascal_case(&resource.resource)),
536            primary_key,
537            select_columns,
538            soft_delete_where: if has_soft_delete(resource) {
539                " AND \"deleted_at\" IS NULL".to_string()
540            } else {
541                String::new()
542            },
543            collection_endpoints,
544        })
545    }
546}
547
548fn generate_list_helper(
549    context: &ResourceContext<'_>,
550    endpoint: &CollectionEndpoint<'_>,
551) -> Result<String, String> {
552    let filters = endpoint.spec.filters.clone().unwrap_or_default();
553    let search_fields = endpoint.spec.search.clone().unwrap_or_default();
554    let sort_fields = endpoint.spec.sort.clone().unwrap_or_default();
555
556    let filter_decls = if filters.is_empty() {
557        "        let _ = filters;".to_string()
558    } else {
559        filters
560            .iter()
561            .map(|field_name| {
562                let field = context.resource.schema.get(field_name).ok_or_else(|| {
563                    format!(
564                        "Unknown filter field '{field_name}' on resource '{}'",
565                        context.resource.resource
566                    )
567                })?;
568                Ok(generate_filter_declaration(field_name, field))
569            })
570            .collect::<Result<Vec<_>, String>>()?
571            .join("\n")
572    };
573
574    let filter_args = filters
575        .iter()
576        .map(|field_name| {
577            parameter_expression(
578                field_name,
579                context
580                    .resource
581                    .schema
582                    .get(field_name)
583                    .expect("filter field validated"),
584            )
585        })
586        .collect::<Vec<_>>();
587
588    let search_decl = if search_fields.is_empty() {
589        String::new()
590    } else {
591        "        let search_term = search.map(|value| value.term.clone());".to_string()
592    };
593    let search_param = if search_fields.is_empty() {
594        "_search"
595    } else {
596        "search"
597    };
598
599    let search_predicate = if search_fields.is_empty() {
600        String::new()
601    } else {
602        search_expression(&search_fields)
603    };
604
605    let sort_decls = if sort_fields.is_empty() {
606        "        let _ = sort;".to_string()
607    } else {
608        (0..sort_fields.len())
609            .map(|index| {
610                format!(
611                    "        let sort_field_{index} = sort_field_at(sort, {index});\n        let sort_direction_{index} = sort_direction_at(sort, {index});"
612                )
613            })
614            .collect::<Vec<_>>()
615            .join("\n")
616    };
617
618    let filter_positions = filters
619        .iter()
620        .enumerate()
621        .map(|(index, field_name)| (field_name.clone(), index + 1))
622        .collect::<Vec<_>>();
623
624    let search_position = if search_fields.is_empty() {
625        None
626    } else {
627        Some(filter_positions.len() + 1)
628    };
629
630    let cursor_position = filter_positions.len() + usize::from(search_position.is_some()) + 1;
631    let cursor_sort_positions = (0..sort_fields.len())
632        .map(|index| {
633            let base = cursor_position + 1 + (index * 2);
634            (base, base + 1)
635        })
636        .collect::<Vec<_>>();
637    let offset_sort_positions = (0..sort_fields.len())
638        .map(|index| {
639            let base =
640                filter_positions.len() + usize::from(search_position.is_some()) + 1 + (index * 2);
641            (base, base + 1)
642        })
643        .collect::<Vec<_>>();
644
645    let filter_predicates = generate_filter_predicates(context, &filter_positions)?;
646    let filter_clause = filter_predicates.join("\n");
647
648    let cursor_order_by = generate_order_by(context, &sort_fields, &cursor_sort_positions)?;
649    let offset_order_by = generate_order_by(context, &sort_fields, &offset_sort_positions)?;
650
651    let mut cursor_args = filter_args.clone();
652    if search_position.is_some() {
653        cursor_args.push("search_term.as_deref()".to_string());
654    }
655    cursor_args.push("cursor".to_string());
656    for index in 0..sort_fields.len() {
657        cursor_args.push(format!("sort_field_{index}.as_deref()"));
658        cursor_args.push(format!("sort_direction_{index}"));
659    }
660    cursor_args.push("*limit + 1".to_string());
661
662    let mut count_args = filter_args.clone();
663    if search_position.is_some() {
664        count_args.push("search_term.as_deref()".to_string());
665    }
666
667    let mut row_args = count_args.clone();
668    for index in 0..sort_fields.len() {
669        row_args.push(format!("sort_field_{index}.as_deref()"));
670        row_args.push(format!("sort_direction_{index}"));
671    }
672    row_args.push("*limit".to_string());
673    row_args.push("*offset".to_string());
674
675    let cursor_query = generate_cursor_query(
676        context,
677        &filter_clause,
678        search_position.map(|position| (position, search_predicate.as_str())),
679        cursor_position,
680        &cursor_order_by,
681        cursor_args.len(),
682        &cursor_args,
683    );
684    let offset_query = generate_offset_query(
685        context,
686        &filter_clause,
687        search_position.map(|position| (position, search_predicate.as_str())),
688        &offset_order_by,
689        row_args.len() - 1,
690        row_args.len(),
691        &count_args,
692        &row_args,
693    );
694
695    Ok(format!(
696        r###"    async fn {helper_name}(
697        &self,
698        filters: &FilterSet,
699        {search_param}: Option<&SearchParam>,
700        sort: &SortParam,
701        page: &PageRequest,
702    ) -> Result<(Vec<ResourceRow>, Value), shaperail_core::ShaperailError> {{
703{filter_decls}
704{search_decl}
705{sort_decls}
706
707        match page {{
708            PageRequest::Cursor {{ after, limit }} => {{
709                let cursor = match after {{
710                    Some(cursor_value) => Some(uuid::Uuid::parse_str(
711                        &shaperail_runtime::db::decode_cursor(cursor_value)?
712                    ).map_err(|_| shaperail_core::ShaperailError::Validation(vec![shaperail_core::FieldError {{
713                        field: "cursor".to_string(),
714                        message: "Invalid cursor value".to_string(),
715                        code: "invalid_cursor".to_string(),
716                    }}]))?),
717                    None => None,
718                }};
719{cursor_query}
720            }}
721            PageRequest::Offset {{ offset, limit }} => {{
722{offset_query}
723            }}
724        }}
725    }}"###,
726        helper_name = endpoint.helper_name,
727        search_param = search_param,
728        filter_decls = indent_block(&filter_decls, 2),
729        search_decl = indent_block(&search_decl, 2),
730        sort_decls = indent_block(&sort_decls, 2),
731        cursor_query = indent_block(&cursor_query, 4),
732        offset_query = indent_block(&offset_query, 4),
733    ))
734}
735
736fn generate_cursor_query(
737    context: &ResourceContext<'_>,
738    filter_clause: &str,
739    search_position: Option<(usize, &str)>,
740    cursor_position: usize,
741    order_by: &str,
742    limit_position: usize,
743    args: &[String],
744) -> String {
745    format!(
746        r###"                let rows = sqlx::query_as!(
747                    {record_name},
748                    r#"
749                    SELECT
750                        {select_columns}
751                    FROM "{table_name}"
752                    WHERE TRUE
753{soft_delete_clause}
754{filter_clause}{search_clause}
755                        AND (${cursor_position}::uuid IS NULL OR "{primary_key}" > ${cursor_position})
756                    ORDER BY
757{order_by}
758                    LIMIT ${limit_position}
759                    "#,
760                    {args}
761                )
762                .fetch_all(&self.pool)
763                .await?;
764
765                let has_more = rows.len() as i64 > *limit;
766                let mut result_rows = rows;
767                if has_more {{
768                    result_rows.truncate(*limit as usize);
769                }}
770
771                let data = result_rows
772                    .iter()
773                    .map(row_from_model)
774                    .collect::<Result<Vec<_>, _>>()?;
775                let cursor = if has_more {{
776                    result_rows
777                        .last()
778                        .map(|row| shaperail_runtime::db::encode_cursor(&row.{primary_key}.to_string()))
779                }} else {{
780                    None
781                }};
782
783                Ok((
784                    data,
785                    serde_json::json!({{
786                        "cursor": cursor,
787                        "has_more": has_more
788                    }})
789                ))"###,
790        record_name = context.record_name,
791        select_columns = context.select_columns,
792        table_name = context.resource.resource,
793        soft_delete_clause = if has_soft_delete(context.resource) {
794            "                        AND \"deleted_at\" IS NULL\n"
795        } else {
796            ""
797        },
798        filter_clause = if filter_clause.is_empty() {
799            String::new()
800        } else {
801            format!("{filter_clause}\n")
802        },
803        search_clause = search_position
804            .map(|(position, expression)| {
805                format!(
806                    "\n                        AND (${position}::text IS NULL OR to_tsvector('english', {expression}) @@ plainto_tsquery('english', ${position}))"
807                )
808            })
809            .unwrap_or_default(),
810        cursor_position = cursor_position,
811        primary_key = context.primary_key,
812        order_by = order_by,
813        limit_position = limit_position,
814        args = args.join(",\n                    "),
815    )
816}
817
818#[allow(clippy::too_many_arguments)]
819fn generate_offset_query(
820    context: &ResourceContext<'_>,
821    filter_clause: &str,
822    search_position: Option<(usize, &str)>,
823    order_by: &str,
824    limit_position: usize,
825    offset_position: usize,
826    count_args: &[String],
827    row_args: &[String],
828) -> String {
829    let count_macro_args = if count_args.is_empty() {
830        String::new()
831    } else {
832        format!(
833            ",\n                    {}",
834            count_args.join(",\n                    ")
835        )
836    };
837
838    format!(
839        r###"                let total = sqlx::query_scalar!(
840                    r#"
841                    SELECT COUNT(*) as "count!"
842                    FROM "{table_name}"
843                    WHERE TRUE
844{soft_delete_clause}
845{filter_clause}{search_clause}
846                    "#{count_macro_args}
847                )
848                .fetch_one(&self.pool)
849                .await?;
850
851                let rows = sqlx::query_as!(
852                    {record_name},
853                    r#"
854                    SELECT
855                        {select_columns}
856                    FROM "{table_name}"
857                    WHERE TRUE
858{soft_delete_clause}
859{filter_clause}{search_clause}
860                    ORDER BY
861{order_by}
862                    LIMIT ${limit_param}
863                    OFFSET ${offset_param}
864                    "#,
865                    {row_args}
866                )
867                .fetch_all(&self.pool)
868                .await?;
869
870                let data = rows
871                    .iter()
872                    .map(row_from_model)
873                    .collect::<Result<Vec<_>, _>>()?;
874
875                Ok((
876                    data,
877                    serde_json::json!({{
878                        "offset": offset,
879                        "limit": limit,
880                        "total": total
881                    }})
882                ))"###,
883        table_name = context.resource.resource,
884        soft_delete_clause = if has_soft_delete(context.resource) {
885            "                        AND \"deleted_at\" IS NULL\n"
886        } else {
887            ""
888        },
889        filter_clause = if filter_clause.is_empty() {
890            String::new()
891        } else {
892            format!("{filter_clause}\n")
893        },
894        search_clause = search_position
895            .map(|(position, expression)| {
896                format!(
897                    "\n                        AND (${position}::text IS NULL OR to_tsvector('english', {expression}) @@ plainto_tsquery('english', ${position}))"
898                )
899            })
900            .unwrap_or_default(),
901        count_macro_args = count_macro_args,
902        record_name = context.record_name,
903        select_columns = context.select_columns,
904        order_by = order_by,
905        limit_param = limit_position,
906        offset_param = offset_position,
907        row_args = row_args.join(",\n                    "),
908    )
909}
910
911fn generate_filter_predicates(
912    context: &ResourceContext<'_>,
913    positions: &[(String, usize)],
914) -> Result<Vec<String>, String> {
915    positions
916        .iter()
917        .map(|(field_name, position)| {
918            let field = context.resource.schema.get(field_name).ok_or_else(|| {
919                format!(
920                    "Unknown filter field '{field_name}' on resource '{}'",
921                    context.resource.resource
922                )
923            })?;
924            Ok(format!(
925                "                AND (${position}::{cast} IS NULL OR \"{field_name}\" = ${position})",
926                cast = sql_cast_type(field)
927            ))
928        })
929        .collect()
930}
931
932fn generate_order_by(
933    context: &ResourceContext<'_>,
934    sort_fields: &[String],
935    positions: &[(usize, usize)],
936) -> Result<String, String> {
937    if sort_fields.is_empty() {
938        return Ok(format!("\"{}\" ASC", context.primary_key));
939    }
940
941    let mut clauses = Vec::new();
942    for ((field_param, direction_param), field_name) in positions.iter().zip(sort_fields) {
943        for candidate in sort_fields {
944            let field = context.resource.schema.get(candidate).ok_or_else(|| {
945                format!(
946                    "Unknown sort field '{candidate}' on resource '{}'",
947                    context.resource.resource
948                )
949            })?;
950            let sort_expr = sortable_expression(candidate, field);
951            clauses.push(format!(
952                "                CASE WHEN ${field_param}::text = '{candidate}' AND ${direction_param}::text = 'asc' THEN {sort_expr} END ASC"
953            ));
954            clauses.push(format!(
955                "                CASE WHEN ${field_param}::text = '{candidate}' AND ${direction_param}::text = 'desc' THEN {sort_expr} END DESC"
956            ));
957        }
958        let _ = field_name;
959    }
960    clauses.push(format!("                \"{}\" ASC", context.primary_key));
961    Ok(clauses.join(",\n"))
962}
963
964fn generate_insert_body(context: &ResourceContext<'_>) -> Result<String, String> {
965    let mut declarations = Vec::new();
966    let mut columns = Vec::new();
967    let mut values = Vec::new();
968    let mut args = Vec::new();
969
970    for (field_name, field) in context
971        .resource
972        .schema
973        .iter()
974        .filter(|(_, f)| f.is_persisted())
975    {
976        let variable_name = sanitize_identifier(field_name);
977        declarations.push(generate_insert_declaration(
978            field_name,
979            field,
980            &variable_name,
981        )?);
982        let index = columns.len() + 1;
983        columns.push(format!("\"{field_name}\""));
984        values.push(format!("${index}"));
985        args.push(variable_name);
986    }
987
988    Ok(format!(
989        r###"{declarations}
990        let row = sqlx::query_as!(
991            {record_name},
992            r#"
993            INSERT INTO "{table_name}" ({columns})
994            VALUES ({values})
995            RETURNING
996                {select_columns}
997            "#,
998            {args}
999        )
1000        .fetch_one(&self.pool)
1001        .await?;
1002
1003        row_from_model(&row)"###,
1004        declarations = declarations.join("\n"),
1005        record_name = context.record_name,
1006        table_name = context.resource.resource,
1007        columns = columns.join(", "),
1008        values = values.join(", "),
1009        select_columns = context.select_columns,
1010        args = args.join(",\n            "),
1011    ))
1012}
1013
1014fn generate_update_body(context: &ResourceContext<'_>) -> Result<String, String> {
1015    let mut declarations = Vec::new();
1016    let mut set_clauses = Vec::new();
1017    let mut args = vec!["id".to_string()];
1018    let mut has_mutable_fields = Vec::new();
1019    let mut index = 2usize;
1020
1021    for (field_name, field) in &context.resource.schema {
1022        if field.primary || field.generated || field.transient {
1023            continue;
1024        }
1025
1026        let present_name = format!("{}_present", sanitize_identifier(field_name));
1027        let value_name = sanitize_identifier(field_name);
1028        declarations.push(generate_update_declaration(
1029            field_name,
1030            field,
1031            &present_name,
1032            &value_name,
1033        ));
1034        has_mutable_fields.push(present_name.clone());
1035        set_clauses.push(format!(
1036            "\"{field_name}\" = CASE WHEN ${present_param} THEN ${value_param} ELSE \"{field_name}\" END",
1037            present_param = index,
1038            value_param = index + 1
1039        ));
1040        args.push(present_name);
1041        args.push(value_name);
1042        index += 2;
1043    }
1044
1045    if let Some(updated_at) = context.resource.schema.get("updated_at") {
1046        if updated_at.generated && updated_at.field_type == FieldType::Timestamp {
1047            declarations.push("        let updated_at = chrono::Utc::now();".to_string());
1048            set_clauses.push(format!("\"updated_at\" = ${index}"));
1049            args.push("updated_at".to_string());
1050        }
1051    }
1052
1053    let guard = if has_mutable_fields.is_empty() {
1054        String::new()
1055    } else {
1056        format!(
1057            "        if !({}) {}",
1058            has_mutable_fields.join(" || "),
1059            r#"{
1060            return Err(shaperail_core::ShaperailError::Validation(vec![shaperail_core::FieldError {
1061                field: "body".to_string(),
1062                message: "No valid fields to update".to_string(),
1063                code: "empty_update".to_string(),
1064            }]));
1065        }"#
1066        )
1067    };
1068
1069    Ok(format!(
1070        r###"{declarations}
1071{guard}
1072        let row = sqlx::query_as!(
1073            {record_name},
1074            r#"
1075            UPDATE "{table_name}"
1076            SET {set_clauses}
1077            WHERE "{primary_key}" = $1{soft_delete_where}
1078            RETURNING
1079                {select_columns}
1080            "#,
1081            {args}
1082        )
1083        .fetch_optional(&self.pool)
1084        .await?
1085        .ok_or(shaperail_core::ShaperailError::NotFound)?;
1086
1087        row_from_model(&row)"###,
1088        declarations = declarations.join("\n"),
1089        guard = guard,
1090        record_name = context.record_name,
1091        table_name = context.resource.resource,
1092        set_clauses = set_clauses.join(", "),
1093        primary_key = context.primary_key,
1094        soft_delete_where = context.soft_delete_where,
1095        select_columns = context.select_columns,
1096        args = args.join(",\n            "),
1097    ))
1098}
1099
1100fn generate_soft_delete_body(context: &ResourceContext<'_>) -> String {
1101    if !has_soft_delete(context.resource) {
1102        return generate_hard_delete_body(context);
1103    }
1104
1105    format!(
1106        r###"        let deleted_at = chrono::Utc::now();
1107        let row = sqlx::query_as!(
1108            {record_name},
1109            r#"
1110            UPDATE "{table_name}"
1111            SET "deleted_at" = $2
1112            WHERE "{primary_key}" = $1 AND "deleted_at" IS NULL
1113            RETURNING
1114                {select_columns}
1115            "#,
1116            id,
1117            deleted_at
1118        )
1119        .fetch_optional(&self.pool)
1120        .await?
1121        .ok_or(shaperail_core::ShaperailError::NotFound)?;
1122
1123        row_from_model(&row)"###,
1124        record_name = context.record_name,
1125        table_name = context.resource.resource,
1126        primary_key = context.primary_key,
1127        select_columns = context.select_columns,
1128    )
1129}
1130
1131fn generate_hard_delete_body(context: &ResourceContext<'_>) -> String {
1132    format!(
1133        r###"        let row = sqlx::query_as!(
1134            {record_name},
1135            r#"
1136            DELETE FROM "{table_name}"
1137            WHERE "{primary_key}" = $1
1138            RETURNING
1139                {select_columns}
1140            "#,
1141            id
1142        )
1143        .fetch_optional(&self.pool)
1144        .await?
1145        .ok_or(shaperail_core::ShaperailError::NotFound)?;
1146
1147        row_from_model(&row)"###,
1148        record_name = context.record_name,
1149        table_name = context.resource.resource,
1150        primary_key = context.primary_key,
1151        select_columns = context.select_columns,
1152    )
1153}
1154
1155fn generate_insert_declaration(
1156    field_name: &str,
1157    field: &FieldSchema,
1158    variable_name: &str,
1159) -> Result<String, String> {
1160    if field.generated {
1161        return Ok(format!(
1162            "        let {variable_name} = {};",
1163            generated_value_expression(field)
1164        ));
1165    }
1166
1167    let parse_type = parse_type(field);
1168    let parsed = format!(
1169        "shaperail_runtime::db::parse_optional_json::<{parse_type}>(data, {field_name:?})?"
1170    );
1171
1172    let expression = match (field_is_required(field), field.default.as_ref()) {
1173        (true, Some(default)) => format!(
1174            "match {parsed} {{ Some(value) => value, None => {} }}",
1175            default_expression(field_name, field, default)?
1176        ),
1177        (true, None) => format!("shaperail_runtime::db::require_field({parsed}, {field_name:?})?"),
1178        (false, Some(default)) if model_field_is_optional(field) => format!(
1179            "match {parsed} {{ Some(value) => Some(value), None => Some({}) }}",
1180            default_expression(field_name, field, default)?
1181        ),
1182        (false, Some(default)) => format!(
1183            "match {parsed} {{ Some(value) => value, None => {} }}",
1184            default_expression(field_name, field, default)?
1185        ),
1186        (false, None) => parsed,
1187    };
1188
1189    Ok(format!("        let {variable_name} = {expression};"))
1190}
1191
1192fn generate_update_declaration(
1193    field_name: &str,
1194    field: &FieldSchema,
1195    present_name: &str,
1196    value_name: &str,
1197) -> String {
1198    format!(
1199        "        let {present_name} = data.contains_key({field_name:?});\n        let {value_name} = shaperail_runtime::db::parse_optional_json::<{parse_type}>(data, {field_name:?})?;",
1200        parse_type = parse_type(field)
1201    )
1202}
1203
1204fn generate_filter_declaration(field_name: &str, field: &FieldSchema) -> String {
1205    let parser = match field.field_type {
1206        FieldType::Uuid => "uuid::Uuid::parse_str(text).map_err(|_| shaperail_core::ShaperailError::Internal(\"invalid uuid filter\".to_string()))",
1207        FieldType::String | FieldType::Enum | FieldType::File => "Ok(text.to_string())",
1208        FieldType::Integer => "text.parse::<i32>().map_err(|_| shaperail_core::ShaperailError::Internal(\"invalid integer filter\".to_string()))",
1209        FieldType::Bigint => "text.parse::<i64>().map_err(|_| shaperail_core::ShaperailError::Internal(\"invalid bigint filter\".to_string()))",
1210        FieldType::Number => "text.parse::<f64>().map_err(|_| shaperail_core::ShaperailError::Internal(\"invalid number filter\".to_string()))",
1211        FieldType::Boolean => "text.parse::<bool>().map_err(|_| shaperail_core::ShaperailError::Internal(\"invalid boolean filter\".to_string()))",
1212        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()))",
1213        FieldType::Date => "chrono::NaiveDate::parse_from_str(text, \"%Y-%m-%d\").map_err(|_| shaperail_core::ShaperailError::Internal(\"invalid date filter\".to_string()))",
1214        FieldType::Json => "serde_json::from_str::<serde_json::Value>(text).map_err(|_| shaperail_core::ShaperailError::Internal(\"invalid json filter\".to_string()))",
1215        FieldType::Array => "serde_json::from_str::<Vec<serde_json::Value>>(text).map_err(|_| shaperail_core::ShaperailError::Internal(\"invalid array filter\".to_string()))",
1216    };
1217
1218    format!(
1219        "        let {var} = parse_filter(filters, {field_name:?}, \"invalid_filter\", |text| {parser})?;",
1220        var = field_parameter_name(field_name)
1221    )
1222}
1223
1224fn field_parameter_name(field_name: &str) -> String {
1225    format!("filter_{}", sanitize_identifier(field_name))
1226}
1227
1228fn parameter_expression(field_name: &str, field: &FieldSchema) -> String {
1229    let var = field_parameter_name(field_name);
1230    match field.field_type {
1231        FieldType::String | FieldType::Enum | FieldType::File => format!("{var}.as_deref()"),
1232        _ => var,
1233    }
1234}
1235
1236fn select_column_sql(field_name: &str, field: &FieldSchema) -> String {
1237    let nullability = if model_field_is_optional(field) {
1238        "?"
1239    } else {
1240        "!"
1241    };
1242    let expression = match field.field_type {
1243        FieldType::Number => format!("\"{field_name}\"::DOUBLE PRECISION"),
1244        _ => format!("\"{field_name}\""),
1245    };
1246    format!(
1247        "{expression} as \"{field_name}{nullability}: {type_name}\"",
1248        type_name = query_type(field)
1249    )
1250}
1251
1252fn sortable_expression(field_name: &str, field: &FieldSchema) -> String {
1253    match field.field_type {
1254        FieldType::Json | FieldType::Array | FieldType::Uuid => format!("\"{field_name}\"::text"),
1255        FieldType::Number => format!("\"{field_name}\"::DOUBLE PRECISION"),
1256        _ => format!("\"{field_name}\""),
1257    }
1258}
1259
1260fn search_expression(fields: &[String]) -> String {
1261    fields
1262        .iter()
1263        .map(|field| format!("COALESCE(\"{field}\"::text, '')"))
1264        .collect::<Vec<_>>()
1265        .join(" || ' ' || ")
1266}
1267
1268fn sql_cast_type(field: &FieldSchema) -> String {
1269    match field.field_type {
1270        FieldType::Uuid => "uuid".to_string(),
1271        FieldType::String | FieldType::Enum | FieldType::File => "text".to_string(),
1272        FieldType::Integer => "integer".to_string(),
1273        FieldType::Bigint => "bigint".to_string(),
1274        FieldType::Number => "double precision".to_string(),
1275        FieldType::Boolean => "boolean".to_string(),
1276        FieldType::Timestamp => "timestamptz".to_string(),
1277        FieldType::Date => "date".to_string(),
1278        FieldType::Json => "jsonb".to_string(),
1279        FieldType::Array => match field.items.as_ref().map(|i| &i.field_type) {
1280            Some(FieldType::Uuid) => "uuid[]".to_string(),
1281            Some(FieldType::Integer) => "integer[]".to_string(),
1282            Some(FieldType::Bigint) => "bigint[]".to_string(),
1283            Some(FieldType::Number) => "double precision[]".to_string(),
1284            Some(FieldType::Boolean) => "boolean[]".to_string(),
1285            Some(FieldType::Timestamp) => "timestamptz[]".to_string(),
1286            Some(FieldType::Date) => "date[]".to_string(),
1287            _ => "text[]".to_string(),
1288        },
1289    }
1290}
1291
1292fn query_type(field: &FieldSchema) -> String {
1293    match field.field_type {
1294        FieldType::Uuid => "uuid::Uuid".to_string(),
1295        FieldType::String | FieldType::Enum | FieldType::File => "String".to_string(),
1296        FieldType::Integer => "i32".to_string(),
1297        FieldType::Bigint => "i64".to_string(),
1298        FieldType::Number => "f64".to_string(),
1299        FieldType::Boolean => "bool".to_string(),
1300        FieldType::Timestamp => "chrono::DateTime<chrono::Utc>".to_string(),
1301        FieldType::Date => "chrono::NaiveDate".to_string(),
1302        FieldType::Json => "serde_json::Value".to_string(),
1303        FieldType::Array => match field.items.as_ref().map(|i| &i.field_type) {
1304            Some(FieldType::Uuid) => "Vec<uuid::Uuid>".to_string(),
1305            Some(FieldType::Integer) => "Vec<i32>".to_string(),
1306            Some(FieldType::Bigint) => "Vec<i64>".to_string(),
1307            Some(FieldType::Number) => "Vec<f64>".to_string(),
1308            Some(FieldType::Boolean) => "Vec<bool>".to_string(),
1309            Some(FieldType::Timestamp) => "Vec<chrono::DateTime<chrono::Utc>>".to_string(),
1310            Some(FieldType::Date) => "Vec<chrono::NaiveDate>".to_string(),
1311            _ => "Vec<String>".to_string(),
1312        },
1313    }
1314}
1315
1316fn parse_type(field: &FieldSchema) -> String {
1317    query_type(field)
1318}
1319
1320fn model_field_type(field: &FieldSchema) -> String {
1321    let base = query_type(field);
1322    if model_field_is_optional(field) {
1323        format!("Option<{base}>")
1324    } else {
1325        base
1326    }
1327}
1328
1329fn model_field_is_optional(field: &FieldSchema) -> bool {
1330    !(field.primary || (field.required && !field.nullable))
1331}
1332
1333fn field_is_required(field: &FieldSchema) -> bool {
1334    field.primary || (field.required && !field.nullable)
1335}
1336
1337fn generated_value_expression(field: &FieldSchema) -> String {
1338    match field.field_type {
1339        FieldType::Uuid => "uuid::Uuid::new_v4()".to_string(),
1340        FieldType::Timestamp => {
1341            if model_field_is_optional(field) {
1342                "Some(chrono::Utc::now())".to_string()
1343            } else {
1344                "chrono::Utc::now()".to_string()
1345            }
1346        }
1347        FieldType::Date => {
1348            if model_field_is_optional(field) {
1349                "Some(chrono::Utc::now().date_naive())".to_string()
1350            } else {
1351                "chrono::Utc::now().date_naive()".to_string()
1352            }
1353        }
1354        _ => "Default::default()".to_string(),
1355    }
1356}
1357
1358fn default_expression(
1359    field_name: &str,
1360    field: &FieldSchema,
1361    default: &serde_json::Value,
1362) -> Result<String, String> {
1363    Ok(match field.field_type {
1364        FieldType::Uuid => format!(
1365            "parse_embedded_json::<uuid::Uuid>({field_name:?}, serde_json::json!({default}))?"
1366        ),
1367        FieldType::String | FieldType::Enum | FieldType::File => {
1368            let value = default
1369                .as_str()
1370                .ok_or_else(|| format!("Default for '{field_name}' must be a string"))?;
1371            format!("{value:?}.to_string()")
1372        }
1373        FieldType::Integer => format!(
1374            "parse_embedded_json::<i32>({field_name:?}, serde_json::json!({default}))?"
1375        ),
1376        FieldType::Bigint => format!(
1377            "parse_embedded_json::<i64>({field_name:?}, serde_json::json!({default}))?"
1378        ),
1379        FieldType::Number => format!(
1380            "parse_embedded_json::<f64>({field_name:?}, serde_json::json!({default}))?"
1381        ),
1382        FieldType::Boolean => default
1383            .as_bool()
1384            .ok_or_else(|| format!("Default for '{field_name}' must be a boolean"))?
1385            .to_string(),
1386        FieldType::Timestamp => format!(
1387            "parse_embedded_json::<chrono::DateTime<chrono::Utc>>({field_name:?}, serde_json::json!({default}))?"
1388        ),
1389        FieldType::Date => format!(
1390            "parse_embedded_json::<chrono::NaiveDate>({field_name:?}, serde_json::json!({default}))?"
1391        ),
1392        FieldType::Json => format!("serde_json::json!({default})"),
1393        FieldType::Array => format!(
1394            "parse_embedded_json::<{}>({field_name:?}, serde_json::json!({default}))?",
1395            query_type(field)
1396        ),
1397    })
1398}
1399
1400fn has_soft_delete(resource: &ResourceDefinition) -> bool {
1401    resource
1402        .endpoints
1403        .as_ref()
1404        .map(|endpoints| endpoints.values().any(|endpoint| endpoint.soft_delete))
1405        .unwrap_or(false)
1406}
1407
1408fn sanitize_identifier(value: &str) -> String {
1409    let mut output = String::new();
1410    for ch in value.chars() {
1411        if ch.is_ascii_alphanumeric() {
1412            output.push(ch.to_ascii_lowercase());
1413        } else {
1414            output.push('_');
1415        }
1416    }
1417
1418    if output.chars().next().is_some_and(|ch| ch.is_ascii_digit()) {
1419        output.insert(0, '_');
1420    }
1421
1422    output
1423}
1424
1425fn to_pascal_case(value: &str) -> String {
1426    value
1427        .split('_')
1428        .filter(|part| !part.is_empty())
1429        .map(|part| {
1430            let mut chars = part.chars();
1431            match chars.next() {
1432                Some(first) => {
1433                    let mut segment = String::new();
1434                    segment.extend(first.to_uppercase());
1435                    segment.push_str(chars.as_str());
1436                    segment
1437                }
1438                None => String::new(),
1439            }
1440        })
1441        .collect::<String>()
1442}
1443
1444fn indent_block(block: &str, indent: usize) -> String {
1445    if block.trim().is_empty() {
1446        return String::new();
1447    }
1448
1449    let prefix = "    ".repeat(indent);
1450    block
1451        .lines()
1452        .map(|line| {
1453            if line.is_empty() {
1454                String::new()
1455            } else {
1456                format!("{prefix}{line}")
1457            }
1458        })
1459        .collect::<Vec<_>>()
1460        .join("\n")
1461}
1462
1463#[cfg(test)]
1464mod tests {
1465    use super::*;
1466    use indexmap::IndexMap;
1467    use shaperail_core::{
1468        AuthRule, EndpointSpec, FieldSchema, HttpMethod, PaginationStyle, ResourceDefinition,
1469    };
1470
1471    fn sample_resource() -> ResourceDefinition {
1472        let mut schema = IndexMap::new();
1473        schema.insert(
1474            "id".to_string(),
1475            FieldSchema {
1476                field_type: FieldType::Uuid,
1477                primary: true,
1478                generated: true,
1479                required: false,
1480                unique: false,
1481                nullable: false,
1482                reference: None,
1483                min: None,
1484                max: None,
1485                format: None,
1486                values: None,
1487                default: None,
1488                sensitive: false,
1489                search: false,
1490                items: None,
1491                transient: false,
1492            },
1493        );
1494        schema.insert(
1495            "email".to_string(),
1496            FieldSchema {
1497                field_type: FieldType::String,
1498                primary: false,
1499                generated: false,
1500                required: true,
1501                unique: true,
1502                nullable: false,
1503                reference: None,
1504                min: None,
1505                max: None,
1506                format: None,
1507                values: None,
1508                default: None,
1509                sensitive: false,
1510                search: true,
1511                items: None,
1512                transient: false,
1513            },
1514        );
1515        schema.insert(
1516            "created_at".to_string(),
1517            FieldSchema {
1518                field_type: FieldType::Timestamp,
1519                primary: false,
1520                generated: true,
1521                required: false,
1522                unique: false,
1523                nullable: false,
1524                reference: None,
1525                min: None,
1526                max: None,
1527                format: None,
1528                values: None,
1529                default: None,
1530                sensitive: false,
1531                search: false,
1532                items: None,
1533                transient: false,
1534            },
1535        );
1536
1537        let mut endpoints = indexmap::IndexMap::new();
1538        endpoints.insert(
1539            "list".to_string(),
1540            EndpointSpec {
1541                method: Some(HttpMethod::Get),
1542                path: Some("/users".to_string()),
1543                auth: Some(AuthRule::Public),
1544                filters: Some(vec!["email".to_string()]),
1545                search: Some(vec!["email".to_string()]),
1546                pagination: Some(PaginationStyle::Cursor),
1547                sort: Some(vec!["created_at".to_string()]),
1548                ..Default::default()
1549            },
1550        );
1551
1552        ResourceDefinition {
1553            resource: "users".to_string(),
1554            version: 1,
1555            db: None,
1556            tenant_key: None,
1557            schema,
1558            endpoints: Some(endpoints),
1559            relations: None,
1560            indexes: None,
1561        }
1562    }
1563
1564    #[test]
1565    fn generates_query_as_store_module() {
1566        let resource = sample_resource();
1567        let code = generate_resource_module(&resource).unwrap();
1568
1569        assert!(code.contains("impl ResourceStore for UsersStore"));
1570        assert!(code.contains("sqlx::query_as!"));
1571        assert!(code.contains("find_all_list"));
1572    }
1573
1574    #[test]
1575    fn sensitive_field_emits_skip_serializing() {
1576        let mut resource = sample_resource();
1577        resource.schema.get_mut("email").unwrap().sensitive = true;
1578        let code = generate_resource_module(&resource).unwrap();
1579
1580        let lines: Vec<&str> = code.lines().collect();
1581        let email_idx = lines
1582            .iter()
1583            .position(|l| l.contains("pub email:"))
1584            .expect("generated module should contain `pub email:`");
1585        assert!(
1586            lines[email_idx - 1].contains("#[serde(skip_serializing)]"),
1587            "expected #[serde(skip_serializing)] above sensitive field, got: {:?}",
1588            lines[email_idx - 1]
1589        );
1590
1591        let id_idx = lines
1592            .iter()
1593            .position(|l| l.contains("pub id:"))
1594            .expect("generated module should contain `pub id:`");
1595        assert!(
1596            !lines[id_idx - 1].contains("skip_serializing"),
1597            "non-sensitive id field should not carry skip_serializing"
1598        );
1599    }
1600
1601    #[test]
1602    fn generates_registry_module() {
1603        let resource = sample_resource();
1604        let project = generate_project(&[resource]).unwrap();
1605
1606        assert!(project.mod_rs.contains("pub mod users;"));
1607        assert!(project.mod_rs.contains("build_store_registry"));
1608    }
1609
1610    #[test]
1611    fn collect_custom_handlers_returns_non_convention_endpoints_with_handler() {
1612        let mut endpoints = indexmap::IndexMap::new();
1613        endpoints.insert(
1614            "list".to_string(),
1615            EndpointSpec {
1616                method: Some(HttpMethod::Get),
1617                path: Some("/items".to_string()),
1618                handler: None,
1619                ..Default::default()
1620            },
1621        );
1622        endpoints.insert(
1623            "archive".to_string(),
1624            EndpointSpec {
1625                method: Some(HttpMethod::Post),
1626                path: Some("/items/:id/archive".to_string()),
1627                handler: Some("archive_item".to_string()),
1628                ..Default::default()
1629            },
1630        );
1631        let resource = ResourceDefinition {
1632            resource: "items".to_string(),
1633            version: 1,
1634            db: None,
1635            tenant_key: None,
1636            schema: indexmap::IndexMap::new(),
1637            endpoints: Some(endpoints),
1638            relations: None,
1639            indexes: None,
1640        };
1641        let resources = [resource];
1642        let result = collect_custom_handlers(&resources);
1643        assert_eq!(result.len(), 1);
1644        assert_eq!(result[0].0, "items");
1645        assert_eq!(result[0].1, vec![("archive", "archive_item")]);
1646    }
1647
1648    #[test]
1649    fn collect_custom_handlers_ignores_convention_endpoints() {
1650        let mut endpoints = indexmap::IndexMap::new();
1651        for action in &["list", "get", "create", "update", "delete"] {
1652            endpoints.insert(
1653                action.to_string(),
1654                EndpointSpec {
1655                    method: Some(HttpMethod::Get),
1656                    handler: Some("some_fn".to_string()),
1657                    ..Default::default()
1658                },
1659            );
1660        }
1661        let resource = ResourceDefinition {
1662            resource: "items".to_string(),
1663            version: 1,
1664            db: None,
1665            tenant_key: None,
1666            schema: indexmap::IndexMap::new(),
1667            endpoints: Some(endpoints),
1668            relations: None,
1669            indexes: None,
1670        };
1671        let resources = [resource];
1672        let result = collect_custom_handlers(&resources);
1673        assert!(
1674            result.is_empty(),
1675            "convention endpoints should not be collected"
1676        );
1677    }
1678
1679    #[test]
1680    fn generate_handler_map_fn_empty_returns_new() {
1681        let resources: Vec<ResourceDefinition> = vec![];
1682        let output = generate_handler_map_fn(&resources);
1683        assert!(output.contains("CustomHandlerMap::new()"));
1684        assert!(!output.contains("map.insert"));
1685    }
1686
1687    #[test]
1688    fn generate_handler_map_fn_with_handler_contains_insert() {
1689        let mut endpoints = indexmap::IndexMap::new();
1690        endpoints.insert(
1691            "archive".to_string(),
1692            EndpointSpec {
1693                method: Some(HttpMethod::Post),
1694                path: Some("/items/:id/archive".to_string()),
1695                handler: Some("archive_item".to_string()),
1696                ..Default::default()
1697            },
1698        );
1699        let resource = ResourceDefinition {
1700            resource: "items".to_string(),
1701            version: 1,
1702            db: None,
1703            tenant_key: None,
1704            schema: indexmap::IndexMap::new(),
1705            endpoints: Some(endpoints),
1706            relations: None,
1707            indexes: None,
1708        };
1709        let resources = [resource];
1710        let output = generate_handler_map_fn(&resources);
1711        assert!(output.contains("items_handlers::archive_item"));
1712        assert!(output.contains(r#"handler_key("items", "archive")"#));
1713    }
1714}