use shaperail_core::{EndpointSpec, FieldSchema, FieldType, HttpMethod, ResourceDefinition};
pub struct GeneratedRustModule {
pub file_name: String,
pub contents: String,
}
pub struct GeneratedRustProject {
pub modules: Vec<GeneratedRustModule>,
pub mod_rs: String,
}
pub fn generate_project(resources: &[ResourceDefinition]) -> Result<GeneratedRustProject, String> {
let mut modules = Vec::with_capacity(resources.len());
for resource in resources {
modules.push(GeneratedRustModule {
file_name: format!("{}.rs", resource.resource),
contents: generate_resource_module(resource)?,
});
}
Ok(GeneratedRustProject {
modules,
mod_rs: generate_registry_module(resources),
})
}
pub fn generate_resource_module(resource: &ResourceDefinition) -> Result<String, String> {
let context = ResourceContext::new(resource)?;
let model_fields = resource
.schema
.iter()
.map(|(name, field)| format!(" pub {name}: {},", model_field_type(field)))
.collect::<Vec<_>>()
.join("\n");
let list_helpers = context
.collection_endpoints
.iter()
.map(|endpoint| generate_list_helper(&context, endpoint))
.collect::<Result<Vec<_>, _>>()?
.join("\n\n");
let list_dispatch = if context.collection_endpoints.is_empty() {
" 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()
} else {
let arms = context
.collection_endpoints
.iter()
.map(|endpoint| {
format!(
" {path:?} => self.{helper}(filters, search, sort, page).await,",
path = endpoint.spec.path(),
helper = endpoint.helper_name
)
})
.collect::<Vec<_>>()
.join("\n");
format!(
" match endpoint.path() {{\n{arms}\n _ => Err(shaperail_core::ShaperailError::Internal(format!(\"No generated list query for {{}}\", endpoint.path()))),\n }}"
)
};
Ok(format!(
r###"//! Generated query module for the `{resource_name}` resource.
//! DO NOT EDIT — this file is auto-generated by `shaperail generate`.
use serde::{{Deserialize, Serialize}};
use serde_json::{{Map, Value}};
use shaperail_core::EndpointSpec;
#[allow(unused_imports)]
use shaperail_runtime::db::{{
async_trait, parse_embedded_json, parse_filter, parse_optional_json, require_field,
row_from_model, sort_direction_at, sort_field_at, FilterSet, PageRequest, ResourceRow,
ResourceStore, SearchParam, SortParam,
}};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct {record_name} {{
{model_fields}
}}
pub struct {store_name} {{
pool: sqlx::PgPool,
}}
impl {store_name} {{
pub fn new(pool: sqlx::PgPool) -> Self {{
Self {{ pool }}
}}
{list_helpers}
}}
#[async_trait]
impl ResourceStore for {store_name} {{
fn resource_name(&self) -> &str {{
"{resource_name}"
}}
async fn find_by_id(&self, id: &uuid::Uuid) -> Result<ResourceRow, shaperail_core::ShaperailError> {{
let row = sqlx::query_as!(
{record_name},
r#"
SELECT
{select_columns}
FROM "{table_name}"
WHERE "{primary_key}" = $1{soft_delete_where}
"#,
id
)
.fetch_optional(&self.pool)
.await?
.ok_or(shaperail_core::ShaperailError::NotFound)?;
row_from_model(&row)
}}
async fn find_all(
&self,
endpoint: &EndpointSpec,
filters: &FilterSet,
search: Option<&SearchParam>,
sort: &SortParam,
page: &PageRequest,
) -> Result<(Vec<ResourceRow>, Value), shaperail_core::ShaperailError> {{
{list_dispatch}
}}
async fn insert(&self, data: &Map<String, Value>) -> Result<ResourceRow, shaperail_core::ShaperailError> {{
{insert_body}
}}
async fn update_by_id(
&self,
id: &uuid::Uuid,
data: &Map<String, Value>,
) -> Result<ResourceRow, shaperail_core::ShaperailError> {{
{update_body}
}}
async fn soft_delete_by_id(&self, id: &uuid::Uuid) -> Result<ResourceRow, shaperail_core::ShaperailError> {{
{soft_delete_body}
}}
async fn hard_delete_by_id(&self, id: &uuid::Uuid) -> Result<ResourceRow, shaperail_core::ShaperailError> {{
{hard_delete_body}
}}
}}
"###,
resource_name = resource.resource,
record_name = context.record_name,
store_name = context.store_name,
model_fields = model_fields,
list_helpers = list_helpers,
select_columns = context.select_columns,
table_name = resource.resource,
primary_key = context.primary_key,
soft_delete_where = context.soft_delete_where,
list_dispatch = list_dispatch,
insert_body = generate_insert_body(&context)?,
update_body = generate_update_body(&context)?,
soft_delete_body = generate_soft_delete_body(&context),
hard_delete_body = generate_hard_delete_body(&context),
))
}
fn generate_registry_module(resources: &[ResourceDefinition]) -> String {
let module_lines = resources
.iter()
.map(|resource| format!("pub mod {};", resource.resource))
.collect::<Vec<_>>()
.join("\n");
let registry_lines = resources
.iter()
.map(|resource| {
let store_name = format!("{}Store", to_pascal_case(&resource.resource));
format!(
" stores.insert({name:?}.to_string(), std::sync::Arc::new({module}::{store_name}::new(pool.clone())));",
name = resource.resource,
module = resource.resource
)
})
.collect::<Vec<_>>()
.join("\n");
format!(
r#"#![allow(dead_code)]
{module_lines}
pub fn build_store_registry(pool: sqlx::PgPool) -> shaperail_runtime::db::StoreRegistry {{
let mut stores: std::collections::HashMap<
String,
std::sync::Arc<dyn shaperail_runtime::db::ResourceStore>,
> = std::collections::HashMap::new();
{registry_lines}
std::sync::Arc::new(stores)
}}
/// Returns an empty controller map. Register custom controller functions here
/// or populate from `resources/<name>.controller.rs` files.
pub fn build_controller_map() -> shaperail_runtime::handlers::controller::ControllerMap {{
shaperail_runtime::handlers::controller::ControllerMap::new()
}}
{controller_traits}
"#,
controller_traits = generate_controller_traits(resources)
)
}
fn generate_controller_traits(resources: &[ResourceDefinition]) -> String {
let mut output = String::new();
for resource in resources {
let endpoints_with_controllers: Vec<_> = resource
.endpoints
.as_ref()
.map(|endpoints| {
endpoints
.iter()
.filter(|(_, ep)| ep.controller.is_some())
.collect::<Vec<_>>()
})
.unwrap_or_default();
if endpoints_with_controllers.is_empty() {
continue;
}
let pascal = to_pascal_case(&resource.resource);
let mut trait_methods = Vec::new();
for (action, ep) in &endpoints_with_controllers {
let controller = ep.controller.as_ref().unwrap();
let action_pascal = to_pascal_case(action);
let input_fields: Vec<_> = ep
.input
.as_ref()
.map(|fields| {
fields
.iter()
.filter_map(|name| {
resource.schema.get(name).map(|field| {
format!(" pub {name}: {},", model_field_type(field))
})
})
.collect()
})
.unwrap_or_default();
if !input_fields.is_empty() {
output.push_str(&format!(
r#"
/// Input fields for the {resource_name} {action} endpoint.
/// Auto-generated from the resource schema — do not edit.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct {pascal}{action_pascal}Input {{
{fields}
}}
"#,
resource_name = resource.resource,
fields = input_fields.join("\n"),
));
}
if let Some(before) = &controller.before {
if !before.starts_with(shaperail_core::WASM_HOOK_PREFIX) {
let input_type = if input_fields.is_empty() {
"serde_json::Value".to_string()
} else {
format!("{pascal}{action_pascal}Input")
};
trait_methods.push(format!(
" /// 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>;"
));
}
}
if let Some(after) = &controller.after {
if !after.starts_with(shaperail_core::WASM_HOOK_PREFIX) {
trait_methods.push(format!(
" /// 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>;"
));
}
}
}
if !trait_methods.is_empty() {
output.push_str(&format!(
r#"
/// Controller trait for the {resource_name} resource.
/// Implement this trait in `controllers/{resource_name}.controller.rs`.
/// The compiler will enforce correct signatures — no guessing needed.
#[shaperail_runtime::db::async_trait]
pub trait {pascal}Controller {{
{methods}
}}
"#,
resource_name = resource.resource,
methods = trait_methods.join("\n\n"),
));
}
}
output
}
#[derive(Clone)]
struct CollectionEndpoint<'a> {
spec: &'a EndpointSpec,
helper_name: String,
}
struct ResourceContext<'a> {
resource: &'a ResourceDefinition,
record_name: String,
store_name: String,
primary_key: String,
select_columns: String,
soft_delete_where: String,
collection_endpoints: Vec<CollectionEndpoint<'a>>,
}
impl<'a> ResourceContext<'a> {
fn new(resource: &'a ResourceDefinition) -> Result<Self, String> {
let primary_key = resource
.schema
.iter()
.find(|(_, field)| field.primary)
.map(|(name, _)| name.clone())
.unwrap_or_else(|| "id".to_string());
let select_columns = resource
.schema
.iter()
.map(|(name, field)| select_column_sql(name, field))
.collect::<Vec<_>>()
.join(",\n ");
let collection_endpoints = resource
.endpoints
.as_ref()
.map(|endpoints| {
endpoints
.iter()
.filter(|(_, endpoint)| {
*endpoint.method() == HttpMethod::Get && !endpoint.path().contains(":id")
})
.map(|(name, endpoint)| CollectionEndpoint {
spec: endpoint,
helper_name: format!("find_all_{}", sanitize_identifier(name)),
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
Ok(Self {
resource,
record_name: format!("{}Record", to_pascal_case(&resource.resource)),
store_name: format!("{}Store", to_pascal_case(&resource.resource)),
primary_key,
select_columns,
soft_delete_where: if has_soft_delete(resource) {
" AND \"deleted_at\" IS NULL".to_string()
} else {
String::new()
},
collection_endpoints,
})
}
}
fn generate_list_helper(
context: &ResourceContext<'_>,
endpoint: &CollectionEndpoint<'_>,
) -> Result<String, String> {
let filters = endpoint.spec.filters.clone().unwrap_or_default();
let search_fields = endpoint.spec.search.clone().unwrap_or_default();
let sort_fields = endpoint.spec.sort.clone().unwrap_or_default();
let filter_decls = filters
.iter()
.map(|field_name| {
let field = context.resource.schema.get(field_name).ok_or_else(|| {
format!(
"Unknown filter field '{field_name}' on resource '{}'",
context.resource.resource
)
})?;
Ok(generate_filter_declaration(field_name, field))
})
.collect::<Result<Vec<_>, String>>()?
.join("\n");
let filter_args = filters
.iter()
.map(|field_name| {
parameter_expression(
field_name,
context
.resource
.schema
.get(field_name)
.expect("filter field validated"),
)
})
.collect::<Vec<_>>();
let search_decl = if search_fields.is_empty() {
String::new()
} else {
" let search_term = search.map(|value| value.term.clone());".to_string()
};
let search_param = if search_fields.is_empty() {
"_search"
} else {
"search"
};
let search_predicate = if search_fields.is_empty() {
String::new()
} else {
search_expression(&search_fields)
};
let sort_decls = (0..sort_fields.len())
.map(|index| {
format!(
" let sort_field_{index} = sort_field_at(sort, {index});\n let sort_direction_{index} = sort_direction_at(sort, {index});"
)
})
.collect::<Vec<_>>()
.join("\n");
let filter_positions = filters
.iter()
.enumerate()
.map(|(index, field_name)| (field_name.clone(), index + 1))
.collect::<Vec<_>>();
let search_position = if search_fields.is_empty() {
None
} else {
Some(filter_positions.len() + 1)
};
let cursor_position = filter_positions.len() + usize::from(search_position.is_some()) + 1;
let cursor_sort_positions = (0..sort_fields.len())
.map(|index| {
let base = cursor_position + 1 + (index * 2);
(base, base + 1)
})
.collect::<Vec<_>>();
let offset_sort_positions = (0..sort_fields.len())
.map(|index| {
let base =
filter_positions.len() + usize::from(search_position.is_some()) + 1 + (index * 2);
(base, base + 1)
})
.collect::<Vec<_>>();
let filter_predicates = generate_filter_predicates(context, &filter_positions)?;
let filter_clause = filter_predicates.join("\n");
let cursor_order_by = generate_order_by(context, &sort_fields, &cursor_sort_positions)?;
let offset_order_by = generate_order_by(context, &sort_fields, &offset_sort_positions)?;
let mut cursor_args = filter_args.clone();
if search_position.is_some() {
cursor_args.push("search_term.as_deref()".to_string());
}
cursor_args.push("cursor".to_string());
for index in 0..sort_fields.len() {
cursor_args.push(format!("sort_field_{index}.as_deref()"));
cursor_args.push(format!("sort_direction_{index}"));
}
cursor_args.push("*limit + 1".to_string());
let mut count_args = filter_args.clone();
if search_position.is_some() {
count_args.push("search_term.as_deref()".to_string());
}
let mut row_args = count_args.clone();
for index in 0..sort_fields.len() {
row_args.push(format!("sort_field_{index}.as_deref()"));
row_args.push(format!("sort_direction_{index}"));
}
row_args.push("*limit".to_string());
row_args.push("*offset".to_string());
let cursor_query = generate_cursor_query(
context,
&filter_clause,
search_position.map(|position| (position, search_predicate.as_str())),
cursor_position,
&cursor_order_by,
cursor_args.len(),
&cursor_args,
);
let offset_query = generate_offset_query(
context,
&filter_clause,
search_position.map(|position| (position, search_predicate.as_str())),
&offset_order_by,
row_args.len() - 1,
row_args.len(),
&count_args,
&row_args,
);
Ok(format!(
r###" async fn {helper_name}(
&self,
filters: &FilterSet,
{search_param}: Option<&SearchParam>,
sort: &SortParam,
page: &PageRequest,
) -> Result<(Vec<ResourceRow>, Value), shaperail_core::ShaperailError> {{
{filter_decls}
{search_decl}
{sort_decls}
match page {{
PageRequest::Cursor {{ after, limit }} => {{
let cursor = match after {{
Some(cursor_value) => Some(uuid::Uuid::parse_str(
&shaperail_runtime::db::decode_cursor(cursor_value)?
).map_err(|_| shaperail_core::ShaperailError::Validation(vec![shaperail_core::FieldError {{
field: "cursor".to_string(),
message: "Invalid cursor value".to_string(),
code: "invalid_cursor".to_string(),
}}]))?),
None => None,
}};
{cursor_query}
}}
PageRequest::Offset {{ offset, limit }} => {{
{offset_query}
}}
}}
}}"###,
helper_name = endpoint.helper_name,
search_param = search_param,
filter_decls = indent_block(&filter_decls, 2),
search_decl = indent_block(&search_decl, 2),
sort_decls = indent_block(&sort_decls, 2),
cursor_query = indent_block(&cursor_query, 4),
offset_query = indent_block(&offset_query, 4),
))
}
fn generate_cursor_query(
context: &ResourceContext<'_>,
filter_clause: &str,
search_position: Option<(usize, &str)>,
cursor_position: usize,
order_by: &str,
limit_position: usize,
args: &[String],
) -> String {
format!(
r###" let rows = sqlx::query_as!(
{record_name},
r#"
SELECT
{select_columns}
FROM "{table_name}"
WHERE TRUE
{soft_delete_clause}
{filter_clause}{search_clause}
AND (${cursor_position}::uuid IS NULL OR "{primary_key}" > ${cursor_position})
ORDER BY
{order_by}
LIMIT ${limit_position}
"#,
{args}
)
.fetch_all(&self.pool)
.await?;
let has_more = rows.len() as i64 > *limit;
let mut result_rows = rows;
if has_more {{
result_rows.truncate(*limit as usize);
}}
let data = result_rows
.iter()
.map(row_from_model)
.collect::<Result<Vec<_>, _>>()?;
let cursor = if has_more {{
result_rows
.last()
.map(|row| shaperail_runtime::db::encode_cursor(&row.{primary_key}.to_string()))
}} else {{
None
}};
Ok((
data,
serde_json::json!({{
"cursor": cursor,
"has_more": has_more
}})
))"###,
record_name = context.record_name,
select_columns = context.select_columns,
table_name = context.resource.resource,
soft_delete_clause = if has_soft_delete(context.resource) {
" AND \"deleted_at\" IS NULL\n"
} else {
""
},
filter_clause = if filter_clause.is_empty() {
String::new()
} else {
format!("{filter_clause}\n")
},
search_clause = search_position
.map(|(position, expression)| {
format!(
"\n AND (${position}::text IS NULL OR to_tsvector('english', {expression}) @@ plainto_tsquery('english', ${position}))"
)
})
.unwrap_or_default(),
cursor_position = cursor_position,
primary_key = context.primary_key,
order_by = order_by,
limit_position = limit_position,
args = args.join(",\n "),
)
}
#[allow(clippy::too_many_arguments)]
fn generate_offset_query(
context: &ResourceContext<'_>,
filter_clause: &str,
search_position: Option<(usize, &str)>,
order_by: &str,
limit_position: usize,
offset_position: usize,
count_args: &[String],
row_args: &[String],
) -> String {
let count_macro_args = if count_args.is_empty() {
String::new()
} else {
format!(
",\n {}",
count_args.join(",\n ")
)
};
format!(
r###" let total = sqlx::query_scalar!(
r#"
SELECT COUNT(*) as "count!"
FROM "{table_name}"
WHERE TRUE
{soft_delete_clause}
{filter_clause}{search_clause}
"#{count_macro_args}
)
.fetch_one(&self.pool)
.await?;
let rows = sqlx::query_as!(
{record_name},
r#"
SELECT
{select_columns}
FROM "{table_name}"
WHERE TRUE
{soft_delete_clause}
{filter_clause}{search_clause}
ORDER BY
{order_by}
LIMIT ${limit_param}
OFFSET ${offset_param}
"#,
{row_args}
)
.fetch_all(&self.pool)
.await?;
let data = rows
.iter()
.map(row_from_model)
.collect::<Result<Vec<_>, _>>()?;
Ok((
data,
serde_json::json!({{
"offset": offset,
"limit": limit,
"total": total
}})
))"###,
table_name = context.resource.resource,
soft_delete_clause = if has_soft_delete(context.resource) {
" AND \"deleted_at\" IS NULL\n"
} else {
""
},
filter_clause = if filter_clause.is_empty() {
String::new()
} else {
format!("{filter_clause}\n")
},
search_clause = search_position
.map(|(position, expression)| {
format!(
"\n AND (${position}::text IS NULL OR to_tsvector('english', {expression}) @@ plainto_tsquery('english', ${position}))"
)
})
.unwrap_or_default(),
count_macro_args = count_macro_args,
record_name = context.record_name,
select_columns = context.select_columns,
order_by = order_by,
limit_param = limit_position,
offset_param = offset_position,
row_args = row_args.join(",\n "),
)
}
fn generate_filter_predicates(
context: &ResourceContext<'_>,
positions: &[(String, usize)],
) -> Result<Vec<String>, String> {
positions
.iter()
.map(|(field_name, position)| {
let field = context.resource.schema.get(field_name).ok_or_else(|| {
format!(
"Unknown filter field '{field_name}' on resource '{}'",
context.resource.resource
)
})?;
Ok(format!(
" AND (${position}::{cast} IS NULL OR \"{field_name}\" = ${position})",
cast = sql_cast_type(field)
))
})
.collect()
}
fn generate_order_by(
context: &ResourceContext<'_>,
sort_fields: &[String],
positions: &[(usize, usize)],
) -> Result<String, String> {
if sort_fields.is_empty() {
return Ok(format!("\"{}\" ASC", context.primary_key));
}
let mut clauses = Vec::new();
for ((field_param, direction_param), field_name) in positions.iter().zip(sort_fields) {
for candidate in sort_fields {
let field = context.resource.schema.get(candidate).ok_or_else(|| {
format!(
"Unknown sort field '{candidate}' on resource '{}'",
context.resource.resource
)
})?;
let sort_expr = sortable_expression(candidate, field);
clauses.push(format!(
" CASE WHEN ${field_param}::text = '{candidate}' AND ${direction_param}::text = 'asc' THEN {sort_expr} END ASC"
));
clauses.push(format!(
" CASE WHEN ${field_param}::text = '{candidate}' AND ${direction_param}::text = 'desc' THEN {sort_expr} END DESC"
));
}
let _ = field_name;
}
clauses.push(format!(" \"{}\" ASC", context.primary_key));
Ok(clauses.join(",\n"))
}
fn generate_insert_body(context: &ResourceContext<'_>) -> Result<String, String> {
let mut declarations = Vec::new();
let mut columns = Vec::new();
let mut values = Vec::new();
let mut args = Vec::new();
for (index, (field_name, field)) in context.resource.schema.iter().enumerate() {
let variable_name = sanitize_identifier(field_name);
declarations.push(generate_insert_declaration(
field_name,
field,
&variable_name,
)?);
columns.push(format!("\"{field_name}\""));
values.push(format!("${}", index + 1));
args.push(variable_name);
}
Ok(format!(
r###"{declarations}
let row = sqlx::query_as!(
{record_name},
r#"
INSERT INTO "{table_name}" ({columns})
VALUES ({values})
RETURNING
{select_columns}
"#,
{args}
)
.fetch_one(&self.pool)
.await?;
row_from_model(&row)"###,
declarations = declarations.join("\n"),
record_name = context.record_name,
table_name = context.resource.resource,
columns = columns.join(", "),
values = values.join(", "),
select_columns = context.select_columns,
args = args.join(",\n "),
))
}
fn generate_update_body(context: &ResourceContext<'_>) -> Result<String, String> {
let mut declarations = Vec::new();
let mut set_clauses = Vec::new();
let mut args = vec!["id".to_string()];
let mut has_mutable_fields = Vec::new();
let mut index = 2usize;
for (field_name, field) in &context.resource.schema {
if field.primary || field.generated {
continue;
}
let present_name = format!("{}_present", sanitize_identifier(field_name));
let value_name = sanitize_identifier(field_name);
declarations.push(generate_update_declaration(
field_name,
field,
&present_name,
&value_name,
));
has_mutable_fields.push(present_name.clone());
set_clauses.push(format!(
"\"{field_name}\" = CASE WHEN ${present_param} THEN ${value_param} ELSE \"{field_name}\" END",
present_param = index,
value_param = index + 1
));
args.push(present_name);
args.push(value_name);
index += 2;
}
if let Some(updated_at) = context.resource.schema.get("updated_at") {
if updated_at.generated && updated_at.field_type == FieldType::Timestamp {
declarations.push(" let updated_at = chrono::Utc::now();".to_string());
set_clauses.push(format!("\"updated_at\" = ${index}"));
args.push("updated_at".to_string());
}
}
let guard = if has_mutable_fields.is_empty() {
String::new()
} else {
format!(
" if !({}) {}",
has_mutable_fields.join(" || "),
r#"{
return Err(shaperail_core::ShaperailError::Validation(vec![shaperail_core::FieldError {
field: "body".to_string(),
message: "No valid fields to update".to_string(),
code: "empty_update".to_string(),
}]));
}"#
)
};
Ok(format!(
r###"{declarations}
{guard}
let row = sqlx::query_as!(
{record_name},
r#"
UPDATE "{table_name}"
SET {set_clauses}
WHERE "{primary_key}" = $1{soft_delete_where}
RETURNING
{select_columns}
"#,
{args}
)
.fetch_optional(&self.pool)
.await?
.ok_or(shaperail_core::ShaperailError::NotFound)?;
row_from_model(&row)"###,
declarations = declarations.join("\n"),
guard = guard,
record_name = context.record_name,
table_name = context.resource.resource,
set_clauses = set_clauses.join(", "),
primary_key = context.primary_key,
soft_delete_where = context.soft_delete_where,
select_columns = context.select_columns,
args = args.join(",\n "),
))
}
fn generate_soft_delete_body(context: &ResourceContext<'_>) -> String {
if !has_soft_delete(context.resource) {
return generate_hard_delete_body(context);
}
format!(
r###" let deleted_at = chrono::Utc::now();
let row = sqlx::query_as!(
{record_name},
r#"
UPDATE "{table_name}"
SET "deleted_at" = $2
WHERE "{primary_key}" = $1 AND "deleted_at" IS NULL
RETURNING
{select_columns}
"#,
id,
deleted_at
)
.fetch_optional(&self.pool)
.await?
.ok_or(shaperail_core::ShaperailError::NotFound)?;
row_from_model(&row)"###,
record_name = context.record_name,
table_name = context.resource.resource,
primary_key = context.primary_key,
select_columns = context.select_columns,
)
}
fn generate_hard_delete_body(context: &ResourceContext<'_>) -> String {
format!(
r###" let row = sqlx::query_as!(
{record_name},
r#"
DELETE FROM "{table_name}"
WHERE "{primary_key}" = $1
RETURNING
{select_columns}
"#,
id
)
.fetch_optional(&self.pool)
.await?
.ok_or(shaperail_core::ShaperailError::NotFound)?;
row_from_model(&row)"###,
record_name = context.record_name,
table_name = context.resource.resource,
primary_key = context.primary_key,
select_columns = context.select_columns,
)
}
fn generate_insert_declaration(
field_name: &str,
field: &FieldSchema,
variable_name: &str,
) -> Result<String, String> {
if field.generated {
return Ok(format!(
" let {variable_name} = {};",
generated_value_expression(field)
));
}
let parse_type = parse_type(field);
let parsed = format!(
"shaperail_runtime::db::parse_optional_json::<{parse_type}>(data, {field_name:?})?"
);
let expression = match (field_is_required(field), field.default.as_ref()) {
(true, Some(default)) => format!(
"match {parsed} {{ Some(value) => value, None => {} }}",
default_expression(field_name, field, default)?
),
(true, None) => format!("shaperail_runtime::db::require_field({parsed}, {field_name:?})?"),
(false, Some(default)) if model_field_is_optional(field) => format!(
"match {parsed} {{ Some(value) => Some(value), None => Some({}) }}",
default_expression(field_name, field, default)?
),
(false, Some(default)) => format!(
"match {parsed} {{ Some(value) => value, None => {} }}",
default_expression(field_name, field, default)?
),
(false, None) => parsed,
};
Ok(format!(" let {variable_name} = {expression};"))
}
fn generate_update_declaration(
field_name: &str,
field: &FieldSchema,
present_name: &str,
value_name: &str,
) -> String {
format!(
" let {present_name} = data.contains_key({field_name:?});\n let {value_name} = shaperail_runtime::db::parse_optional_json::<{parse_type}>(data, {field_name:?})?;",
parse_type = parse_type(field)
)
}
fn generate_filter_declaration(field_name: &str, field: &FieldSchema) -> String {
let parser = match field.field_type {
FieldType::Uuid => "uuid::Uuid::parse_str(text).map_err(|_| shaperail_core::ShaperailError::Internal(\"invalid uuid filter\".to_string()))",
FieldType::String | FieldType::Enum | FieldType::File => "Ok(text.to_string())",
FieldType::Integer => "text.parse::<i32>().map_err(|_| shaperail_core::ShaperailError::Internal(\"invalid integer filter\".to_string()))",
FieldType::Bigint => "text.parse::<i64>().map_err(|_| shaperail_core::ShaperailError::Internal(\"invalid bigint filter\".to_string()))",
FieldType::Number => "text.parse::<f64>().map_err(|_| shaperail_core::ShaperailError::Internal(\"invalid number filter\".to_string()))",
FieldType::Boolean => "text.parse::<bool>().map_err(|_| shaperail_core::ShaperailError::Internal(\"invalid boolean filter\".to_string()))",
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()))",
FieldType::Date => "chrono::NaiveDate::parse_from_str(text, \"%Y-%m-%d\").map_err(|_| shaperail_core::ShaperailError::Internal(\"invalid date filter\".to_string()))",
FieldType::Json => "serde_json::from_str::<serde_json::Value>(text).map_err(|_| shaperail_core::ShaperailError::Internal(\"invalid json filter\".to_string()))",
FieldType::Array => "serde_json::from_str::<Vec<serde_json::Value>>(text).map_err(|_| shaperail_core::ShaperailError::Internal(\"invalid array filter\".to_string()))",
};
format!(
" let {var} = parse_filter(filters, {field_name:?}, \"invalid_filter\", |text| {parser})?;",
var = field_parameter_name(field_name)
)
}
fn field_parameter_name(field_name: &str) -> String {
format!("filter_{}", sanitize_identifier(field_name))
}
fn parameter_expression(field_name: &str, field: &FieldSchema) -> String {
let var = field_parameter_name(field_name);
match field.field_type {
FieldType::String | FieldType::Enum | FieldType::File => format!("{var}.as_deref()"),
_ => var,
}
}
fn select_column_sql(field_name: &str, field: &FieldSchema) -> String {
let nullability = if model_field_is_optional(field) {
"?"
} else {
"!"
};
let expression = match field.field_type {
FieldType::Number => format!("\"{field_name}\"::DOUBLE PRECISION"),
_ => format!("\"{field_name}\""),
};
format!(
"{expression} as \"{field_name}{nullability}: {type_name}\"",
type_name = query_type(field)
)
}
fn sortable_expression(field_name: &str, field: &FieldSchema) -> String {
match field.field_type {
FieldType::Json | FieldType::Array | FieldType::Uuid => format!("\"{field_name}\"::text"),
FieldType::Number => format!("\"{field_name}\"::DOUBLE PRECISION"),
_ => format!("\"{field_name}\""),
}
}
fn search_expression(fields: &[String]) -> String {
fields
.iter()
.map(|field| format!("COALESCE(\"{field}\"::text, '')"))
.collect::<Vec<_>>()
.join(" || ' ' || ")
}
fn sql_cast_type(field: &FieldSchema) -> String {
match field.field_type {
FieldType::Uuid => "uuid".to_string(),
FieldType::String | FieldType::Enum | FieldType::File => "text".to_string(),
FieldType::Integer => "integer".to_string(),
FieldType::Bigint => "bigint".to_string(),
FieldType::Number => "double precision".to_string(),
FieldType::Boolean => "boolean".to_string(),
FieldType::Timestamp => "timestamptz".to_string(),
FieldType::Date => "date".to_string(),
FieldType::Json => "jsonb".to_string(),
FieldType::Array => match field.items.as_deref() {
Some("uuid") => "uuid[]".to_string(),
Some("integer") => "integer[]".to_string(),
Some("bigint") => "bigint[]".to_string(),
Some("number") => "double precision[]".to_string(),
Some("boolean") => "boolean[]".to_string(),
_ => "text[]".to_string(),
},
}
}
fn query_type(field: &FieldSchema) -> String {
match field.field_type {
FieldType::Uuid => "uuid::Uuid".to_string(),
FieldType::String | FieldType::Enum | FieldType::File => "String".to_string(),
FieldType::Integer => "i32".to_string(),
FieldType::Bigint => "i64".to_string(),
FieldType::Number => "f64".to_string(),
FieldType::Boolean => "bool".to_string(),
FieldType::Timestamp => "chrono::DateTime<chrono::Utc>".to_string(),
FieldType::Date => "chrono::NaiveDate".to_string(),
FieldType::Json => "serde_json::Value".to_string(),
FieldType::Array => match field.items.as_deref() {
Some("uuid") => "Vec<uuid::Uuid>".to_string(),
Some("integer") => "Vec<i32>".to_string(),
Some("bigint") => "Vec<i64>".to_string(),
Some("number") => "Vec<f64>".to_string(),
Some("boolean") => "Vec<bool>".to_string(),
Some("timestamp") => "Vec<chrono::DateTime<chrono::Utc>>".to_string(),
Some("date") => "Vec<chrono::NaiveDate>".to_string(),
_ => "Vec<String>".to_string(),
},
}
}
fn parse_type(field: &FieldSchema) -> String {
query_type(field)
}
fn model_field_type(field: &FieldSchema) -> String {
let base = query_type(field);
if model_field_is_optional(field) {
format!("Option<{base}>")
} else {
base
}
}
fn model_field_is_optional(field: &FieldSchema) -> bool {
!(field.primary || (field.required && !field.nullable))
}
fn field_is_required(field: &FieldSchema) -> bool {
field.primary || (field.required && !field.nullable)
}
fn generated_value_expression(field: &FieldSchema) -> String {
match field.field_type {
FieldType::Uuid => "uuid::Uuid::new_v4()".to_string(),
FieldType::Timestamp => {
if model_field_is_optional(field) {
"Some(chrono::Utc::now())".to_string()
} else {
"chrono::Utc::now()".to_string()
}
}
FieldType::Date => {
if model_field_is_optional(field) {
"Some(chrono::Utc::now().date_naive())".to_string()
} else {
"chrono::Utc::now().date_naive()".to_string()
}
}
_ => "Default::default()".to_string(),
}
}
fn default_expression(
field_name: &str,
field: &FieldSchema,
default: &serde_json::Value,
) -> Result<String, String> {
Ok(match field.field_type {
FieldType::Uuid => format!(
"parse_embedded_json::<uuid::Uuid>({field_name:?}, serde_json::json!({default}))?"
),
FieldType::String | FieldType::Enum | FieldType::File => {
let value = default
.as_str()
.ok_or_else(|| format!("Default for '{field_name}' must be a string"))?;
format!("{value:?}.to_string()")
}
FieldType::Integer => format!(
"parse_embedded_json::<i32>({field_name:?}, serde_json::json!({default}))?"
),
FieldType::Bigint => format!(
"parse_embedded_json::<i64>({field_name:?}, serde_json::json!({default}))?"
),
FieldType::Number => format!(
"parse_embedded_json::<f64>({field_name:?}, serde_json::json!({default}))?"
),
FieldType::Boolean => default
.as_bool()
.ok_or_else(|| format!("Default for '{field_name}' must be a boolean"))?
.to_string(),
FieldType::Timestamp => format!(
"parse_embedded_json::<chrono::DateTime<chrono::Utc>>({field_name:?}, serde_json::json!({default}))?"
),
FieldType::Date => format!(
"parse_embedded_json::<chrono::NaiveDate>({field_name:?}, serde_json::json!({default}))?"
),
FieldType::Json => format!("serde_json::json!({default})"),
FieldType::Array => format!(
"parse_embedded_json::<{}>({field_name:?}, serde_json::json!({default}))?",
query_type(field)
),
})
}
fn has_soft_delete(resource: &ResourceDefinition) -> bool {
resource
.endpoints
.as_ref()
.map(|endpoints| endpoints.values().any(|endpoint| endpoint.soft_delete))
.unwrap_or(false)
}
fn sanitize_identifier(value: &str) -> String {
let mut output = String::new();
for ch in value.chars() {
if ch.is_ascii_alphanumeric() {
output.push(ch.to_ascii_lowercase());
} else {
output.push('_');
}
}
if output.chars().next().is_some_and(|ch| ch.is_ascii_digit()) {
output.insert(0, '_');
}
output
}
fn to_pascal_case(value: &str) -> String {
value
.split('_')
.filter(|part| !part.is_empty())
.map(|part| {
let mut chars = part.chars();
match chars.next() {
Some(first) => {
let mut segment = String::new();
segment.extend(first.to_uppercase());
segment.push_str(chars.as_str());
segment
}
None => String::new(),
}
})
.collect::<String>()
}
fn indent_block(block: &str, indent: usize) -> String {
if block.trim().is_empty() {
return String::new();
}
let prefix = " ".repeat(indent);
block
.lines()
.map(|line| {
if line.is_empty() {
String::new()
} else {
format!("{prefix}{line}")
}
})
.collect::<Vec<_>>()
.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
use indexmap::IndexMap;
use shaperail_core::{
AuthRule, EndpointSpec, FieldSchema, HttpMethod, PaginationStyle, ResourceDefinition,
};
fn sample_resource() -> ResourceDefinition {
let mut schema = IndexMap::new();
schema.insert(
"id".to_string(),
FieldSchema {
field_type: FieldType::Uuid,
primary: true,
generated: true,
required: false,
unique: false,
nullable: false,
reference: None,
min: None,
max: None,
format: None,
values: None,
default: None,
sensitive: false,
search: false,
items: None,
},
);
schema.insert(
"email".to_string(),
FieldSchema {
field_type: FieldType::String,
primary: false,
generated: false,
required: true,
unique: true,
nullable: false,
reference: None,
min: None,
max: None,
format: None,
values: None,
default: None,
sensitive: false,
search: true,
items: None,
},
);
schema.insert(
"created_at".to_string(),
FieldSchema {
field_type: FieldType::Timestamp,
primary: false,
generated: true,
required: false,
unique: false,
nullable: false,
reference: None,
min: None,
max: None,
format: None,
values: None,
default: None,
sensitive: false,
search: false,
items: None,
},
);
let mut endpoints = indexmap::IndexMap::new();
endpoints.insert(
"list".to_string(),
EndpointSpec {
method: Some(HttpMethod::Get),
path: Some("/users".to_string()),
auth: Some(AuthRule::Public),
input: None,
filters: Some(vec!["email".to_string()]),
search: Some(vec!["email".to_string()]),
pagination: Some(PaginationStyle::Cursor),
sort: Some(vec!["created_at".to_string()]),
cache: None,
controller: None,
events: None,
jobs: None,
upload: None,
soft_delete: false,
},
);
ResourceDefinition {
resource: "users".to_string(),
version: 1,
db: None,
tenant_key: None,
schema,
endpoints: Some(endpoints),
relations: None,
indexes: None,
}
}
#[test]
fn generates_query_as_store_module() {
let resource = sample_resource();
let code = generate_resource_module(&resource).unwrap();
assert!(code.contains("impl ResourceStore for UsersStore"));
assert!(code.contains("sqlx::query_as!"));
assert!(code.contains("find_all_list"));
}
#[test]
fn generates_registry_module() {
let resource = sample_resource();
let project = generate_project(&[resource]).unwrap();
assert!(project.mod_rs.contains("pub mod users;"));
assert!(project.mod_rs.contains("build_store_registry"));
}
}