use std::path::Path;
use autumn_schema_core::{
Backend, Column, ColumnDefault, ColumnType, ForeignKey, 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("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>,
}
impl ParsedSchema {
#[must_use]
pub const fn from_tables(tables: Vec<Table>) -> Self {
Self {
tables,
diagnostics: 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 {
tables: Vec::new(),
diagnostics: 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.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 {
tables: Vec::new(),
diagnostics: 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);
}
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",
),
})
}
}
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,
reference: ReferenceSpec,
}
fn parse_field_attrs(field: &syn::Field) -> FieldAttrs {
let mut out = FieldAttrs {
is_id: false,
is_indexed: false,
is_unique: false,
is_default: false,
reference: ReferenceSpec::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,
"references" => {
let mut target = None;
if !matches!(attr.meta, syn::Meta::Path(_)) {
let _ = attr.parse_nested_meta(|meta| {
if meta.path.is_ident("table")
&& let Ok(value) = meta.value()
&& let Ok(lit) = value.parse::<syn::LitStr>()
{
target = Some(lit.value());
} 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
}
struct RawField {
name: 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: ident.to_string(),
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;
let column_ty = if is_reference {
Some(ColumnType::Int64)
} else {
ColumnType::from_rust_type(&raw.rust_type)
};
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(|| convention_default(&raw.name, &ty, is_pk, raw.attrs.is_default, backend));
match &raw.attrs.reference {
ReferenceSpec::None => {}
ReferenceSpec::Inferred => {
let base = raw.name.strip_suffix("_id").unwrap_or(&raw.name);
column.references = Some(ForeignKey::new(naming::pluralize(base), "id"));
}
ReferenceSpec::Explicit(target) => {
column.references = Some(ForeignKey::new(target.clone(), "id"));
}
}
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 && backend == Backend::Postgres {
return Some(ColumnDefault::Sql("gen_random_uuid()".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::*;
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 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 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 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", on_delete = "cascade")]
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 @ SchemaParseError::Syntax { .. } => panic!("expected Io error, got {other:?}"),
}
}
}