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 rustfmt_in_place(path: &std::path::Path) {
if path.extension().and_then(|e| e.to_str()) != Some("rs") {
return;
}
let result = std::process::Command::new("rustfmt")
.arg("--edition")
.arg("2021")
.arg(path)
.status();
match result {
Ok(status) if status.success() => {}
Ok(status) => {
eprintln!(
"warning: rustfmt exited with {:?} for {}; leaving generated file unformatted",
status.code(),
path.display()
);
}
Err(err) => {
eprintln!(
"warning: rustfmt not found on PATH ({}); skipping format pass for {}. \
Install with `rustup component add rustfmt`.",
err,
path.display()
);
}
}
}
pub fn generate_resource_module(resource: &ResourceDefinition) -> Result<String, String> {
let context = ResourceContext::new(resource)?;
let model_fields = resource
.schema
.iter()
.filter(|(_, field)| field.is_persisted())
.map(|(name, field)| {
let attr = if field.sensitive {
" #[serde(skip_serializing)]\n"
} else {
""
};
format!("{attr} 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 collect_controller_hooks(resources: &[ResourceDefinition]) -> Vec<(&str, Vec<&str>)> {
resources
.iter()
.filter_map(|resource| {
let hooks: Vec<&str> = resource
.endpoints
.as_ref()
.map(|eps| {
eps.iter()
.filter_map(|(_, ep)| ep.controller.as_ref())
.flat_map(|c| {
let before = c
.before
.as_deref()
.filter(|s| !s.starts_with(shaperail_core::WASM_HOOK_PREFIX));
let after = c
.after
.as_deref()
.filter(|s| !s.starts_with(shaperail_core::WASM_HOOK_PREFIX));
[before, after].into_iter().flatten()
})
.collect()
})
.unwrap_or_default();
if hooks.is_empty() {
None
} else {
Some((resource.resource.as_str(), hooks))
}
})
.collect()
}
pub const HANDLER_CONVENTIONS: &[&str] = &["list", "get", "create", "update", "delete"];
fn collect_custom_handlers(resources: &[ResourceDefinition]) -> Vec<(&str, Vec<(&str, &str)>)> {
resources
.iter()
.filter_map(|resource| {
let handlers: Vec<(&str, &str)> = resource
.endpoints
.as_ref()
.map(|eps| {
eps.iter()
.filter(|(action, _)| !HANDLER_CONVENTIONS.contains(&action.as_str()))
.filter_map(|(action, ep)| {
ep.handler.as_deref().map(|h| (action.as_str(), h))
})
.collect()
})
.unwrap_or_default();
if handlers.is_empty() {
None
} else {
Some((resource.resource.as_str(), handlers))
}
})
.collect()
}
fn collect_job_names(resources: &[ResourceDefinition]) -> Vec<String> {
let mut names = std::collections::BTreeSet::new();
for resource in resources {
if let Some(endpoints) = &resource.endpoints {
for (_, ep) in endpoints {
for job in ep.jobs.as_deref().unwrap_or_default() {
names.insert(job.clone());
}
}
}
}
names.into_iter().collect()
}
fn collect_job_path_decls(resources: &[ResourceDefinition]) -> Vec<String> {
collect_job_names(resources)
.iter()
.map(|name| format!("#[path = \"../jobs/{name}.rs\"]\nmod job_{name};"))
.collect()
}
fn collect_handler_path_decls(resources: &[ResourceDefinition]) -> Vec<String> {
collect_custom_handlers(resources)
.iter()
.map(|(name, _)| {
format!("#[path = \"../resources/{name}.handlers.rs\"]\nmod {name}_handlers;")
})
.collect()
}
fn generate_job_registry(resources: &[ResourceDefinition]) -> String {
let job_names = collect_job_names(resources);
if job_names.is_empty() {
return r#"pub fn build_job_registry() -> shaperail_runtime::jobs::JobRegistry {
shaperail_runtime::jobs::JobRegistry::new()
}"#
.to_string();
}
let inserts: Vec<String> = job_names
.iter()
.map(|name| {
format!(
r#" handlers.insert(
"{name}".to_string(),
std::sync::Arc::new(|payload: serde_json::Value| {{
Box::pin(job_{name}::handle(payload))
as std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), shaperail_core::ShaperailError>> + Send>>
}}) as shaperail_runtime::jobs::JobHandler,
);"#
)
})
.collect();
format!(
r#"pub fn build_job_registry() -> shaperail_runtime::jobs::JobRegistry {{
let mut handlers: std::collections::HashMap<String, shaperail_runtime::jobs::JobHandler> =
std::collections::HashMap::new();
{inserts}
shaperail_runtime::jobs::JobRegistry::from_handlers(handlers)
}}"#,
inserts = inserts.join("\n")
)
}
fn generate_handler_map_fn(resources: &[ResourceDefinition]) -> String {
let custom = collect_custom_handlers(resources);
if custom.is_empty() {
return r#"pub fn build_handler_map() -> shaperail_runtime::handlers::custom::CustomHandlerMap {
shaperail_runtime::handlers::custom::CustomHandlerMap::new()
}"#
.to_string();
}
let inserts: Vec<String> = custom
.iter()
.flat_map(|(res_name, handlers)| {
handlers.iter().map(move |(action, handler_fn)| {
format!(
" map.insert(\n shaperail_runtime::handlers::custom::handler_key({res_name:?}, {action:?}),\n std::sync::Arc::new(|req, state, res, ep| {{\n Box::pin({res_name}_handlers::{handler_fn}(req, state, res, ep))\n }}),\n );"
)
})
})
.collect();
format!(
r#"pub fn build_handler_map() -> shaperail_runtime::handlers::custom::CustomHandlerMap {{
let mut map = shaperail_runtime::handlers::custom::CustomHandlerMap::new();
{inserts}
map
}}"#,
inserts = inserts.join("\n"),
)
}
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");
let ctrl_hooks = collect_controller_hooks(resources);
let ctrl_path_decls: Vec<String> = ctrl_hooks
.iter()
.map(|(name, _)| {
format!("#[path = \"../resources/{name}.controller.rs\"]\nmod {name}_controller;")
})
.collect();
let mut seen_ctrl = std::collections::HashSet::new();
let ctrl_register_lines: Vec<String> = ctrl_hooks
.iter()
.flat_map(|(res_name, hooks)| {
hooks
.iter()
.filter_map(|fn_name| {
let key = (*res_name, *fn_name);
if seen_ctrl.insert(key) {
Some(format!(
" map.register({res_name:?}, {fn_name:?}, {res_name}_controller::{fn_name});"
))
} else {
None
}
})
.collect::<Vec<_>>()
})
.collect();
let ctrl_map_body = if ctrl_register_lines.is_empty() {
" shaperail_runtime::handlers::controller::ControllerMap::new()".to_string()
} else {
let mut lines = vec![
" let mut map = shaperail_runtime::handlers::controller::ControllerMap::new();"
.to_string(),
];
lines.extend(ctrl_register_lines);
lines.push(" map".to_string());
lines.join("\n")
};
let mut preamble_parts = vec![module_lines.clone()];
if !ctrl_path_decls.is_empty() {
preamble_parts.push(ctrl_path_decls.join("\n"));
}
let job_path_decls = collect_job_path_decls(resources);
if !job_path_decls.is_empty() {
preamble_parts.push(job_path_decls.join("\n"));
}
let handler_path_decls = collect_handler_path_decls(resources);
if !handler_path_decls.is_empty() {
preamble_parts.push(handler_path_decls.join("\n"));
}
let preamble = preamble_parts.join("\n\n");
format!(
r#"#![allow(dead_code)]
{preamble}
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)
}}
pub fn build_controller_map() -> shaperail_runtime::handlers::controller::ControllerMap {{
{ctrl_map_body}
}}
{job_registry_fn}
{handler_map_fn}
"#,
job_registry_fn = generate_job_registry(resources),
handler_map_fn = generate_handler_map_fn(resources),
)
}
#[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()
.filter(|(_, field)| field.is_persisted())
.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 = if filters.is_empty() {
" let _ = filters;".to_string()
} else {
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 = if sort_fields.is_empty() {
" let _ = sort;".to_string()
} else {
(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 (field_name, field) in context
.resource
.schema
.iter()
.filter(|(_, f)| f.is_persisted())
{
let variable_name = sanitize_identifier(field_name);
declarations.push(generate_insert_declaration(
field_name,
field,
&variable_name,
)?);
let index = columns.len() + 1;
columns.push(format!("\"{field_name}\""));
values.push(format!("${index}"));
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 || field.transient {
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::<i64>().map_err(|_| shaperail_core::ShaperailError::Internal(\"invalid integer 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 => "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_ref().map(|i| &i.field_type) {
Some(FieldType::Uuid) => "uuid[]".to_string(),
Some(FieldType::Integer) => "bigint[]".to_string(),
Some(FieldType::Number) => "double precision[]".to_string(),
Some(FieldType::Boolean) => "boolean[]".to_string(),
Some(FieldType::Timestamp) => "timestamptz[]".to_string(),
Some(FieldType::Date) => "date[]".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 => "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_ref().map(|i| &i.field_type) {
Some(FieldType::Uuid) => "Vec<uuid::Uuid>".to_string(),
Some(FieldType::Integer) => "Vec<i64>".to_string(),
Some(FieldType::Number) => "Vec<f64>".to_string(),
Some(FieldType::Boolean) => "Vec<bool>".to_string(),
Some(FieldType::Timestamp) => "Vec<chrono::DateTime<chrono::Utc>>".to_string(),
Some(FieldType::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::<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,
transient: false,
},
);
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,
transient: false,
},
);
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,
transient: false,
},
);
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),
filters: Some(vec!["email".to_string()]),
search: Some(vec!["email".to_string()]),
pagination: Some(PaginationStyle::Cursor),
sort: Some(vec!["created_at".to_string()]),
..Default::default()
},
);
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 sensitive_field_emits_skip_serializing() {
let mut resource = sample_resource();
resource.schema.get_mut("email").unwrap().sensitive = true;
let code = generate_resource_module(&resource).unwrap();
let lines: Vec<&str> = code.lines().collect();
let email_idx = lines
.iter()
.position(|l| l.contains("pub email:"))
.expect("generated module should contain `pub email:`");
assert!(
lines[email_idx - 1].contains("#[serde(skip_serializing)]"),
"expected #[serde(skip_serializing)] above sensitive field, got: {:?}",
lines[email_idx - 1]
);
let id_idx = lines
.iter()
.position(|l| l.contains("pub id:"))
.expect("generated module should contain `pub id:`");
assert!(
!lines[id_idx - 1].contains("skip_serializing"),
"non-sensitive id field should not carry skip_serializing"
);
}
#[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"));
}
#[test]
fn collect_custom_handlers_returns_non_convention_endpoints_with_handler() {
let mut endpoints = indexmap::IndexMap::new();
endpoints.insert(
"list".to_string(),
EndpointSpec {
method: Some(HttpMethod::Get),
path: Some("/items".to_string()),
handler: None,
..Default::default()
},
);
endpoints.insert(
"archive".to_string(),
EndpointSpec {
method: Some(HttpMethod::Post),
path: Some("/items/:id/archive".to_string()),
handler: Some("archive_item".to_string()),
..Default::default()
},
);
let resource = ResourceDefinition {
resource: "items".to_string(),
version: 1,
db: None,
tenant_key: None,
schema: indexmap::IndexMap::new(),
endpoints: Some(endpoints),
relations: None,
indexes: None,
};
let resources = [resource];
let result = collect_custom_handlers(&resources);
assert_eq!(result.len(), 1);
assert_eq!(result[0].0, "items");
assert_eq!(result[0].1, vec![("archive", "archive_item")]);
}
#[test]
fn collect_custom_handlers_ignores_convention_endpoints() {
let mut endpoints = indexmap::IndexMap::new();
for action in &["list", "get", "create", "update", "delete"] {
endpoints.insert(
action.to_string(),
EndpointSpec {
method: Some(HttpMethod::Get),
handler: Some("some_fn".to_string()),
..Default::default()
},
);
}
let resource = ResourceDefinition {
resource: "items".to_string(),
version: 1,
db: None,
tenant_key: None,
schema: indexmap::IndexMap::new(),
endpoints: Some(endpoints),
relations: None,
indexes: None,
};
let resources = [resource];
let result = collect_custom_handlers(&resources);
assert!(
result.is_empty(),
"convention endpoints should not be collected"
);
}
#[test]
fn generate_handler_map_fn_empty_returns_new() {
let resources: Vec<ResourceDefinition> = vec![];
let output = generate_handler_map_fn(&resources);
assert!(output.contains("CustomHandlerMap::new()"));
assert!(!output.contains("map.insert"));
}
#[test]
fn generate_handler_map_fn_with_handler_contains_insert() {
let mut endpoints = indexmap::IndexMap::new();
endpoints.insert(
"archive".to_string(),
EndpointSpec {
method: Some(HttpMethod::Post),
path: Some("/items/:id/archive".to_string()),
handler: Some("archive_item".to_string()),
..Default::default()
},
);
let resource = ResourceDefinition {
resource: "items".to_string(),
version: 1,
db: None,
tenant_key: None,
schema: indexmap::IndexMap::new(),
endpoints: Some(endpoints),
relations: None,
indexes: None,
};
let resources = [resource];
let output = generate_handler_map_fn(&resources);
assert!(output.contains("items_handlers::archive_item"));
assert!(output.contains(r#"handler_key("items", "archive")"#));
}
}