rullst-orm-macros 13.0.0-alpha.2

Procedural macros and derive attributes for the rullst-orm Active Record pattern.
Documentation
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))?;
            }
        }
        // The derived `<struct>s` name must be portable only when no explicit
        // `table` replaces it (a non-ASCII or 64-byte struct name, say).
        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 = \"...\")]`?")
                );
            }
            // Nexus reads this option from the shared `orm` namespace. The ORM
            // validates its shape but deliberately assigns it no SQL meaning.
            "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(())
    }
}

/// The application's SQLx `FromRow` also reads struct-level `#[sqlx(...)]`
/// options. Only `default`, which fills missing columns without renaming any,
/// keeps the decoded shape aligned with the generated SQL.
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}`"))),
    }
}

/// Generated Scout projections and searches use the table name as the index,
/// and every engine accepts only a lowercase name starting with a letter.
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}");
            // The same table remains valid without Scout projection.
            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}");
        }
        // A struct-level `default` changes no column name.
        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}"
            );
        }
    }
}