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_deref() {
1280            Some("uuid") => "uuid[]".to_string(),
1281            Some("integer") => "integer[]".to_string(),
1282            Some("bigint") => "bigint[]".to_string(),
1283            Some("number") => "double precision[]".to_string(),
1284            Some("boolean") => "boolean[]".to_string(),
1285            _ => "text[]".to_string(),
1286        },
1287    }
1288}
1289
1290fn query_type(field: &FieldSchema) -> String {
1291    match field.field_type {
1292        FieldType::Uuid => "uuid::Uuid".to_string(),
1293        FieldType::String | FieldType::Enum | FieldType::File => "String".to_string(),
1294        FieldType::Integer => "i32".to_string(),
1295        FieldType::Bigint => "i64".to_string(),
1296        FieldType::Number => "f64".to_string(),
1297        FieldType::Boolean => "bool".to_string(),
1298        FieldType::Timestamp => "chrono::DateTime<chrono::Utc>".to_string(),
1299        FieldType::Date => "chrono::NaiveDate".to_string(),
1300        FieldType::Json => "serde_json::Value".to_string(),
1301        FieldType::Array => match field.items.as_deref() {
1302            Some("uuid") => "Vec<uuid::Uuid>".to_string(),
1303            Some("integer") => "Vec<i32>".to_string(),
1304            Some("bigint") => "Vec<i64>".to_string(),
1305            Some("number") => "Vec<f64>".to_string(),
1306            Some("boolean") => "Vec<bool>".to_string(),
1307            Some("timestamp") => "Vec<chrono::DateTime<chrono::Utc>>".to_string(),
1308            Some("date") => "Vec<chrono::NaiveDate>".to_string(),
1309            _ => "Vec<String>".to_string(),
1310        },
1311    }
1312}
1313
1314fn parse_type(field: &FieldSchema) -> String {
1315    query_type(field)
1316}
1317
1318fn model_field_type(field: &FieldSchema) -> String {
1319    let base = query_type(field);
1320    if model_field_is_optional(field) {
1321        format!("Option<{base}>")
1322    } else {
1323        base
1324    }
1325}
1326
1327fn model_field_is_optional(field: &FieldSchema) -> bool {
1328    !(field.primary || (field.required && !field.nullable))
1329}
1330
1331fn field_is_required(field: &FieldSchema) -> bool {
1332    field.primary || (field.required && !field.nullable)
1333}
1334
1335fn generated_value_expression(field: &FieldSchema) -> String {
1336    match field.field_type {
1337        FieldType::Uuid => "uuid::Uuid::new_v4()".to_string(),
1338        FieldType::Timestamp => {
1339            if model_field_is_optional(field) {
1340                "Some(chrono::Utc::now())".to_string()
1341            } else {
1342                "chrono::Utc::now()".to_string()
1343            }
1344        }
1345        FieldType::Date => {
1346            if model_field_is_optional(field) {
1347                "Some(chrono::Utc::now().date_naive())".to_string()
1348            } else {
1349                "chrono::Utc::now().date_naive()".to_string()
1350            }
1351        }
1352        _ => "Default::default()".to_string(),
1353    }
1354}
1355
1356fn default_expression(
1357    field_name: &str,
1358    field: &FieldSchema,
1359    default: &serde_json::Value,
1360) -> Result<String, String> {
1361    Ok(match field.field_type {
1362        FieldType::Uuid => format!(
1363            "parse_embedded_json::<uuid::Uuid>({field_name:?}, serde_json::json!({default}))?"
1364        ),
1365        FieldType::String | FieldType::Enum | FieldType::File => {
1366            let value = default
1367                .as_str()
1368                .ok_or_else(|| format!("Default for '{field_name}' must be a string"))?;
1369            format!("{value:?}.to_string()")
1370        }
1371        FieldType::Integer => format!(
1372            "parse_embedded_json::<i32>({field_name:?}, serde_json::json!({default}))?"
1373        ),
1374        FieldType::Bigint => format!(
1375            "parse_embedded_json::<i64>({field_name:?}, serde_json::json!({default}))?"
1376        ),
1377        FieldType::Number => format!(
1378            "parse_embedded_json::<f64>({field_name:?}, serde_json::json!({default}))?"
1379        ),
1380        FieldType::Boolean => default
1381            .as_bool()
1382            .ok_or_else(|| format!("Default for '{field_name}' must be a boolean"))?
1383            .to_string(),
1384        FieldType::Timestamp => format!(
1385            "parse_embedded_json::<chrono::DateTime<chrono::Utc>>({field_name:?}, serde_json::json!({default}))?"
1386        ),
1387        FieldType::Date => format!(
1388            "parse_embedded_json::<chrono::NaiveDate>({field_name:?}, serde_json::json!({default}))?"
1389        ),
1390        FieldType::Json => format!("serde_json::json!({default})"),
1391        FieldType::Array => format!(
1392            "parse_embedded_json::<{}>({field_name:?}, serde_json::json!({default}))?",
1393            query_type(field)
1394        ),
1395    })
1396}
1397
1398fn has_soft_delete(resource: &ResourceDefinition) -> bool {
1399    resource
1400        .endpoints
1401        .as_ref()
1402        .map(|endpoints| endpoints.values().any(|endpoint| endpoint.soft_delete))
1403        .unwrap_or(false)
1404}
1405
1406fn sanitize_identifier(value: &str) -> String {
1407    let mut output = String::new();
1408    for ch in value.chars() {
1409        if ch.is_ascii_alphanumeric() {
1410            output.push(ch.to_ascii_lowercase());
1411        } else {
1412            output.push('_');
1413        }
1414    }
1415
1416    if output.chars().next().is_some_and(|ch| ch.is_ascii_digit()) {
1417        output.insert(0, '_');
1418    }
1419
1420    output
1421}
1422
1423fn to_pascal_case(value: &str) -> String {
1424    value
1425        .split('_')
1426        .filter(|part| !part.is_empty())
1427        .map(|part| {
1428            let mut chars = part.chars();
1429            match chars.next() {
1430                Some(first) => {
1431                    let mut segment = String::new();
1432                    segment.extend(first.to_uppercase());
1433                    segment.push_str(chars.as_str());
1434                    segment
1435                }
1436                None => String::new(),
1437            }
1438        })
1439        .collect::<String>()
1440}
1441
1442fn indent_block(block: &str, indent: usize) -> String {
1443    if block.trim().is_empty() {
1444        return String::new();
1445    }
1446
1447    let prefix = "    ".repeat(indent);
1448    block
1449        .lines()
1450        .map(|line| {
1451            if line.is_empty() {
1452                String::new()
1453            } else {
1454                format!("{prefix}{line}")
1455            }
1456        })
1457        .collect::<Vec<_>>()
1458        .join("\n")
1459}
1460
1461#[cfg(test)]
1462mod tests {
1463    use super::*;
1464    use indexmap::IndexMap;
1465    use shaperail_core::{
1466        AuthRule, EndpointSpec, FieldSchema, HttpMethod, PaginationStyle, ResourceDefinition,
1467    };
1468
1469    fn sample_resource() -> ResourceDefinition {
1470        let mut schema = IndexMap::new();
1471        schema.insert(
1472            "id".to_string(),
1473            FieldSchema {
1474                field_type: FieldType::Uuid,
1475                primary: true,
1476                generated: true,
1477                required: false,
1478                unique: false,
1479                nullable: false,
1480                reference: None,
1481                min: None,
1482                max: None,
1483                format: None,
1484                values: None,
1485                default: None,
1486                sensitive: false,
1487                search: false,
1488                items: None,
1489                transient: false,
1490            },
1491        );
1492        schema.insert(
1493            "email".to_string(),
1494            FieldSchema {
1495                field_type: FieldType::String,
1496                primary: false,
1497                generated: false,
1498                required: true,
1499                unique: true,
1500                nullable: false,
1501                reference: None,
1502                min: None,
1503                max: None,
1504                format: None,
1505                values: None,
1506                default: None,
1507                sensitive: false,
1508                search: true,
1509                items: None,
1510                transient: false,
1511            },
1512        );
1513        schema.insert(
1514            "created_at".to_string(),
1515            FieldSchema {
1516                field_type: FieldType::Timestamp,
1517                primary: false,
1518                generated: true,
1519                required: false,
1520                unique: false,
1521                nullable: false,
1522                reference: None,
1523                min: None,
1524                max: None,
1525                format: None,
1526                values: None,
1527                default: None,
1528                sensitive: false,
1529                search: false,
1530                items: None,
1531                transient: false,
1532            },
1533        );
1534
1535        let mut endpoints = indexmap::IndexMap::new();
1536        endpoints.insert(
1537            "list".to_string(),
1538            EndpointSpec {
1539                method: Some(HttpMethod::Get),
1540                path: Some("/users".to_string()),
1541                auth: Some(AuthRule::Public),
1542                filters: Some(vec!["email".to_string()]),
1543                search: Some(vec!["email".to_string()]),
1544                pagination: Some(PaginationStyle::Cursor),
1545                sort: Some(vec!["created_at".to_string()]),
1546                ..Default::default()
1547            },
1548        );
1549
1550        ResourceDefinition {
1551            resource: "users".to_string(),
1552            version: 1,
1553            db: None,
1554            tenant_key: None,
1555            schema,
1556            endpoints: Some(endpoints),
1557            relations: None,
1558            indexes: None,
1559        }
1560    }
1561
1562    #[test]
1563    fn generates_query_as_store_module() {
1564        let resource = sample_resource();
1565        let code = generate_resource_module(&resource).unwrap();
1566
1567        assert!(code.contains("impl ResourceStore for UsersStore"));
1568        assert!(code.contains("sqlx::query_as!"));
1569        assert!(code.contains("find_all_list"));
1570    }
1571
1572    #[test]
1573    fn sensitive_field_emits_skip_serializing() {
1574        let mut resource = sample_resource();
1575        resource.schema.get_mut("email").unwrap().sensitive = true;
1576        let code = generate_resource_module(&resource).unwrap();
1577
1578        let lines: Vec<&str> = code.lines().collect();
1579        let email_idx = lines
1580            .iter()
1581            .position(|l| l.contains("pub email:"))
1582            .expect("generated module should contain `pub email:`");
1583        assert!(
1584            lines[email_idx - 1].contains("#[serde(skip_serializing)]"),
1585            "expected #[serde(skip_serializing)] above sensitive field, got: {:?}",
1586            lines[email_idx - 1]
1587        );
1588
1589        let id_idx = lines
1590            .iter()
1591            .position(|l| l.contains("pub id:"))
1592            .expect("generated module should contain `pub id:`");
1593        assert!(
1594            !lines[id_idx - 1].contains("skip_serializing"),
1595            "non-sensitive id field should not carry skip_serializing"
1596        );
1597    }
1598
1599    #[test]
1600    fn generates_registry_module() {
1601        let resource = sample_resource();
1602        let project = generate_project(&[resource]).unwrap();
1603
1604        assert!(project.mod_rs.contains("pub mod users;"));
1605        assert!(project.mod_rs.contains("build_store_registry"));
1606    }
1607
1608    #[test]
1609    fn collect_custom_handlers_returns_non_convention_endpoints_with_handler() {
1610        let mut endpoints = indexmap::IndexMap::new();
1611        endpoints.insert(
1612            "list".to_string(),
1613            EndpointSpec {
1614                method: Some(HttpMethod::Get),
1615                path: Some("/items".to_string()),
1616                handler: None,
1617                ..Default::default()
1618            },
1619        );
1620        endpoints.insert(
1621            "archive".to_string(),
1622            EndpointSpec {
1623                method: Some(HttpMethod::Post),
1624                path: Some("/items/:id/archive".to_string()),
1625                handler: Some("archive_item".to_string()),
1626                ..Default::default()
1627            },
1628        );
1629        let resource = ResourceDefinition {
1630            resource: "items".to_string(),
1631            version: 1,
1632            db: None,
1633            tenant_key: None,
1634            schema: indexmap::IndexMap::new(),
1635            endpoints: Some(endpoints),
1636            relations: None,
1637            indexes: None,
1638        };
1639        let resources = [resource];
1640        let result = collect_custom_handlers(&resources);
1641        assert_eq!(result.len(), 1);
1642        assert_eq!(result[0].0, "items");
1643        assert_eq!(result[0].1, vec![("archive", "archive_item")]);
1644    }
1645
1646    #[test]
1647    fn collect_custom_handlers_ignores_convention_endpoints() {
1648        let mut endpoints = indexmap::IndexMap::new();
1649        for action in &["list", "get", "create", "update", "delete"] {
1650            endpoints.insert(
1651                action.to_string(),
1652                EndpointSpec {
1653                    method: Some(HttpMethod::Get),
1654                    handler: Some("some_fn".to_string()),
1655                    ..Default::default()
1656                },
1657            );
1658        }
1659        let resource = ResourceDefinition {
1660            resource: "items".to_string(),
1661            version: 1,
1662            db: None,
1663            tenant_key: None,
1664            schema: indexmap::IndexMap::new(),
1665            endpoints: Some(endpoints),
1666            relations: None,
1667            indexes: None,
1668        };
1669        let resources = [resource];
1670        let result = collect_custom_handlers(&resources);
1671        assert!(
1672            result.is_empty(),
1673            "convention endpoints should not be collected"
1674        );
1675    }
1676
1677    #[test]
1678    fn generate_handler_map_fn_empty_returns_new() {
1679        let resources: Vec<ResourceDefinition> = vec![];
1680        let output = generate_handler_map_fn(&resources);
1681        assert!(output.contains("CustomHandlerMap::new()"));
1682        assert!(!output.contains("map.insert"));
1683    }
1684
1685    #[test]
1686    fn generate_handler_map_fn_with_handler_contains_insert() {
1687        let mut endpoints = indexmap::IndexMap::new();
1688        endpoints.insert(
1689            "archive".to_string(),
1690            EndpointSpec {
1691                method: Some(HttpMethod::Post),
1692                path: Some("/items/:id/archive".to_string()),
1693                handler: Some("archive_item".to_string()),
1694                ..Default::default()
1695            },
1696        );
1697        let resource = ResourceDefinition {
1698            resource: "items".to_string(),
1699            version: 1,
1700            db: None,
1701            tenant_key: None,
1702            schema: indexmap::IndexMap::new(),
1703            endpoints: Some(endpoints),
1704            relations: None,
1705            indexes: None,
1706        };
1707        let resources = [resource];
1708        let output = generate_handler_map_fn(&resources);
1709        assert!(output.contains("items_handlers::archive_item"));
1710        assert!(output.contains(r#"handler_key("items", "archive")"#));
1711    }
1712}