use super::common::*;
use crate::parser::ParsedRelation;
use proc_macro2::Span;
use std::collections::HashSet;
use syn::meta::ParseNestedMeta;
use syn::{Field, spanned::Spanned};
pub(in crate::parser) struct FieldAttributes {
pub is_hidden: bool,
pub is_skipped: bool,
pub is_masked: bool,
pub is_encrypted: bool,
pub is_db_default: bool,
pub sqlx_json: Option<bool>,
pub rag_context: bool,
pub embedding_for: Option<String>,
relation_type: String,
relation_model: String,
foreign_key: String,
related_key: String,
pivot_table: String,
local_key: String,
morph_name: String,
cascade_soft_delete: bool,
}
impl FieldAttributes {
pub fn parse(field: &Field, diagnostic_span: Span) -> Result<Self, syn::Error> {
let mut parsed = Self::default();
let mut seen = HashSet::new();
for attribute in field.attrs.iter().filter(|attribute| {
attribute.path().is_ident("orm") || attribute.path().is_ident("sqlx")
}) {
if attribute.path().is_ident("orm") {
attribute.parse_nested_meta(|meta| parsed.apply_orm(meta, &mut seen))?;
} else {
attribute.parse_nested_meta(|meta| parsed.apply_sqlx(meta, &mut seen))?;
}
}
parsed.validate(&seen, diagnostic_span)?;
Ok(parsed)
}
pub fn is_relation(&self) -> bool {
!self.relation_type.is_empty()
}
pub fn into_relation(self, field_name: syn::Ident) -> ParsedRelation {
ParsedRelation {
field_name,
rel_type: self.relation_type,
rel_model: self.relation_model,
foreign_key: self.foreign_key,
local_key: self.local_key,
related_key: self.related_key,
pivot_table: self.pivot_table,
morph_name: self.morph_name,
cascade_soft_delete: self.cascade_soft_delete,
}
}
fn apply_orm(
&mut self,
meta: ParseNestedMeta<'_>,
seen: &mut HashSet<&'static str>,
) -> Result<(), syn::Error> {
let key = path_name(&meta)?;
match key.as_str() {
"hidden" => {
mark_once(seen, "hidden", &meta)?;
self.is_hidden = true;
}
"skip" => {
mark_once(seen, "orm_skip", &meta)?;
self.is_skipped = true;
}
"masked" => {
mark_once(seen, "masked", &meta)?;
self.is_masked = true;
}
"encrypted" => {
mark_once(seen, "encrypted", &meta)?;
self.is_encrypted = true;
}
"db_default" => {
mark_once(seen, "db_default", &meta)?;
self.is_db_default = true;
}
"cascade_soft_delete" => {
mark_once(seen, "cascade_soft_delete", &meta)?;
self.cascade_soft_delete = true;
}
"rag_context" => {
mark_once(seen, "rag_context", &meta)?;
self.rag_context = true;
}
"json" => mark_once(seen, "json", &meta)?,
_ => return self.apply_orm_value(key, meta, seen),
}
Ok(())
}
fn apply_orm_value(
&mut self,
key: String,
meta: ParseNestedMeta<'_>,
seen: &mut HashSet<&'static str>,
) -> Result<(), syn::Error> {
match key.as_str() {
"has_many" | "has_one" | "belongs_to" | "belongs_to_many" | "morph_many"
| "morph_one" | "morph_to" => {
mark_once(seen, "relation", &meta)?;
let value = string_value(&meta)?;
validate_relation_attribute(&key, &value, meta.path.span())?;
self.relation_type = key;
self.relation_model = value;
}
"hasmany" => return Err(typo(&meta, "hasmany", "has_many")),
"belongsto" => return Err(typo(&meta, "belongsto", "belongs_to")),
"hasone" => return Err(typo(&meta, "hasone", "has_one")),
"foreignkey" | "fk" => return Err(typo(&meta, &key, "foreign_key")),
"foreign_key" => {
self.foreign_key = relation_identifier(&meta, seen, "foreign_key")?;
}
"related_key" => {
self.related_key = relation_identifier(&meta, seen, "related_key")?;
}
"pivot_table" => {
self.pivot_table = relation_identifier(&meta, seen, "pivot_table")?;
}
"local_key" => {
self.local_key = relation_identifier(&meta, seen, "local_key")?;
}
"name" | "morph_name" => {
self.morph_name = relation_identifier(&meta, seen, "morph_name")?;
}
"embedding_for" => {
self.embedding_for = Some(identifier_value(&meta, seen, "embedding_for")?);
}
_ => return Err(meta.error(format!("unsupported ORM field option `{key}`"))),
}
Ok(())
}
fn apply_sqlx(
&mut self,
meta: ParseNestedMeta<'_>,
seen: &mut HashSet<&'static str>,
) -> Result<(), syn::Error> {
let key = path_name(&meta)?;
match key.as_str() {
"skip" => {
mark_once(seen, "sqlx_skip", &meta)?;
self.is_skipped = true;
}
"default" => mark_once(seen, "sqlx_default", &meta)?,
"json" => {
mark_once(seen, "json", &meta)?;
let mut nullable = false;
if meta.input.peek(syn::token::Paren) {
meta.parse_nested_meta(|nested| {
if !nested.path.is_ident("nullable") || nullable {
return Err(nested
.error("SQLx json accepts only one optional `nullable` modifier"));
}
nullable = true;
Ok(())
})?;
}
self.sqlx_json = Some(nullable);
}
"rename" | "try_from" | "flatten" => {
return Err(meta.error(format!(
"#[sqlx({key})] is not supported by #[derive(Orm)]; use matching persisted field names and types"
)));
}
_ => return Err(meta.error(format!("unsupported SQLx field option `{key}`"))),
}
Ok(())
}
fn validate(
&self,
seen: &HashSet<&'static str>,
span: proc_macro2::Span,
) -> Result<(), syn::Error> {
let relation_option = [
"foreign_key",
"related_key",
"pivot_table",
"local_key",
"morph_name",
"cascade_soft_delete",
]
.into_iter()
.any(|option| seen.contains(option));
if !self.is_relation() && relation_option {
return Err(syn::Error::new(
span,
"relationship options require exactly one relation declaration",
));
}
if seen.contains("local_key")
&& matches!(self.relation_type.as_str(), "belongs_to" | "morph_to")
{
return Err(syn::Error::new(
span,
"local_key does not apply to belongs_to or morph_to relations; name the related model's key with related_key",
));
}
if seen.contains("related_key")
&& !matches!(
self.relation_type.as_str(),
"belongs_to" | "belongs_to_many" | "morph_to"
)
{
return Err(syn::Error::new(
span,
"related_key is supported only on belongs_to, belongs_to_many or morph_to relations",
));
}
if self.cascade_soft_delete
&& !matches!(self.relation_type.as_str(), "has_many" | "has_one")
{
return Err(syn::Error::new(
span,
"cascade_soft_delete is supported only on has_many or has_one relations",
));
}
if !self.pivot_table.is_empty() && self.relation_type != "belongs_to_many" {
return Err(syn::Error::new(
span,
"pivot_table is supported only on belongs_to_many relations",
));
}
if self.relation_type == "belongs_to_many" && self.pivot_table.is_empty() {
return Err(syn::Error::new(
span,
"belongs_to_many requires pivot_table = \"...\"",
));
}
if !self.morph_name.is_empty()
&& !matches!(
self.relation_type.as_str(),
"morph_many" | "morph_one" | "morph_to"
)
{
return Err(syn::Error::new(
span,
"morph_name is supported only on polymorphic relations",
));
}
if self.is_db_default && (self.is_relation() || self.is_skipped || self.is_encrypted) {
return Err(syn::Error::new(
span,
"db_default requires a persisted, unencrypted non-relation field",
));
}
if self.embedding_for.is_some() && (self.is_relation() || self.is_skipped) {
return Err(syn::Error::new(
span,
"embedding_for requires a persisted non-relation field",
));
}
Ok(())
}
}
impl Default for FieldAttributes {
fn default() -> Self {
Self {
is_hidden: false,
is_skipped: false,
is_masked: false,
is_encrypted: false,
is_db_default: false,
sqlx_json: None,
rag_context: false,
embedding_for: None,
relation_type: String::new(),
relation_model: String::new(),
foreign_key: String::new(),
related_key: String::new(),
pivot_table: String::new(),
local_key: "id".to_string(),
morph_name: String::new(),
cascade_soft_delete: false,
}
}
}
#[cfg(test)]
mod tests {
#[test]
fn relation_keys_that_a_relation_ignores_fail_compilation() {
use syn::parse_quote;
let inputs: [syn::DeriveInput; 4] = [
parse_quote! {
struct Post {
id: i32,
author_ref: i32,
#[orm(belongs_to = "User", foreign_key = "author_ref", local_key = "legacy_id")]
author: Option<User>,
}
},
parse_quote! {
struct Comment {
id: i32,
commentable_id: i32,
commentable_type: String,
#[orm(morph_to = "Post", morph_name = "commentable", local_key = "id")]
post: Option<Post>,
}
},
parse_quote! {
struct User {
id: i32,
#[orm(has_many = "Post", related_key = "author_ref")]
posts: Option<Vec<Post>>,
}
},
parse_quote! {
struct Post {
id: i32,
#[orm(morph_one = "Image", morph_name = "imageable", related_key = "r_id")]
image: Option<Image>,
}
},
];
for input in inputs {
let error = match crate::parser::parse(&input) {
Ok(_) => panic!("an ignored relation key must fail for `{}`", input.ident),
Err(error) => error,
};
assert!(error.to_string().contains("_key"), "{error}");
}
let applicable: syn::DeriveInput = parse_quote! {
struct Post {
id: i32,
user_ref: i32,
#[orm(belongs_to = "User", foreign_key = "user_ref", related_key = "legacy_id")]
user: Option<User>,
#[orm(has_many = "Comment", foreign_key = "post_ref", local_key = "id")]
comments: Option<Vec<Comment>>,
#[orm(belongs_to_many = "Tag", pivot_table = "post_tags", local_key = "id", related_key = "tag_id")]
tags: Option<Vec<Tag>>,
}
};
assert!(crate::parser::parse(&applicable).is_ok());
}
#[test]
fn orm_and_sqlx_skip_combine_but_each_stays_unique() {
use syn::parse_quote;
let combined: syn::DeriveInput = parse_quote! {
struct Account {
id: i32,
#[orm(skip)]
#[sqlx(skip)]
session_cache: String,
}
};
let parsed =
crate::parser::parse(&combined).expect("orm(skip) with sqlx(skip) should parse");
assert!(
parsed
.skipped_fields
.iter()
.any(|field| field == "session_cache")
);
assert!(
!parsed
.normal_fields
.iter()
.any(|field| field == "session_cache")
);
for duplicate in [
parse_quote! { struct Account { id: i32, #[orm(skip, skip)] cache: String } },
parse_quote! { struct Account { id: i32, #[sqlx(skip)] #[sqlx(skip)] cache: String } },
] {
let duplicate: syn::DeriveInput = duplicate;
let error = match crate::parser::parse(&duplicate) {
Ok(_) => panic!("a repeated skip option must fail"),
Err(error) => error,
};
assert!(error.to_string().contains("duplicate"), "{error}");
}
}
#[test]
fn db_default_needs_a_plain_persisted_column() {
use syn::parse_quote;
let accepted: syn::DeriveInput = parse_quote! {
#[orm(tenant_column = "school")]
struct Note {
id: i32,
school: String,
#[orm(db_default)]
created_at: Option<DateTime<Utc>>,
}
};
let parsed = crate::parser::parse(&accepted).expect("db_default on a plain column");
assert_eq!(parsed.db_default_fields.len(), 1);
assert_eq!(parsed.db_default_fields[0], "created_at");
assert!(
parsed
.normal_fields
.iter()
.any(|field| field == "created_at")
);
let rejected: [syn::DeriveInput; 6] = [
parse_quote! { struct Note { #[orm(db_default)] id: i32 } },
parse_quote! {
#[orm(tenant_column = "school")]
struct Note { id: i32, #[orm(db_default)] school: String }
},
parse_quote! { struct Note { id: i32, #[orm(db_default, skip)] cache: String } },
parse_quote! { struct Note { id: i32, #[orm(db_default, encrypted)] body: String } },
parse_quote! {
struct Note { id: i32, #[orm(db_default, belongs_to = "User")] user: Option<User> }
},
parse_quote! { struct Note { id: i32, #[orm(db_default, db_default)] at: String } },
];
for input in rejected {
assert!(crate::parser::parse(&input).is_err(), "{}", input.ident);
}
}
}