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