use quote::ToTokens;
use crate::error::Error;
use crate::model::{
ColumnDef, DefaultValue, EnumDef, ForeignKey, OnDelete, RelationDef, Schema, SqlType, TableDef,
};
enum ParsedField {
Column(Box<ColumnDef>),
Relation(RelationDef),
}
pub fn parse_sources(sources: &[(String, String)]) -> Result<Schema, Error> {
let mut files = Vec::new();
for (name, code) in sources {
let file =
syn::parse_file(code).map_err(|e| Error::new(format!("{name}: {e}")))?;
files.push((name, file));
}
let mut enums: Vec<EnumDef> = Vec::new();
for (name, file) in &files {
for item in &file.items {
if let syn::Item::Enum(e) = item
&& has_derive(&e.attrs, "DbEnum")
{
let def = enum_from_item(e).map_err(|e| Error::new(format!("{name}: {e}")))?;
if enums.iter().any(|x| x.rust_name == def.rust_name) {
return Err(Error::new(format!("{name}: duplicate DbEnum {}", def.rust_name)));
}
enums.push(def);
}
}
}
enums.sort_by(|a, b| a.rust_name.cmp(&b.rust_name));
let mut tables: Vec<TableDef> = Vec::new();
for (name, file) in &files {
for item in &file.items {
if let syn::Item::Struct(s) = item
&& has_derive(&s.attrs, "Table")
{
let table = table_from_struct(s, Some(&enums))
.map_err(|e| Error::new(format!("{name}: {e}")))?;
if tables.iter().any(|t| t.name == table.name) {
return Err(Error::new(format!("{name}: duplicate table {}", table.name)));
}
tables.push(table);
}
}
}
tables.sort_by(|a, b| a.name.cmp(&b.name));
let by_struct: Vec<(String, String)> = tables
.iter()
.map(|t| (t.struct_name.clone(), t.name.clone()))
.collect();
let table_names: Vec<String> = tables.iter().map(|t| t.name.clone()).collect();
for table in &mut tables {
for col in &mut table.columns {
if let Some(fk) = &mut col.references {
if let Some((_, tn)) = by_struct.iter().find(|(s, _)| *s == fk.table) {
fk.table = tn.clone();
} else if !table_names.contains(&fk.table) {
return Err(Error::new(format!(
"{}.{}: references unknown table/struct `{}`",
table.struct_name, col.name, fk.table
)));
}
}
}
}
for table in &mut tables {
let fk_targets: Vec<(String, String)> = table
.columns
.iter()
.filter_map(|c| c.references.as_ref().map(|fk| (c.name.clone(), fk.table.clone())))
.collect();
for rel in &mut table.relations {
if let Some((_, target)) = fk_targets.iter().find(|(name, _)| *name == rel.local_column)
{
rel.target_table = target.clone();
}
}
}
Ok(Schema { tables, enums })
}
pub fn enum_from_item(item: &syn::ItemEnum) -> Result<EnumDef, Error> {
let rust_name = item.ident.to_string();
let mut values = Vec::new();
for v in &item.variants {
if !matches!(v.fields, syn::Fields::Unit) {
return Err(Error::new(format!(
"{rust_name}::{}: DbEnum variants must be unit variants",
v.ident
)));
}
values.push(to_snake_case(&v.ident.to_string()));
}
if values.is_empty() {
return Err(Error::new(format!("{rust_name}: DbEnum needs at least one variant")));
}
Ok(EnumDef { rust_name, values })
}
pub fn table_from_struct(
item: &syn::ItemStruct,
enums: Option<&[EnumDef]>,
) -> Result<TableDef, Error> {
let struct_name = item.ident.to_string();
let mut table_name: Option<String> = None;
let mut composite_uniques: Vec<Vec<String>> = Vec::new();
let mut composite_indexes: Vec<Vec<String>> = Vec::new();
for attr in item.attrs.iter().filter(|a| a.path().is_ident("orm")) {
attr.parse_nested_meta(|meta| {
if meta.path.is_ident("table") {
let lit: syn::LitStr = meta.value()?.parse()?;
table_name = Some(lit.value());
Ok(())
} else if meta.path.is_ident("unique") {
composite_uniques.push(parse_struct_column_list(&meta, "unique")?);
Ok(())
} else if meta.path.is_ident("index") {
composite_indexes.push(parse_struct_column_list(&meta, "index")?);
Ok(())
} else {
Err(meta.error(
"unknown #[orm(...)] key on a struct; expected `table = \"...\"`, \
`unique(col, ...)` or `index(col, ...)`",
))
}
})
.map_err(|e| Error::new(format!("{struct_name}: {e}")))?;
}
let name = table_name.unwrap_or_else(|| pluralize(&to_snake_case(&struct_name)));
let syn::Fields::Named(fields) = &item.fields else {
return Err(Error::new(format!("{struct_name}: a Table struct needs named fields")));
};
let mut columns = Vec::new();
let mut relations = Vec::new();
for field in &fields.named {
match field_from_field(&struct_name, field, enums)? {
ParsedField::Column(c) => columns.push(*c),
ParsedField::Relation(r) => relations.push(r),
}
}
for rel in &mut relations {
let Some(col) = columns.iter().find(|c| c.name == rel.local_column) else {
return Err(Error::new(format!(
"{struct_name}.{}: relation column `{}` is not a field on this struct",
rel.field, rel.local_column
)));
};
if col.references.is_none() {
return Err(Error::new(format!(
"{struct_name}.{}: relation column `{}` has no #[orm(references(...))] — a relation \
must join through a foreign key",
rel.field, rel.local_column
)));
}
rel.nullable = col.nullable;
}
for cols in composite_uniques.iter().chain(composite_indexes.iter()) {
for col in cols {
if columns.iter().all(|c| &c.name != col) {
return Err(Error::new(format!(
"{struct_name}: unique(...)/index(...) references unknown column `{col}`"
)));
}
}
}
let table = TableDef {
name,
struct_name: struct_name.clone(),
columns,
relations,
composite_uniques,
composite_indexes,
};
if table.primary_key().is_empty() {
return Err(Error::new(format!(
"{struct_name}: no primary key — add an `id: i64` field or mark fields with #[orm(primary_key)]"
)));
}
Ok(table)
}
fn parse_struct_column_list(meta: &syn::meta::ParseNestedMeta, key: &str) -> syn::Result<Vec<String>> {
let mut cols = Vec::new();
meta.parse_nested_meta(|m| {
let Some(ident) = m.path.get_ident() else {
return Err(m.error("expected a column name"));
};
cols.push(ident.to_string());
Ok(())
})?;
if cols.is_empty() {
return Err(meta.error(format!("{key}(...) needs at least one column, e.g. {key}(a, b)")));
}
Ok(cols)
}
fn field_from_field(
struct_name: &str,
field: &syn::Field,
enums: Option<&[EnumDef]>,
) -> Result<ParsedField, Error> {
if let Some(rel) = relation_from_field(struct_name, field)? {
return Ok(ParsedField::Relation(rel));
}
Ok(ParsedField::Column(Box::new(column_from_field(struct_name, field, enums)?)))
}
fn relation_from_field(
struct_name: &str,
field: &syn::Field,
) -> Result<Option<RelationDef>, Error> {
let field_name = field.ident.as_ref().expect("named field").to_string();
let ctx = format!("{struct_name}.{field_name}");
let mut local_column: Option<String> = None;
let mut other_key = false;
for attr in field.attrs.iter().filter(|a| a.path().is_ident("orm")) {
let metas = attr
.parse_args_with(syn::punctuated::Punctuated::<syn::Meta, syn::Token![,]>::parse_terminated)
.map_err(|e| Error::new(format!("{ctx}: {e}")))?;
for meta in &metas {
if meta.path().is_ident("relation") {
let syn::Meta::NameValue(nv) = meta else {
return Err(Error::new(format!("{ctx}: expected `relation = fk_column`")));
};
let syn::Expr::Path(p) = &nv.value else {
return Err(Error::new(format!("{ctx}: relation value must be a column name")));
};
let Some(ident) = p.path.get_ident() else {
return Err(Error::new(format!("{ctx}: relation value must be a column name")));
};
local_column = Some(ident.to_string());
} else {
other_key = true;
}
}
}
let Some(local_column) = local_column else {
return Ok(None);
};
if other_key {
return Err(Error::new(format!(
"{ctx}: a #[orm(relation = ...)] field takes no other orm keys"
)));
}
let (nullable, inner) = unwrap_option(&field.ty);
if !nullable {
return Err(Error::new(format!(
"{ctx}: a relation field must be `Option<Target>` (it is `None` until loaded)"
)));
}
let Some(target_struct) = last_segment_ident(inner) else {
return Err(Error::new(format!("{ctx}: relation target must be a struct type")));
};
Ok(Some(RelationDef {
field: field_name,
target_struct,
target_table: String::new(),
local_column,
nullable: false,
}))
}
fn column_from_field(
struct_name: &str,
field: &syn::Field,
enums: Option<&[EnumDef]>,
) -> Result<ColumnDef, Error> {
let name = field
.ident
.as_ref()
.expect("named field")
.to_string();
let ctx = format!("{struct_name}.{name}");
let mut unique = false;
let mut json = false;
let mut text = false;
let mut index = false;
let mut explicit_pk = false;
let mut default: Option<DefaultValue> = None;
let mut references: Option<ForeignKey> = None;
let mut renamed_from: Option<String> = None;
for attr in field.attrs.iter().filter(|a| a.path().is_ident("orm")) {
attr.parse_nested_meta(|meta| {
if meta.path.is_ident("unique") {
unique = true;
} else if meta.path.is_ident("json") {
json = true;
} else if meta.path.is_ident("text") {
text = true;
} else if meta.path.is_ident("index") {
index = true;
} else if meta.path.is_ident("primary_key") {
explicit_pk = true;
} else if meta.path.is_ident("renamed_from") {
let lit: syn::LitStr = meta.value()?.parse()?;
renamed_from = Some(lit.value());
} else if meta.path.is_ident("default") {
let expr: syn::Expr = meta.value()?.parse()?;
default = Some(parse_default(&expr).map_err(|m| meta.error(m))?);
} else if meta.path.is_ident("references") {
let mut target: Option<String> = None;
let mut on_delete: Option<OnDelete> = None;
meta.parse_nested_meta(|m| {
if m.path.is_ident("on_delete") {
let ident: syn::Ident = m.value()?.parse()?;
on_delete = Some(match ident.to_string().as_str() {
"cascade" => OnDelete::Cascade,
"set_null" => OnDelete::SetNull,
"restrict" => OnDelete::Restrict,
other => {
return Err(m.error(format!(
"unknown on_delete `{other}`; expected cascade, set_null or restrict"
)));
}
});
} else if let Some(ident) = m.path.get_ident() {
target = Some(ident.to_string());
} else {
return Err(m.error("expected a struct name, e.g. references(Event)"));
}
Ok(())
})?;
let Some(target) = target else {
return Err(meta.error("references(...) needs a target, e.g. references(Event)"));
};
references = Some(ForeignKey { table: target, column: "id".into(), on_delete });
} else {
return Err(meta.error(
"unknown #[orm(...)] key; expected unique, json, text, index, primary_key, default, references or renamed_from",
));
}
Ok(())
})
.map_err(|e| Error::new(format!("{ctx}: {e}")))?;
}
let (nullable, inner) = unwrap_option(&field.ty);
let rust_type = inner.to_token_stream().to_string().replace(' ', "");
let (ty, is_enum, check_in) = if json {
(SqlType::Text, false, None)
} else if text {
if native_sql_type(inner).is_some() {
return Err(Error::new(format!(
"{ctx}: #[orm(text)] is for non-native types (this one maps natively already)"
)));
}
(SqlType::Text, true, None)
} else if let Some(t) = native_sql_type(inner) {
(t, false, None)
} else if let Some(enums) = enums {
let type_name = last_segment_ident(inner);
match enums.iter().find(|e| Some(e.rust_name.as_str()) == type_name.as_deref()) {
Some(e) => (SqlType::Text, true, Some(e.values.clone())),
None => {
return Err(Error::new(format!(
"{ctx}: unsupported type `{rust_type}` — use a native type, derive DbEnum on it, or mark the field #[orm(json)]"
)));
}
}
} else {
(SqlType::Text, true, None)
};
let mut primary_key = explicit_pk;
if name == "id" && ty == SqlType::Integer && !nullable {
primary_key = true;
}
if primary_key && nullable {
return Err(Error::new(format!("{ctx}: a primary key cannot be Option")));
}
if let Some(d) = &default {
validate_default(&ctx, ty, d, check_in.as_deref())?;
}
if index && (unique || primary_key) {
return Err(Error::new(format!(
"{ctx}: #[orm(index)] is redundant — unique/primary key columns are already indexed"
)));
}
Ok(ColumnDef {
name,
rust_type,
ty,
nullable,
primary_key,
unique,
json,
is_enum,
index,
default,
references,
check_in,
renamed_from,
})
}
fn parse_default(expr: &syn::Expr) -> Result<DefaultValue, String> {
match expr {
syn::Expr::Path(p) if p.path.is_ident("now") => Ok(DefaultValue::Now),
syn::Expr::Lit(l) => match &l.lit {
syn::Lit::Int(i) => Ok(DefaultValue::Int(i.base10_parse().map_err(|e| e.to_string())?)),
syn::Lit::Float(f) => {
Ok(DefaultValue::Float(f.base10_parse().map_err(|e| e.to_string())?))
}
syn::Lit::Str(s) => Ok(DefaultValue::Text(s.value())),
syn::Lit::Bool(b) => Ok(DefaultValue::Bool(b.value)),
_ => Err("unsupported default literal".into()),
},
syn::Expr::Unary(u) if matches!(u.op, syn::UnOp::Neg(_)) => match parse_default(&u.expr)? {
DefaultValue::Int(i) => Ok(DefaultValue::Int(-i)),
DefaultValue::Float(f) => Ok(DefaultValue::Float(-f)),
_ => Err("cannot negate this default".into()),
},
_ => Err("expected a literal or `now`, e.g. default = 0 or default = now".into()),
}
}
fn validate_default(
ctx: &str,
ty: SqlType,
d: &DefaultValue,
check_in: Option<&[String]>,
) -> Result<(), Error> {
let ok = matches!(
(ty, d),
(SqlType::Integer, DefaultValue::Int(_))
| (SqlType::Real, DefaultValue::Float(_))
| (SqlType::Real, DefaultValue::Int(_))
| (SqlType::Text, DefaultValue::Text(_))
| (SqlType::Boolean, DefaultValue::Bool(_))
| (SqlType::Timestamp, DefaultValue::Now)
);
if !ok {
return Err(Error::new(format!(
"{ctx}: default {d:?} does not fit column type {ty:?}"
)));
}
if let (Some(values), DefaultValue::Text(s)) = (check_in, d)
&& !values.iter().any(|v| v == s)
{
return Err(Error::new(format!(
"{ctx}: default '{s}' is not one of the enum values {values:?}"
)));
}
Ok(())
}
pub fn native_sql_type(ty: &syn::Type) -> Option<SqlType> {
let seg = last_segment(ty)?;
Some(match seg.ident.to_string().as_str() {
"i8" | "i16" | "i32" | "i64" | "u8" | "u16" | "u32" | "u64" | "isize" | "usize" => {
SqlType::Integer
}
"f32" | "f64" => SqlType::Real,
"bool" => SqlType::Boolean,
"String" => SqlType::Text,
"Uuid" => SqlType::Text,
"NaiveDateTime" | "DateTime" => SqlType::Timestamp,
"NaiveDate" | "NaiveTime" => SqlType::Text,
"Vec" => {
if let syn::PathArguments::AngleBracketed(args) = &seg.arguments
&& let Some(syn::GenericArgument::Type(inner)) = args.args.first()
&& last_segment(inner).is_some_and(|s| s.ident == "u8")
{
SqlType::Blob
} else {
return None;
}
}
_ => return None,
})
}
fn unwrap_option(ty: &syn::Type) -> (bool, &syn::Type) {
if let Some(seg) = last_segment(ty)
&& 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 last_segment(ty: &syn::Type) -> Option<&syn::PathSegment> {
if let syn::Type::Path(p) = ty {
p.path.segments.last()
} else {
None
}
}
fn last_segment_ident(ty: &syn::Type) -> Option<String> {
last_segment(ty).map(|s| s.ident.to_string())
}
pub fn has_derive(attrs: &[syn::Attribute], name: &str) -> bool {
attrs
.iter()
.filter(|a| a.path().is_ident("derive"))
.any(|a| {
let mut found = false;
let _ = a.parse_nested_meta(|meta| {
if meta.path.segments.last().is_some_and(|s| s.ident == name) {
found = true;
}
Ok(())
});
found
})
}
pub fn to_snake_case(s: &str) -> String {
let mut out = String::new();
for (i, ch) in s.chars().enumerate() {
if ch.is_uppercase() {
if i != 0 {
out.push('_');
}
out.extend(ch.to_lowercase());
} else {
out.push(ch);
}
}
out
}
pub fn pluralize(s: &str) -> String {
if let Some(stem) = s.strip_suffix('y')
&& stem.chars().last().is_some_and(|c| !"aeiou".contains(c))
{
return format!("{stem}ies");
}
if s.ends_with('s')
|| s.ends_with('x')
|| s.ends_with('z')
|| s.ends_with("ch")
|| s.ends_with("sh")
{
return format!("{s}es");
}
format!("{s}s")
}