use std::collections::BTreeMap;
use std::path::Path;
use autumn_schema_core::{
Backend, Column, ColumnDefault, ColumnType, ForeignKey, ForeignKeyAction, Index, SerialKind,
Table,
};
use crate::generate::naming;
#[derive(Debug, thiserror::Error)]
pub enum SchemaParseError {
#[error("failed to parse Rust source at line {line}, column {column}: {message}")]
Syntax {
message: String,
line: usize,
column: usize,
},
#[error(
"{table}.{column} is #[encrypted] in both modes by different models; \
remove the model that is not production code"
)]
ConflictingEncryptedMode {
table: String,
column: String,
},
#[error("failed to read {path}: {source}")]
Io {
path: String,
#[source]
source: std::io::Error,
},
}
impl SchemaParseError {
fn from_syn(err: &syn::Error) -> Self {
let start = err.span().start();
Self::Syntax {
message: err.to_string(),
line: start.line,
column: start.column + 1,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SchemaDiagnostic {
pub model: String,
pub table: String,
pub field: String,
pub rust_type: String,
pub message: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParsedSchema {
pub tables: Vec<Table>,
pub diagnostics: Vec<SchemaDiagnostic>,
pub renames: Vec<RenameHint>,
pub implicit_columns: Vec<(String, String)>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RenameHint {
pub table: String,
pub column: Option<String>,
pub from: String,
}
impl ParsedSchema {
#[must_use]
pub const fn from_tables(tables: Vec<Table>) -> Self {
Self {
tables,
diagnostics: Vec::new(),
renames: Vec::new(),
implicit_columns: Vec::new(),
}
}
}
pub fn parse_model_source(src: &str, backend: Backend) -> Result<ParsedSchema, SchemaParseError> {
let file = syn::parse_file(src).map_err(|e| SchemaParseError::from_syn(&e))?;
let mut out = ParsedSchema::from_tables(Vec::new());
for item in &file.items {
if let syn::Item::Struct(item_struct) = item
&& let Some(model_attr) = find_model_attr(&item_struct.attrs)
{
let table = build_table(item_struct, model_attr, backend, &mut out.diagnostics);
out.renames.extend(rename_hints(item_struct, &table.name));
let declares_created_at = item_struct.fields.iter().any(|f| {
f.ident
.as_ref()
.is_some_and(|ident| field_column_name(f, ident) == "created_at")
});
if !declares_created_at && table.columns.iter().any(|c| c.name == "created_at") {
out.implicit_columns
.push((table.name.clone(), "created_at".to_owned()));
}
out.tables.push(table);
}
}
Ok(out)
}
pub fn parse_models_dir(dir: &Path, backend: Backend) -> Result<ParsedSchema, SchemaParseError> {
let read = std::fs::read_dir(dir).map_err(|source| SchemaParseError::Io {
path: dir.display().to_string(),
source,
})?;
let mut paths: Vec<std::path::PathBuf> = Vec::new();
for entry in read {
let entry = entry.map_err(|source| SchemaParseError::Io {
path: dir.display().to_string(),
source,
})?;
let path = entry.path();
let file_type = entry.file_type().map_err(|source| SchemaParseError::Io {
path: path.display().to_string(),
source,
})?;
if file_type.is_file() && path.extension().is_some_and(|ext| ext == "rs") {
paths.push(path);
}
}
paths.sort();
let mut out = ParsedSchema::from_tables(Vec::new());
for path in paths {
let src = std::fs::read_to_string(&path).map_err(|source| SchemaParseError::Io {
path: path.display().to_string(),
source,
})?;
let mut parsed = parse_model_source(&src, backend)?;
out.tables.append(&mut parsed.tables);
out.diagnostics.append(&mut parsed.diagnostics);
out.renames.append(&mut parsed.renames);
out.implicit_columns.append(&mut parsed.implicit_columns);
}
Ok(out)
}
pub fn parse_models_path(path: &Path, backend: Backend) -> Result<ParsedSchema, SchemaParseError> {
if path.is_dir() {
parse_models_dir(path, backend)
} else if path.is_file() {
let src = std::fs::read_to_string(path).map_err(|source| SchemaParseError::Io {
path: path.display().to_string(),
source,
})?;
parse_model_source(&src, backend)
} else {
Err(SchemaParseError::Io {
path: path.display().to_string(),
source: std::io::Error::new(
std::io::ErrorKind::NotFound,
"expected a `.rs` model file or a directory of model files",
),
})
}
}
pub fn parse_encrypted_columns(
src: &str,
) -> Result<BTreeMap<String, BTreeMap<String, bool>>, SchemaParseError> {
let file = syn::parse_file(src).map_err(|e| SchemaParseError::from_syn(&e))?;
let mut scan = EncryptedScan::default();
if production_enabled(&file.attrs) {
syn::visit::Visit::visit_file(&mut scan, &file);
}
if let Some((table, column)) = scan.conflict {
return Err(SchemaParseError::ConflictingEncryptedMode { table, column });
}
Ok(scan.found)
}
pub fn item_attrs(item: &syn::Item) -> &[syn::Attribute] {
match item {
syn::Item::Mod(i) => &i.attrs,
syn::Item::Fn(i) => &i.attrs,
syn::Item::Impl(i) => &i.attrs,
syn::Item::Const(i) => &i.attrs,
syn::Item::Static(i) => &i.attrs,
syn::Item::Trait(i) => &i.attrs,
syn::Item::Enum(i) => &i.attrs,
syn::Item::Type(i) => &i.attrs,
syn::Item::Union(i) => &i.attrs,
syn::Item::Struct(i) => &i.attrs,
syn::Item::ForeignMod(i) => &i.attrs,
_ => &[],
}
}
pub fn foreign_item_attrs(item: &syn::ForeignItem) -> &[syn::Attribute] {
match item {
syn::ForeignItem::Fn(i) => &i.attrs,
syn::ForeignItem::Static(i) => &i.attrs,
syn::ForeignItem::Type(i) => &i.attrs,
syn::ForeignItem::Macro(i) => &i.attrs,
_ => &[],
}
}
pub fn pat_attrs(pat: &syn::Pat) -> &[syn::Attribute] {
match pat {
syn::Pat::Const(p) => &p.attrs,
syn::Pat::Ident(p) => &p.attrs,
syn::Pat::Lit(p) => &p.attrs,
syn::Pat::Macro(p) => &p.attrs,
syn::Pat::Or(p) => &p.attrs,
syn::Pat::Paren(p) => &p.attrs,
syn::Pat::Path(p) => &p.attrs,
syn::Pat::Range(p) => &p.attrs,
syn::Pat::Reference(p) => &p.attrs,
syn::Pat::Rest(p) => &p.attrs,
syn::Pat::Slice(p) => &p.attrs,
syn::Pat::Struct(p) => &p.attrs,
syn::Pat::Tuple(p) => &p.attrs,
syn::Pat::TupleStruct(p) => &p.attrs,
syn::Pat::Type(p) => &p.attrs,
syn::Pat::Wild(p) => &p.attrs,
_ => &[],
}
}
pub fn generic_param_attrs(param: &syn::GenericParam) -> &[syn::Attribute] {
match param {
syn::GenericParam::Lifetime(p) => &p.attrs,
syn::GenericParam::Type(p) => &p.attrs,
syn::GenericParam::Const(p) => &p.attrs,
}
}
pub fn fn_arg_attrs(arg: &syn::FnArg) -> &[syn::Attribute] {
match arg {
syn::FnArg::Receiver(r) => &r.attrs,
syn::FnArg::Typed(t) => &t.attrs,
}
}
pub fn trait_item_attrs(item: &syn::TraitItem) -> &[syn::Attribute] {
match item {
syn::TraitItem::Fn(i) => &i.attrs,
syn::TraitItem::Const(i) => &i.attrs,
syn::TraitItem::Type(i) => &i.attrs,
_ => &[],
}
}
pub fn impl_item_attrs(item: &syn::ImplItem) -> &[syn::Attribute] {
match item {
syn::ImplItem::Fn(i) => &i.attrs,
syn::ImplItem::Const(i) => &i.attrs,
syn::ImplItem::Type(i) => &i.attrs,
_ => &[],
}
}
pub fn expr_attrs(expr: &syn::Expr) -> &[syn::Attribute] {
match expr {
syn::Expr::Array(e) => &e.attrs,
syn::Expr::Assign(e) => &e.attrs,
syn::Expr::Async(e) => &e.attrs,
syn::Expr::Await(e) => &e.attrs,
syn::Expr::Binary(e) => &e.attrs,
syn::Expr::Block(e) => &e.attrs,
syn::Expr::Break(e) => &e.attrs,
syn::Expr::Call(e) => &e.attrs,
syn::Expr::Cast(e) => &e.attrs,
syn::Expr::Closure(e) => &e.attrs,
syn::Expr::Const(e) => &e.attrs,
syn::Expr::Continue(e) => &e.attrs,
syn::Expr::Field(e) => &e.attrs,
syn::Expr::ForLoop(e) => &e.attrs,
syn::Expr::Group(e) => &e.attrs,
syn::Expr::If(e) => &e.attrs,
syn::Expr::Index(e) => &e.attrs,
syn::Expr::Infer(e) => &e.attrs,
syn::Expr::Let(e) => &e.attrs,
syn::Expr::Lit(e) => &e.attrs,
syn::Expr::Loop(e) => &e.attrs,
syn::Expr::Macro(e) => &e.attrs,
syn::Expr::Match(e) => &e.attrs,
syn::Expr::MethodCall(e) => &e.attrs,
syn::Expr::Paren(e) => &e.attrs,
syn::Expr::Path(e) => &e.attrs,
syn::Expr::Range(e) => &e.attrs,
syn::Expr::RawAddr(e) => &e.attrs,
syn::Expr::Reference(e) => &e.attrs,
syn::Expr::Repeat(e) => &e.attrs,
syn::Expr::Return(e) => &e.attrs,
syn::Expr::Struct(e) => &e.attrs,
syn::Expr::Try(e) => &e.attrs,
syn::Expr::TryBlock(e) => &e.attrs,
syn::Expr::Tuple(e) => &e.attrs,
syn::Expr::Unary(e) => &e.attrs,
syn::Expr::Unsafe(e) => &e.attrs,
syn::Expr::While(e) => &e.attrs,
syn::Expr::Yield(e) => &e.attrs,
_ => &[],
}
}
pub fn production_enabled(attrs: &[syn::Attribute]) -> bool {
attrs.iter().all(|a| {
if a.path().is_ident("cfg") {
a.parse_args::<syn::Meta>()
.map_or(true, |m| cfg_value(&m) != Some(false))
} else if a.path().is_ident("cfg_attr") {
cfg_attr_keeps_item(a)
} else {
true
}
})
}
fn cfg_attr_keeps_item(attr: &syn::Attribute) -> bool {
use syn::punctuated::Punctuated;
let Ok(args) = attr.parse_args_with(Punctuated::<syn::Meta, syn::Token![,]>::parse_terminated)
else {
return true;
};
let mut args = args.into_iter();
let Some(predicate) = args.next() else {
return true;
};
if cfg_value(&predicate) != Some(true) {
return true;
}
args.all(|meta| match &meta {
syn::Meta::List(list) if list.path.is_ident("cfg") => list
.parse_args::<syn::Meta>()
.map_or(true, |m| cfg_value(&m) != Some(false)),
syn::Meta::List(list) if list.path.is_ident("cfg_attr") => {
let inner = syn::Attribute {
pound_token: attr.pound_token,
style: attr.style,
bracket_token: attr.bracket_token,
meta: syn::Meta::List(list.clone()),
};
cfg_attr_keeps_item(&inner)
}
_ => true,
})
}
fn cfg_value(meta: &syn::Meta) -> Option<bool> {
use syn::punctuated::Punctuated;
match meta {
syn::Meta::Path(path) if path.is_ident("test") => Some(false),
syn::Meta::List(list) => {
let inner: Vec<Option<bool>> = list
.parse_args_with(Punctuated::<syn::Meta, syn::Token![,]>::parse_terminated)
.ok()?
.iter()
.map(cfg_value)
.collect();
if list.path.is_ident("not") {
inner.first().copied().flatten().map(|v| !v)
} else if list.path.is_ident("all") {
if inner.contains(&Some(false)) {
Some(false)
} else if inner.iter().all(|v| *v == Some(true)) {
Some(true)
} else {
None
}
} else if list.path.is_ident("any") {
if inner.contains(&Some(true)) {
Some(true)
} else if inner.iter().all(|v| *v == Some(false)) {
Some(false)
} else {
None
}
} else {
None
}
}
_ => None,
}
}
#[derive(Default)]
struct EncryptedScan {
found: BTreeMap<String, BTreeMap<String, bool>>,
conflict: Option<(String, String)>,
}
impl<'ast> syn::visit::Visit<'ast> for EncryptedScan {
fn visit_item_mod(&mut self, module: &'ast syn::ItemMod) {
if production_enabled(&module.attrs) {
syn::visit::visit_item_mod(self, module);
}
}
fn visit_item(&mut self, item: &'ast syn::Item) {
if production_enabled(item_attrs(item)) {
syn::visit::visit_item(self, item);
}
}
fn visit_trait_item(&mut self, item: &'ast syn::TraitItem) {
if production_enabled(trait_item_attrs(item)) {
syn::visit::visit_trait_item(self, item);
}
}
fn visit_impl_item(&mut self, item: &'ast syn::ImplItem) {
if production_enabled(impl_item_attrs(item)) {
syn::visit::visit_impl_item(self, item);
}
}
fn visit_expr(&mut self, expr: &'ast syn::Expr) {
if production_enabled(expr_attrs(expr)) {
syn::visit::visit_expr(self, expr);
}
}
fn visit_local(&mut self, local: &'ast syn::Local) {
if production_enabled(&local.attrs) {
syn::visit::visit_local(self, local);
}
}
fn visit_arm(&mut self, arm: &'ast syn::Arm) {
if production_enabled(&arm.attrs) {
syn::visit::visit_arm(self, arm);
}
}
fn visit_foreign_item(&mut self, item: &'ast syn::ForeignItem) {
if production_enabled(foreign_item_attrs(item)) {
syn::visit::visit_foreign_item(self, item);
}
}
fn visit_pat(&mut self, pat: &'ast syn::Pat) {
if production_enabled(pat_attrs(pat)) {
syn::visit::visit_pat(self, pat);
}
}
fn visit_generic_param(&mut self, param: &'ast syn::GenericParam) {
if production_enabled(generic_param_attrs(param)) {
syn::visit::visit_generic_param(self, param);
}
}
fn visit_fn_arg(&mut self, arg: &'ast syn::FnArg) {
if production_enabled(fn_arg_attrs(arg)) {
syn::visit::visit_fn_arg(self, arg);
}
}
fn visit_field(&mut self, field: &'ast syn::Field) {
if production_enabled(&field.attrs) {
syn::visit::visit_field(self, field);
}
}
fn visit_variant(&mut self, variant: &'ast syn::Variant) {
if production_enabled(&variant.attrs) {
syn::visit::visit_variant(self, variant);
}
}
fn visit_field_value(&mut self, field: &'ast syn::FieldValue) {
if production_enabled(&field.attrs) {
syn::visit::visit_field_value(self, field);
}
}
fn visit_item_struct(&mut self, item_struct: &'ast syn::ItemStruct) {
if !production_enabled(&item_struct.attrs) {
return;
}
if let Some(model_attr) = find_model_attr(&item_struct.attrs)
&& let syn::Fields::Named(named) = &item_struct.fields
{
let model_name = item_struct.ident.to_string();
let table = parse_model_args(model_attr)
.table
.unwrap_or_else(|| naming::pluralize(&naming::pascal_to_snake(&model_name)));
for field in &named.named {
let Some(ident) = field.ident.as_ref() else {
continue;
};
if !production_enabled(&field.attrs) {
continue;
}
if let Some(attr) = field.attrs.iter().find(|a| is_encrypted_attr(a)) {
let column = field_column_name(field, ident);
let deterministic = is_deterministic_encrypted(attr);
let known = self.found.entry(table.clone()).or_default();
if known.insert(column.clone(), deterministic) == Some(!deterministic) {
self.conflict.get_or_insert_with(|| (table.clone(), column));
}
}
}
}
syn::visit::visit_item_struct(self, item_struct);
}
}
pub fn parse_encrypted_columns_path(
path: &Path,
) -> Result<BTreeMap<String, BTreeMap<String, bool>>, SchemaParseError> {
let mut sources = Vec::new();
for file in model_source_files(path)? {
let src = std::fs::read_to_string(&file).map_err(|source| SchemaParseError::Io {
path: file.display().to_string(),
source,
})?;
sources.push((file, src));
}
let disabled = disabled_module_paths(&sources);
let mut out: BTreeMap<String, BTreeMap<String, bool>> = BTreeMap::new();
for (file, src) in &sources {
if disabled.excludes(file) {
continue;
}
for (table, columns) in parse_encrypted_columns(src)? {
let known = out.entry(table.clone()).or_default();
for (column, deterministic) in columns {
if known.insert(column.clone(), deterministic) == Some(!deterministic) {
return Err(SchemaParseError::ConflictingEncryptedMode { table, column });
}
}
}
}
Ok(out)
}
#[must_use]
pub fn disabled_module_paths(sources: &[(std::path::PathBuf, String)]) -> DisabledModules {
let mut active: Vec<&(std::path::PathBuf, String)> = sources.iter().collect();
loop {
let mut found = ModulePaths::default();
for (file, src) in &active {
let Ok(parsed) = syn::parse_file(src) else {
continue;
};
let stem = file
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or_default();
let parent = file.parent().unwrap_or_else(|| Path::new(""));
let module_dir = if matches!(stem, "lib" | "main" | "mod") {
parent.to_path_buf()
} else {
parent.join(stem)
};
collect_module_paths(
&parsed.items,
(&module_dir, parent),
!production_enabled(&parsed.attrs),
&mut found,
);
}
let modules = DisabledModules {
disabled: found.disabled,
enabled: found.enabled,
};
let before = active.len();
active.retain(|(file, _)| !modules.excludes(file));
if active.len() == before {
return modules;
}
}
}
pub struct DisabledModules {
disabled: Vec<std::path::PathBuf>,
enabled: Vec<std::path::PathBuf>,
}
impl DisabledModules {
#[must_use]
pub fn excludes(&self, file: &Path) -> bool {
self.disabled.iter().any(|d| {
file.starts_with(d)
&& !self
.enabled
.iter()
.any(|e| file.starts_with(e) && e.starts_with(d))
})
}
}
fn normalize_lexically(path: &Path) -> std::path::PathBuf {
use std::path::Component;
let mut out = std::path::PathBuf::new();
for part in path.components() {
match part {
Component::CurDir => {}
Component::ParentDir
if matches!(out.components().next_back(), Some(Component::Normal(_))) =>
{
out.pop();
}
other => out.push(other.as_os_str()),
}
}
out
}
#[derive(Default)]
struct ModulePaths {
disabled: Vec<std::path::PathBuf>,
enabled: Vec<std::path::PathBuf>,
}
fn collect_module_paths(
items: &[syn::Item],
dirs: (&Path, &Path),
parent_disabled: bool,
out: &mut ModulePaths,
) {
let (dir, path_base) = dirs;
let paths_of = |module: &syn::ItemMod| -> Vec<std::path::PathBuf> {
let custom = module
.attrs
.iter()
.filter(|a| a.path().is_ident("path"))
.find_map(|a| match &a.meta {
syn::Meta::NameValue(nv) => match &nv.value {
syn::Expr::Lit(syn::ExprLit {
lit: syn::Lit::Str(path),
..
}) => Some(path.value()),
_ => None,
},
_ => None,
});
custom.map_or_else(
|| {
vec![
dir.join(format!("{}.rs", module.ident)),
dir.join(module.ident.to_string()),
]
},
|custom| vec![normalize_lexically(&path_base.join(custom))],
)
};
for item in items {
let syn::Item::Mod(module) = item else {
continue;
};
let disabled = parent_disabled || !production_enabled(&module.attrs);
match &module.content {
None if disabled => out.disabled.extend(paths_of(module)),
None => out.enabled.extend(paths_of(module)),
Some((_, nested)) => {
let nested_dir = dir.join(module.ident.to_string());
collect_module_paths(nested, (&nested_dir, &nested_dir), disabled, out);
}
}
}
}
fn model_source_files(path: &Path) -> Result<Vec<std::path::PathBuf>, SchemaParseError> {
if path.is_file() {
return Ok(vec![path.to_path_buf()]);
}
if !path.is_dir() {
return Err(SchemaParseError::Io {
path: path.display().to_string(),
source: std::io::Error::new(
std::io::ErrorKind::NotFound,
"expected a `.rs` model file or a directory of model files",
),
});
}
let mut paths = Vec::new();
let mut stack = vec![path.to_path_buf()];
let root_depth = path.components().count();
while let Some(dir) = stack.pop() {
let read = std::fs::read_dir(&dir).map_err(|source| SchemaParseError::Io {
path: dir.display().to_string(),
source,
})?;
for entry in read {
let entry = entry.map_err(|source| SchemaParseError::Io {
path: dir.display().to_string(),
source,
})?;
let candidate = entry.path();
let Ok(meta) = std::fs::metadata(&candidate) else {
continue;
};
if meta.is_dir() {
let target =
std::fs::canonicalize(&candidate).unwrap_or_else(|_| candidate.clone());
let here = std::fs::canonicalize(&dir).unwrap_or_else(|_| dir.clone());
let too_deep = candidate.components().count() > root_depth + 64;
if !here.starts_with(&target) && !too_deep {
stack.push(candidate);
}
} else if meta.is_file() && candidate.extension().is_some_and(|ext| ext == "rs") {
paths.push(candidate);
}
}
}
paths.sort();
paths.dedup();
Ok(paths)
}
fn is_encrypted_attr(attr: &syn::Attribute) -> bool {
attr.path()
.segments
.last()
.is_some_and(|seg| seg.ident == "encrypted")
}
fn is_deterministic_encrypted(attr: &syn::Attribute) -> bool {
if matches!(attr.meta, syn::Meta::Path(_)) {
return false;
}
let mut deterministic = false;
let _ = attr.parse_nested_meta(|meta| {
if meta.path.is_ident("deterministic") {
deterministic = true;
} else if meta.input.peek(syn::token::Paren) {
let _ = meta.parse_nested_meta(|_| Ok(()));
} else if let Ok(value) = meta.value() {
let _ = value.parse::<syn::Lit>();
}
Ok(())
});
deterministic
}
fn rename_hints(item: &syn::ItemStruct, table: &str) -> Vec<RenameHint> {
let mut out = Vec::new();
if let Some(from) = renamed_from(&item.attrs) {
out.push(RenameHint {
table: table.to_owned(),
column: None,
from,
});
}
if let syn::Fields::Named(named) = &item.fields {
for field in &named.named {
if let (Some(ident), Some(from)) = (&field.ident, renamed_from(&field.attrs)) {
out.push(RenameHint {
table: table.to_owned(),
column: Some(field_column_name(field, ident)),
from,
});
}
}
}
out
}
fn field_column_name(field: &syn::Field, ident: &syn::Ident) -> String {
use proc_macro2::TokenTree;
for attr in field.attrs.iter().filter(|a| a.path().is_ident("diesel")) {
let syn::Meta::List(list) = &attr.meta else {
continue;
};
let tokens: Vec<TokenTree> = list.tokens.clone().into_iter().collect();
for window in tokens.windows(3) {
let [TokenTree::Ident(key), TokenTree::Punct(eq), value] = window else {
continue;
};
if key != "column_name" || eq.as_char() != '=' {
continue;
}
match value {
TokenTree::Ident(name) => return unraw(name),
TokenTree::Literal(literal) => {
if let Ok(text) = syn::parse_str::<syn::LitStr>(&literal.to_string()) {
return text.value();
}
}
_ => {}
}
}
}
unraw(ident)
}
fn unraw(ident: &syn::Ident) -> String {
let raw = ident.to_string();
raw.strip_prefix("r#").unwrap_or(&raw).to_owned()
}
fn renamed_from(attrs: &[syn::Attribute]) -> Option<String> {
let mut found = attrs.iter().filter(|a| a.path().is_ident("renamed_from"));
let attr = found.next()?;
if found.next().is_some() {
return None;
}
let syn::Meta::List(list) = &attr.meta else {
return None;
};
let name = syn::parse2::<syn::LitStr>(list.tokens.clone())
.ok()?
.value();
crate::schema::rename::is_plain_identifier(&name).then_some(name)
}
fn find_model_attr(attrs: &[syn::Attribute]) -> Option<&syn::Attribute> {
attrs.iter().find(|attr| {
attr.path()
.segments
.last()
.is_some_and(|seg| seg.ident == "model")
})
}
#[derive(Default)]
struct ModelArgs {
table: Option<String>,
managed: bool,
}
fn parse_model_args(attr: &syn::Attribute) -> ModelArgs {
let mut args = ModelArgs::default();
if matches!(attr.meta, syn::Meta::Path(_)) {
return args;
}
let _ = attr.parse_nested_meta(|meta| {
if meta.path.is_ident("table")
&& let Ok(value) = meta.value()
&& let Ok(lit) = value.parse::<syn::LitStr>()
{
args.table = Some(lit.value());
} else if meta.path.is_ident("managed") {
args.managed = true;
} else if meta.path.is_ident("schema")
&& let Ok(value) = meta.value()
&& let Ok(lit) = value.parse::<syn::LitStr>()
&& lit.value() == "managed"
{
args.managed = true;
} else if meta.input.peek(syn::token::Paren) {
let _ = meta.parse_nested_meta(|_| Ok(()));
} else if let Ok(value) = meta.value() {
let _ = value.parse::<syn::Lit>();
}
Ok(())
});
args
}
#[derive(Default, PartialEq, Eq)]
enum ReferenceSpec {
#[default]
None,
Inferred,
Explicit(String),
}
#[allow(clippy::struct_excessive_bools)] struct FieldAttrs {
is_id: bool,
is_indexed: bool,
is_unique: bool,
is_default: bool,
is_translatable: bool,
is_collaborative: bool,
reference: ReferenceSpec,
on_delete: Option<ForeignKeyAction>,
on_update: Option<ForeignKeyAction>,
reference_error: Option<String>,
decimal_shape: Option<Result<(u8, u8), String>>,
}
const TRANSLATABLE_COLUMN_DEFAULT: &str = "'{}'";
const COLLABORATIVE_COLUMN_DEFAULT: &str = r#"'{"elems":[]}'"#;
fn parse_field_attrs(field: &syn::Field) -> FieldAttrs {
let mut out = FieldAttrs {
is_id: false,
is_indexed: false,
is_unique: false,
is_default: false,
is_translatable: false,
is_collaborative: false,
reference: ReferenceSpec::None,
on_delete: None,
on_update: None,
reference_error: None,
decimal_shape: None,
};
for attr in &field.attrs {
let Some(ident) = attr.path().get_ident() else {
continue;
};
match ident.to_string().as_str() {
"id" => out.is_id = true,
"indexed" => out.is_indexed = true,
"unique" => out.is_unique = true,
"default" => out.is_default = true,
"translatable" => out.is_translatable = true,
"collaborative" => out.is_collaborative = true,
"decimal_shape" if !matches!(out.decimal_shape, Some(Ok(_))) => {
out.decimal_shape = Some(parse_decimal_shape(attr));
}
"references" => {
let mut target = None;
if !matches!(attr.meta, syn::Meta::Path(_)) {
let _ = attr.parse_nested_meta(|meta| {
let action_key = ["on_delete", "on_update"]
.into_iter()
.find(|key| meta.path.is_ident(key));
if meta.path.is_ident("table")
&& let Ok(value) = meta.value()
&& let Ok(lit) = value.parse::<syn::LitStr>()
{
target = Some(lit.value());
} else if let Some(key) = action_key
&& let Ok(value) = meta.value()
&& let Ok(lit) = value.parse::<syn::LitStr>()
{
match ForeignKeyAction::from_attr(&lit.value()) {
Ok(action) if key == "on_delete" => out.on_delete = action,
Ok(action) => out.on_update = action,
Err(e) => out.reference_error = Some(e.to_string()),
}
} else if meta.input.peek(syn::token::Paren) {
let _ = meta.parse_nested_meta(|_| Ok(()));
} else if let Ok(value) = meta.value() {
let _ = value.parse::<syn::Lit>();
}
Ok(())
});
}
out.reference = target.map_or(ReferenceSpec::Inferred, ReferenceSpec::Explicit);
}
_ => {}
}
}
out
}
fn parse_decimal_shape(attr: &syn::Attribute) -> Result<(u8, u8), String> {
let mut precision = None;
let mut scale = None;
attr.parse_nested_meta(|meta| {
let slot = if meta.path.is_ident("precision") {
&mut precision
} else if meta.path.is_ident("scale") {
&mut scale
} else {
return Err(meta.error("expected `precision` or `scale`"));
};
*slot = Some(meta.value()?.parse::<syn::LitInt>()?.base10_parse::<u8>()?);
Ok(())
})
.map_err(|e| format!("bad `#[decimal_shape]`: {e}"))?;
let (Some(precision), Some(scale)) = (precision, scale) else {
return Err("`#[decimal_shape]` needs `precision` and `scale`".to_owned());
};
let max = crate::generate::dsl::MAX_DECIMAL_PRECISION;
if precision == 0 || u32::from(precision) > max || scale > precision {
return Err(format!(
"`#[decimal_shape(precision = {precision}, scale = {scale})]` is out of range \
(precision 1 to {max}, scale 0 to precision)"
));
}
Ok((precision, scale))
}
fn reference_action_problem(raw: &RawField) -> Option<String> {
if let Some(error) = &raw.attrs.reference_error {
return Some(error.clone());
}
if raw.nullable {
return None;
}
[raw.attrs.on_delete, raw.attrs.on_update]
.into_iter()
.flatten()
.find_map(|action| match action {
ForeignKeyAction::SetNull => Some("set_null"),
ForeignKeyAction::SetDefault => Some("set_default"),
_ => None,
})
.map(|value| format!("foreign-key action `{value}` needs an `Option<_>` field"))
}
struct RawField {
name: String,
field: String,
attrs: FieldAttrs,
nullable: bool,
rust_type: String,
}
#[allow(clippy::too_many_lines)]
fn build_table(
item: &syn::ItemStruct,
model_attr: &syn::Attribute,
backend: Backend,
diagnostics: &mut Vec<SchemaDiagnostic>,
) -> Table {
let model_name = item.ident.to_string();
let args = parse_model_args(model_attr);
let table_name = args
.table
.clone()
.unwrap_or_else(|| naming::pluralize(&naming::pascal_to_snake(&model_name)));
let mut raw_fields: Vec<RawField> = Vec::new();
if let syn::Fields::Named(named) = &item.fields {
for field in &named.named {
let Some(ident) = &field.ident else { continue };
let (nullable, inner_ty) = strip_option(&field.ty);
raw_fields.push(RawField {
name: field_column_name(field, ident),
field: unraw(ident),
attrs: parse_field_attrs(field),
nullable,
rust_type: type_to_string(inner_ty),
});
}
}
let pk_field_names = resolve_pk_field_names(&raw_fields);
let needs_synthetic_id = pk_field_names.is_empty();
let mut table = Table::new(table_name.clone(), backend);
table.managed = args.managed;
if needs_synthetic_id {
let mut id = Column::new("id", ColumnType::Int64);
id.primary_key = true;
table.columns.push(id);
table.primary_key.push("id".to_owned());
}
let mut plain_index_fields: Vec<String> = Vec::new();
let mut unique_index_fields: Vec<String> = Vec::new();
let mut has_created_at = false;
for raw in &raw_fields {
if raw.name == "created_at" {
has_created_at = true;
}
let is_pk = pk_field_names.contains(&raw.name);
let is_reference = raw.attrs.reference != ReferenceSpec::None;
if is_reference && let Some(problem) = reference_action_problem(raw) {
diagnostics.push(SchemaDiagnostic {
model: model_name.clone(),
table: table_name.clone(),
field: raw.name.clone(),
rust_type: raw.rust_type.clone(),
message: format!("{problem} on `{model_name}.{}`; column skipped", raw.name),
});
continue;
}
let column_ty = if is_reference {
Some(ColumnType::Int64)
} else if raw.attrs.is_translatable
&& ColumnType::rust_type_leaf(&raw.rust_type) == "Translated"
{
Some(ColumnType::Text)
} else {
ColumnType::from_rust_type(&raw.rust_type)
};
let column_ty = match (column_ty, &raw.attrs.decimal_shape) {
(Some(ColumnType::Decimal { .. }), Some(Ok((precision, scale)))) => {
Some(ColumnType::Decimal {
precision: *precision,
scale: *scale,
})
}
(Some(ColumnType::Decimal { .. }), Some(Err(problem))) => {
diagnostics.push(SchemaDiagnostic {
model: model_name.clone(),
table: table_name.clone(),
field: raw.name.clone(),
rust_type: raw.rust_type.clone(),
message: format!("{problem} on `{model_name}.{}`; column skipped", raw.name),
});
continue;
}
(ty, _) => ty,
};
let Some(ty) = column_ty else {
diagnostics.push(SchemaDiagnostic {
model: model_name.clone(),
table: table_name.clone(),
field: raw.name.clone(),
rust_type: raw.rust_type.clone(),
message: format!(
"unsupported field type `{}` on `{model_name}.{}`; column skipped \
(enum/CHECK + association resolution is a later slice)",
raw.rust_type, raw.name
),
});
continue;
};
let mut column = Column::new(raw.name.clone(), ty.clone());
column.nullable = raw.nullable;
column.primary_key = is_pk;
column.unique = raw.attrs.is_unique;
column.default = column
.default
.or_else(|| {
raw.attrs
.is_translatable
.then(|| ColumnDefault::Sql(TRANSLATABLE_COLUMN_DEFAULT.to_owned()))
.or_else(|| {
raw.attrs
.is_collaborative
.then(|| ColumnDefault::Sql(COLLABORATIVE_COLUMN_DEFAULT.to_owned()))
})
})
.or_else(|| convention_default(&raw.name, &ty, is_pk, raw.attrs.is_default, backend));
match &raw.attrs.reference {
ReferenceSpec::None => {}
ReferenceSpec::Inferred => {
let base = raw.field.strip_suffix("_id").unwrap_or(&raw.field);
column.references = Some(ForeignKey::new(naming::pluralize(base), "id"));
}
ReferenceSpec::Explicit(target) => {
column.references = Some(ForeignKey::new(target.clone(), "id"));
}
}
column.references = column.references.map(|fk| {
fk.with_on_delete(raw.attrs.on_delete)
.with_on_update(raw.attrs.on_update)
});
if raw.attrs.is_unique {
unique_index_fields.push(raw.name.clone());
}
if raw.attrs.is_indexed || is_reference {
plain_index_fields.push(raw.name.clone());
}
if is_pk {
table.primary_key.push(raw.name.clone());
}
table.columns.push(column);
}
if !has_created_at {
let mut created = Column::new("created_at", ColumnType::Timestamp);
created.default = Some(ColumnDefault::Now);
table.columns.push(created);
}
if let [pk_name] = table.primary_key.as_slice() {
let pk_name = pk_name.clone();
if let Some(pk) = table.columns.iter_mut().find(|c| c.name == pk_name)
&& matches!(pk.ty, ColumnType::Int64)
&& pk.default.is_none()
{
pk.serial = Some(SerialKind::BigSerial);
}
}
plain_index_fields.retain(|name| !unique_index_fields.contains(name));
plain_index_fields.dedup();
for field_name in &plain_index_fields {
table.indexes.push(Index {
name: format!("idx_{table_name}_{field_name}"),
columns: vec![field_name.clone()],
unique: false,
definition: None,
is_partial: false,
key_columns: Vec::new(),
});
}
for field_name in &unique_index_fields {
table.indexes.push(Index {
name: format!("idx_{table_name}_{field_name}_unique"),
columns: vec![field_name.clone()],
unique: true,
definition: None,
is_partial: false,
key_columns: Vec::new(),
});
}
table
}
fn convention_default(
name: &str,
ty: &ColumnType,
is_primary_key: bool,
is_default: bool,
backend: Backend,
) -> Option<ColumnDefault> {
if name == "created_at"
&& is_default
&& matches!(ty, ColumnType::Timestamp | ColumnType::TimestampTz)
{
return Some(ColumnDefault::Now);
}
if is_primary_key && *ty == ColumnType::Uuid {
return Some(ColumnDefault::Sql(match backend {
Backend::Postgres => "gen_random_uuid()".to_owned(),
Backend::Sqlite => autumn_schema_core::SQLITE_UUID_V4_SQL.to_owned(),
}));
}
None
}
fn resolve_pk_field_names(fields: &[RawField]) -> Vec<String> {
let explicit: Vec<String> = fields
.iter()
.filter(|f| f.attrs.is_id)
.map(|f| f.name.clone())
.collect();
if !explicit.is_empty() {
return explicit;
}
fields
.iter()
.find(|f| !f.nullable && matches!(f.rust_type.as_str(), "i32" | "i64"))
.map_or_else(Vec::new, |f| vec![f.name.clone()])
}
fn strip_option(ty: &syn::Type) -> (bool, &syn::Type) {
if let syn::Type::Path(type_path) = ty
&& type_path.qself.is_none()
&& let Some(seg) = type_path.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 type_to_string(ty: &syn::Type) -> String {
quote::quote!(#ty).to_string()
}
#[cfg(test)]
#[allow(clippy::needless_raw_string_hashes)]
mod tests {
use super::*;
#[test]
fn encrypted_columns_are_read_per_table() {
let found = parse_encrypted_columns(
r#"
#[model]
pub struct Account {
#[id]
pub id: i64,
#[encrypted]
pub api_token: String,
#[encrypted(deterministic)]
pub email: String,
pub name: String,
}
"#,
)
.unwrap();
assert_eq!(
found.get("accounts"),
Some(&BTreeMap::from([
("api_token".to_owned(), false),
("email".to_owned(), true),
]))
);
}
#[test]
fn encrypted_columns_honor_the_table_name_override() {
let found = parse_encrypted_columns(
r#"
#[model(table = "legacy_people")]
pub struct Person {
#[id]
pub id: i64,
#[encrypted]
pub ssn: String,
}
"#,
)
.unwrap();
assert!(found.contains_key("legacy_people"));
assert!(!found.contains_key("people"));
}
#[test]
fn a_model_without_encrypted_columns_is_omitted() {
let found = parse_encrypted_columns(
r#"
#[model]
pub struct Post {
#[id]
pub id: i64,
pub title: String,
}
"#,
)
.unwrap();
assert!(found.is_empty());
}
#[test]
fn encrypted_fields_outside_a_model_struct_are_ignored() {
let found = parse_encrypted_columns(
r#"
pub struct NotAModel {
#[encrypted]
pub secret: String,
}
"#,
)
.unwrap();
assert!(found.is_empty());
}
#[test]
fn encrypted_column_scan_reports_a_syntax_error() {
assert!(matches!(
parse_encrypted_columns("this is not rust"),
Err(SchemaParseError::Syntax { .. })
));
}
fn parse_one(src: &str) -> Table {
let parsed = parse_model_source(src, Backend::Postgres).expect("parse");
assert_eq!(parsed.tables.len(), 1, "expected exactly one table");
parsed.tables.into_iter().next().unwrap()
}
fn col<'a>(table: &'a Table, name: &str) -> &'a Column {
table
.columns
.iter()
.find(|c| c.name == name)
.unwrap_or_else(|| panic!("column `{name}` not found in {:?}", table.name))
}
#[test]
fn translatable_column_carries_the_empty_container_default() {
let src = r#"
#[autumn_web::model]
pub struct Post {
#[id]
pub id: i64,
#[translatable]
pub title: autumn_web::i18n::Translated,
pub slug: String,
}
"#;
let table = parse_one(src);
let title = col(&table, "title");
assert_eq!(title.ty, ColumnType::Text, "storage is a plain TEXT column");
assert!(!title.nullable);
assert_eq!(
title.default,
Some(ColumnDefault::Sql("'{}'".to_owned())),
"the empty-container default is part of the storage contract"
);
let slug = col(&table, "slug");
assert_eq!(slug.ty, ColumnType::Text);
assert_eq!(slug.default, None);
}
#[test]
fn collaborative_column_carries_the_empty_document_default() {
let src = r#"
#[autumn_web::model]
pub struct Note {
#[id]
pub id: i64,
#[collaborative]
pub body: autumn_web::collab::CollabText,
pub title: String,
}
"#;
let table = parse_one(src);
let body = col(&table, "body");
assert_eq!(body.ty, ColumnType::Text, "storage is a plain TEXT column");
assert!(!body.nullable);
assert_eq!(
body.default,
Some(ColumnDefault::Sql(r#"'{"elems":[]}'"#.to_owned())),
"the empty-document default is part of the storage contract"
);
let title = col(&table, "title");
assert_eq!(title.ty, ColumnType::Text);
assert_eq!(title.default, None);
}
#[test]
fn a_plain_text_column_gains_no_translatable_default() {
let src = r#"
#[autumn_web::model]
pub struct Post {
#[id]
pub id: i64,
pub title: String,
}
"#;
assert_eq!(col(&parse_one(src), "title").default, None);
}
#[test]
fn unmarked_translated_lookalikes_are_skipped() {
for rust_type in ["domain::Translated", "Translated"] {
let src = format!(
r#"
#[autumn_web::model]
pub struct Post {{
#[id]
pub id: i64,
pub title: {rust_type},
}}
"#
);
let parsed = parse_model_source(&src, Backend::Postgres).expect("parse");
let table = &parsed.tables[0];
assert!(
table.columns.iter().all(|c| c.name != "title"),
"`{rust_type}` without `#[translatable]` must be skipped"
);
assert!(
parsed.diagnostics.iter().any(|d| d.field == "title"),
"a diagnostic must name the skipped `{rust_type}` field"
);
}
}
#[test]
fn marked_translated_maps_to_text_for_any_path_spelling() {
for rust_type in [
"autumn_web::i18n::Translated",
"crate::i18n::Translated",
"Translated",
] {
let src = format!(
r#"
#[autumn_web::model]
pub struct Post {{
#[id]
pub id: i64,
#[translatable]
pub title: {rust_type},
}}
"#
);
let table = parse_one(&src);
let title = col(&table, "title");
assert_eq!(title.ty, ColumnType::Text, "`{rust_type}` storage");
assert_eq!(
title.default,
Some(ColumnDefault::Sql("'{}'".to_owned())),
"`{rust_type}` keeps the empty-container default"
);
}
}
#[test]
fn basic_model_id_string_option_bool_created_at() {
let src = r#"
#[autumn_web::model]
pub struct Post {
#[id]
pub id: i64,
pub title: String,
pub views: Option<i32>,
pub published: bool,
#[default]
pub created_at: chrono::NaiveDateTime,
}
"#;
let table = parse_one(src);
assert_eq!(table.name, "posts");
assert!(!table.managed, "unmarked model is not managed");
assert_eq!(table.backend, Backend::Postgres);
assert_eq!(table.primary_key, vec!["id".to_owned()]);
let id = col(&table, "id");
assert_eq!(id.ty, ColumnType::Int64);
assert!(id.primary_key);
let title = col(&table, "title");
assert_eq!(title.ty, ColumnType::Text);
assert!(!title.nullable);
let views = col(&table, "views");
assert_eq!(views.ty, ColumnType::Int32);
assert!(views.nullable, "Option<i32> is nullable");
let published = col(&table, "published");
assert_eq!(published.ty, ColumnType::Bool);
let created = col(&table, "created_at");
assert_eq!(created.ty, ColumnType::Timestamp);
assert_eq!(created.default, Some(ColumnDefault::Now));
}
#[test]
fn full_basic_table_equals_hand_built() {
let src = r#"
#[autumn_web::model]
pub struct Widget {
#[id]
pub id: i64,
pub name: String,
#[default]
pub created_at: chrono::NaiveDateTime,
}
"#;
let table = parse_one(src);
let mut expected = Table::new("widgets", Backend::Postgres);
expected.managed = false;
let mut id = Column::new("id", ColumnType::Int64);
id.primary_key = true;
id.serial = Some(SerialKind::BigSerial);
expected.columns.push(id);
expected.columns.push(Column::new("name", ColumnType::Text));
let mut created = Column::new("created_at", ColumnType::Timestamp);
created.default = Some(ColumnDefault::Now);
expected.columns.push(created);
expected.primary_key.push("id".to_owned());
assert_eq!(table, expected);
}
#[test]
fn table_override_is_honored() {
let src = r#"
#[autumn_web::model(table = "people")]
pub struct Person {
#[id]
pub id: i64,
pub name: String,
#[default]
pub created_at: chrono::NaiveDateTime,
}
"#;
let table = parse_one(src);
assert_eq!(table.name, "people");
}
#[test]
fn managed_marker_sets_managed_true() {
let marked = parse_one(
r#"
#[model(managed)]
pub struct Account {
#[id]
pub id: i64,
#[default]
pub created_at: chrono::NaiveDateTime,
}
"#,
);
assert!(marked.managed, "#[model(managed)] opts in");
let via_schema = parse_one(
r#"
#[model(schema = "managed")]
pub struct Account {
#[id]
pub id: i64,
#[default]
pub created_at: chrono::NaiveDateTime,
}
"#,
);
assert!(via_schema.managed, "#[model(schema = \"managed\")] opts in");
let unmarked = parse_one(
r#"
#[model]
pub struct Account {
#[id]
pub id: i64,
#[default]
pub created_at: chrono::NaiveDateTime,
}
"#,
);
assert!(!unmarked.managed, "unmarked model defaults to unmanaged");
}
#[test]
fn references_field_yields_fk_int64_and_auto_index() {
let src = r#"
#[model]
pub struct Comment {
#[id]
pub id: i64,
#[references]
pub post_id: i64,
pub body: String,
#[default]
pub created_at: chrono::NaiveDateTime,
}
"#;
let table = parse_one(src);
let post = col(&table, "post_id");
assert_eq!(post.ty, ColumnType::Int64);
assert_eq!(post.references, Some(ForeignKey::new("posts", "id")));
let idx = table
.indexes
.iter()
.find(|i| i.columns == vec!["post_id".to_owned()])
.expect("auto-index on the reference column");
assert_eq!(idx.name, "idx_comments_post_id");
assert!(!idx.unique);
}
#[test]
fn references_table_override() {
let src = r#"
#[model]
pub struct Comment {
#[id]
pub id: i64,
#[references(table = "blog_posts")]
pub post_id: i64,
#[default]
pub created_at: chrono::NaiveDateTime,
}
"#;
let table = parse_one(src);
assert_eq!(
col(&table, "post_id").references,
Some(ForeignKey::new("blog_posts", "id"))
);
}
#[test]
fn now_default_only_for_created_at_not_other_timestamps() {
let src = r#"
#[model]
pub struct Event {
#[id]
pub id: i64,
#[default]
pub occurred_at: chrono::NaiveDateTime,
#[default]
pub created_at: chrono::NaiveDateTime,
}
"#;
let table = parse_one(src);
assert_eq!(col(&table, "occurred_at").default, None);
assert_eq!(col(&table, "created_at").default, Some(ColumnDefault::Now));
}
#[test]
fn hand_written_created_at_without_default_marker_has_no_inferred_default() {
let src = r#"
#[autumn_web::model]
pub struct Event {
#[id]
pub id: i64,
pub created_at: chrono::NaiveDateTime,
}
"#;
let table = parse_one(src);
let created = col(&table, "created_at");
assert_eq!(created.ty, ColumnType::Timestamp);
assert_eq!(created.default, None);
}
#[test]
fn hand_written_agg_event_created_at_has_no_inferred_default() {
let src = r#"
#[autumn_web::model(table = "test_agg_events")]
pub struct AggEvent {
#[id]
pub id: i64,
pub post_id: i64,
pub score: i32,
pub created_at: chrono::NaiveDateTime,
}
"#;
let table = parse_one(src);
assert_eq!(table.name, "test_agg_events");
assert_eq!(col(&table, "created_at").default, None);
}
#[test]
fn soft_delete_deleted_at_has_no_inferred_default() {
let src = r#"
#[model]
pub struct Post {
#[id]
pub id: i64,
pub title: String,
#[default]
pub deleted_at: Option<chrono::NaiveDateTime>,
#[default]
pub created_at: chrono::NaiveDateTime,
}
"#;
let table = parse_one(src);
let deleted_at = col(&table, "deleted_at");
assert_eq!(deleted_at.default, None);
assert!(deleted_at.nullable);
assert_eq!(col(&table, "created_at").default, Some(ColumnDefault::Now));
}
#[test]
fn uuid_primary_key_recovers_gen_random_uuid_default_on_postgres() {
let src = r#"
#[autumn_web::model]
pub struct Session {
#[id]
pub id: uuid::Uuid,
pub token: String,
#[default]
pub created_at: chrono::NaiveDateTime,
}
"#;
let table = parse_one(src);
let id = col(&table, "id");
assert_eq!(id.ty, ColumnType::Uuid);
assert!(id.primary_key);
assert_eq!(
id.default,
Some(ColumnDefault::Sql("gen_random_uuid()".to_owned()))
);
}
#[test]
fn uuid_primary_key_recovers_the_v4_expression_default_on_sqlite() {
let src = r#"
#[autumn_web::model]
pub struct Session {
#[id]
pub id: autumn_web::db::sqlite_types::SqliteUuid,
pub token: String,
}
"#;
let parsed = parse_model_source(src, Backend::Sqlite).expect("parse");
let table = parsed.tables.into_iter().next().unwrap();
let id = col(&table, "id");
assert_eq!(id.ty, ColumnType::Uuid);
assert!(id.primary_key);
assert_eq!(
id.default,
Some(ColumnDefault::Sql(
autumn_schema_core::SQLITE_UUID_V4_SQL.to_owned()
))
);
}
#[test]
fn non_pk_uuid_field_has_no_inferred_default() {
let src = r#"
#[autumn_web::model]
pub struct ApiKey {
#[id]
pub id: i64,
pub external_ref: uuid::Uuid,
#[default]
pub created_at: chrono::NaiveDateTime,
}
"#;
let table = parse_one(src);
let external = col(&table, "external_ref");
assert_eq!(external.ty, ColumnType::Uuid);
assert!(!external.primary_key);
assert_eq!(external.default, None);
}
#[test]
fn bigserial_primary_key_has_no_inferred_default() {
let src = r#"
#[autumn_web::model]
pub struct Post {
#[id]
pub id: i64,
pub title: String,
#[default]
pub created_at: chrono::NaiveDateTime,
}
"#;
let table = parse_one(src);
let id = col(&table, "id");
assert_eq!(id.ty, ColumnType::Int64);
assert!(id.primary_key);
assert_eq!(id.default, None);
}
#[test]
fn indexed_field_produces_index() {
let src = r#"
#[model]
pub struct User {
#[id]
pub id: i64,
#[indexed]
pub slug: String,
#[default]
pub created_at: chrono::NaiveDateTime,
}
"#;
let table = parse_one(src);
let idx = table
.indexes
.iter()
.find(|i| i.columns == vec!["slug".to_owned()])
.expect("index on the #[indexed] column");
assert_eq!(idx.name, "idx_users_slug");
assert!(!idx.unique);
}
#[test]
fn unique_field_produces_unique_index_and_no_plain_index() {
let src = r#"
#[model]
pub struct Account {
#[id]
pub id: i64,
#[unique]
#[indexed]
pub email: String,
#[default]
pub created_at: chrono::NaiveDateTime,
}
"#;
let table = parse_one(src);
assert!(col(&table, "email").unique);
let email_indexes: Vec<&Index> = table
.indexes
.iter()
.filter(|i| i.columns == vec!["email".to_owned()])
.collect();
assert_eq!(
email_indexes.len(),
1,
"a unique field must not also get a redundant plain index"
);
assert!(email_indexes[0].unique);
assert_eq!(email_indexes[0].name, "idx_accounts_email_unique");
}
#[test]
fn each_scalar_type_maps_correctly() {
let src = r#"
#[model]
pub struct Kitchen {
#[id]
pub id: i64,
pub a: String,
pub b: i32,
pub c: i64,
pub d: bool,
pub e: f32,
pub f: f64,
pub g: uuid::Uuid,
pub h: chrono::NaiveDateTime,
pub i: chrono::DateTime<chrono::Utc>,
pub j: Vec<u8>,
pub k: autumn_web::storage::Blob,
pub l: rust_decimal::Decimal,
#[default]
pub created_at: chrono::NaiveDateTime,
}
"#;
let table = parse_one(src);
assert_eq!(col(&table, "a").ty, ColumnType::Text);
assert_eq!(col(&table, "b").ty, ColumnType::Int32);
assert_eq!(col(&table, "c").ty, ColumnType::Int64);
assert_eq!(col(&table, "d").ty, ColumnType::Bool);
assert_eq!(col(&table, "e").ty, ColumnType::Float32);
assert_eq!(col(&table, "f").ty, ColumnType::Float64);
assert_eq!(col(&table, "g").ty, ColumnType::Uuid);
assert_eq!(col(&table, "h").ty, ColumnType::Timestamp);
assert_eq!(col(&table, "i").ty, ColumnType::TimestampTz);
assert_eq!(col(&table, "j").ty, ColumnType::Bytes);
assert_eq!(col(&table, "k").ty, ColumnType::Attachment);
assert_eq!(
col(&table, "l").ty,
ColumnType::Decimal {
precision: 12,
scale: 2
}
);
}
#[test]
fn decimal_shape_attr_sets_precision_and_scale() {
let src = r#"
#[model]
pub struct Product {
#[id]
pub id: i64,
#[decimal_shape(precision = 10, scale = 2)]
pub price: rust_decimal::Decimal,
#[decimal_shape(precision = 5, scale = 0)]
pub qty: Option<autumn_web::db::sqlite_types::SqliteDecimal>,
pub plain: rust_decimal::Decimal,
}
"#;
let table = parse_one(src);
assert_eq!(
col(&table, "price").ty,
ColumnType::Decimal {
precision: 10,
scale: 2
}
);
let qty = col(&table, "qty");
assert_eq!(
qty.ty,
ColumnType::Decimal {
precision: 5,
scale: 0
}
);
assert!(qty.nullable);
assert_eq!(
col(&table, "plain").ty,
ColumnType::Decimal {
precision: 12,
scale: 2
}
);
}
#[test]
fn malformed_decimal_shape_attr_skips_the_column() {
for attr in [
"#[decimal_shape(precision = 10)]",
"#[decimal_shape(scale = 2)]",
"#[decimal_shape(precision = 10, scale = 2, extra = 1)]",
"#[decimal_shape(precision = \"ten\", scale = 2)]",
"#[decimal_shape(precision = 0, scale = 0)]",
"#[decimal_shape(precision = 29, scale = 2)]",
"#[decimal_shape(precision = 300, scale = 2)]",
"#[decimal_shape(precision = 4, scale = 5)]",
"#[decimal_shape]",
] {
let src = format!(
"#[model]\npub struct Product {{\n #[id]\n pub id: i64,\n \
{attr}\n pub price: rust_decimal::Decimal,\n}}\n"
);
let parsed = parse_model_source(&src, Backend::Sqlite).expect("parse");
assert!(
parsed.tables[0].columns.iter().all(|c| c.name != "price"),
"`{attr}` did not skip the column"
);
assert_eq!(
parsed.diagnostics.len(),
1,
"`{attr}` did not add one diagnostic"
);
assert_eq!(parsed.diagnostics[0].field, "price");
assert!(
parsed.diagnostics[0].message.contains("decimal_shape"),
"the diagnostic names the attribute: {}",
parsed.diagnostics[0].message
);
}
}
#[test]
fn decimal_shape_attr_on_a_non_decimal_field_is_ignored() {
let src = r#"
#[model]
pub struct Product {
#[id]
pub id: i64,
#[decimal_shape(precision = 10, scale = 2)]
pub name: String,
}
"#;
let parsed = parse_model_source(src, Backend::Postgres).expect("parse");
assert!(parsed.diagnostics.is_empty());
assert_eq!(col(&parsed.tables[0], "name").ty, ColumnType::Text);
}
#[test]
fn unknown_field_type_produces_diagnostic_not_wrong_mapping() {
let src = r#"
#[model]
pub struct Post {
#[id]
pub id: i64,
pub title: String,
pub status: PostStatus,
#[default]
pub created_at: chrono::NaiveDateTime,
}
"#;
let parsed = parse_model_source(src, Backend::Postgres).expect("parse");
let table = &parsed.tables[0];
assert!(table.columns.iter().all(|c| c.name != "status"));
assert_eq!(parsed.diagnostics.len(), 1);
let diag = &parsed.diagnostics[0];
assert_eq!(diag.model, "Post");
assert_eq!(diag.table, "posts");
assert_eq!(diag.field, "status");
assert!(diag.rust_type.contains("PostStatus"));
}
#[test]
fn diagnostic_records_custom_table_name() {
let src = r#"
#[model(table = "app_users")]
pub struct User {
#[id]
pub id: i64,
pub name: String,
pub role: UserRole,
}
"#;
let parsed = parse_model_source(src, Backend::Postgres).expect("parse");
assert_eq!(parsed.tables[0].name, "app_users");
assert_eq!(parsed.diagnostics.len(), 1);
let diag = &parsed.diagnostics[0];
assert_eq!(diag.model, "User");
assert_eq!(diag.table, "app_users");
assert_eq!(diag.field, "role");
}
#[test]
fn synthesizes_reserved_id_and_created_at_when_absent() {
let src = r#"
#[model]
pub struct Note {
pub body: String,
}
"#;
let table = parse_one(src);
assert_eq!(table.primary_key, vec!["id".to_owned()]);
let id = col(&table, "id");
assert_eq!(id.ty, ColumnType::Int64);
assert!(id.primary_key);
assert_eq!(table.columns.first().unwrap().name, "id");
let created = col(&table, "created_at");
assert_eq!(created.ty, ColumnType::Timestamp);
assert_eq!(created.default, Some(ColumnDefault::Now));
}
#[test]
fn pk_falls_back_to_first_integer_field_without_id_attr() {
let src = r#"
#[model]
pub struct Legacy {
pub legacy_key: i64,
pub name: String,
#[default]
pub created_at: chrono::NaiveDateTime,
}
"#;
let table = parse_one(src);
assert_eq!(table.primary_key, vec!["legacy_key".to_owned()]);
assert!(col(&table, "legacy_key").primary_key);
assert!(table.columns.iter().all(|c| c.name != "id"));
}
#[test]
fn non_model_structs_are_ignored() {
let src = r#"
pub struct NotAModel {
pub x: i64,
}
#[model]
pub struct Real {
#[id]
pub id: i64,
#[default]
pub created_at: chrono::NaiveDateTime,
}
"#;
let parsed = parse_model_source(src, Backend::Postgres).expect("parse");
assert_eq!(parsed.tables.len(), 1);
assert_eq!(parsed.tables[0].name, "reals");
}
#[test]
fn syntax_error_is_reported_with_location() {
let err = parse_model_source("pub struct { ", Backend::Postgres).unwrap_err();
assert!(matches!(err, SchemaParseError::Syntax { .. }));
}
#[test]
fn parses_a_real_repo_model_file() {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../examples/bookmarks/src/models/bookmark.rs");
let Ok(src) = std::fs::read_to_string(&path) else {
eprintln!("skipping: {} not found", path.display());
return;
};
let parsed = parse_model_source(&src, Backend::Postgres).expect("real model parses");
assert_eq!(parsed.tables.len(), 1);
let table = &parsed.tables[0];
assert_eq!(table.name, "bookmarks");
assert_eq!(table.primary_key, vec!["id".to_owned()]);
assert_eq!(col(table, "url").ty, ColumnType::Text);
assert_eq!(col(table, "alive").ty, ColumnType::Bool);
assert_eq!(col(table, "created_at").default, Some(ColumnDefault::Now));
assert!(
table
.indexes
.iter()
.any(|i| i.name == "idx_bookmarks_url" && !i.unique)
);
assert!(
table
.indexes
.iter()
.any(|i| i.name == "idx_bookmarks_tag" && !i.unique)
);
}
#[test]
fn sqlite_backend_is_carried_through() {
let src = r#"
#[model]
pub struct Thing {
#[id]
pub id: i64,
#[default]
pub created_at: chrono::NaiveDateTime,
}
"#;
let parsed = parse_model_source(src, Backend::Sqlite).expect("parse");
assert_eq!(parsed.tables[0].backend, Backend::Sqlite);
}
#[test]
fn model_unknown_kv_arg_is_ignored_and_known_args_survive() {
let src = r#"
#[model(table = "widgets", future_unknown = "x")]
pub struct Widget {
#[id]
pub id: i64,
#[default]
pub created_at: chrono::NaiveDateTime,
}
"#;
let table = parse_one(src);
assert_eq!(table.name, "widgets");
}
#[test]
fn model_unknown_nested_list_arg_is_consumed_and_managed_survives() {
let src = r#"
#[model(managed, future_list(a = 1, b = 2))]
pub struct Account {
#[id]
pub id: i64,
#[default]
pub created_at: chrono::NaiveDateTime,
}
"#;
let table = parse_one(src);
assert!(table.managed, "unknown nested list must not drop `managed`");
}
#[test]
fn references_unknown_kv_arg_is_ignored_and_target_survives() {
let src = r#"
#[model]
pub struct Comment {
#[id]
pub id: i64,
#[references(table = "users", future_key = "x")]
pub author_id: i64,
#[default]
pub created_at: chrono::NaiveDateTime,
}
"#;
let table = parse_one(src);
assert_eq!(
col(&table, "author_id").references,
Some(ForeignKey::new("users", "id"))
);
}
const SINGLE_FILE_MODEL: &str = r#"
#[model]
pub struct Post {
#[id]
pub id: i64,
pub body: String,
}
"#;
#[test]
fn parse_models_path_reads_a_single_rs_file() {
let dir = tempfile::tempdir().expect("tempdir");
let file = dir.path().join("models.rs");
std::fs::write(&file, SINGLE_FILE_MODEL).expect("write model file");
let parsed = parse_models_path(&file, Backend::Postgres).expect("parse single file");
assert_eq!(parsed.tables.len(), 1);
assert_eq!(parsed.tables[0].name, "posts");
}
#[test]
fn parse_models_path_reads_a_directory() {
let dir = tempfile::tempdir().expect("tempdir");
let models_dir = dir.path().join("models");
std::fs::create_dir(&models_dir).expect("mkdir");
std::fs::write(models_dir.join("post.rs"), SINGLE_FILE_MODEL).expect("write model file");
let parsed = parse_models_path(&models_dir, Backend::Postgres).expect("parse dir");
assert_eq!(parsed.tables.len(), 1);
assert_eq!(parsed.tables[0].name, "posts");
}
#[test]
fn parse_models_path_missing_path_is_a_clear_io_error() {
let dir = tempfile::tempdir().expect("tempdir");
let missing = dir.path().join("does_not_exist.rs");
let err = parse_models_path(&missing, Backend::Postgres).unwrap_err();
match err {
SchemaParseError::Io { path, .. } => {
assert!(path.contains("does_not_exist.rs"), "path names the input");
}
other => panic!("expected Io error, got {other:?}"),
}
}
#[test]
fn field_renamed_from_records_a_column_hint() {
let parsed = parse_model_source(
r#"
#[model(managed)]
pub struct Post {
#[id]
pub id: i64,
#[renamed_from("title")]
pub headline: String,
}
"#,
Backend::Postgres,
)
.expect("parse");
assert_eq!(
parsed.renames,
vec![RenameHint {
table: "posts".to_owned(),
column: Some("headline".to_owned()),
from: "title".to_owned(),
}]
);
assert!(
parsed.tables[0]
.columns
.iter()
.any(|c| c.name == "headline")
);
}
#[test]
fn struct_renamed_from_records_a_table_hint() {
let parsed = parse_model_source(
r#"
#[model(managed)]
#[renamed_from("articles")]
pub struct Post {
#[id]
pub id: i64,
}
"#,
Backend::Postgres,
)
.expect("parse");
assert_eq!(
parsed.renames,
vec![RenameHint {
table: "posts".to_owned(),
column: None,
from: "articles".to_owned(),
}]
);
}
#[test]
fn malformed_renamed_from_is_ignored() {
let parsed = parse_model_source(
r#"
#[model(managed)]
pub struct Post {
#[id]
pub id: i64,
#[renamed_from("Bad Name")]
pub a: String,
#[renamed_from = "b_old"]
pub b: String,
#[renamed_from]
pub c: String,
#[renamed_from("x; DROP TABLE posts")]
pub d: String,
#[renamed_from("e_old", "e_other")]
pub e: String,
}
"#,
Backend::Postgres,
)
.expect("parse");
assert!(parsed.renames.is_empty(), "{:?}", parsed.renames);
}
#[test]
fn renamed_from_hints_aggregate_across_a_directory() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(
dir.path().join("a.rs"),
"#[model(managed)] pub struct Post { #[id] pub id: i64, #[renamed_from(\"title\")] pub headline: String }",
)
.expect("write a");
std::fs::write(
dir.path().join("b.rs"),
"#[model(managed)] #[renamed_from(\"people\")] pub struct User { #[id] pub id: i64 }",
)
.expect("write b");
let parsed = parse_models_dir(dir.path(), Backend::Postgres).expect("parse dir");
assert_eq!(parsed.renames.len(), 2, "{:?}", parsed.renames);
}
#[test]
fn raw_ident_field_names_the_unrawed_column() {
let parsed = parse_model_source(
"#[model] pub struct Item { #[id] pub id: i64, pub r#type: String }",
Backend::Postgres,
)
.expect("parse");
let names: Vec<&str> = parsed.tables[0]
.columns
.iter()
.map(|c| c.name.as_str())
.collect();
assert!(names.contains(&"type"), "{names:?}");
assert!(!names.iter().any(|n| n.starts_with("r#")), "{names:?}");
}
#[test]
fn encrypted_columns_use_the_sql_column_name() {
let found = parse_encrypted_columns(
r#"
#[model]
pub struct Item {
#[id] pub id: i64,
#[encrypted] pub r#type: String,
#[encrypted] #[diesel(column_name = "tok")] pub token: String,
}
"#,
)
.expect("parse");
let cols: Vec<&str> = found["items"].keys().map(String::as_str).collect();
assert_eq!(cols, vec!["tok", "type"]);
}
#[test]
fn encrypted_columns_ignore_cfg_test_fixtures() {
let found = parse_encrypted_columns(
r#"
#[model]
pub struct User { #[id] pub id: i64, #[encrypted(deterministic)] pub email: String }
#[cfg(test)]
mod tests {
#[model]
pub struct User { #[id] pub id: i64, #[encrypted] pub email: String }
}
"#,
)
.expect("parse");
assert!(found["users"]["email"], "{found:?}");
}
#[test]
fn encrypted_columns_ignore_cfg_test_items_of_every_kind() {
let found = parse_encrypted_columns(
r#"
#[cfg(test)] fn a() { #[model] struct U { #[id] id: i64, #[encrypted] email: String } }
#[cfg(test)] impl X { fn b() { #[model] struct V { #[id] id: i64, #[encrypted] e: String } } }
struct X;
impl X {
#[cfg(test)] fn c() { #[model] struct W { #[id] id: i64, #[encrypted] f: String } }
}
trait T {
#[cfg(test)] fn d() { #[model] struct Y { #[id] id: i64, #[encrypted] g: String } }
}
#[cfg(test)] const K: () = { #[model] struct Z { #[id] id: i64, #[encrypted] h: String } };
#[cfg(test)] static S: () = { #[model] struct Q { #[id] id: i64, #[encrypted] i: String } };
#[cfg(test)] trait TT { fn e() { #[model] struct R { #[id] id: i64, #[encrypted] j: String } } }
"#,
)
.expect("parse");
assert!(found.is_empty(), "{found:?}");
}
#[test]
fn encrypted_columns_ignore_cfg_test_expressions_and_consts() {
let found = parse_encrypted_columns(
r#"
fn f() {
#[cfg(test)] { #[model] struct U { #[id] id: i64, #[encrypted] a: String } }
match 1 { #[cfg(test)] _ => { #[model] struct A { #[id] id: i64, #[encrypted] k: String } } }
let _r = R { #[cfg(test)] f: { #[model] struct B { #[id] id: i64, #[encrypted] l: String } } };
#[cfg(test)] let _x = { #[model] struct V { #[id] id: i64, #[encrypted] b: String } };
}
enum EV { #[cfg(test)] V = { #[model] struct H { #[id] id: i64, #[encrypted] n: String } 0 } }
fn param(#[cfg(test)] x: [(); { #[model] struct M { #[id] id: i64, #[encrypted] q: String } 0 }]) {}
struct GP<#[cfg(test)] T = [(); { #[model] struct N { #[id] id: i64, #[encrypted] r: String } 0 }]>(T);
fn clo() { let _ = |#[cfg(test)] x: [(); { #[model] struct CL { #[id] id: i64, #[encrypted] s: String } 0 }]| {}; }
#[cfg(test)] extern "C" { static FX: [u8; { #[model] struct FM { #[id] id: i64, #[encrypted] t: String } 0 }]; }
struct TY;
impl TY { #[cfg(test)] type A = [(); { #[model] struct J { #[id] id: i64, #[encrypted] o: String } 0 }]; }
trait TT2 { #[cfg(test)] type B = [(); { #[model] struct K { #[id] id: i64, #[encrypted] p: String } 0 }]; }
#[cfg(test)] enum E { V = { #[model] struct G { #[id] id: i64, #[encrypted] m: String } 0 } }
struct X;
impl X {
#[cfg(test)] const C: () = { #[model] struct W { #[id] id: i64, #[encrypted] c: String } };
}
trait T {
#[cfg(test)] const D: () = { #[model] struct Y { #[id] id: i64, #[encrypted] d: String } };
}
"#,
)
.expect("parse");
assert!(found.is_empty(), "{found:?}");
}
#[test]
fn encrypted_columns_skip_out_of_line_cfg_test_files() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("lib.rs"), "#[cfg(test)] mod tests;\n").unwrap();
std::fs::write(
dir.path().join("models.rs"),
"#[model] pub struct User { #[id] pub id: i64, #[encrypted(deterministic)] pub email: String }",
)
.unwrap();
std::fs::write(
dir.path().join("tests.rs"),
"#[model] pub struct User { #[id] pub id: i64, #[encrypted] pub email: String }",
)
.unwrap();
let found = parse_encrypted_columns_path(dir.path()).unwrap();
assert!(found["users"]["email"], "{found:?}");
}
#[test]
fn encrypted_columns_skip_a_test_file_named_by_a_dotdot_path() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("lib.rs"),
"#[cfg(test)] #[path = \"nested/../fixtures.rs\"] mod fixtures;\n",
)
.unwrap();
std::fs::write(
dir.path().join("models.rs"),
"#[model] pub struct User { #[id] pub id: i64, #[encrypted(deterministic)] pub email: String }",
)
.unwrap();
std::fs::write(
dir.path().join("fixtures.rs"),
"#[model] pub struct User { #[id] pub id: i64, #[encrypted] pub email: String }",
)
.unwrap();
let found = parse_encrypted_columns_path(dir.path()).unwrap();
assert!(found["users"]["email"], "{found:?}");
assert_eq!(
normalize_lexically(Path::new("/a/./b/../c")),
Path::new("/a/c")
);
}
#[test]
fn encrypted_columns_skip_files_under_a_disabled_inline_module() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("tests")).unwrap();
std::fs::write(
dir.path().join("lib.rs"),
"#[cfg(test)] mod tests { mod fixtures; }\n",
)
.unwrap();
std::fs::write(
dir.path().join("models.rs"),
"#[model] pub struct User { #[id] pub id: i64, #[encrypted(deterministic)] pub email: String }",
)
.unwrap();
std::fs::write(
dir.path().join("tests").join("fixtures.rs"),
"#[model] pub struct User { #[id] pub id: i64, #[encrypted] pub email: String }",
)
.unwrap();
let found = parse_encrypted_columns_path(dir.path()).unwrap();
assert!(found["users"]["email"], "{found:?}");
}
#[test]
fn encrypted_columns_ignore_cfg_disabled_fields() {
let found = parse_encrypted_columns(
r#"
#[model]
pub struct User {
#[id] pub id: i64,
#[cfg(not(test))] #[encrypted(deterministic)] pub email: String,
#[cfg(test)] #[encrypted] pub email: String,
}
"#,
)
.expect("parse");
assert!(found["users"]["email"], "{found:?}");
}
#[test]
fn encrypted_columns_skip_a_path_override_of_a_disabled_module() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("fixtures")).unwrap();
std::fs::write(
dir.path().join("lib.rs"),
"#[cfg(test)] #[path = \"fixtures/special.rs\"] mod tests;\n",
)
.unwrap();
std::fs::write(
dir.path().join("models.rs"),
"#[model] pub struct User { #[id] pub id: i64, #[encrypted(deterministic)] pub email: String }",
)
.unwrap();
std::fs::write(
dir.path().join("fixtures").join("special.rs"),
"#[model] pub struct User { #[id] pub id: i64, #[encrypted] pub email: String }",
)
.unwrap();
let found = parse_encrypted_columns_path(dir.path()).unwrap();
assert!(found["users"]["email"], "{found:?}");
}
#[test]
fn a_path_override_does_not_disable_the_conventional_file() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("fixtures")).unwrap();
std::fs::write(
dir.path().join("lib.rs"),
"#[cfg(test)] #[path = \"fixtures/test_models.rs\"] mod models;\n#[cfg(not(test))] mod models;\n",
)
.unwrap();
std::fs::write(
dir.path().join("models.rs"),
"#[model] pub struct User { #[id] pub id: i64, #[encrypted(deterministic)] pub email: String }",
)
.unwrap();
std::fs::write(
dir.path().join("fixtures").join("test_models.rs"),
"#[model] pub struct User { #[id] pub id: i64, #[encrypted] pub email: String }",
)
.unwrap();
let found = parse_encrypted_columns_path(dir.path()).unwrap();
assert!(found["users"]["email"], "{found:?}");
}
#[test]
fn an_enabled_twin_keeps_the_conventional_file() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("lib.rs"),
"#[cfg(test)] mod models;\n#[cfg(not(test))] mod models;\n",
)
.unwrap();
std::fs::write(
dir.path().join("models.rs"),
"#[model] pub struct User { #[id] pub id: i64, #[encrypted(deterministic)] pub email: String }",
)
.unwrap();
let found = parse_encrypted_columns_path(dir.path()).unwrap();
assert!(found["users"]["email"], "{found:?}");
}
#[test]
fn an_enabled_twin_keeps_a_shared_custom_path() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("lib.rs"),
"#[cfg(test)] #[path = \"models.rs\"] mod a;\n#[cfg(not(test))] #[path = \"models.rs\"] mod b;\n",
)
.unwrap();
std::fs::write(
dir.path().join("models.rs"),
"#[model] pub struct User { #[id] pub id: i64, #[encrypted(deterministic)] pub email: String }",
)
.unwrap();
let found = parse_encrypted_columns_path(dir.path()).unwrap();
assert!(found["users"]["email"], "{found:?}");
}
#[test]
fn conflicting_encrypted_modes_are_an_error() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("a.rs"),
"#[model] pub struct User { #[id] pub id: i64, #[encrypted(deterministic)] pub email: String }",
)
.unwrap();
std::fs::write(
dir.path().join("z_fixture.rs"),
"#[model] pub struct User { #[id] pub id: i64, #[encrypted] pub email: String }",
)
.unwrap();
let err = parse_encrypted_columns_path(dir.path()).unwrap_err();
assert!(
matches!(err, SchemaParseError::ConflictingEncryptedMode { ref column, .. } if column == "email"),
"{err:?}"
);
}
#[cfg(unix)]
#[test]
fn encrypted_columns_follow_symlinked_sources() {
let dir = tempfile::tempdir().unwrap();
let real = tempfile::tempdir().unwrap();
std::fs::write(
real.path().join("models.rs"),
"#[model] pub struct User { #[id] pub id: i64, #[encrypted(deterministic)] pub email: String }",
)
.unwrap();
std::os::unix::fs::symlink(real.path().join("models.rs"), dir.path().join("models.rs"))
.unwrap();
std::os::unix::fs::symlink(real.path(), dir.path().join("linked")).unwrap();
std::os::unix::fs::symlink(dir.path(), dir.path().join("loop")).unwrap();
std::os::unix::fs::symlink(real.path(), dir.path().join("alias")).unwrap();
let found = parse_encrypted_columns_path(dir.path()).unwrap();
assert!(found["users"]["email"], "{found:?}");
}
#[test]
fn conflicting_modes_in_one_file_are_an_error() {
let err = parse_encrypted_columns(
r#"
#[model] pub struct User { #[id] pub id: i64, #[encrypted(deterministic)] pub email: String }
#[cfg(feature = "fixtures")]
mod fixtures {
#[model] pub struct User { #[id] pub id: i64, #[encrypted] pub email: String }
}
"#,
)
.unwrap_err();
assert!(
matches!(err, SchemaParseError::ConflictingEncryptedMode { .. }),
"{err:?}"
);
}
#[test]
fn another_crate_root_can_enable_a_disabled_module() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("lib.rs"), "#[cfg(test)] mod models;\n").unwrap();
std::fs::write(dir.path().join("main.rs"), "mod models;\n").unwrap();
std::fs::write(
dir.path().join("models.rs"),
"#[model] pub struct User { #[id] pub id: i64, #[encrypted(deterministic)] pub email: String }",
)
.unwrap();
let found = parse_encrypted_columns_path(dir.path()).unwrap();
assert!(found["users"]["email"], "{found:?}");
}
#[test]
fn an_enabled_path_under_a_disabled_directory_is_kept() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("fixtures")).unwrap();
std::fs::write(
dir.path().join("lib.rs"),
"#[cfg(test)] mod fixtures;\n#[path = \"fixtures/models.rs\"] mod production_models;\n",
)
.unwrap();
std::fs::write(
dir.path().join("fixtures").join("models.rs"),
"#[model] pub struct User { #[id] pub id: i64, #[encrypted(deterministic)] pub email: String }",
)
.unwrap();
let found = parse_encrypted_columns_path(dir.path()).unwrap();
assert!(found["users"]["email"], "{found:?}");
}
#[test]
fn module_and_cfg_helpers_survive_odd_input() {
let attrs = |src: &str| syn::parse_str::<syn::ItemFn>(src).unwrap().attrs;
assert!(production_enabled(&attrs("#[cfg_attr] fn f() {}")));
assert!(production_enabled(&attrs("#[cfg_attr(test)] fn f() {}")));
assert!(production_enabled(&attrs(
"#[cfg_attr(not(test), derive(Debug))] fn f() {}"
)));
assert!(production_enabled(&attrs(
"#[cfg_attr(not(test), cfg(feature = \"x\"))] fn f() {}"
)));
assert!(production_enabled(&attrs("#[cfg] fn f() {}")));
assert!(production_enabled(&attrs("#[cfg(\"literal\")] fn f() {}")));
assert!(!production_enabled(&attrs("#[cfg(any())] fn f() {}")));
assert!(production_enabled(&attrs("#[cfg(all())] fn f() {}")));
assert!(
parse_encrypted_columns(
"#![cfg(test)]\n#[model] pub struct U { #[encrypted] pub a: String }"
)
.unwrap()
.is_empty()
);
let dir = std::path::PathBuf::from("src");
let modules = disabled_module_paths(&[
(dir.join("bad.rs"), "fn {".to_owned()),
(
dir.join("lib.rs"),
"#![cfg(test)]\n#[path = 1] mod odd;\n#[cfg(test)] #[path] mod odd2;".to_owned(),
),
]);
assert!(modules.excludes(&dir.join("odd.rs")));
assert!(!modules.excludes(&dir.join("other.rs")));
}
#[cfg(unix)]
#[test]
fn a_dangling_symlink_is_skipped() {
let dir = tempfile::tempdir().unwrap();
std::os::unix::fs::symlink(dir.path().join("gone.rs"), dir.path().join("dangling.rs"))
.unwrap();
std::fs::write(
dir.path().join("models.rs"),
"#[model] pub struct User { #[id] pub id: i64, #[encrypted] pub email: String }",
)
.unwrap();
let found = parse_encrypted_columns_path(dir.path()).unwrap();
assert!(!found["users"]["email"], "{found:?}");
}
#[test]
fn encrypted_columns_are_found_in_inline_modules() {
let found = parse_encrypted_columns(
r#"
mod accounts {
pub mod inner {
#[model]
pub struct Account { #[id] pub id: i64, #[encrypted] pub token: String }
}
}
"#,
)
.expect("parse");
assert!(found["accounts"].contains_key("token"), "{found:?}");
}
#[test]
fn diesel_column_name_names_the_column() {
let parsed = parse_model_source(
r#"
#[model(managed)]
pub struct Post {
#[id]
pub id: i64,
#[diesel(column_name = "headline")]
pub title: String,
#[diesel(column_name = body_text)]
#[renamed_from("body")]
pub body: String,
}
"#,
Backend::Postgres,
)
.expect("parse");
let table = &parsed.tables[0];
assert_eq!(col(table, "headline").ty, ColumnType::Text);
assert_eq!(col(table, "body_text").ty, ColumnType::Text);
assert!(!table.columns.iter().any(|c| c.name == "title"));
assert_eq!(parsed.renames[0].column.as_deref(), Some("body_text"));
}
#[test]
fn diesel_column_name_after_another_key_is_read() {
let parsed = parse_model_source(
r#"#[model] pub struct Doc {
#[id] pub id: i64,
#[diesel(deserialize_as = Vec<u8>, column_name = "blob_data")]
pub data: Vec<u8>,
}"#,
Backend::Postgres,
)
.expect("parse");
assert_eq!(col(&parsed.tables[0], "blob_data").ty, ColumnType::Bytes);
}
#[test]
fn references_target_comes_from_the_field_name() {
let parsed = parse_model_source(
r#"#[model(managed)] pub struct Post {
#[id] pub id: i64,
#[diesel(column_name = owner_id)]
#[references]
pub author_id: i64,
}"#,
Backend::Postgres,
)
.expect("parse");
let fk = col(&parsed.tables[0], "owner_id")
.references
.clone()
.unwrap();
assert_eq!(fk.table, "authors");
}
#[test]
fn references_actions_are_parsed() {
let src = r#"
#[model]
pub struct Comment {
#[id]
pub id: i64,
#[references(table = "users", on_delete = "cascade", on_update = "restrict")]
pub author_id: i64,
#[references(on_delete = "set_null")]
pub post_id: Option<i64>,
#[references(on_delete = "no_action")]
pub thread_id: i64,
}
"#;
let table = parse_one(src);
assert_eq!(
col(&table, "author_id").references,
Some(
ForeignKey::new("users", "id")
.with_on_delete(Some(ForeignKeyAction::Cascade))
.with_on_update(Some(ForeignKeyAction::Restrict))
)
);
assert_eq!(
col(&table, "post_id").references,
Some(ForeignKey::new("posts", "id").with_on_delete(Some(ForeignKeyAction::SetNull)))
);
assert_eq!(
col(&table, "thread_id").references,
Some(ForeignKey::new("threads", "id"))
);
}
#[test]
fn references_unknown_action_skips_the_column_with_a_diagnostic() {
let src = r#"
#[model]
pub struct Comment {
#[id]
pub id: i64,
#[references(on_delete = "casade")]
pub author_id: i64,
}
"#;
let parsed = parse_model_source(src, Backend::Postgres).expect("parse");
assert!(
parsed.tables[0]
.columns
.iter()
.all(|c| c.name != "author_id")
);
assert_eq!(parsed.diagnostics.len(), 1);
let diag = &parsed.diagnostics[0];
assert_eq!(diag.field, "author_id");
assert!(diag.message.contains("`casade`"), "{}", diag.message);
}
#[test]
fn references_null_writing_action_on_a_required_column_skips_it_with_a_diagnostic() {
for action in ["set_null", "set_default"] {
let src = format!(
r#"
#[model]
pub struct Comment {{
#[id]
pub id: i64,
#[references(on_update = "{action}")]
pub author_id: i64,
}}
"#
);
let parsed = parse_model_source(&src, Backend::Postgres).expect("parse");
assert!(
parsed.tables[0]
.columns
.iter()
.all(|c| c.name != "author_id")
);
assert_eq!(parsed.diagnostics.len(), 1);
assert!(
parsed.diagnostics[0].message.contains(action),
"{}",
parsed.diagnostics[0].message
);
}
}
}