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
178fn 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
212pub const HANDLER_CONVENTIONS: &[&str] = &["list", "get", "create", "update", "delete"];
214
215fn 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
242fn 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
257fn 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
265fn 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
275fn 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 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 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 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}