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