use proc_macro2::TokenStream;
use quote::quote;
use syn::{
Data, DeriveInput, Field, Fields, Lit, Meta, Result, Token, Type, punctuated::Punctuated,
};
use rango_orm_core::{ColumnType, type_map};
use crate::rango_core_path;
use crate::utils::to_snake_case;
#[derive(Default)]
struct FieldAttr {
unique: bool,
index: bool,
primary_key: bool,
auto_now_add: bool,
auto_now: bool,
column: Option<String>,
default: Option<String>,
comment: Option<String>,
on_delete: Option<String>,
}
#[derive(Default)]
struct ModelAttr {
table: Option<String>,
comment: Option<String>,
managed: Option<bool>,
hooks: bool,
ordering: Vec<syn::Expr>,
constraints: Vec<syn::Expr>,
}
pub fn expand(input: DeriveInput) -> Result<TokenStream> {
let struct_name = &input.ident;
let model_attr = parse_model_attr(&input)?;
let table_name = model_attr
.table
.unwrap_or_else(|| to_snake_case(&struct_name.to_string()));
let managed = model_attr.managed.unwrap_or(true);
let ordering_exprs = &model_attr.ordering;
let constraints_exprs = &model_attr.constraints;
let fields = match &input.data {
Data::Struct(s) => match &s.fields {
Fields::Named(f) => &f.named,
_ => {
return Err(syn::Error::new_spanned(
struct_name,
"Model requires named fields",
));
}
},
_ => {
return Err(syn::Error::new_spanned(
struct_name,
"Model can only be derived on structs",
));
}
};
let core = rango_core_path();
let column_defs: Vec<TokenStream> = fields
.iter()
.filter(|f| !is_many_to_many(&f.ty))
.map(|f| generate_column_def(f, &core))
.collect::<Result<Vec<_>>>()?;
let mut field_value_entries = Vec::new();
let mut pk_value_expr = quote! { #core::SqlValue::NullUuid }; let mut pk_col_name = "id".to_string();
let mut pk_found = false;
let mut set_pk_body = quote! { let _ = id; };
let mut pk_type = quote! { #core::UnsupportedPk };
let m2m_reads: Vec<_> = fields
.iter()
.filter(|f| is_many_to_many(&f.ty))
.map(|f| {
let fname = f.ident.as_ref().unwrap();
quote! { let _ = &self.#fname; }
})
.collect();
for f in fields.iter() {
if is_many_to_many(&f.ty) {
continue;
}
let fname = f.ident.as_ref().unwrap();
let attr = parse_field_attr(f)?;
let is_pk = fname == "id" || attr.primary_key;
let col_name = column_name(fname, attr.column);
if is_pk {
pk_found = true;
pk_col_name = col_name.clone();
pk_value_expr = quote! {
#core::ToSqlValue::to_sql_value(&self.#fname)
};
let ty = &f.ty;
let ty_str = quote!(#ty).to_string().replace(' ', "");
let last = ty_str.rsplit("::").next().unwrap_or("");
if matches!(
last,
"FieldUuid" | "FieldInt" | "FieldBigInt" | "FieldAutoInt" | "FieldAutoBigInt"
) {
pk_type = quote! { <#ty as #core::PrimaryKeyField>::Pk };
}
match last {
"FieldAutoInt" => {
set_pk_body =
quote! { self.#fname = #core::FieldAutoInt(i32::try_from(id).ok()); };
}
"FieldAutoBigInt" => {
set_pk_body = quote! { self.#fname = #core::FieldAutoBigInt(Some(id)); };
}
_ => {}
}
} else {
field_value_entries.push(quote! {
(#col_name, #core::ToSqlValue::to_sql_value(&self.#fname))
});
}
}
if !pk_found {
return Err(syn::Error::new_spanned(
struct_name,
"Rango: no primary key found. Declare a field named `id` or annotate one with `#[field(primary_key)]`, or use `#[rango::model]` to add an implicit `id`",
));
}
let mut from_row_fields = Vec::new();
for f in fields.iter() {
if is_many_to_many(&f.ty) {
let fname = f.ident.as_ref().unwrap();
from_row_fields.push(quote! { #fname: #core::ManyToMany::empty() });
continue;
}
let fname = f.ident.as_ref().unwrap();
let col_name = column_name(fname, parse_field_attr(f)?.column);
let (nullable, inner_ty) = extract_option(&f.ty);
let type_str = type_name(inner_ty);
let getter = field_type_to_getter(&type_str, &col_name, nullable, &core);
from_row_fields.push(quote! { #fname: #getter });
}
let mut column_consts = Vec::new();
for f in fields.iter().filter(|f| !is_many_to_many(&f.ty)) {
let fname = f.ident.as_ref().unwrap();
let col_name = column_name(fname, parse_field_attr(f)?.column);
let const_name = syn::Ident::new(
&fname.to_string().trim_start_matches("r#").to_uppercase(),
fname.span(),
);
let (_, field_ty) = extract_option(&f.ty);
let doc = format!("Typed reference to the `{col_name}` column.");
column_consts.push(quote! {
#[doc = #doc]
pub const #const_name: #core::Column<Self, #field_ty> = #core::Column::new(#col_name);
});
}
for f in fields.iter().filter(|f| is_many_to_many(&f.ty)) {
let fname = f.ident.as_ref().unwrap();
let const_name = syn::Ident::new(
&fname.to_string().trim_start_matches("r#").to_uppercase(),
fname.span(),
);
let Some(to_ty) = first_generic(&f.ty) else {
return Err(syn::Error::new_spanned(&f.ty, "expected ManyToMany<Model>"));
};
let doc = format!("Typed reference to the `{fname}` many-to-many relation.");
column_consts.push(quote! {
#[doc = #doc]
pub const #const_name: #core::ManyToManyField<Self, #to_ty> = #core::ManyToManyField::new();
});
}
let comment_expr = match &model_attr.comment {
Some(c) => quote! { Some(#c.to_string()) },
None => quote! { None },
};
let core2 = rango_core_path();
let default_hooks = if model_attr.hooks {
quote! {}
} else {
quote! { impl #core2::ModelHooks for #struct_name {} }
};
Ok(quote! {
#default_hooks
impl #core2::Model for #struct_name {
type Pk = #pk_type;
fn table_name() -> &'static str {
#table_name
}
fn schema() -> #core2::TableSchema {
use #core2::{CheckConstraint, UniqueConstraint, asc, desc};
#core2::TableSchema {
table_name: #table_name.to_string(),
columns: vec![
#(#column_defs),*
],
constraints: vec![
#((#constraints_exprs).into()),*
],
ordering: vec![
#(#ordering_exprs),*
],
managed: #managed,
comment: #comment_expr,
}
}
}
impl #core2::FromRow for #struct_name {
fn from_row(row: &dyn #core2::RangoRow) -> Result<Self, #core2::RowError> {
Ok(Self {
#(#from_row_fields),*
})
}
}
impl #core2::Filterable for #struct_name {}
#[allow(dead_code)]
impl #struct_name {
#(#column_consts)*
}
impl #core2::ModelValues for #struct_name {
fn field_values(&self) -> Vec<(&'static str, #core2::SqlValue)> {
#(#m2m_reads)*
vec![#(#field_value_entries),*]
}
fn pk_value(&self) -> #core2::SqlValue {
#pk_value_expr
}
fn pk_column() -> &'static str {
#pk_col_name
}
fn set_generated_pk(&mut self, id: i64) {
#set_pk_body
}
}
})
}
fn column_name(field: &syn::Ident, column: Option<String>) -> String {
column.unwrap_or_else(|| field.to_string().trim_start_matches("r#").to_string())
}
fn parse_model_attr(input: &DeriveInput) -> Result<ModelAttr> {
let mut attr = ModelAttr::default();
for a in &input.attrs {
if !a.path().is_ident("model") {
continue;
}
let nested = a.parse_args_with(Punctuated::<Meta, Token![,]>::parse_terminated)?;
for meta in nested {
match meta {
Meta::NameValue(nv) if nv.path.is_ident("table") => {
if let syn::Expr::Lit(expr_lit) = &nv.value
&& let Lit::Str(s) = &expr_lit.lit
{
attr.table = Some(s.value());
}
}
Meta::NameValue(nv) if nv.path.is_ident("comment") => {
if let syn::Expr::Lit(expr_lit) = &nv.value
&& let Lit::Str(s) = &expr_lit.lit
{
attr.comment = Some(s.value());
}
}
Meta::Path(p) if p.is_ident("hooks") => attr.hooks = true,
Meta::NameValue(nv) if nv.path.is_ident("managed") => {
if let syn::Expr::Lit(expr_lit) = &nv.value
&& let Lit::Bool(b) = &expr_lit.lit
{
attr.managed = Some(b.value);
}
}
Meta::NameValue(nv) if nv.path.is_ident("ordering") => {
if let syn::Expr::Array(arr) = &nv.value {
attr.ordering = arr.elems.iter().cloned().collect();
}
}
Meta::NameValue(nv) if nv.path.is_ident("constraints") => {
if let syn::Expr::Array(arr) = &nv.value {
attr.constraints = arr.elems.iter().cloned().collect();
}
}
_ => {}
}
}
}
Ok(attr)
}
fn parse_field_attr(field: &Field) -> Result<FieldAttr> {
let mut attr = FieldAttr::default();
for a in &field.attrs {
if !a.path().is_ident("field") {
continue;
}
let nested = a.parse_args_with(Punctuated::<Meta, Token![,]>::parse_terminated)?;
for meta in nested {
match &meta {
Meta::Path(p) if p.is_ident("unique") => attr.unique = true,
Meta::Path(p) if p.is_ident("index") => attr.index = true,
Meta::Path(p) if p.is_ident("primary_key") => attr.primary_key = true,
Meta::Path(p) if p.is_ident("auto_now_add") => attr.auto_now_add = true,
Meta::Path(p) if p.is_ident("auto_now") => attr.auto_now = true,
Meta::NameValue(nv) if nv.path.is_ident("column") => {
if let syn::Expr::Lit(expr_lit) = &nv.value
&& let Lit::Str(s) = &expr_lit.lit
{
attr.column = Some(s.value());
}
}
Meta::NameValue(nv) if nv.path.is_ident("default") => {
if let syn::Expr::Lit(expr_lit) = &nv.value
&& let Lit::Str(s) = &expr_lit.lit
{
attr.default = Some(s.value());
}
}
Meta::NameValue(nv) if nv.path.is_ident("on_delete") => {
if let syn::Expr::Lit(expr_lit) = &nv.value
&& let Lit::Str(s) = &expr_lit.lit
{
attr.on_delete = Some(s.value());
}
}
Meta::NameValue(nv) if nv.path.is_ident("comment") => {
if let syn::Expr::Lit(expr_lit) = &nv.value
&& let Lit::Str(s) = &expr_lit.lit
{
attr.comment = Some(s.value());
}
}
_ => {}
}
}
}
Ok(attr)
}
fn generate_column_def(field: &Field, core: &TokenStream) -> Result<TokenStream> {
let field_name = field.ident.as_ref().unwrap();
let attr = parse_field_attr(field)?;
let col_name = column_name(field_name, attr.column);
let (nullable, inner_ty) = extract_option(&field.ty);
let is_pk = attr.primary_key || field_name == "id";
let is_index = attr.index;
let is_unique = is_pk || attr.unique;
let col_type = map_field_type(inner_ty, core)?;
let is_foreign_key = type_name(inner_ty).starts_with("ForeignKey<");
if attr.auto_now_add || attr.auto_now {
let type_str = type_name(inner_ty);
if !matches!(
type_str.as_str(),
"FieldDate" | "FieldTime" | "FieldDateTime"
) {
return Err(syn::Error::new_spanned(
field,
"#[field(auto_now_add)] and #[field(auto_now)] are only valid on FieldDate, FieldTime, or FieldDateTime",
));
}
}
let default_val = if attr.auto_now_add || attr.auto_now {
quote! { Some(#core::DefaultValue::CurrentTimestamp) }
} else if let Some(d) = attr.default {
quote! { Some(#core::DefaultValue::Literal(#d.to_string())) }
} else {
quote! { None }
};
let references = if is_foreign_key {
let target = first_generic(inner_ty)
.ok_or_else(|| syn::Error::new_spanned(&field.ty, "expected ForeignKey<Model>"))?;
let action = match attr.on_delete.as_deref() {
None | Some("cascade") => quote! { Cascade },
Some("set_null") => {
if !nullable {
return Err(syn::Error::new_spanned(
field,
"on_delete = \"set_null\" requires an Option<ForeignKey<...>> field",
));
}
quote! { SetNull }
}
Some("set_default") => quote! { SetDefault },
Some("restrict") => quote! { Restrict },
Some("no_action") => quote! { NoAction },
Some(other) => {
return Err(syn::Error::new_spanned(
field,
format!("Invalid on_delete value: {other}"),
));
}
};
quote! {
Some(#core::FkReference {
table: <#target as #core::Model>::table_name().to_string(),
column: <#target as #core::ModelValues>::pk_column().to_string(),
on_delete: #core::ReferentialAction::#action,
on_update: #core::ReferentialAction::NoAction,
})
}
} else {
quote! { None }
};
Ok(quote! {
#core::ColumnDef {
name: #col_name.to_string(),
col_type: #col_type,
nullable: #nullable,
primary_key: #is_pk,
unique: #is_unique,
default: #default_val,
references: #references,
index: #is_index || #is_foreign_key,
}
})
}
pub(crate) fn field_type_to_getter(
type_str: &str,
col: &impl quote::ToTokens,
nullable: bool,
core: &TokenStream,
) -> TokenStream {
let getter = match type_str {
"FieldBool" => quote! { row.get_bool(#col).map(#core::FieldBool)? },
"FieldSmallInt" => quote! { row.get_i16(#col).map(#core::FieldSmallInt)? },
"FieldInt" => quote! { row.get_i32(#col).map(#core::FieldInt)? },
"FieldBigInt" => quote! { row.get_i64(#col).map(#core::FieldBigInt)? },
"FieldAutoInt" => quote! { #core::FieldAutoInt(Some(row.get_i32(#col)?)) },
"FieldAutoBigInt" => quote! { #core::FieldAutoBigInt(Some(row.get_i64(#col)?)) },
"FieldFloat" => quote! { row.get_f32(#col).map(#core::FieldFloat)? },
"FieldDouble" => quote! { row.get_f64(#col).map(#core::FieldDouble)? },
"FieldText" => quote! { row.get_string(#col).map(#core::FieldText)? },
"FieldEmail" => quote! { row.get_string(#col).map(#core::FieldEmail::from_db)? },
"FieldUrl" => quote! { row.get_string(#col).map(#core::FieldUrl::from_db)? },
"FieldBytes" => quote! { row.get_bytes(#col).map(#core::FieldBytes)? },
"FieldUuid" => quote! { row.get_uuid(#col).map(#core::FieldUuid)? },
"FieldDateTime" => quote! { row.get_datetime(#col).map(#core::FieldDateTime)? },
"FieldDate" => quote! { row.get_date(#col).map(#core::FieldDate)? },
"FieldTime" => quote! { row.get_time(#col).map(#core::FieldTime)? },
"FieldJson" => quote! { row.get_json(#col).map(#core::FieldJson)? },
s if s.starts_with("ForeignKey<") => {
let target = fk_target(s);
quote! { <<#target as #core::Model>::Pk as #core::PrimaryKey>::from_row(row, #col).map(#core::ForeignKey::<#target>::new)? }
}
s if s.starts_with("FieldVarchar<") => {
let (min, max) = parse_two_generics(s, "FieldVarchar");
let min_lit = proc_macro2::Literal::usize_unsuffixed(min);
let max_lit = proc_macro2::Literal::usize_unsuffixed(max);
quote! { row.get_string(#col).map(#core::FieldVarchar::<#min_lit, #max_lit>::from_db)? }
}
s if s.starts_with("FieldPassword<") => {
let (min, max) = parse_two_generics(s, "FieldPassword");
let min_lit = proc_macro2::Literal::usize_unsuffixed(min);
let max_lit = proc_macro2::Literal::usize_unsuffixed(max);
quote! { row.get_string(#col).map(|s| #core::FieldPassword::<#min_lit, #max_lit>::from_hash(s))? }
}
s if s.starts_with("FieldDecimal<") => {
let (p, sc) = parse_two_generics(s, "FieldDecimal");
let p_lit = proc_macro2::Literal::u8_unsuffixed(p as u8);
let sc_lit = proc_macro2::Literal::u8_unsuffixed(sc as u8);
quote! { row.get_decimal(#col).map(#core::FieldDecimal::<#p_lit, #sc_lit>::from_decimal)? }
}
s if s.starts_with("FieldRange<") => {
let (min, max) = parse_two_generics_i64(s, "FieldRange");
let min_lit = proc_macro2::Literal::i64_unsuffixed(min);
let max_lit = proc_macro2::Literal::i64_unsuffixed(max);
quote! { row.get_i64(#col).map(#core::FieldRange::<#min_lit, #max_lit>::from_db)? }
}
_ => quote! { compile_error!("Unknown field type in FromRow") },
};
if nullable {
match type_str {
"FieldBool" => {
quote! { if row.is_null(#col) { None } else { Some(row.get_bool(#col).map(#core::FieldBool)?) } }
}
"FieldSmallInt" => {
quote! { if row.is_null(#col) { None } else { Some(row.get_i16(#col).map(#core::FieldSmallInt)?) } }
}
"FieldInt" => {
quote! { if row.is_null(#col) { None } else { Some(row.get_i32(#col).map(#core::FieldInt)?) } }
}
"FieldBigInt" => {
quote! { if row.is_null(#col) { None } else { Some(row.get_i64(#col).map(#core::FieldBigInt)?) } }
}
"FieldText" => {
quote! { if row.is_null(#col) { None } else { Some(row.get_string(#col).map(#core::FieldText)?) } }
}
"FieldEmail" => {
quote! { if row.is_null(#col) { None } else { Some(row.get_string(#col).map(#core::FieldEmail::from_db)?) } }
}
"FieldUuid" => {
quote! { if row.is_null(#col) { None } else { Some(row.get_uuid(#col).map(#core::FieldUuid)?) } }
}
"FieldDateTime" => {
quote! { if row.is_null(#col) { None } else { Some(row.get_datetime(#col).map(#core::FieldDateTime)?) } }
}
"FieldJson" => {
quote! { if row.is_null(#col) { None } else { Some(row.get_json(#col).map(#core::FieldJson)?) } }
}
s if s.starts_with("ForeignKey<") => {
let target = fk_target(s);
quote! { if row.is_null(#col) { None } else { Some(<<#target as #core::Model>::Pk as #core::PrimaryKey>::from_row(row, #col).map(#core::ForeignKey::<#target>::new)?) } }
}
s if s.starts_with("FieldVarchar<") => {
let (min, max) = parse_two_generics(s, "FieldVarchar");
let min_lit = proc_macro2::Literal::usize_unsuffixed(min);
let max_lit = proc_macro2::Literal::usize_unsuffixed(max);
quote! { if row.is_null(#col) { None } else { Some(row.get_string(#col).map(#core::FieldVarchar::<#min_lit, #max_lit>::from_db)?) } }
}
_ => quote! { if row.is_null(#col) { None } else { Some(#getter) } },
}
} else {
getter
}
}
fn parse_two_generics(s: &str, prefix: &str) -> (usize, usize) {
let inner = s
.trim_start_matches(&format!("{}<", prefix))
.trim_end_matches('>');
let parts: Vec<&str> = inner.split(',').collect();
if parts.len() == 2 {
let min = parts[0].trim().parse().unwrap_or(0);
let max = parts[1].trim().parse().unwrap_or(255);
(min, max)
} else {
(0, 255)
}
}
fn parse_two_generics_i64(s: &str, prefix: &str) -> (i64, i64) {
let inner = s
.trim_start_matches(&format!("{}<", prefix))
.trim_end_matches('>');
let parts: Vec<&str> = inner.split(',').collect();
if parts.len() == 2 {
let min = parts[0].trim().parse().unwrap_or(i64::MIN);
let max = parts[1].trim().parse().unwrap_or(i64::MAX);
(min, max)
} else {
(i64::MIN, i64::MAX)
}
}
fn first_generic(ty: &Type) -> Option<&Type> {
let Type::Path(p) = ty else { return None };
let syn::PathArguments::AngleBracketed(args) = &p.path.segments.last()?.arguments else {
return None;
};
args.args.iter().find_map(|a| match a {
syn::GenericArgument::Type(t) => Some(t),
_ => None,
})
}
fn fk_target(type_str: &str) -> TokenStream {
let inner = type_str
.strip_prefix("ForeignKey<")
.and_then(|t| t.strip_suffix('>'))
.unwrap_or("");
inner.parse().unwrap_or_else(|_| quote! { () })
}
pub(crate) fn type_name(ty: &Type) -> String {
let last = match ty {
Type::Path(tp) => tp.path.segments.last().map(|seg| quote!(#seg)),
_ => None,
};
last.unwrap_or_else(|| quote!(#ty))
.to_string()
.replace(' ', "")
}
fn is_many_to_many(ty: &Type) -> bool {
let s = type_name(ty);
s.starts_with("ManyToMany<")
}
pub(crate) fn extract_option(ty: &Type) -> (bool, &Type) {
if let Type::Path(tp) = ty
&& let Some(seg) = tp.path.segments.last()
&& seg.ident == "Option"
&& let syn::PathArguments::AngleBracketed(args) = &seg.arguments
&& let Some(syn::GenericArgument::Type(inner)) = args.args.first()
{
return (true, inner);
}
(false, ty)
}
fn map_field_type(ty: &Type, core: &TokenStream) -> Result<TokenStream> {
let type_str = type_name(ty);
if type_str.starts_with("ForeignKey<") {
let target = first_generic(ty)
.ok_or_else(|| syn::Error::new_spanned(ty, "expected ForeignKey<Model>"))?;
return Ok(quote! { <<#target as #core::Model>::Pk as #core::PrimaryKey>::COLUMN_TYPE });
}
match type_map::column_type(&type_str) {
Ok(ct) => Ok(column_type_tokens(&ct, core)),
Err(type_map::TypeMapError::Unknown) => Err(unsupported_type(ty, &type_str)),
Err(type_map::TypeMapError::Invalid(name)) => Err(syn::Error::new(
proc_macro2::Span::call_site(),
format!("Invalid {name} syntax: `{type_str}`"),
)),
}
}
fn column_type_tokens(ct: &ColumnType, core: &TokenStream) -> TokenStream {
match ct {
ColumnType::Bool => quote! { #core::ColumnType::Bool },
ColumnType::SmallInt => quote! { #core::ColumnType::SmallInt },
ColumnType::Int => quote! { #core::ColumnType::Int },
ColumnType::BigInt => quote! { #core::ColumnType::BigInt },
ColumnType::AutoInt => quote! { #core::ColumnType::AutoInt },
ColumnType::AutoBigInt => quote! { #core::ColumnType::AutoBigInt },
ColumnType::Float => quote! { #core::ColumnType::Float },
ColumnType::Double => quote! { #core::ColumnType::Double },
ColumnType::Text => quote! { #core::ColumnType::Text },
ColumnType::Bytea => quote! { #core::ColumnType::Bytea },
ColumnType::Uuid => quote! { #core::ColumnType::Uuid },
ColumnType::Date => quote! { #core::ColumnType::Date },
ColumnType::Time => quote! { #core::ColumnType::Time },
ColumnType::DateTime => quote! { #core::ColumnType::DateTime },
ColumnType::Json => quote! { #core::ColumnType::Json },
ColumnType::Jsonb => quote! { #core::ColumnType::Jsonb },
ColumnType::Varchar(n) => quote! { #core::ColumnType::Varchar(#n) },
ColumnType::Decimal { precision, scale } => {
quote! { #core::ColumnType::Decimal { precision: #precision, scale: #scale } }
}
}
}
fn unsupported_type(ty: &Type, type_str: &str) -> syn::Error {
let hint = type_map::rango_type_hint(type_str);
syn::Error::new_spanned(
ty,
format!("`{type_str}` is not a Rango field type: {hint}."),
)
}