use proc_macro::TokenStream;
mod dao;
mod database;
mod entity;
mod relation;
mod schema;
mod util;
fn into_tokens(result: syn::Result<proc_macro2::TokenStream>) -> TokenStream {
match result {
Ok(ts) => ts.into(),
Err(e) => e.to_compile_error().into(),
}
}
#[proc_macro_attribute]
pub fn entity(args: TokenStream, input: TokenStream) -> TokenStream {
into_tokens(entity::expand(args.into(), input.into()))
}
#[proc_macro_attribute]
pub fn dao(_args: TokenStream, input: TokenStream) -> TokenStream {
into_tokens(dao::expand(input.into()))
}
#[proc_macro_derive(Relation, attributes(embedded, relation))]
pub fn derive_relation(input: TokenStream) -> TokenStream {
into_tokens(relation::expand(input.into()))
}
#[proc_macro_attribute]
pub fn database(args: TokenStream, input: TokenStream) -> TokenStream {
into_tokens(database::expand(args.into(), input.into()))
}
#[proc_macro]
pub fn migrations_dir(input: TokenStream) -> TokenStream {
into_tokens(migrations_dir_impl(input.into()))
}
fn migrations_dir_impl(input: proc_macro2::TokenStream) -> syn::Result<proc_macro2::TokenStream> {
let lit: syn::LitStr = syn::parse2(input)?;
let rel = lit.value();
let manifest = std::env::var("CARGO_MANIFEST_DIR")
.map_err(|_| syn::Error::new(lit.span(), "CARGO_MANIFEST_DIR 없음"))?;
let dir = std::path::Path::new(&manifest).join(&rel);
let files = scan_migration_files(&dir, lit.span())?;
let items = files.iter().map(|(from, to, p)| {
quote::quote! { ::roomrs::Migration::sql(#from, #to, include_str!(#p)) }
});
Ok(quote::quote! { vec![#(#items),*] })
}
fn scan_migration_files(
dir: &std::path::Path,
span: proc_macro2::Span,
) -> syn::Result<Vec<(u32, u32, String)>> {
let entries = std::fs::read_dir(dir).map_err(|e| {
syn::Error::new(
span,
format!(
"마이그레이션 디렉터리를 읽을 수 없습니다 ({}): {e}",
dir.display()
),
)
})?;
let mut files: Vec<(u32, u32, String)> = Vec::new();
for entry in entries {
let path = entry
.map_err(|e| syn::Error::new(span, e.to_string()))?
.path();
if path.extension().and_then(|e| e.to_str()) != Some("sql") {
continue;
}
let name = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
let mut parts = name.splitn(3, '_');
let (Some(f), Some(t)) = (parts.next(), parts.next()) else {
return Err(syn::Error::new(
span,
format!("파일명 규칙 위반: {name}.sql — {{from}}_{{to}}_이름.sql 형식 필요"),
));
};
let (Ok(from), Ok(to)) = (f.parse::<u32>(), t.parse::<u32>()) else {
return Err(syn::Error::new(
span,
format!("파일명 버전 파싱 실패: {name}.sql — 숫자_{{from}}_{{to}} 필요"),
));
};
if (f.len() > 1 && f.starts_with('0')) || (t.len() > 1 && t.starts_with('0')) {
return Err(syn::Error::new(
span,
format!("파일명 버전 선행 0 금지: {name}.sql — \"01\" 대신 \"1\" 을 사용하세요"),
));
}
if from >= to {
return Err(syn::Error::new(
span,
format!("마이그레이션 버전 역행: {name}.sql — from({from}) < to({to}) 여야 합니다"),
));
}
files.push((from, to, path.to_string_lossy().replace('\\', "/")));
}
files.sort();
for w in files.windows(2) {
if w[0].0 == w[1].0 {
return Err(syn::Error::new(
span,
format!(
"중복 from 버전: {} — 같은 버전에서 출발하는 마이그레이션이 2개 이상입니다 ({}_{} / {}_{})",
w[0].0, w[0].0, w[0].1, w[1].0, w[1].1
),
));
}
}
Ok(files)
}
#[cfg(test)]
mod lib_tests {
use super::scan_migration_files;
use proc_macro2::Span;
fn dir_with(names: &[&str]) -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
for n in names {
std::fs::write(dir.path().join(n), "-- sql").unwrap();
}
dir
}
#[test]
fn scan_ok_sorted() {
let d = dir_with(&["2_3_b.sql", "1_2_a.sql", "readme.txt"]);
let files = scan_migration_files(d.path(), Span::call_site()).unwrap();
let pairs: Vec<(u32, u32)> = files.iter().map(|(f, t, _)| (*f, *t)).collect();
assert_eq!(pairs, vec![(1, 2), (2, 3)]);
}
#[test]
fn scan_rejects_leading_zeros() {
let d = dir_with(&["01_2_x.sql"]);
let err = scan_migration_files(d.path(), Span::call_site()).unwrap_err();
assert!(err.to_string().contains("선행 0"), "{err}");
let d2 = dir_with(&["1_02_x.sql"]);
let err2 = scan_migration_files(d2.path(), Span::call_site()).unwrap_err();
assert!(err2.to_string().contains("선행 0"), "{err2}");
let d3 = dir_with(&["0_1_init.sql"]);
assert!(scan_migration_files(d3.path(), Span::call_site()).is_ok());
}
#[test]
fn scan_rejects_downgrade_and_dup_from() {
let d = dir_with(&["2_1_bad.sql"]);
assert!(scan_migration_files(d.path(), Span::call_site()).is_err());
let d2 = dir_with(&["1_2_a.sql", "1_3_b.sql"]);
let err = scan_migration_files(d2.path(), Span::call_site()).unwrap_err();
assert!(err.to_string().contains("중복 from"), "{err}");
}
}