use super::common::*;
use crate::parser::SoftDeleteConfig;
use std::collections::HashSet;
use syn::meta::ParseNestedMeta;
use syn::{DeriveInput, Token, spanned::Spanned};
pub(in crate::parser) struct ModelAttributes {
pub backend: String,
pub table_name: String,
pub global_scope: String,
pub tenant_column: String,
pub auditable: bool,
pub searchable: bool,
pub policy: String,
pub before_save: String,
pub after_save: String,
pub before_delete: String,
pub after_delete: String,
pub after_fetch: String,
pub soft_delete: Option<SoftDeleteConfig>,
}
impl ModelAttributes {
pub fn parse(input: &DeriveInput) -> Result<Self, syn::Error> {
let mut parsed = Self::new(format!("{}s", input.ident.to_string().to_lowercase()));
let mut seen = HashSet::new();
for attribute in &input.attrs {
if attribute.path().is_ident("orm") {
attribute.parse_nested_meta(|meta| parsed.apply(meta, &mut seen))?;
} else if attribute.path().is_ident("sqlx") {
attribute.parse_nested_meta(|meta| apply_sqlx(&meta, &mut seen))?;
}
}
if !seen.contains("table") {
validate_sql_identifier(&parsed.table_name, "default table name", input.ident.span())?;
}
if parsed.searchable {
validate_scout_index(&parsed.table_name, input.ident.span())?;
}
Ok(parsed)
}
fn new(table_name: String) -> Self {
Self {
backend: "sqlx".to_string(),
table_name,
global_scope: String::new(),
tenant_column: String::new(),
auditable: false,
searchable: false,
policy: String::new(),
before_save: String::new(),
after_save: String::new(),
before_delete: String::new(),
after_delete: String::new(),
after_fetch: String::new(),
soft_delete: None,
}
}
fn apply(
&mut self,
meta: ParseNestedMeta<'_>,
seen: &mut HashSet<&'static str>,
) -> Result<(), syn::Error> {
if meta.path.is_ident("auditable") {
mark_once(seen, "auditable", &meta)?;
self.auditable = true;
return Ok(());
}
if meta.path.is_ident("searchable") {
mark_once(seen, "searchable", &meta)?;
self.searchable = true;
return Ok(());
}
if meta.path.is_ident("soft_delete") {
mark_once(seen, "soft_delete", &meta)?;
self.soft_delete = Some(parse_soft_delete(&meta)?);
return Ok(());
}
let key = path_name(&meta)?;
match key.as_str() {
"backend" => {
mark_once(seen, "backend", &meta)?;
let value = string_value(&meta)?;
if !matches!(value.as_str(), "sqlx" | "turso") {
return Err(meta.error("ORM backend must be `sqlx` or `turso`"));
}
self.backend = value;
}
"table" | "table_name" => {
mark_once(seen, "table", &meta)?;
let value = string_value(&meta)?;
validate_sql_identifier(&value, "table name", meta.path.span())?;
self.table_name = value;
}
"tabel" | "tbl" | "tablename" => {
return Err(
meta.error("unknown model attribute; did you mean `#[orm(table = \"...\")]`?")
);
}
"tenant" => {
mark_once(seen, "nexus_tenant", &meta)?;
let value = string_value(&meta)?;
validate_rust_identifier(&value, "Nexus tenant field", meta.path.span())?;
}
"global_scope" => {
self.global_scope = identifier_value(&meta, seen, "global_scope")?;
}
"tenant_column" => {
self.tenant_column = identifier_value(&meta, seen, "tenant_column")?;
}
"policy" => self.policy = identifier_value(&meta, seen, "policy")?,
"before_save" => {
self.before_save = identifier_value(&meta, seen, "before_save")?;
}
"after_save" => self.after_save = identifier_value(&meta, seen, "after_save")?,
"before_delete" => {
self.before_delete = identifier_value(&meta, seen, "before_delete")?;
}
"after_delete" => {
self.after_delete = identifier_value(&meta, seen, "after_delete")?;
}
"after_fetch" => self.after_fetch = identifier_value(&meta, seen, "after_fetch")?,
_ => return Err(meta.error(format!("unsupported ORM model option `{key}`"))),
}
Ok(())
}
}
fn apply_sqlx(
meta: &ParseNestedMeta<'_>,
seen: &mut HashSet<&'static str>,
) -> Result<(), syn::Error> {
let key = path_name(meta)?;
match key.as_str() {
"default" => mark_once(seen, "sqlx_default", meta),
"rename_all" => Err(meta.error(
"#[sqlx(rename_all)] is not supported by #[derive(Orm)]; use matching persisted field names and types",
)),
_ => Err(meta.error(format!("unsupported SQLx model option `{key}`"))),
}
}
fn validate_scout_index(table_name: &str, span: proc_macro2::Span) -> Result<(), syn::Error> {
let valid = table_name
.as_bytes()
.first()
.is_some_and(u8::is_ascii_lowercase)
&& table_name
.bytes()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_');
if !valid {
return Err(syn::Error::new(
span,
format!(
"searchable models use their table name `{table_name}` as the Scout index, which must start with a lowercase ASCII letter and contain only lowercase letters, digits, or underscores; set a lowercase #[orm(table = \"...\")]"
),
));
}
Ok(())
}
fn parse_soft_delete(meta: &ParseNestedMeta<'_>) -> Result<SoftDeleteConfig, syn::Error> {
let mut column = None;
let mut value = None;
let mut delval = None;
let mut seen = HashSet::new();
if meta.input.peek(syn::token::Paren) {
meta.parse_nested_meta(|nested| {
let key = path_name(&nested)?;
match key.as_str() {
"field" | "column" => {
mark_once(&mut seen, "column", &nested)?;
let parsed = string_value(&nested)?;
validate_sql_identifier(&parsed, "soft-delete column", nested.path.span())?;
column = Some(parsed);
}
"value" => {
mark_once(&mut seen, "value", &nested)?;
value = Some(bounded_sql_fragment(&nested)?);
}
"delval" => {
mark_once(&mut seen, "delval", &nested)?;
delval = Some(bounded_sql_fragment(&nested)?);
}
_ => return Err(nested.error(format!("unsupported soft_delete option `{key}`"))),
}
Ok(())
})?;
} else if meta.input.peek(Token![=]) {
return Err(meta.error("soft_delete accepts no value or a parenthesized option list"));
}
Ok(SoftDeleteConfig {
column: column.unwrap_or_else(|| "deleted_at".to_string()),
value: value.unwrap_or_default(),
delval: delval.unwrap_or_default(),
})
}
fn bounded_sql_fragment(meta: &ParseNestedMeta<'_>) -> Result<String, syn::Error> {
let value = string_value(meta)?;
if value.len() > 128
|| value.contains([';', '\0', '#'])
|| value.contains("--")
|| value.contains("/*")
|| value.contains("*/")
{
return Err(meta.error(
"soft-delete SQL fragments are limited to 128 bytes and cannot contain separators or comments",
));
}
Ok(value)
}
#[cfg(test)]
mod tests {
#[test]
fn persisted_column_names_must_match_the_portable_sql_grammar() {
for column in ["r#type".to_string(), "café".to_string(), "a".repeat(65)] {
let identifier =
syn::parse_str::<syn::Ident>(&column).expect("valid Rust field identifier");
let input: syn::DeriveInput = syn::parse_quote! {
struct Record { id: i32, #identifier: String }
};
assert!(
crate::parser::parse(&input).is_err(),
"accepted non-portable column: {column}"
);
}
}
#[test]
fn an_explicit_table_replaces_an_unportable_default_table_name() {
for struct_name in ["Usuário".to_string(), "A".repeat(64)] {
let ident = syn::parse_str::<syn::Ident>(&struct_name).expect("valid Rust identifier");
let explicit: syn::DeriveInput = syn::parse_quote! {
#[orm(table = "usuarios")]
struct #ident { id: i32, nome: String }
};
let parsed = crate::parser::parse(&explicit).unwrap_or_else(|error| {
panic!("explicit table rejected for {struct_name}: {error}")
});
assert_eq!(parsed.table_name, "usuarios");
let implicit: syn::DeriveInput = syn::parse_quote! {
struct #ident { id: i32, nome: String }
};
let error = match crate::parser::parse(&implicit) {
Ok(_) => panic!("unportable default table accepted for {struct_name}"),
Err(error) => error,
};
assert!(error.to_string().contains("default table name"), "{error}");
}
}
#[test]
fn searchable_tables_must_be_valid_scout_index_names() {
for table in ["UserProfiles", "_drafts", "users2FA"] {
let input: syn::DeriveInput = syn::parse_quote! {
#[orm(table = #table, searchable)]
struct Record { id: i32 }
};
let error = match crate::parser::parse(&input) {
Ok(_) => panic!("searchable table `{table}` must be rejected"),
Err(error) => error,
};
assert!(error.to_string().contains("Scout index"), "{error}");
let plain: syn::DeriveInput = syn::parse_quote! {
#[orm(table = #table)]
struct Record { id: i32 }
};
assert!(crate::parser::parse(&plain).is_ok(), "{table}");
}
let default_name: syn::DeriveInput = syn::parse_quote! {
#[orm(searchable)]
struct ScoutUser { id: i32 }
};
let parsed = crate::parser::parse(&default_name).expect("lowercase default index");
assert_eq!(parsed.table_name, "scoutusers");
}
#[test]
fn container_sqlx_options_cannot_rename_decoded_columns() {
for (option, expected) in [
(
quote::quote!(rename_all = "camelCase"),
"#[sqlx(rename_all)]",
),
(quote::quote!(transparent), "unsupported SQLx model option"),
(
quote::quote!(type_name = "account"),
"unsupported SQLx model option",
),
(quote::quote!(no_pg_array), "unsupported SQLx model option"),
] {
let input: syn::DeriveInput = syn::parse_quote! {
#[sqlx(#option)]
struct Account { id: i32, display_name: String }
};
let error = match crate::parser::parse(&input) {
Ok(_) => panic!("accepted #[sqlx({option})] on the model"),
Err(error) => error,
};
assert!(error.to_string().contains(expected), "{error}");
}
let input: syn::DeriveInput = syn::parse_quote! {
#[sqlx(default)]
struct Account { id: i32, display_name: String }
};
assert!(crate::parser::parse(&input).is_ok());
let duplicate: syn::DeriveInput = syn::parse_quote! {
#[sqlx(default, default)]
struct Account { id: i32 }
};
assert!(crate::parser::parse(&duplicate).is_err());
}
#[test]
fn soft_delete_fragments_reject_every_supported_dialect_comment() {
for fragment in [
"0 /*",
"0 /* hidden */",
"0 */",
"0 # trailing",
"0 -- trailing",
] {
for option in ["value", "delval"] {
let option = syn::Ident::new(option, proc_macro2::Span::call_site());
let input: syn::DeriveInput = syn::parse_quote! {
#[orm(soft_delete(#option = #fragment))]
struct Record { id: i32, deleted_at: Option<String> }
};
assert!(
crate::parser::parse(&input).is_err(),
"accepted SQL comment: {fragment}"
);
}
}
for fragment in [
"0",
"NULL",
"CURRENT_TIMESTAMP",
"strftime('%Y-%m-%d', 'now')",
] {
let input: syn::DeriveInput = syn::parse_quote! {
#[orm(soft_delete(delval = #fragment))]
struct Record { id: i32, deleted_at: Option<String> }
};
assert!(
crate::parser::parse(&input).is_ok(),
"rejected bounded expression: {fragment}"
);
}
}
}