#[cfg(test)]
mod tests {
use super::*;
use proc_macro2::Span;
#[test]
fn test_split_top_level() {
let parts = split_top_level("a, b, c");
assert_eq!(parts, vec!["a", " b", " c"]);
let parts = split_top_level("a(1, 2), b, c(3, 4)");
assert_eq!(parts, vec!["a(1, 2)", " b", " c(3, 4)"]);
let parts = split_top_level("a), b");
assert_eq!(parts, vec!["a)", " b"]);
}
#[test]
fn test_strip_outer_call() {
assert_eq!(
strip_outer_call("soft_delete(field = \"a\")", "soft_delete"),
Some("field = \"a\"".to_string())
);
assert_eq!(
strip_outer_call(" soft_delete ( field = \"a\" ) ", "soft_delete"),
Some("field = \"a\"".to_string())
);
assert_eq!(strip_outer_call("soft_delete", "soft_delete"), None);
assert_eq!(strip_outer_call("soft_delete(a", "soft_delete"), None);
assert_eq!(strip_outer_call("soft_delete a)", "soft_delete"), None);
}
#[test]
fn test_validate_relation_attribute() {
assert!(validate_relation_attribute("has_many", "User", Span::call_site()).is_ok());
assert!(validate_relation_attribute("has_one", "Profile", Span::call_site()).is_ok());
assert!(validate_relation_attribute("belongs_to", "Team", Span::call_site()).is_ok());
assert!(validate_relation_attribute("belongs_to_many", "Role", Span::call_site()).is_ok());
assert!(validate_relation_attribute("morph_many", "Comment", Span::call_site()).is_ok());
assert!(validate_relation_attribute("morph_one", "Image", Span::call_site()).is_ok());
assert!(validate_relation_attribute("morph_to", "Post", Span::call_site()).is_ok());
assert!(validate_relation_attribute("has_many", "", Span::call_site()).is_err());
assert!(validate_relation_attribute("belongs_to", "", Span::call_site()).is_err());
assert!(validate_relation_attribute("foreign_key", "", Span::call_site()).is_err());
assert!(validate_relation_attribute("related_key", "", Span::call_site()).is_err());
assert!(validate_relation_attribute("pivot_table", "", Span::call_site()).is_err());
assert!(validate_relation_attribute("local_key", "", Span::call_site()).is_err());
assert!(validate_relation_attribute("name", "", Span::call_site()).is_err());
}
#[test]
fn test_parse_model_attributes() {
use syn::parse_quote;
let input: DeriveInput = parse_quote! {
#[orm(table_name = "test_table", global_scope = "active_only", tenant_column = "tenant_id", policy = "TestPolicy")]
#[orm(auditable, searchable)]
#[orm(before_save = "before_s", after_save = "after_s")]
#[orm(before_delete = "before_d", after_delete = "after_d")]
#[orm(after_fetch = "after_f")]
#[orm(soft_delete(column = "del_at", value = "1", delval = "0"))]
struct TestModel {
id: i32,
tenant_id: String,
del_at: i64,
#[orm(has_many = "M1")] m1: Vec<M1>,
#[orm(has_one = "M2")] m2: M2,
#[orm(belongs_to = "M3")] m3: M3,
#[orm(belongs_to_many = "M4", pivot_table = "piv_m4")] m4: Vec<M4>,
#[orm(morph_many = "M5", name = "m5_able", local_key = "custom_id")] m5: Vec<M5>,
#[orm(morph_one = "M6", name = "m6_able", foreign_key = "f_id")] m6: M6,
m7_able_id: i32,
m7_able_type: String,
#[orm(morph_to = "M7", morph_name = "m7_able")] m7: Option<M7>,
#[orm(skip)] skipped: i32,
#[orm(hidden)] hidden: String,
#[orm(masked)] token: String,
}
};
let parsed = parse(&input).unwrap();
assert_eq!(parsed.table_name, "test_table");
assert_eq!(parsed.global_scope, "active_only");
assert_eq!(parsed.tenant_column, "tenant_id");
assert_eq!(parsed.policy, "TestPolicy");
assert!(parsed.auditable);
assert!(parsed.searchable);
assert_eq!(parsed.before_save, "before_s");
assert_eq!(parsed.after_save, "after_s");
assert_eq!(parsed.before_delete, "before_d");
assert_eq!(parsed.after_delete, "after_d");
assert_eq!(parsed.after_fetch, "after_f");
let sd = parsed.soft_delete.unwrap();
assert_eq!(sd.column, "del_at");
assert_eq!(sd.value, "1");
assert_eq!(sd.delval, "0");
let r = &parsed.relations;
assert_eq!(r.len(), 7);
assert_eq!(r[0].rel_type, "has_many");
assert_eq!(r[1].rel_type, "has_one");
assert_eq!(r[2].rel_type, "belongs_to");
assert_eq!(r[3].rel_type, "belongs_to_many");
assert_eq!(r[3].pivot_table, "piv_m4");
assert_eq!(r[4].rel_type, "morph_many");
assert_eq!(r[4].morph_name, "m5_able");
assert_eq!(r[4].local_key, "custom_id");
assert_eq!(r[5].rel_type, "morph_one");
assert_eq!(r[5].morph_name, "m6_able");
assert_eq!(r[5].foreign_key, "f_id");
assert_eq!(r[6].rel_type, "morph_to");
assert_eq!(r[6].morph_name, "m7_able");
assert!(parsed.skipped_fields.iter().any(|i| i == "skipped"));
assert!(parsed.hidden_fields.iter().any(|i| i == "hidden"));
}
#[test]
fn morph_to_requires_persisted_id_and_string_discriminator() {
use syn::parse_quote;
let invalid_id: DeriveInput = parse_quote! {
struct Comment {
id: i32,
commentable_id: Vec<i32>,
commentable_type: String,
#[orm(morph_to = "Post", morph_name = "commentable")]
post: Option<Post>,
}
};
let error = match parse(&invalid_id) {
Ok(_) => panic!("an unsupported polymorphic id type must fail"),
Err(error) => error,
};
assert!(error.to_string().contains("id fields support"));
let missing_discriminator: DeriveInput = parse_quote! {
struct Comment {
id: i32,
commentable_id: i32,
#[orm(morph_to = "Post", morph_name = "commentable")]
post: Option<Post>,
}
};
let error = match parse(&missing_discriminator) {
Ok(_) => panic!("missing discriminator must fail"),
Err(error) => error,
};
assert!(error.to_string().contains("commentable_type"));
let invalid_discriminator: DeriveInput = parse_quote! {
struct Comment {
id: i32,
commentable_id: i32,
commentable_type: i32,
#[orm(morph_to = "Post", morph_name = "commentable")]
post: Option<Post>,
}
};
let error = match parse(&invalid_discriminator) {
Ok(_) => panic!("numeric discriminator must fail"),
Err(error) => error,
};
assert!(error.to_string().contains("must use String"));
}
#[test]
fn parses_supported_orm_backends_and_defaults_to_sqlx() {
use syn::parse_quote;
let default_backend: DeriveInput = parse_quote! {
struct DefaultModel { id: i64 }
};
assert_eq!(
parse(&default_backend)
.expect("default backend should parse")
.backend,
"sqlx"
);
let turso_backend: DeriveInput = parse_quote! {
#[orm(table = "users", backend = "turso")]
struct TursoModel { id: i64, name: String }
};
assert_eq!(
parse(&turso_backend)
.expect("Turso backend should parse")
.backend,
"turso"
);
}
#[test]
fn rejects_unknown_orm_backend() {
use syn::parse_quote;
let input: DeriveInput = parse_quote! {
#[orm(backend = "mongodb")]
struct UnsupportedModel { id: i64 }
};
let error = parse(&input)
.err()
.expect("unknown backend must be rejected");
assert!(error.to_string().contains("`sqlx` or `turso`"));
}
#[test]
fn rejects_unsupported_model_shapes_without_rendering_the_full_ast() {
let pathological_union = "#[Li=555527577500346.07.10/..0/..1./005L.07.100/..1./07.10/..0/..1./005L.03.77500346.07.10/..0/..1./005L10.07.10/..0/..1./005L.07.100/..1./07.10/..0/..1./005L.07.100/..1./005LL7.500346.07.10/..0/..1./005L107.100/..1./07.10/..0/..1./005L.07.100/..1./0/..1./00=5555555SSSSSSSSSSS]#[K]union n<l,>{}";
let input: DeriveInput = syn::parse_str(pathological_union)
.expect("the release-campaign union must remain a valid derive input");
let error = match parse(&input) {
Ok(_) => panic!("unions must not be accepted as ORM models"),
Err(error) => error,
};
assert_eq!(error.to_string(), "Orm macro can only be used on structs");
let pathological_struct = pathological_union.replacen("union n", "struct n", 1);
let input: DeriveInput = syn::parse_str(&pathological_struct)
.expect("the release-campaign struct must remain a valid derive input");
let error = match parse(&input) {
Ok(_) => panic!("models without a persisted id must not be accepted"),
Err(error) => error,
};
assert_eq!(
error.to_string(),
"Orm models require a persisted named `id` field"
);
let tuple: DeriveInput = syn::parse_str("struct Tuple(i64);")
.expect("the tuple-struct regression input must parse");
let error = match parse(&tuple) {
Ok(_) => panic!("tuple structs must not be accepted as ORM models"),
Err(error) => error,
};
assert_eq!(
error.to_string(),
"Orm macro only supports structs with named fields"
);
}
#[test]
fn rejects_missing_id_without_rendering_a_pathological_field() {
let source = include_str!(
"../../rullst-orm/fuzz/corpus/fuzz_parser/pathological_field_span"
);
let input: DeriveInput = syn::parse_str(source)
.expect("the release-campaign field input must remain a valid derive input");
let error = match parse(&input) {
Ok(_) => panic!("models without a persisted id must not be accepted"),
Err(error) => error,
};
assert_eq!(
error.to_string(),
"Orm models require a persisted named `id` field"
);
}
#[test]
fn tenant_column_must_reference_a_supported_persisted_field() {
use syn::parse_quote;
let missing: DeriveInput = parse_quote! {
#[orm(table = "records", tenant_column = "tenant_id")]
struct Record { id: i32, name: String }
};
let error = parse(&missing)
.err()
.expect("missing tenant field must fail");
assert!(error.to_string().contains("must name a persisted field"));
let unsupported: DeriveInput = parse_quote! {
#[orm(table = "records", tenant_column = "tenant_id")]
struct Record { id: i32, tenant_id: i64 }
};
let error = parse(&unsupported)
.err()
.expect("unsupported tenant type must fail");
assert!(error.to_string().contains("String, i32, f64, or bool"));
}
#[test]
fn test_parse_sensitive_fields_audit() {
use syn::parse_quote;
let input: DeriveInput = parse_quote! {
#[orm(table = "t", auditable)]
struct Model { id: i32, password: String }
};
assert!(parse(&input).is_err());
let input2: DeriveInput = parse_quote! {
#[orm(table = "t", auditable)]
struct Model { id: i32, token_field: String }
};
assert!(parse(&input2).is_err());
let input3: DeriveInput = parse_quote! {
#[orm(table = "t", auditable)]
struct Model { id: i32, my_secret: String }
};
assert!(parse(&input3).is_err());
let input4: DeriveInput = parse_quote! {
#[orm(table = "t", auditable)]
struct Model { id: i32, api_key: String }
};
assert!(parse(&input4).is_err());
let input5: DeriveInput = parse_quote! {
#[orm(table = "t", auditable)]
struct Model { id: i32, #[orm(masked)] password: String }
};
assert!(parse(&input5).is_ok());
let input6: DeriveInput = parse_quote! {
#[orm(table = "t", auditable)]
struct Model { id: i32, #[orm(skip)] password: String }
};
assert!(parse(&input6).is_ok());
let input7: DeriveInput = parse_quote! {
#[orm(table = "t", auditable)]
struct Model { id: i32, #[orm(has_one="P")] password: String }
};
assert!(parse(&input7).is_ok());
}
#[test]
fn parses_supported_encrypted_fields() {
use syn::parse_quote;
let input: DeriveInput = parse_quote! {
#[orm(table = "vault_records")]
struct VaultRecord {
id: i32,
#[orm(encrypted)]
secret: String,
#[orm(encrypted)]
recovery_code: Option<String>,
}
};
let parsed = parse(&input).expect("encrypted String fields should parse");
assert_eq!(parsed.encrypted_fields.len(), 2);
assert_eq!(parsed.encrypted_fields[0].name, "secret");
assert_eq!(parsed.encrypted_fields[0].kind, EncryptedFieldKind::String);
assert_eq!(parsed.encrypted_fields[1].name, "recovery_code");
assert_eq!(
parsed.encrypted_fields[1].kind,
EncryptedFieldKind::OptionalString
);
}
#[test]
fn rejects_invalid_encrypted_field_declarations() {
use syn::parse_quote;
let wrong_type: DeriveInput = parse_quote! {
struct VaultRecord {
id: i32,
#[orm(encrypted)]
secret: i64,
}
};
assert!(parse(&wrong_type)
.err()
.expect("non-string encryption must fail")
.to_string()
.contains("supports only String and Option<String>"));
let skipped: DeriveInput = parse_quote! {
struct VaultRecord {
id: i32,
#[orm(encrypted, skip)]
secret: String,
}
};
assert!(parse(&skipped)
.err()
.expect("encrypted skipped fields must fail")
.to_string()
.contains("cannot be combined"));
}
#[test]
fn secret_string_fields_are_redacted_but_other_types_are_not() {
for type_name in [
"SecretString",
"rullst_orm::SecretString",
"Option<SecretString>",
"Option<rullst_orm::privacy::SecretString>",
] {
let ty: syn::Type = syn::parse_str(type_name).expect("SecretString type");
assert!(is_secret_string_type(&ty), "{type_name}");
}
for plain in [
"String",
"Option<String>",
"Option<Option<SecretString>>",
"Vec<SecretString>",
"SecretString<u8>",
] {
let ty: syn::Type = syn::parse_str(plain).expect("plain type");
assert!(!is_secret_string_type(&ty), "{plain}");
}
let input: DeriveInput = syn::parse_quote! {
struct Customer {
id: i32,
cpf: SecretString,
#[orm(masked)]
hint: String,
name: String,
}
};
let parsed = parse(&input).expect("customer should parse");
let redacted: Vec<String> = parsed
.normal_fields
.iter()
.filter(|field| parsed.is_redacted(field))
.map(ToString::to_string)
.collect();
assert_eq!(redacted, vec!["cpf", "hint"]);
}
}