Skip to main content

shaperail_codegen/
rust.rs

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
216/// Generate typed controller trait stubs for all resources that declare controllers.
217///
218/// For each resource with controller declarations, generates:
219/// - An input struct for each endpoint action that has a controller
220/// - A trait with the exact function signatures the controller must implement
221///
222/// This eliminates the #1 source of LLM errors: guessing controller function signatures.
223fn 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            // Determine input fields for this endpoint
250            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            // Generate trait method for before hook
281            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            // Generate trait method for after hook
295            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}