Skip to main content

vespertide_macro/
lib.rs

1// MigrationOptions and MigrationError are now in vespertide-core
2// Test-only env mutation uses `std::env::set_var` / `remove_var`, which became
3// `unsafe` in Rust 2024. All sites are serialized via `serial_test::serial`.
4#![cfg_attr(
5    test,
6    expect(
7        unsafe_code,
8        reason = "serial_test serializes Rust 2024 std::env var setters used by macro tests"
9    )
10)]
11//! Procedural macro [`vespertide_migration!`] for zero-runtime-cost
12//! database migrations.
13//!
14//! Loads JSON/YAML migration files at compile time, emits per-backend SQL
15//! as static strings, runs them via a small runtime helper.
16
17use std::env;
18use std::fmt::Write;
19use std::path::PathBuf;
20
21use proc_macro::TokenStream;
22#[cfg(not(test))]
23use proc_macro_crate::{FoundCrate, crate_name};
24use proc_macro2::{Span, TokenStream as TokenStream2};
25use quote::quote;
26use syn::parse::{Parse, ParseStream};
27use syn::{Ident, Token};
28use vespertide_loader::{load_config_or_default, load_migrations_at_compile_time};
29use vespertide_planner::apply_action;
30use vespertide_query::{DatabaseBackend, build_plan_queries};
31
32struct MacroInput {
33    pool: proc_macro2::TokenStream,
34    version_table: Option<String>,
35    verbose: bool,
36}
37
38impl Parse for MacroInput {
39    fn parse(input: ParseStream) -> syn::Result<Self> {
40        let mut pool_tokens = Vec::new();
41        while !input.is_empty() && !input.peek(Token![,]) {
42            pool_tokens.push(input.parse::<proc_macro2::TokenTree>()?);
43        }
44        let pool: proc_macro2::TokenStream = pool_tokens.into_iter().collect();
45        let mut version_table = None;
46        let mut verbose = false;
47
48        while !input.is_empty() {
49            input.parse::<Token![,]>()?;
50            if input.is_empty() {
51                break;
52            }
53
54            let key: Ident = input.parse()?;
55            if key == "version_table" {
56                input.parse::<Token![=]>()?;
57                let value: syn::LitStr = input.parse()?;
58                version_table = Some(value.value());
59            } else if key == "verbose" {
60                verbose = true;
61            } else {
62                return Err(syn::Error::new(
63                    key.span(),
64                    "unsupported option for vespertide_migration!",
65                ));
66            }
67        }
68
69        Ok(MacroInput {
70            pool,
71            version_table,
72            verbose,
73        })
74    }
75}
76
77/// Returns the root path of the facade `vespertide` crate.
78///
79/// `proc-macro-crate` makes `vespertide_migration!` work when downstream crates
80/// rename the facade dependency in `Cargo.toml`.
81#[cfg(not(test))]
82fn vespertide_crate_path() -> syn::Result<TokenStream2> {
83    match crate_name("vespertide").map_err(|e| {
84        syn::Error::new(
85            Span::call_site(),
86            format!("vespertide crate not found in Cargo.toml dependencies: {e}"),
87        )
88    })? {
89        FoundCrate::Itself => Ok(quote! { crate }),
90        FoundCrate::Name(name) => {
91            let ident = Ident::new(&name, Span::call_site());
92            Ok(quote! { ::#ident })
93        }
94    }
95}
96
97#[cfg(test)]
98#[expect(
99    clippy::unnecessary_wraps,
100    reason = "test stub mirrors the production syn::Result<TokenStream2> helper so macro codegen call sites stay uniform"
101)]
102fn vespertide_crate_path() -> syn::Result<TokenStream2> {
103    Ok(quote! { ::vespertide })
104}
105
106/// Generated migration block with raw SQL strings for string-based codegen.
107#[derive(Debug)]
108pub(crate) struct MigrationBlock {
109    /// Migration version number
110    pub version: u32,
111    /// Migration ID for validation
112    pub migration_id: String,
113    /// Migration comment (for verbose logging)
114    pub comment: String,
115    /// `PostgreSQL` SQL statements
116    pub pg_sqls: Vec<String>,
117    /// `MySQL` SQL statements
118    pub mysql_sqls: Vec<String>,
119    /// `SQLite` SQL statements
120    pub sqlite_sqls: Vec<String>,
121}
122
123pub(crate) fn build_migration_block(
124    migration: &vespertide_core::MigrationPlan,
125    baseline_schema: &mut Vec<vespertide_core::TableDef>,
126) -> Result<MigrationBlock, String> {
127    let version = migration.version;
128
129    // Use the current baseline schema (from all previous migrations)
130    let queries = build_plan_queries(migration, baseline_schema)
131        .map_err(|e| format!("Failed to build queries for migration version {version}: {e}"))?;
132
133    // Update baseline schema incrementally by applying each action
134    for action in &migration.actions {
135        let _ = apply_action(baseline_schema, action);
136    }
137
138    // Flatten all SQL into per-backend string arrays
139    // perf: pre-count backend SQL statements so macro expansion avoids Vec growth reallocations.
140    let mut pg_sqls = Vec::with_capacity(queries.iter().map(|q| q.postgres.len()).sum());
141    let mut mysql_sqls = Vec::with_capacity(queries.iter().map(|q| q.mysql.len()).sum());
142    let mut sqlite_sqls = Vec::with_capacity(queries.iter().map(|q| q.sqlite.len()).sum());
143
144    for q in &queries {
145        for stmt in &q.postgres {
146            pg_sqls.push(stmt.build(DatabaseBackend::Postgres));
147        }
148        for stmt in &q.mysql {
149            mysql_sqls.push(stmt.build(DatabaseBackend::MySql));
150        }
151        for stmt in &q.sqlite {
152            sqlite_sqls.push(stmt.build(DatabaseBackend::Sqlite));
153        }
154    }
155
156    let comment = migration.comment.as_deref().unwrap_or("").to_string();
157
158    Ok(MigrationBlock {
159        version,
160        migration_id: migration.id.clone(),
161        comment,
162        pg_sqls,
163        mysql_sqls,
164        sqlite_sqls,
165    })
166}
167
168fn generate_migration_code(
169    pool: &TokenStream2,
170    version_table: &str,
171    migration_blocks: &[MigrationBlock],
172    verbose: bool,
173    lock_timeout_ms: Option<u64>,
174    statement_timeout_ms: Option<u64>,
175) -> syn::Result<TokenStream2> {
176    // Build entire output as a String, parse once at the end.
177    // This avoids per-token IPC overhead from quote! (see rust-lang/rust#65080).
178    let pool_str = pool.to_string();
179    let krate = vespertide_crate_path()?;
180    let krate_str = krate.to_string();
181    let mut code = String::with_capacity(1_048_576);
182
183    code.push_str("{\n");
184
185    // Emit compact SQL blobs (outside async block for zero runtime cost).
186    for b in migration_blocks {
187        write_sql_blob(&mut code, &format!("__V{}_PG", b.version), &b.pg_sqls)?;
188        write_sql_blob(&mut code, &format!("__V{}_MYSQL", b.version), &b.mysql_sqls)?;
189        write_sql_blob(
190            &mut code,
191            &format!("__V{}_SQLITE", b.version),
192            &b.sqlite_sqls,
193        )?;
194    }
195
196    writeln!(
197        code,
198        "static __VESPERTIDE_MIGRATIONS: &[{krate_str}::runtime::EmbeddedMigration] = &["
199    )
200    .map_err(write_codegen_error)?;
201    for b in migration_blocks {
202        writeln!(code, "{krate_str}::runtime::EmbeddedMigration::new({}u32, {:?}, {:?}, __V{}_PG, __V{}_MYSQL, __V{}_SQLITE),", b.version, b.migration_id, b.comment, b.version, b.version, b.version).map_err(write_codegen_error)?;
203    }
204    code.push_str("];\n");
205
206    code.push_str("async {\n");
207    writeln!(code, "let __pool = &{pool_str};").map_err(write_codegen_error)?;
208    // F94: when a timeout is configured, route through the options-aware
209    // runtime so backend-appropriate lock/statement timeouts are applied.
210    // Otherwise emit the original call unchanged (zero codegen churn).
211    if lock_timeout_ms.is_none() && statement_timeout_ms.is_none() {
212        writeln!(code, "{krate_str}::runtime::run_embedded_migrations(__pool, {version_table:?}, {verbose}, __VESPERTIDE_MIGRATIONS).await").map_err(write_codegen_error)?;
213    } else {
214        let lock = render_opt_u64(lock_timeout_ms);
215        let stmt = render_opt_u64(statement_timeout_ms);
216        writeln!(code, "{krate_str}::runtime::run_embedded_migrations_with_options(__pool, {version_table:?}, {verbose}, __VESPERTIDE_MIGRATIONS, {krate_str}::runtime::MigrationRuntimeOptions::from_millis({lock}, {stmt})).await").map_err(write_codegen_error)?;
217    }
218    code.push_str("}\n"); // async block
219    code.push_str("}\n"); // outer block
220
221    // Single parse — ONE IPC call to rustc tokenizer
222    code.parse::<TokenStream2>().map_err(|e| {
223        syn::Error::new(
224            Span::call_site(),
225            format!("vespertide_migration codegen produced invalid Rust: {e}"),
226        )
227    })
228}
229
230/// Render an `Option<u64>` as Rust source for codegen (`Some(5000u64)` / `None`).
231fn render_opt_u64(value: Option<u64>) -> String {
232    value.map_or_else(|| "None".to_string(), |v| format!("Some({v}u64)"))
233}
234
235fn write_codegen_error(e: std::fmt::Error) -> syn::Error {
236    syn::Error::new(
237        Span::call_site(),
238        format!("vespertide_migration codegen failed while writing Rust: {e}"),
239    )
240}
241
242/// Write a compact SQL blob declaration into the code string.
243fn write_sql_blob(code: &mut String, ident: &str, sqls: &[String]) -> syn::Result<()> {
244    let mut blob = String::new();
245    for sql in sqls {
246        blob.push_str(sql);
247        blob.push('\0');
248    }
249
250    writeln!(code, "static {ident}: &str = {blob:?};").map_err(write_codegen_error)
251}
252
253/// Inner implementation that works with `proc_macro2::TokenStream` for testability.
254pub(crate) fn vespertide_migration_impl(input: TokenStream2) -> syn::Result<TokenStream2> {
255    let input: MacroInput = syn::parse2(input)?;
256    let pool = &input.pool;
257    let verbose = input.verbose;
258
259    // Get project root from CARGO_MANIFEST_DIR (same as load_migrations_at_compile_time)
260    let project_root = match env::var("CARGO_MANIFEST_DIR") {
261        Ok(dir) => Some(PathBuf::from(dir)),
262        Err(_) => None,
263    };
264
265    // Load config to get prefix
266    let config = match load_config_or_default(project_root) {
267        Ok(config) => config,
268        Err(e) => {
269            return Err(syn::Error::new(
270                proc_macro2::Span::call_site(),
271                format!("Failed to load config at compile time: {e}"),
272            ));
273        }
274    };
275    let prefix = config.prefix();
276
277    // Apply prefix to version_table if not explicitly provided
278    let version_table = input.version_table.map_or_else(
279        || config.apply_prefix("vespertide_version"),
280        |vt| config.apply_prefix(&vt),
281    );
282
283    // Load migration files and build SQL at compile time
284    let migrations = match load_migrations_at_compile_time() {
285        Ok(migrations) => migrations,
286        Err(e) => {
287            return Err(syn::Error::new(
288                proc_macro2::Span::call_site(),
289                format!("Failed to load migrations at compile time: {e}"),
290            ));
291        }
292    };
293    // Apply prefix to migrations and build SQL using incremental baseline schema
294    let mut baseline_schema = Vec::new();
295    let mut migration_blocks = Vec::new();
296
297    for migration in &migrations {
298        // Apply prefix to migration table names
299        let prefixed_migration = migration.clone().with_prefix(prefix);
300        match build_migration_block(&prefixed_migration, &mut baseline_schema) {
301            Ok(block) => migration_blocks.push(block),
302            Err(e) => {
303                return Err(syn::Error::new(proc_macro2::Span::call_site(), e));
304            }
305        }
306    }
307
308    generate_migration_code(
309        pool,
310        &version_table,
311        &migration_blocks,
312        verbose,
313        config.lock_timeout_ms(),
314        config.statement_timeout_ms(),
315    )
316}
317
318/// Zero-runtime migration entry point.
319///
320/// Irreducible proc-macro shell: the `#[proc_macro]` attribute requires the
321/// signature `fn(TokenStream) -> TokenStream` (NOT `TokenStream2`), so this
322/// wrapper can only convert types and dispatch to
323/// `vespertide_migration_impl` (which is fully unit-tested via
324/// `proc_macro2::TokenStream` in `tests/mod.rs`). Proc-macro entry fns
325/// cannot run in unit tests (they require the compiler's proc-macro server),
326/// so this shell is excluded from coverage.
327#[cfg(not(tarpaulin_include))]
328#[proc_macro]
329pub fn vespertide_migration(input: TokenStream) -> TokenStream {
330    let input2 = TokenStream2::from(input);
331    match vespertide_migration_impl(input2) {
332        Ok(ts) => ts.into(),
333        Err(e) => e.to_compile_error().into(),
334    }
335}
336
337#[cfg(test)]
338mod tests;