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