1use proc_macro::TokenStream;
7
8mod dao;
9mod database;
10mod entity;
11mod relation;
12mod schema;
13mod util;
14
15fn into_tokens(result: syn::Result<proc_macro2::TokenStream>) -> TokenStream {
17 match result {
18 Ok(ts) => ts.into(),
19 Err(e) => e.to_compile_error().into(),
20 }
21}
22
23#[proc_macro_attribute]
26pub fn entity(args: TokenStream, input: TokenStream) -> TokenStream {
27 into_tokens(entity::expand(args.into(), input.into()))
28}
29
30#[proc_macro_attribute]
33pub fn dao(_args: TokenStream, input: TokenStream) -> TokenStream {
34 into_tokens(dao::expand(input.into()))
35}
36
37#[proc_macro_derive(Relation, attributes(embedded, relation))]
39pub fn derive_relation(input: TokenStream) -> TokenStream {
40 into_tokens(relation::expand(input.into()))
41}
42
43#[proc_macro_attribute]
70pub fn database(args: TokenStream, input: TokenStream) -> TokenStream {
71 into_tokens(database::expand(args.into(), input.into()))
72}
73
74#[proc_macro]
84pub fn migrations_dir(input: TokenStream) -> TokenStream {
85 into_tokens(migrations_dir_impl(input.into()))
86}
87
88fn migrations_dir_impl(input: proc_macro2::TokenStream) -> syn::Result<proc_macro2::TokenStream> {
90 let lit: syn::LitStr = syn::parse2(input)?;
91 let rel = lit.value();
92 let manifest = std::env::var("CARGO_MANIFEST_DIR")
93 .map_err(|_| syn::Error::new(lit.span(), "CARGO_MANIFEST_DIR 없음"))?;
94 let dir = std::path::Path::new(&manifest).join(&rel);
95 let files = scan_migration_files(&dir, lit.span())?;
96
97 let items = files.iter().map(|(from, to, p)| {
98 quote::quote! { ::roomrs::Migration::sql(#from, #to, include_str!(#p)) }
99 });
100 Ok(quote::quote! { vec![#(#items),*] })
101}
102
103fn scan_migration_files(
106 dir: &std::path::Path,
107 span: proc_macro2::Span,
108) -> syn::Result<Vec<(u32, u32, String)>> {
109 let entries = std::fs::read_dir(dir).map_err(|e| {
110 syn::Error::new(
111 span,
112 format!(
113 "마이그레이션 디렉터리를 읽을 수 없습니다 ({}): {e}",
114 dir.display()
115 ),
116 )
117 })?;
118
119 let mut files: Vec<(u32, u32, String)> = Vec::new();
121 for entry in entries {
122 let path = entry
123 .map_err(|e| syn::Error::new(span, e.to_string()))?
124 .path();
125 if path.extension().and_then(|e| e.to_str()) != Some("sql") {
126 continue;
127 }
128 let name = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
129 let mut parts = name.splitn(3, '_');
130 let (Some(f), Some(t)) = (parts.next(), parts.next()) else {
131 return Err(syn::Error::new(
132 span,
133 format!("파일명 규칙 위반: {name}.sql — {{from}}_{{to}}_이름.sql 형식 필요"),
134 ));
135 };
136 let (Ok(from), Ok(to)) = (f.parse::<u32>(), t.parse::<u32>()) else {
137 return Err(syn::Error::new(
138 span,
139 format!("파일명 버전 파싱 실패: {name}.sql — 숫자_{{from}}_{{to}} 필요"),
140 ));
141 };
142 if (f.len() > 1 && f.starts_with('0')) || (t.len() > 1 && t.starts_with('0')) {
145 return Err(syn::Error::new(
146 span,
147 format!("파일명 버전 선행 0 금지: {name}.sql — \"01\" 대신 \"1\" 을 사용하세요"),
148 ));
149 }
150 if from >= to {
152 return Err(syn::Error::new(
153 span,
154 format!("마이그레이션 버전 역행: {name}.sql — from({from}) < to({to}) 여야 합니다"),
155 ));
156 }
157 files.push((from, to, path.to_string_lossy().replace('\\', "/")));
158 }
159 files.sort();
160
161 for w in files.windows(2) {
163 if w[0].0 == w[1].0 {
164 return Err(syn::Error::new(
165 span,
166 format!(
167 "중복 from 버전: {} — 같은 버전에서 출발하는 마이그레이션이 2개 이상입니다 ({}_{} / {}_{})",
168 w[0].0, w[0].0, w[0].1, w[1].0, w[1].1
169 ),
170 ));
171 }
172 }
173 Ok(files)
174}
175
176#[cfg(test)]
177mod lib_tests {
178 use super::scan_migration_files;
179 use proc_macro2::Span;
180
181 fn dir_with(names: &[&str]) -> tempfile::TempDir {
183 let dir = tempfile::tempdir().unwrap();
184 for n in names {
185 std::fs::write(dir.path().join(n), "-- sql").unwrap();
186 }
187 dir
188 }
189
190 #[test]
192 fn scan_ok_sorted() {
193 let d = dir_with(&["2_3_b.sql", "1_2_a.sql", "readme.txt"]);
194 let files = scan_migration_files(d.path(), Span::call_site()).unwrap();
195 let pairs: Vec<(u32, u32)> = files.iter().map(|(f, t, _)| (*f, *t)).collect();
196 assert_eq!(pairs, vec![(1, 2), (2, 3)]);
197 }
198
199 #[test]
201 fn scan_rejects_leading_zeros() {
202 let d = dir_with(&["01_2_x.sql"]);
203 let err = scan_migration_files(d.path(), Span::call_site()).unwrap_err();
204 assert!(err.to_string().contains("선행 0"), "{err}");
205 let d2 = dir_with(&["1_02_x.sql"]);
206 let err2 = scan_migration_files(d2.path(), Span::call_site()).unwrap_err();
207 assert!(err2.to_string().contains("선행 0"), "{err2}");
208 let d3 = dir_with(&["0_1_init.sql"]);
210 assert!(scan_migration_files(d3.path(), Span::call_site()).is_ok());
211 }
212
213 #[test]
215 fn scan_rejects_downgrade_and_dup_from() {
216 let d = dir_with(&["2_1_bad.sql"]);
217 assert!(scan_migration_files(d.path(), Span::call_site()).is_err());
218 let d2 = dir_with(&["1_2_a.sql", "1_3_b.sql"]);
219 let err = scan_migration_files(d2.path(), Span::call_site()).unwrap_err();
220 assert!(err.to_string().contains("중복 from"), "{err}");
221 }
222}