use proc_macro::TokenStream;
use quote::quote;
use syn::parse::{Parse, ParseStream};
use syn::punctuated::Punctuated;
use syn::{parse_macro_input, Expr, ExprLit, FnArg, ItemFn, Lit, MetaNameValue, Token};
struct WarmTestArgs {
migrations: Option<String>,
database_url_env: Option<String>,
}
impl Parse for WarmTestArgs {
fn parse(input: ParseStream) -> syn::Result<Self> {
let mut migrations = None;
let mut database_url_env = None;
let pairs = Punctuated::<MetaNameValue, Token![,]>::parse_terminated(input)?;
for pair in pairs {
let key = pair
.path
.get_ident()
.map(|i| i.to_string())
.unwrap_or_default();
let value = match &pair.value {
Expr::Lit(ExprLit {
lit: Lit::Str(s), ..
}) => s.value(),
other => {
return Err(syn::Error::new_spanned(
other,
"expected a string literal, e.g. migrations = \"./migrations\"",
))
}
};
match key.as_str() {
"migrations" => migrations = Some(value),
"database_url_env" => database_url_env = Some(value),
other => {
return Err(syn::Error::new_spanned(
&pair.path,
format!(
"unknown #[warm_test] argument `{other}` \
(expected `migrations` or `database_url_env`)"
),
))
}
}
}
Ok(WarmTestArgs {
migrations,
database_url_env,
})
}
}
#[proc_macro_attribute]
pub fn warm_test(attr: TokenStream, item: TokenStream) -> TokenStream {
let args = parse_macro_input!(attr as WarmTestArgs);
let input = parse_macro_input!(item as ItemFn);
let attrs = &input.attrs;
let vis = &input.vis;
let sig = &input.sig;
let block = &input.block;
let fn_name = &sig.ident;
if sig.asyncness.is_none() {
return syn::Error::new_spanned(sig, "#[warm_test] functions must be `async fn`")
.to_compile_error()
.into();
}
let pool_ident = match sig.inputs.iter().collect::<Vec<_>>().as_slice() {
[FnArg::Typed(pat_type)] => &pat_type.pat,
_ => {
return syn::Error::new_spanned(
&sig.inputs,
"#[warm_test] functions must take exactly one argument: \
`async fn my_test(pool: sqlx::PgPool)`",
)
.to_compile_error()
.into()
}
};
let migrations_path = args.migrations.unwrap_or_else(|| "./migrations".to_string());
let database_url_env = args
.database_url_env
.unwrap_or_else(|| "DATABASE_URL".to_string());
let expanded = quote! {
#(#attrs)*
#[::tokio::test]
#vis async fn #fn_name() {
let __warmpool_database_url = ::std::env::var(#database_url_env)
.unwrap_or_else(|_| panic!(
"warmpool: environment variable `{}` is not set", #database_url_env
));
let __warmpool_connect_options: ::warmpool::__private::PgConnectOptions =
__warmpool_database_url.parse().unwrap_or_else(|e| panic!(
"warmpool: `{}` is not a valid Postgres connection string: {}",
#database_url_env, e
));
let __warmpool_template = ::warmpool::TemplatePool::builder(__warmpool_connect_options)
.migrations_from(#migrations_path)
.build()
.await
.expect("warmpool: failed to build template pool");
let __warmpool_test_db = __warmpool_template
.create_test_database()
.await
.expect("warmpool: failed to clone test database from template");
let #pool_ident = __warmpool_test_db.pool().clone();
let __warmpool_result = ::warmpool::__private::FutureExt::catch_unwind(
::warmpool::__private::AssertUnwindSafe(async #block)
)
.await;
if let Err(__warmpool_drop_err) = __warmpool_test_db.drop_database().await {
eprintln!("warmpool: failed to drop test database: {__warmpool_drop_err}");
}
if let Err(__warmpool_panic) = __warmpool_result {
::std::panic::resume_unwind(__warmpool_panic);
}
}
};
expanded.into()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_no_args() {
let args: WarmTestArgs = syn::parse_str("").unwrap();
assert!(args.migrations.is_none());
assert!(args.database_url_env.is_none());
}
#[test]
fn parse_migrations_arg() {
let args: WarmTestArgs = syn::parse_str(r#"migrations = "./migrations""#).unwrap();
assert_eq!(args.migrations.as_deref(), Some("./migrations"));
assert!(args.database_url_env.is_none());
}
#[test]
fn parse_database_url_env_arg() {
let args: WarmTestArgs = syn::parse_str(r#"database_url_env = "TEST_DATABASE_URL""#).unwrap();
assert!(args.migrations.is_none());
assert_eq!(args.database_url_env.as_deref(), Some("TEST_DATABASE_URL"));
}
#[test]
fn parse_both_args() {
let args: WarmTestArgs = syn::parse_str(
r#"migrations = "./migrations", database_url_env = "TEST_DATABASE_URL""#,
)
.unwrap();
assert_eq!(args.migrations.as_deref(), Some("./migrations"));
assert_eq!(args.database_url_env.as_deref(), Some("TEST_DATABASE_URL"));
}
#[test]
fn reject_unknown_argument() {
assert!(syn::parse_str::<WarmTestArgs>(r#"foo = \"bar\""#).is_err());
}
#[test]
fn reject_non_string_literal_argument() {
assert!(syn::parse_str::<WarmTestArgs>(r#"migrations = 42"#).is_err());
}
}