Skip to main content

roomrs_macros/
lib.rs

1//! roomrs-macros — procedural macros: `#[entity]`, `#[dao]`, `#[database]`.
2//!
3//! Internal crate — use the `roomrs` facade instead. Generated code
4//! references the `::roomrs` facade paths.
5
6use proc_macro::TokenStream;
7
8mod dao;
9mod database;
10mod entity;
11mod relation;
12mod schema;
13mod util;
14
15/// syn 에러 → 컴파일 에러 토큰
16fn 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/// `#[entity(table = "…")]` — 구조체를 테이블에 매핑 (명세 §5.1).
24/// 필드 속성: `#[pk(autoincrement)]` · `#[column(name/unique/index/default/ignore)]` · `#[json]`
25#[proc_macro_attribute]
26pub fn entity(args: TokenStream, input: TokenStream) -> TokenStream {
27    into_tokens(entity::expand(args.into(), input.into()))
28}
29
30/// `#[dao]` — trait DSL을 소비해 동기 DAO 구현을 생성 (명세 §5.2).
31/// 메서드 속성: `#[query("…")]` · `#[insert(on_conflict/keep_pk)]` · `#[update("…")]` · `#[delete("…")]`
32#[proc_macro_attribute]
33pub fn dao(_args: TokenStream, input: TokenStream) -> TokenStream {
34    into_tokens(dao::expand(input.into()))
35}
36
37/// `#[derive(Relation)]` — 관계 뷰 조립 코드 생성 (명세 결정 로그 7)
38#[proc_macro_derive(Relation, attributes(embedded, relation))]
39pub fn derive_relation(input: TokenStream) -> TokenStream {
40    into_tokens(relation::expand(input.into()))
41}
42
43/// `#[database(entities(…), daos(…), version = N)]` — DB 진입점 생성 (명세 §5.4)
44///
45/// # `DB_NAME` uniqueness (M-11)
46///
47/// The database name is the struct identifier converted to snake_case and
48/// prefixes the snapshot file names (`{db_name}.{version}.json`). It must
49/// be unique across every `#[database]` in the crate: two structs whose
50/// identifiers collide after conversion (e.g. `AB` and `A_b` both become
51/// `a_b`) would share snapshot files and ping-pong the stale check. The
52/// macro cannot detect collisions across modules at expansion time.
53///
54/// # Embedded snapshot growth (L-16)
55///
56/// Every committed snapshot version is compressed and embedded into the
57/// binary, so compile memory and binary size grow monotonically with the
58/// number of snapshot files. A pruning policy ("recent K versions plus
59/// migration gaps") is a candidate for a future release.
60///
61/// # Limitation: newly created snapshot files are not tracked (decision 28)
62///
63/// The macro emits an `include_bytes!` dependency for every snapshot file
64/// it reads, so *modifying* a file triggers re-expansion. A *newly created*
65/// file cannot be registered (proc-macros cannot depend on a directory),
66/// which is why the generated export test fails with a "commit and rebuild"
67/// message even on initial snapshot creation — the rebuild picks the new
68/// file up.
69#[proc_macro_attribute]
70pub fn database(args: TokenStream, input: TokenStream) -> TokenStream {
71    into_tokens(database::expand(args.into(), input.into()))
72}
73
74/// `migrations_dir!("migrations")` — 디렉터리의 `{from}_{to}_이름.sql` 파일들을
75/// 컴파일 타임에 임베드해 `Vec<Migration>` 으로 전개 (명세 §8.2).
76///
77/// 검증: `from < to`(다운그레이드 금지) + `from` 중복 금지 (L-15).
78///
79/// 한계: 기존 파일의 **내용** 변경은 `include_str!` 의존성으로 재빌드가
80/// 보장되지만, 디렉터리에 **새 파일을 추가**한 것은 감지되지 않는다 —
81/// 매크로 호출부 파일을 touch 하거나 `cargo clean -p <크레이트>` 후 빌드해야
82/// 반영된다 (proc-macro는 디렉터리 자체를 의존성으로 등록할 수 없다).
83#[proc_macro]
84pub fn migrations_dir(input: TokenStream) -> TokenStream {
85    into_tokens(migrations_dir_impl(input.into()))
86}
87
88/// migrations_dir! 구현 — 파일명 규칙 검증 + include_str 임베드
89fn 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
103/// 마이그레이션 디렉터리 스캔 — `{from}_{to}_이름.sql` 규칙 검증.
104/// from < to(다운그레이드 금지)·from 중복 금지·선행 0 금지 (L-15)
105fn 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    // (from, to, 절대경로) 수집
120    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        // 선행 0 금지 — "01"과 "1"이 같은 버전으로 중복될 수 있다.
143        // 스냅샷 파일명 정책과 동일 규칙 (L-15)
144        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        // 다운그레이드/자기참조 금지 (L-15)
151        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    // 같은 from 에서 출발하는 마이그레이션 2개 = 체인 모호 (L-15)
162    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    /// 지정 이름 파일들이 있는 임시 디렉터리 생성
182    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    /// 정상 스캔 — from 오름차순 정렬
191    #[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    /// 선행 0 = 에러 — 스냅샷 파일명 정책과 일관 (L-15)
200    #[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        // "0_1_init.sql" 은 유효 — 0 자체는 선행 0이 아니다
209        let d3 = dir_with(&["0_1_init.sql"]);
210        assert!(scan_migration_files(d3.path(), Span::call_site()).is_ok());
211    }
212
213    /// 버전 역행·중복 from = 에러 (기존 L-15 규칙 회귀 방지)
214    #[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}