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