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)) => {
1186 let default_expr = default_expression(field_name, field, default)?;
1187 if default_is_bare_literal(field) {
1194 format!("{parsed}.unwrap_or({default_expr})")
1195 } else {
1196 format!("match {parsed} {{ Some(value) => value, None => {default_expr} }}")
1197 }
1198 }
1199 (true, None) => format!("shaperail_runtime::db::require_field({parsed}, {field_name:?})?"),
1200 (false, Some(default)) if model_field_is_optional(field) => format!(
1201 "match {parsed} {{ Some(value) => Some(value), None => Some({}) }}",
1202 default_expression(field_name, field, default)?
1203 ),
1204 (false, Some(default)) => {
1205 let default_expr = default_expression(field_name, field, default)?;
1206 if default_is_bare_literal(field) {
1207 format!("{parsed}.unwrap_or({default_expr})")
1208 } else {
1209 format!("match {parsed} {{ Some(value) => value, None => {default_expr} }}")
1210 }
1211 }
1212 (false, None) => parsed,
1213 };
1214
1215 Ok(format!(" let {variable_name} = {expression};"))
1216}
1217
1218fn generate_update_declaration(
1219 field_name: &str,
1220 field: &FieldSchema,
1221 present_name: &str,
1222 value_name: &str,
1223) -> String {
1224 format!(
1225 " let {present_name} = data.contains_key({field_name:?});\n let {value_name} = shaperail_runtime::db::parse_optional_json::<{parse_type}>(data, {field_name:?})?;",
1226 parse_type = parse_type(field)
1227 )
1228}
1229
1230fn generate_filter_declaration(field_name: &str, field: &FieldSchema) -> String {
1231 let parser = match field.field_type {
1232 FieldType::Uuid => "uuid::Uuid::parse_str(text).map_err(|_| shaperail_core::ShaperailError::Internal(\"invalid uuid filter\".to_string()))",
1233 FieldType::String | FieldType::Enum | FieldType::File => "Ok(text.to_string())",
1234 FieldType::Integer => "text.parse::<i64>().map_err(|_| shaperail_core::ShaperailError::Internal(\"invalid integer filter\".to_string()))",
1235 FieldType::Number => "text.parse::<f64>().map_err(|_| shaperail_core::ShaperailError::Internal(\"invalid number filter\".to_string()))",
1236 FieldType::Boolean => "text.parse::<bool>().map_err(|_| shaperail_core::ShaperailError::Internal(\"invalid boolean filter\".to_string()))",
1237 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()))",
1238 FieldType::Date => "chrono::NaiveDate::parse_from_str(text, \"%Y-%m-%d\").map_err(|_| shaperail_core::ShaperailError::Internal(\"invalid date filter\".to_string()))",
1239 FieldType::Json => "serde_json::from_str::<serde_json::Value>(text).map_err(|_| shaperail_core::ShaperailError::Internal(\"invalid json filter\".to_string()))",
1240 FieldType::Array => "serde_json::from_str::<Vec<serde_json::Value>>(text).map_err(|_| shaperail_core::ShaperailError::Internal(\"invalid array filter\".to_string()))",
1241 };
1242
1243 format!(
1244 " let {var} = parse_filter(filters, {field_name:?}, \"invalid_filter\", |text| {parser})?;",
1245 var = field_parameter_name(field_name)
1246 )
1247}
1248
1249fn field_parameter_name(field_name: &str) -> String {
1250 format!("filter_{}", sanitize_identifier(field_name))
1251}
1252
1253fn parameter_expression(field_name: &str, field: &FieldSchema) -> String {
1254 let var = field_parameter_name(field_name);
1255 match field.field_type {
1256 FieldType::String | FieldType::Enum | FieldType::File => format!("{var}.as_deref()"),
1257 _ => var,
1258 }
1259}
1260
1261fn select_column_sql(field_name: &str, field: &FieldSchema) -> String {
1262 let nullability = if model_field_is_optional(field) {
1263 "?"
1264 } else {
1265 "!"
1266 };
1267 let expression = match field.field_type {
1268 FieldType::Number => format!("\"{field_name}\"::DOUBLE PRECISION"),
1269 _ => format!("\"{field_name}\""),
1270 };
1271 format!(
1272 "{expression} as \"{field_name}{nullability}: {type_name}\"",
1273 type_name = query_type(field)
1274 )
1275}
1276
1277fn sortable_expression(field_name: &str, field: &FieldSchema) -> String {
1278 match field.field_type {
1279 FieldType::Json | FieldType::Array | FieldType::Uuid => format!("\"{field_name}\"::text"),
1280 FieldType::Number => format!("\"{field_name}\"::DOUBLE PRECISION"),
1281 _ => format!("\"{field_name}\""),
1282 }
1283}
1284
1285fn search_expression(fields: &[String]) -> String {
1286 fields
1287 .iter()
1288 .map(|field| format!("COALESCE(\"{field}\"::text, '')"))
1289 .collect::<Vec<_>>()
1290 .join(" || ' ' || ")
1291}
1292
1293fn sql_cast_type(field: &FieldSchema) -> String {
1294 match field.field_type {
1295 FieldType::Uuid => "uuid".to_string(),
1296 FieldType::String | FieldType::Enum | FieldType::File => "text".to_string(),
1297 FieldType::Integer => "bigint".to_string(),
1298 FieldType::Number => "double precision".to_string(),
1299 FieldType::Boolean => "boolean".to_string(),
1300 FieldType::Timestamp => "timestamptz".to_string(),
1301 FieldType::Date => "date".to_string(),
1302 FieldType::Json => "jsonb".to_string(),
1303 FieldType::Array => match field.items.as_ref().map(|i| &i.field_type) {
1304 Some(FieldType::Uuid) => "uuid[]".to_string(),
1305 Some(FieldType::Integer) => "bigint[]".to_string(),
1306 Some(FieldType::Number) => "double precision[]".to_string(),
1307 Some(FieldType::Boolean) => "boolean[]".to_string(),
1308 Some(FieldType::Timestamp) => "timestamptz[]".to_string(),
1309 Some(FieldType::Date) => "date[]".to_string(),
1310 _ => "text[]".to_string(),
1311 },
1312 }
1313}
1314
1315fn query_type(field: &FieldSchema) -> String {
1316 match field.field_type {
1317 FieldType::Uuid => "uuid::Uuid".to_string(),
1318 FieldType::String | FieldType::Enum | FieldType::File => "String".to_string(),
1319 FieldType::Integer => "i64".to_string(),
1320 FieldType::Number => "f64".to_string(),
1321 FieldType::Boolean => "bool".to_string(),
1322 FieldType::Timestamp => "chrono::DateTime<chrono::Utc>".to_string(),
1323 FieldType::Date => "chrono::NaiveDate".to_string(),
1324 FieldType::Json => "serde_json::Value".to_string(),
1325 FieldType::Array => match field.items.as_ref().map(|i| &i.field_type) {
1326 Some(FieldType::Uuid) => "Vec<uuid::Uuid>".to_string(),
1327 Some(FieldType::Integer) => "Vec<i64>".to_string(),
1328 Some(FieldType::Number) => "Vec<f64>".to_string(),
1329 Some(FieldType::Boolean) => "Vec<bool>".to_string(),
1330 Some(FieldType::Timestamp) => "Vec<chrono::DateTime<chrono::Utc>>".to_string(),
1331 Some(FieldType::Date) => "Vec<chrono::NaiveDate>".to_string(),
1332 _ => "Vec<String>".to_string(),
1333 },
1334 }
1335}
1336
1337fn parse_type(field: &FieldSchema) -> String {
1338 query_type(field)
1339}
1340
1341fn model_field_type(field: &FieldSchema) -> String {
1342 let base = query_type(field);
1343 if model_field_is_optional(field) {
1344 format!("Option<{base}>")
1345 } else {
1346 base
1347 }
1348}
1349
1350fn model_field_is_optional(field: &FieldSchema) -> bool {
1351 if field.nullable {
1352 return true;
1353 }
1354 !(field.primary || field.required || field.generated || field.default.is_some())
1355}
1356
1357fn field_is_required(field: &FieldSchema) -> bool {
1358 !model_field_is_optional(field)
1359}
1360
1361fn generated_value_expression(field: &FieldSchema) -> String {
1362 match field.field_type {
1363 FieldType::Uuid => "uuid::Uuid::new_v4()".to_string(),
1364 FieldType::Timestamp => {
1365 if model_field_is_optional(field) {
1366 "Some(chrono::Utc::now())".to_string()
1367 } else {
1368 "chrono::Utc::now()".to_string()
1369 }
1370 }
1371 FieldType::Date => {
1372 if model_field_is_optional(field) {
1373 "Some(chrono::Utc::now().date_naive())".to_string()
1374 } else {
1375 "chrono::Utc::now().date_naive()".to_string()
1376 }
1377 }
1378 _ => "Default::default()".to_string(),
1379 }
1380}
1381
1382fn default_is_bare_literal(field: &FieldSchema) -> bool {
1390 matches!(field.field_type, FieldType::Boolean)
1391}
1392
1393fn default_expression(
1394 field_name: &str,
1395 field: &FieldSchema,
1396 default: &serde_json::Value,
1397) -> Result<String, String> {
1398 Ok(match field.field_type {
1399 FieldType::Uuid => format!(
1400 "parse_embedded_json::<uuid::Uuid>({field_name:?}, serde_json::json!({default}))?"
1401 ),
1402 FieldType::String | FieldType::Enum | FieldType::File => {
1403 let value = default
1404 .as_str()
1405 .ok_or_else(|| format!("Default for '{field_name}' must be a string"))?;
1406 format!("{value:?}.to_string()")
1407 }
1408 FieldType::Integer => format!(
1409 "parse_embedded_json::<i64>({field_name:?}, serde_json::json!({default}))?"
1410 ),
1411 FieldType::Number => format!(
1412 "parse_embedded_json::<f64>({field_name:?}, serde_json::json!({default}))?"
1413 ),
1414 FieldType::Boolean => default
1415 .as_bool()
1416 .ok_or_else(|| format!("Default for '{field_name}' must be a boolean"))?
1417 .to_string(),
1418 FieldType::Timestamp => format!(
1419 "parse_embedded_json::<chrono::DateTime<chrono::Utc>>({field_name:?}, serde_json::json!({default}))?"
1420 ),
1421 FieldType::Date => format!(
1422 "parse_embedded_json::<chrono::NaiveDate>({field_name:?}, serde_json::json!({default}))?"
1423 ),
1424 FieldType::Json => format!("serde_json::json!({default})"),
1425 FieldType::Array => format!(
1426 "parse_embedded_json::<{}>({field_name:?}, serde_json::json!({default}))?",
1427 query_type(field)
1428 ),
1429 })
1430}
1431
1432fn has_soft_delete(resource: &ResourceDefinition) -> bool {
1433 resource
1434 .endpoints
1435 .as_ref()
1436 .map(|endpoints| endpoints.values().any(|endpoint| endpoint.soft_delete))
1437 .unwrap_or(false)
1438}
1439
1440fn sanitize_identifier(value: &str) -> String {
1441 let mut output = String::new();
1442 for ch in value.chars() {
1443 if ch.is_ascii_alphanumeric() {
1444 output.push(ch.to_ascii_lowercase());
1445 } else {
1446 output.push('_');
1447 }
1448 }
1449
1450 if output.chars().next().is_some_and(|ch| ch.is_ascii_digit()) {
1451 output.insert(0, '_');
1452 }
1453
1454 output
1455}
1456
1457fn to_pascal_case(value: &str) -> String {
1458 value
1459 .split('_')
1460 .filter(|part| !part.is_empty())
1461 .map(|part| {
1462 let mut chars = part.chars();
1463 match chars.next() {
1464 Some(first) => {
1465 let mut segment = String::new();
1466 segment.extend(first.to_uppercase());
1467 segment.push_str(chars.as_str());
1468 segment
1469 }
1470 None => String::new(),
1471 }
1472 })
1473 .collect::<String>()
1474}
1475
1476fn indent_block(block: &str, indent: usize) -> String {
1477 if block.trim().is_empty() {
1478 return String::new();
1479 }
1480
1481 let prefix = " ".repeat(indent);
1482 block
1483 .lines()
1484 .map(|line| {
1485 if line.is_empty() {
1486 String::new()
1487 } else {
1488 format!("{prefix}{line}")
1489 }
1490 })
1491 .collect::<Vec<_>>()
1492 .join("\n")
1493}
1494
1495#[cfg(test)]
1496mod tests {
1497 use super::*;
1498 use indexmap::IndexMap;
1499 use shaperail_core::{
1500 AuthRule, EndpointSpec, FieldSchema, HttpMethod, PaginationStyle, ResourceDefinition,
1501 };
1502
1503 fn sample_resource() -> ResourceDefinition {
1504 let mut schema = IndexMap::new();
1505 schema.insert(
1506 "id".to_string(),
1507 FieldSchema {
1508 field_type: FieldType::Uuid,
1509 primary: true,
1510 generated: true,
1511 required: false,
1512 unique: false,
1513 nullable: false,
1514 reference: None,
1515 min: None,
1516 max: None,
1517 format: None,
1518 values: None,
1519 default: None,
1520 sensitive: false,
1521 search: false,
1522 items: None,
1523 transient: false,
1524 },
1525 );
1526 schema.insert(
1527 "email".to_string(),
1528 FieldSchema {
1529 field_type: FieldType::String,
1530 primary: false,
1531 generated: false,
1532 required: true,
1533 unique: true,
1534 nullable: false,
1535 reference: None,
1536 min: None,
1537 max: None,
1538 format: None,
1539 values: None,
1540 default: None,
1541 sensitive: false,
1542 search: true,
1543 items: None,
1544 transient: false,
1545 },
1546 );
1547 schema.insert(
1548 "created_at".to_string(),
1549 FieldSchema {
1550 field_type: FieldType::Timestamp,
1551 primary: false,
1552 generated: true,
1553 required: false,
1554 unique: false,
1555 nullable: false,
1556 reference: None,
1557 min: None,
1558 max: None,
1559 format: None,
1560 values: None,
1561 default: None,
1562 sensitive: false,
1563 search: false,
1564 items: None,
1565 transient: false,
1566 },
1567 );
1568
1569 let mut endpoints = indexmap::IndexMap::new();
1570 endpoints.insert(
1571 "list".to_string(),
1572 EndpointSpec {
1573 method: Some(HttpMethod::Get),
1574 path: Some("/users".to_string()),
1575 auth: Some(AuthRule::Public),
1576 filters: Some(vec!["email".to_string()]),
1577 search: Some(vec!["email".to_string()]),
1578 pagination: Some(PaginationStyle::Cursor),
1579 sort: Some(vec!["created_at".to_string()]),
1580 ..Default::default()
1581 },
1582 );
1583
1584 ResourceDefinition {
1585 resource: "users".to_string(),
1586 version: 1,
1587 db: None,
1588 tenant_key: None,
1589 schema,
1590 endpoints: Some(endpoints),
1591 relations: None,
1592 indexes: None,
1593 }
1594 }
1595
1596 #[test]
1597 fn generates_query_as_store_module() {
1598 let resource = sample_resource();
1599 let code = generate_resource_module(&resource).unwrap();
1600
1601 assert!(code.contains("impl ResourceStore for UsersStore"));
1602 assert!(code.contains("sqlx::query_as!"));
1603 assert!(code.contains("find_all_list"));
1604 }
1605
1606 #[test]
1607 fn sensitive_field_emits_skip_serializing() {
1608 let mut resource = sample_resource();
1609 resource.schema.get_mut("email").unwrap().sensitive = true;
1610 let code = generate_resource_module(&resource).unwrap();
1611
1612 let lines: Vec<&str> = code.lines().collect();
1613 let email_idx = lines
1614 .iter()
1615 .position(|l| l.contains("pub email:"))
1616 .expect("generated module should contain `pub email:`");
1617 assert!(
1618 lines[email_idx - 1].contains("#[serde(skip_serializing)]"),
1619 "expected #[serde(skip_serializing)] above sensitive field, got: {:?}",
1620 lines[email_idx - 1]
1621 );
1622
1623 let id_idx = lines
1624 .iter()
1625 .position(|l| l.contains("pub id:"))
1626 .expect("generated module should contain `pub id:`");
1627 assert!(
1628 !lines[id_idx - 1].contains("skip_serializing"),
1629 "non-sensitive id field should not carry skip_serializing"
1630 );
1631 }
1632
1633 #[test]
1634 fn generates_registry_module() {
1635 let resource = sample_resource();
1636 let project = generate_project(&[resource]).unwrap();
1637
1638 assert!(project.mod_rs.contains("pub mod users;"));
1639 assert!(project.mod_rs.contains("build_store_registry"));
1640 }
1641
1642 #[test]
1643 fn boolean_default_emits_unwrap_or_form() {
1644 let mut resource = sample_resource();
1650 resource.schema.insert(
1651 "is_active".to_string(),
1652 FieldSchema {
1653 field_type: FieldType::Boolean,
1654 primary: false,
1655 generated: false,
1656 required: true,
1657 unique: false,
1658 nullable: false,
1659 reference: None,
1660 min: None,
1661 max: None,
1662 format: None,
1663 values: None,
1664 default: Some(serde_json::json!(true)),
1665 sensitive: false,
1666 search: false,
1667 items: None,
1668 transient: false,
1669 },
1670 );
1671
1672 let code = generate_resource_module(&resource).unwrap();
1673
1674 assert!(
1675 code.contains(".unwrap_or(true)"),
1676 "boolean default should emit `.unwrap_or(true)` form; got:\n{code}"
1677 );
1678 assert!(
1679 !code.contains("Some(value) => value, None => true"),
1680 "boolean default should NOT keep the manual match form; got:\n{code}"
1681 );
1682 }
1683
1684 #[test]
1685 fn string_default_keeps_match_form() {
1686 let mut resource = sample_resource();
1692 resource.schema.insert(
1693 "tier".to_string(),
1694 FieldSchema {
1695 field_type: FieldType::String,
1696 primary: false,
1697 generated: false,
1698 required: true,
1699 unique: false,
1700 nullable: false,
1701 reference: None,
1702 min: None,
1703 max: None,
1704 format: None,
1705 values: None,
1706 default: Some(serde_json::json!("standard")),
1707 sensitive: false,
1708 search: false,
1709 items: None,
1710 transient: false,
1711 },
1712 );
1713
1714 let code = generate_resource_module(&resource).unwrap();
1715 assert!(
1716 code.contains("Some(value) => value, None => \"standard\".to_string()"),
1717 "string default should keep the explicit match form; got:\n{code}"
1718 );
1719 }
1720
1721 #[test]
1722 fn collect_custom_handlers_returns_non_convention_endpoints_with_handler() {
1723 let mut endpoints = indexmap::IndexMap::new();
1724 endpoints.insert(
1725 "list".to_string(),
1726 EndpointSpec {
1727 method: Some(HttpMethod::Get),
1728 path: Some("/items".to_string()),
1729 handler: None,
1730 ..Default::default()
1731 },
1732 );
1733 endpoints.insert(
1734 "archive".to_string(),
1735 EndpointSpec {
1736 method: Some(HttpMethod::Post),
1737 path: Some("/items/:id/archive".to_string()),
1738 handler: Some("archive_item".to_string()),
1739 ..Default::default()
1740 },
1741 );
1742 let resource = ResourceDefinition {
1743 resource: "items".to_string(),
1744 version: 1,
1745 db: None,
1746 tenant_key: None,
1747 schema: indexmap::IndexMap::new(),
1748 endpoints: Some(endpoints),
1749 relations: None,
1750 indexes: None,
1751 };
1752 let resources = [resource];
1753 let result = collect_custom_handlers(&resources);
1754 assert_eq!(result.len(), 1);
1755 assert_eq!(result[0].0, "items");
1756 assert_eq!(result[0].1, vec![("archive", "archive_item")]);
1757 }
1758
1759 #[test]
1760 fn collect_custom_handlers_ignores_convention_endpoints() {
1761 let mut endpoints = indexmap::IndexMap::new();
1762 for action in &["list", "get", "create", "update", "delete"] {
1763 endpoints.insert(
1764 action.to_string(),
1765 EndpointSpec {
1766 method: Some(HttpMethod::Get),
1767 handler: Some("some_fn".to_string()),
1768 ..Default::default()
1769 },
1770 );
1771 }
1772 let resource = ResourceDefinition {
1773 resource: "items".to_string(),
1774 version: 1,
1775 db: None,
1776 tenant_key: None,
1777 schema: indexmap::IndexMap::new(),
1778 endpoints: Some(endpoints),
1779 relations: None,
1780 indexes: None,
1781 };
1782 let resources = [resource];
1783 let result = collect_custom_handlers(&resources);
1784 assert!(
1785 result.is_empty(),
1786 "convention endpoints should not be collected"
1787 );
1788 }
1789
1790 #[test]
1791 fn generate_handler_map_fn_empty_returns_new() {
1792 let resources: Vec<ResourceDefinition> = vec![];
1793 let output = generate_handler_map_fn(&resources);
1794 assert!(output.contains("CustomHandlerMap::new()"));
1795 assert!(!output.contains("map.insert"));
1796 }
1797
1798 #[test]
1799 fn generate_handler_map_fn_with_handler_contains_insert() {
1800 let mut endpoints = indexmap::IndexMap::new();
1801 endpoints.insert(
1802 "archive".to_string(),
1803 EndpointSpec {
1804 method: Some(HttpMethod::Post),
1805 path: Some("/items/:id/archive".to_string()),
1806 handler: Some("archive_item".to_string()),
1807 ..Default::default()
1808 },
1809 );
1810 let resource = ResourceDefinition {
1811 resource: "items".to_string(),
1812 version: 1,
1813 db: None,
1814 tenant_key: None,
1815 schema: indexmap::IndexMap::new(),
1816 endpoints: Some(endpoints),
1817 relations: None,
1818 indexes: None,
1819 };
1820 let resources = [resource];
1821 let output = generate_handler_map_fn(&resources);
1822 assert!(output.contains("items_handlers::archive_item"));
1823 assert!(output.contains(r#"handler_key("items", "archive")"#));
1824 }
1825
1826 fn field(
1827 ty: FieldType,
1828 primary: bool,
1829 required: bool,
1830 nullable: bool,
1831 generated: bool,
1832 default: Option<serde_json::Value>,
1833 ) -> FieldSchema {
1834 FieldSchema {
1835 field_type: ty,
1836 primary,
1837 generated,
1838 required,
1839 unique: false,
1840 nullable,
1841 reference: None,
1842 min: None,
1843 max: None,
1844 format: None,
1845 values: None,
1846 default,
1847 sensitive: false,
1848 search: false,
1849 items: None,
1850 transient: false,
1851 }
1852 }
1853
1854 #[test]
1855 fn model_optional_for_required_only_field_is_false() {
1856 let f = field(FieldType::Integer, false, true, false, false, None);
1857 assert!(!model_field_is_optional(&f));
1858 }
1859
1860 #[test]
1861 fn model_optional_for_required_with_default_is_false() {
1862 let f = field(
1863 FieldType::Integer,
1864 false,
1865 true,
1866 false,
1867 false,
1868 Some(serde_json::json!(0)),
1869 );
1870 assert!(!model_field_is_optional(&f));
1871 }
1872
1873 #[test]
1874 fn model_optional_for_unrequired_with_default_is_false() {
1875 let f = field(
1877 FieldType::Integer,
1878 false,
1879 false,
1880 false,
1881 false,
1882 Some(serde_json::json!(0)),
1883 );
1884 assert!(!model_field_is_optional(&f));
1885 }
1886
1887 #[test]
1888 fn model_optional_for_generated_without_required_is_false() {
1889 let f = field(FieldType::Timestamp, false, false, false, true, None);
1891 assert!(!model_field_is_optional(&f));
1892 }
1893
1894 #[test]
1895 fn model_optional_for_nullable_is_true() {
1896 let f = field(FieldType::String, false, false, true, false, None);
1897 assert!(model_field_is_optional(&f));
1898 }
1899
1900 #[test]
1901 fn model_optional_for_nullable_with_default_is_true() {
1902 let f = field(
1904 FieldType::String,
1905 false,
1906 false,
1907 true,
1908 false,
1909 Some(serde_json::json!("x")),
1910 );
1911 assert!(model_field_is_optional(&f));
1912 }
1913
1914 #[test]
1915 fn model_optional_for_plain_optional_field_is_true() {
1916 let f = field(FieldType::String, false, false, false, false, None);
1917 assert!(model_field_is_optional(&f));
1918 }
1919
1920 #[test]
1921 fn model_optional_for_primary_key_is_false() {
1922 let f = field(FieldType::Uuid, true, false, false, true, None);
1923 assert!(!model_field_is_optional(&f));
1924 }
1925
1926 #[test]
1927 fn generated_mod_rs_emits_pub_resources_aggregator() {
1928 let mut resource = sample_resource();
1929 let endpoint = resource
1931 .endpoints
1932 .as_mut()
1933 .unwrap()
1934 .get_mut("list")
1935 .unwrap();
1936 endpoint.controller = Some(shaperail_core::ControllerSpec {
1937 before: Some(shaperail_core::HookList::Single("validate".to_string())),
1938 after: None,
1939 });
1940
1941 let project = generate_project(std::slice::from_ref(&resource)).unwrap();
1942 assert!(
1945 project.mod_rs.contains("pub mod users_controller;"),
1946 "controller module must be `pub mod`, not `mod`. Got:\n{}",
1947 project.mod_rs
1948 );
1949 assert!(
1950 project.mod_rs.contains("#[doc(hidden)]"),
1951 "expected #[doc(hidden)] guard on resources aggregator. Got:\n{}",
1952 project.mod_rs
1953 );
1954 assert!(
1955 project.mod_rs.contains("pub mod resources"),
1956 "expected `pub mod resources` aggregator. Got:\n{}",
1957 project.mod_rs
1958 );
1959 assert!(
1960 project.mod_rs.contains("pub use super::users_controller;"),
1961 "expected re-export of users_controller in resources aggregator. Got:\n{}",
1962 project.mod_rs
1963 );
1964 }
1965}