use proc_macro_crate::{FoundCrate, crate_name};
use proc_macro2::{Span, TokenStream, TokenTree};
use quote::{ToTokens, format_ident, quote, quote_spanned};
use syn::ext::IdentExt;
use syn::parse::Parser;
use syn::punctuated::Punctuated;
use syn::spanned::Spanned;
use syn::visit::Visit;
use syn::{AttrStyle, Attribute, FnArg, Ident, ItemFn, Meta, ReturnType, Token, Type};
use crate::attrs::{AttrMode, JobAttrs, ResultTtl, cron_parse_options, split_leading_str};
enum Mode {
Job,
Cron { schedule: String },
}
impl Mode {
fn attr_name(&self) -> &'static str {
match self {
Mode::Job => "#[pgqueue::job]",
Mode::Cron { .. } => "#[pgqueue::cron]",
}
}
fn attr_mode(&self) -> AttrMode {
match self {
Mode::Job => AttrMode::Job,
Mode::Cron { .. } => AttrMode::Cron,
}
}
}
pub(crate) fn expand_job(attr: TokenStream, item: TokenStream) -> syn::Result<TokenStream> {
let attrs = JobAttrs::parse(attr, AttrMode::Job)?;
expand(Mode::Job, attrs, item)
}
pub(crate) fn expand_cron(attr: TokenStream, item: TokenStream) -> syn::Result<TokenStream> {
let (expr, rest) = split_leading_str(attr)?;
let schedule = expr.value();
if schedule.split_ascii_whitespace().count() != 5 {
return Err(syn::Error::new_spanned(
&expr,
format!("invalid cron expression {schedule:?}: expected 5 whitespace-separated fields"),
));
}
let crontab = cronexpr::parse_crontab_with(&schedule, cron_parse_options())
.map_err(|e| syn::Error::new_spanned(&expr, format!("invalid cron expression {schedule:?}: {e}")))?;
reject_a_schedule_that_never_fires(&expr, &schedule, &crontab)?;
let attrs = JobAttrs::parse(rest, AttrMode::Cron)?;
expand(Mode::Cron { schedule }, attrs, item)
}
const CRON_CYCLE_SECONDS: i64 = 146_097 * 86_400;
const CRON_SEARCH_STEP_SECONDS: i64 = 3 * 365 * 86_400;
fn reject_a_schedule_that_never_fires(
expr: &syn::LitStr,
schedule: &str,
crontab: &cronexpr::Crontab,
) -> syn::Result<()> {
let mut cursor: i64 = 0;
while cursor <= CRON_CYCLE_SECONDS {
let fires =
cronexpr::MakeTimestamp::from_second(cursor).is_ok_and(|timestamp| crontab.find_next(timestamp).is_ok());
if fires {
return Ok(());
}
cursor += CRON_SEARCH_STEP_SECONDS;
}
Err(syn::Error::new_spanned(expr, format!("invalid cron expression {schedule:?}: schedule never fires")))
}
fn expand(mode: Mode, attrs: JobAttrs, item: TokenStream) -> syn::Result<TokenStream> {
let func: ItemFn = syn::parse2(item)?;
validate(&mode, &func)?;
let runtime = runtime_crate_path();
let vis = &func.vis;
let ident = &func.sig.ident;
let name = job_name(&mode, &attrs, ident)?;
let ItemAttrs { api_attrs, lint_attrs, deprecated } = ItemAttrs::split(&func.attrs);
let mut types = Vec::new();
for input in &func.sig.inputs {
match input {
FnArg::Typed(pat) => types.push((*pat.ty).clone()),
FnArg::Receiver(receiver) => {
return Err(syn::Error::new_spanned(
receiver,
format!("{} functions cannot take self", mode.attr_name()),
));
}
}
}
let (payload_ty, extractor_tys): (Type, &[Type]) = match mode {
Mode::Job => (types[0].clone(), &types[1..]),
Mode::Cron { .. } => (syn::parse_quote!(()), &types[..]),
};
let ret_ty: Type = match &func.sig.output {
ReturnType::Default => syn::parse_quote!(()),
ReturnType::Type(_, ty) => (**ty).clone(),
};
let output_ty = quote_spanned! {ret_ty.span()=>
<#ret_ty as #runtime::__private::IntoJobResult>::Output
};
let result_binding = Ident::new("__result", ret_ty.span());
let encode_result = quote_spanned! {ret_ty.span()=>
#runtime::__private::encode_result(#result_binding)
};
let call_names: Vec<_> = (0..types.len()).map(|i| format_ident!("__arg{i}", span = Span::call_site())).collect();
let call_params: Vec<_> = call_names.iter().zip(&types).map(|(name, ty)| quote!(#name: #ty)).collect();
let mut inner = func.clone();
inner.vis = syn::Visibility::Inherited;
inner.attrs.retain(|attr| {
let path = attr.path();
if path.is_ident("deprecated") {
return false;
}
!(path.is_ident("doc") && matches!(attr.style, AttrStyle::Outer))
});
for attr in &mut inner.attrs {
if attr.path().is_ident("expect")
&& let Meta::List(list) = &attr.meta
{
let lints = &list.tokens;
*attr = match attr.style {
AttrStyle::Outer => syn::parse_quote!(#[allow(#lints)]),
AttrStyle::Inner(_) => syn::parse_quote!(#![allow(#lints)]),
};
}
}
let config_setters = config_setters(&attrs, &runtime);
let config_mut = if config_setters.is_empty() { quote!() } else { quote!(mut) };
let duration_asserts = duration_bound_assertions(&attrs, &runtime);
let extractor_names: Vec<_> =
(0..extractor_tys.len()).map(|i| format_ident!("__ext{i}", span = Span::call_site())).collect();
let extractions: Vec<_> = extractor_names
.iter()
.zip(extractor_tys)
.map(|(name, ty)| {
quote! {
let #name = <#ty as #runtime::FromJobContext>::from_context(&__ctx)?;
}
})
.collect();
let (job_ctor, invoke, definition_impl) = match &mode {
Mode::Job => (
quote! {
#vis fn job(__args: #payload_ty) -> #runtime::JobBuilder<#ident> {
#runtime::JobBuilder::new(__args)
}
},
quote!(#ident(__args #(, #extractor_names)*)),
quote! {
impl #runtime::JobDefinition for #ident {}
},
),
Mode::Cron { schedule } => {
let revision = attrs.revision.unwrap_or(0);
(
quote! {
#vis fn job() -> #runtime::JobBuilder<#ident> {
#runtime::JobBuilder::new(())
}
},
quote!(#ident(#(#extractor_names),*)),
quote! {
impl #runtime::CronDefinition for #ident {
const SCHEDULE: &'static str = #schedule;
const CRON_REVISION: u64 = #revision;
}
},
)
}
};
let decode_args = match &mode {
Mode::Job => {
quote! {
let __args =
#runtime::__private::decode_payload::<#payload_ty>(&__ctx.job().payload)?;
}
}
Mode::Cron { .. } => quote!(),
};
let args_assoc = quote_spanned! {payload_ty.span()=> type Args = #payload_ty; };
let output_assoc = quote_spanned! {ret_ty.span()=> type Output = #output_ty; };
let allow_deprecated = if deprecated { quote!(#[allow(deprecated)]) } else { quote!() };
let impl_attrs = quote! {
#(#lint_attrs)*
#allow_deprecated
};
let boxed = runtime
.clone()
.into_iter()
.map(|mut token| {
token.set_span(func.block.span());
token
})
.collect::<TokenStream>();
let erased_future = quote_spanned! {func.block.span()=>
#boxed::__private::Box::pin(async move {
#decode_args
#(#extractions)*
let #result_binding: #ret_ty = #invoke.await;
#encode_result
})
};
let handler_binding = quote!(let __handler = #ident;);
let struct_span = ident.span().resolved_at(Span::call_site());
let unraw = ident.unraw().to_string();
let struct_ident = if unraw.is_ascii() {
escaped_ident(&unraw, struct_span)
} else {
let mut struct_ident = ident.clone();
struct_ident.set_span(struct_span);
struct_ident
};
Ok(quote! {
#(#api_attrs)*
#[derive(::core::clone::Clone, ::core::marker::Copy, ::core::fmt::Debug)]
#vis struct #struct_ident;
#(#duration_asserts)*
const _: () = {
#inner
#impl_attrs
impl #ident {
#job_ctor
#vis async fn call(#(#call_params),*) -> #ret_ty {
#handler_binding
__handler(#(#call_names),*).await
}
}
#impl_attrs
impl #runtime::JobType for #ident {
#args_assoc
#output_assoc
const NAME: &'static str = #name;
fn config() -> #runtime::JobConfig {
let #config_mut __config =
<#runtime::JobConfig as ::core::default::Default>::default();
#(#config_setters)*
__config
}
fn erased() -> #runtime::__private::TypeErasedJobHandler {
#runtime::__private::TypeErasedJobHandler::new::<Self>(|__ctx| {
#erased_future
})
}
}
#impl_attrs
#definition_impl
};
})
}
struct ItemAttrs {
api_attrs: Vec<TokenStream>,
lint_attrs: Vec<TokenStream>,
deprecated: bool,
}
impl ItemAttrs {
fn split(attrs: &[Attribute]) -> Self {
let mut split = ItemAttrs { api_attrs: Vec::new(), lint_attrs: Vec::new(), deprecated: false };
for attr in attrs {
let mut attr = attr.clone();
attr.style = AttrStyle::Outer;
let path = attr.path();
if path.is_ident("doc") {
split.api_attrs.push(attr.to_token_stream());
} else if path.is_ident("deprecated") {
split.deprecated = true;
split.api_attrs.push(attr.to_token_stream());
} else if let Some(copy) = struct_lint_attr(&attr) {
split.api_attrs.push(copy.clone());
split.lint_attrs.push(copy);
}
}
split
}
}
fn cfg_attr_wraps_cfg(meta: &Meta) -> bool {
if !meta.path().is_ident("cfg_attr") {
return false;
}
let Meta::List(list) = meta else {
return false;
};
let mut tokens = list.tokens.clone().into_iter();
let predicate =
tokens.by_ref().take_while(|token| !matches!(token, TokenTree::Punct(punct) if punct.as_char() == ','));
if predicate.count() == 0 {
return false;
}
let Ok(attributes) = Punctuated::<Meta, Token![,]>::parse_terminated.parse2(tokens.collect()) else {
return false;
};
attributes.iter().any(|inner| inner.path().is_ident("cfg") || cfg_attr_wraps_cfg(inner))
}
fn struct_lint_attr(attr: &Attribute) -> Option<TokenStream> {
let path = attr.path();
for lint_level in ["allow", "warn", "deny"] {
if path.is_ident(lint_level) {
return Some(attr.to_token_stream());
}
}
let lowered = if path.is_ident("expect") {
quote!(allow)
} else if path.is_ident("forbid") {
quote!(deny)
} else {
return None;
};
if let Meta::List(list) = &attr.meta {
let lints = &list.tokens;
return Some(quote!(#[#lowered(#lints)]));
}
None
}
fn job_name(mode: &Mode, attrs: &JobAttrs, ident: &Ident) -> syn::Result<String> {
if let Some(name) = &attrs.name {
return Ok(name.clone());
}
let max = mode.attr_mode().max_name_bytes();
let name = ident.unraw().to_string();
if name.len() > max {
return Err(syn::Error::new_spanned(
ident,
format!(
"job name must be 1..={max} bytes and contain no NUL; this function's \
name is {} bytes, so pass a shorter `name = \"...\"`",
name.len()
),
));
}
Ok(name)
}
fn runtime_crate_path() -> TokenStream {
match crate_name("pgqueue") {
Ok(found) => found_crate_path(found),
Err(_) => quote!(::pgqueue),
}
}
fn found_crate_path(found: FoundCrate) -> TokenStream {
match found {
FoundCrate::Name(name) => {
let ident = escaped_ident(&name, Span::call_site());
quote!(::#ident)
}
FoundCrate::Itself => quote!(::pgqueue),
}
}
fn escaped_ident(name: &str, span: Span) -> Ident {
if matches!(name, "crate" | "self" | "super" | "Self" | "_" | "$crate") {
Ident::new(name, span)
} else {
Ident::new_raw(name, span)
}
}
fn validate(mode: &Mode, func: &ItemFn) -> syn::Result<()> {
if func.sig.asyncness.is_none() {
return Err(syn::Error::new_spanned(
func.sig.fn_token,
format!("{} functions must be async", mode.attr_name()),
));
}
if let Some(reserved) = reserved_expansion_name(&func.sig.ident) {
return Err(syn::Error::new_spanned(
&func.sig.ident,
format!(
"`{reserved}` is reserved by the {} expansion; rename the function \
and keep the stored job name with `name = \"{reserved}\"` if it matters",
mode.attr_name()
),
));
}
if let syn::Safety::Unsafe(unsafety) = &func.sig.safety {
return Err(syn::Error::new_spanned(unsafety, format!("{} functions cannot be unsafe", mode.attr_name())));
}
if let Some(abi) = &func.sig.abi {
return Err(syn::Error::new_spanned(abi, format!("{} functions cannot declare an ABI", mode.attr_name())));
}
if !func.sig.generics.params.is_empty() {
return Err(syn::Error::new_spanned(
&func.sig.generics,
format!("{} functions cannot be generic", mode.attr_name()),
));
}
if let Some(where_clause) = &func.sig.generics.where_clause
&& !where_clause.predicates.is_empty()
{
return Err(syn::Error::new_spanned(where_clause, format!("{} functions cannot be generic", mode.attr_name())));
}
for (position, input) in func.sig.inputs.iter().enumerate() {
let FnArg::Typed(argument) = input else {
continue;
};
if let Some(cfg) = argument
.attrs
.iter()
.find(|attribute| attribute.path().is_ident("cfg") || cfg_attr_wraps_cfg(&attribute.meta))
{
return Err(syn::Error::new_spanned(
cfg,
format!(
"{} functions cannot gate a parameter with `#[cfg]`; the attribute \
is expanded before `cfg` is evaluated, so the parameter is always \
part of the signature it reads",
mode.attr_name()
),
));
}
if is_impl_trait(&argument.ty) {
return Err(syn::Error::new_spanned(
&argument.ty,
format!(
"{} functions cannot use `impl Trait` in argument position; \
use a concrete type",
mode.attr_name()
),
));
}
if matches!(mode, Mode::Job)
&& position == 0
&& let Some(extractor) = leading_extractor(&argument.ty)
{
return Err(syn::Error::new_spanned(
&argument.ty,
format!(
"the first parameter of a #[pgqueue::job] function is its payload; for a job without one, \
write `_: ()` or `(): ()` first and take the `{extractor}` after it, or, if `{extractor}` is a \
payload type of your own, write it through its module path, such as `crate::{extractor}`"
),
));
}
if matches!(mode, Mode::Job)
&& position == 0
&& let Some(borrow) = borrowed_part(&argument.ty)
{
return Err(syn::Error::new_spanned(
&borrow.tokens,
format!(
"{} functions cannot take a borrowed payload; it is deserialized \
from storage, so it must be owned{}",
mode.attr_name(),
borrow.hint()
),
));
}
}
if let ReturnType::Type(_, ty) = &func.sig.output
&& is_impl_trait(ty)
{
return Err(syn::Error::new_spanned(
ty,
format!(
"{} functions cannot use `impl Trait` in return position; \
use a concrete type",
mode.attr_name()
),
));
}
if let ReturnType::Type(_, ty) = &func.sig.output
&& let Some(borrow) = borrowed_part(ty)
{
return Err(syn::Error::new_spanned(
&borrow.tokens,
format!(
"{} functions cannot return a borrowed type; the output is serialized \
into the job row and read back, so it must be owned{}",
mode.attr_name(),
borrow.hint()
),
));
}
if matches!(mode, Mode::Job) && func.sig.inputs.is_empty() {
return Err(syn::Error::new_spanned(
&func.sig.ident,
"#[pgqueue::job] functions need a payload as their first parameter; \
use `_: ()` for jobs without one, or `(): ()`, which `clippy::ignored_unit_patterns` accepts",
));
}
if let Some(variadic) = &func.sig.variadic {
return Err(syn::Error::new_spanned(variadic, format!("{} functions cannot be variadic", mode.attr_name())));
}
Ok(())
}
fn leading_extractor(ty: &Type) -> Option<&'static str> {
let Type::Path(path) = without_type_wrappers(ty) else {
return None;
};
if path.qself.is_some() {
return None;
}
let segments = &path.path.segments;
let last = segments.last()?;
let extractor = match (last.ident.to_string().as_str(), &last.arguments) {
("JobContext", syn::PathArguments::None) => "JobContext",
("JobState", syn::PathArguments::AngleBracketed(_)) => "JobState",
_ => return None,
};
let names_pgqueue = match segments.first() {
Some(first) if segments.len() > 1 => names_runtime_crate(&first.ident),
_ => path.path.leading_colon.is_none(),
};
names_pgqueue.then_some(extractor)
}
fn names_runtime_crate(segment: &Ident) -> bool {
let name = segment.unraw().to_string();
name == "pgqueue" || matches!(crate_name("pgqueue"), Ok(FoundCrate::Name(dependency)) if dependency == name)
}
fn reserved_expansion_name(ident: &syn::Ident) -> Option<String> {
let raw = ident.to_string();
let name = raw.strip_prefix("r#").unwrap_or(&raw);
let numbered = |prefix: &str| {
name.strip_prefix(prefix).is_some_and(|index| !index.is_empty() && index.bytes().all(|b| b.is_ascii_digit()))
};
let collides = matches!(name, "__config" | "__args" | "__ctx" | "__result" | "__handler")
|| numbered("__ext")
|| numbered("__arg");
collides.then(|| name.to_string())
}
fn without_type_wrappers(mut ty: &Type) -> &Type {
loop {
match ty {
Type::Group(group) => ty = &group.elem,
Type::Paren(paren) => ty = &paren.elem,
_ => return ty,
}
}
}
struct Borrow {
tokens: TokenStream,
cow_target: Option<String>,
}
impl Borrow {
fn hint(&self) -> String {
match &self.cow_target {
Some(target) => format!(
"; a `Cow` written without a lifetime borrows, so write `Cow<'static, {target}>`, or \
`std::borrow::Cow<'static, {target}>` where `Cow` names an alias"
),
None => String::new(),
}
}
}
fn borrowed_part(ty: &Type) -> Option<Borrow> {
struct ElidedLifetimeVisitor {
found: Option<Borrow>,
}
impl<'ast> Visit<'ast> for ElidedLifetimeVisitor {
fn visit_type_reference(&mut self, node: &'ast syn::TypeReference) {
if self.found.is_some() {
return;
}
if node.lifetime.is_none() {
self.found = Some(Borrow { tokens: node.to_token_stream(), cow_target: None });
return;
}
syn::visit::visit_type_reference(self, node);
}
fn visit_lifetime(&mut self, node: &'ast syn::Lifetime) {
if self.found.is_none() && node.ident == "_" {
self.found = Some(Borrow { tokens: node.to_token_stream(), cow_target: None });
}
}
fn visit_type_path(&mut self, node: &'ast syn::TypePath) {
if self.found.is_some() {
return;
}
if node.qself.is_none()
&& let Some(target) = elided_std_cow_target(&node.path)
{
self.found = Some(Borrow { tokens: node.to_token_stream(), cow_target: Some(target) });
return;
}
syn::visit::visit_type_path(self, node);
}
fn visit_type_fn_ptr(&mut self, _node: &'ast syn::TypeFnPtr) {}
fn visit_parenthesized_generic_arguments(&mut self, _node: &'ast syn::ParenthesizedGenericArguments) {}
fn visit_expr(&mut self, _node: &'ast syn::Expr) {}
}
if let Type::Reference(reference) = without_type_wrappers(ty) {
return Some(Borrow { tokens: reference.to_token_stream(), cow_target: None });
}
let mut visitor = ElidedLifetimeVisitor { found: None };
visitor.visit_type(ty);
visitor.found
}
fn elided_std_cow_target(path: &syn::Path) -> Option<String> {
let names = path.segments.iter().map(|segment| segment.ident.to_string()).collect::<Vec<_>>();
let names = names.iter().map(String::as_str).collect::<Vec<_>>();
let standard = match names.as_slice() {
["Cow"] | ["borrow", "Cow"] => path.leading_colon.is_none(),
["std" | "alloc", "borrow", "Cow"] => true,
_ => false,
};
let last = path.segments.last()?;
if !standard || path.segments.iter().rev().skip(1).any(|segment| !segment.arguments.is_empty()) {
return None;
}
let syn::PathArguments::AngleBracketed(arguments) = &last.arguments else {
return None;
};
let [syn::GenericArgument::Type(target)] = arguments.args.iter().collect::<Vec<_>>().as_slice() else {
return None;
};
let unsized_target = match without_type_wrappers(target) {
Type::Slice(_) => true,
Type::Path(target) => {
target.qself.is_none()
&& target.path.segments.last().is_some_and(|segment| {
segment.arguments.is_empty()
&& matches!(segment.ident.to_string().as_str(), "str" | "Path" | "OsStr" | "CStr")
})
}
_ => false,
};
unsized_target.then(|| target.to_token_stream().to_string().replace(" :: ", "::").replace(" ;", ";"))
}
fn is_impl_trait(ty: &Type) -> bool {
struct ImplTraitVisitor {
found: bool,
}
impl<'ast> Visit<'ast> for ImplTraitVisitor {
fn visit_type_impl_trait(&mut self, _node: &'ast syn::TypeImplTrait) {
self.found = true;
}
}
let mut visitor = ImplTraitVisitor { found: false };
visitor.visit_type(ty);
visitor.found
}
fn duration_bound_assertions(attrs: &JobAttrs, runtime: &TokenStream) -> Vec<TokenStream> {
attrs
.durations
.iter()
.map(|(field, ms, span)| {
let message = format!("{field} exceeds pgqueue's maximum supported duration");
quote_spanned! {*span=>
const _: () = ::core::assert!(
#ms <= #runtime::__private::MAX_DURATION_MS,
#message
);
}
})
.collect()
}
fn config_setters(attrs: &JobAttrs, runtime: &TokenStream) -> Vec<TokenStream> {
let mut setters = Vec::new();
if let Some(max_attempts) = attrs.max_attempts {
setters.push(quote!(__config.max_attempts = #max_attempts;));
}
if let Some(timeout) = &attrs.timeout_ms {
setters.push(match timeout {
Some(ms) => quote! {
__config.timeout =
::core::option::Option::Some(::core::time::Duration::from_millis(#ms));
},
None => quote!(__config.timeout = ::core::option::Option::None;),
});
}
if let Some(ttl) = &attrs.result_ttl_ms {
setters.push(match ttl {
ResultTtl::ForMs(ms) => quote! {
__config.retention =
#runtime::JobRetention::For(::core::time::Duration::from_millis(#ms));
},
ResultTtl::Delete => {
quote!(__config.retention = #runtime::JobRetention::DeleteImmediately;)
}
});
}
if let Some(ttl) = &attrs.failed_ttl_ms {
setters.push(match ttl {
ResultTtl::ForMs(ms) => quote! {
__config.failed_retention =
#runtime::JobRetention::For(::core::time::Duration::from_millis(#ms));
},
ResultTtl::Delete => {
quote!(__config.failed_retention = #runtime::JobRetention::DeleteImmediately;)
}
});
}
if let Some(ms) = attrs.retry_delay_ms {
setters.push(quote!(__config.retry_delay = ::core::time::Duration::from_millis(#ms);));
}
if let Some(ms) = attrs.max_backoff_ms {
setters.push(quote! {
__config.backoff = #runtime::JobRetryBackoff::Exponential {
max: ::core::option::Option::Some(::core::time::Duration::from_millis(#ms)),
};
});
}
if let Some(priority) = attrs.priority {
setters.push(quote!(__config.priority = #priority;));
}
setters
}
#[cfg(test)]
mod tests {
use super::*;
use quote::quote;
fn expand_ok(attr: TokenStream, item: TokenStream) -> String {
expand_job(attr, item).map(|t| t.to_string()).unwrap_or_else(|e| panic!("{e}"))
}
fn expand_cron_ok(attr: TokenStream, item: TokenStream) -> String {
expand_cron(attr, item).map(|t| t.to_string()).unwrap_or_else(|e| panic!("{e}"))
}
fn compact(s: &str) -> String {
s.replace(' ', "")
}
#[test]
fn test_reserved_expansion_name_covers_every_bound_name() {
let reserved = |name: &str| reserved_expansion_name(&syn::parse_str::<syn::Ident>(name).unwrap());
for name in [
"__config",
"__args",
"__ctx",
"__result",
"__handler",
"__arg0",
"__arg12",
"__ext0",
"__ext7",
"r#__result",
] {
assert_eq!(reserved(name).as_deref(), Some(name.trim_start_matches("r#")), "{name} must be reserved");
}
for name in [
"__arg",
"__ext",
"__argx",
"__ext1a",
"config",
"__results",
"send_email",
] {
assert_eq!(reserved(name), None, "{name} is not bound by the expansion");
}
}
#[test]
fn test_runtime_crate_path_uses_dependency_alias() {
let path = found_crate_path(FoundCrate::Name("myqueue".to_string()));
assert_eq!(compact(&path.to_string()), "::r#myqueue");
}
#[test]
fn test_runtime_crate_path_escapes_a_reserved_keyword_alias() {
for keyword in ["gen", "async", "await", "dyn", "try", "become"] {
let path = found_crate_path(FoundCrate::Name(keyword.to_string()));
assert_eq!(compact(&path.to_string()), format!("::r#{keyword}"));
}
}
#[test]
fn test_runtime_crate_path_does_not_panic_on_an_unescapable_alias() {
for keyword in ["crate", "self", "super", "Self", "_"] {
let path = found_crate_path(FoundCrate::Name(keyword.to_string()));
assert_eq!(compact(&path.to_string()), format!("::{keyword}"));
}
}
#[test]
fn test_runtime_crate_path_uses_the_self_alias_inside_pgqueue() {
let path = found_crate_path(FoundCrate::Itself);
assert_eq!(compact(&path.to_string()), "::pgqueue");
}
#[test]
fn test_expands_minimal_job() {
let out = expand_ok(
quote!(),
quote! {
async fn send_email(args: SendEmail) -> anyhow::Result<()> {
Ok(())
}
},
);
let flat = compact(&out);
assert!(flat.contains("structr#send_email;"), "{out}");
assert!(flat.contains("impl::pgqueue::JobTypeforsend_email"), "{out}");
assert!(flat.contains("impl::pgqueue::JobDefinitionforsend_email"), "{out}");
assert!(flat.contains("typeArgs=SendEmail;"), "{out}");
assert!(
flat.contains("typeOutput=<anyhow::Result<()>as::pgqueue::__private::IntoJobResult>::Output;"),
"{out}"
);
assert!(flat.contains("constNAME:&'staticstr=\"send_email\";"), "{out}");
assert!(flat.contains("fnjob(__args:SendEmail)"), "{out}");
assert!(flat.contains("asyncfncall(__arg0:SendEmail)->anyhow::Result<()>"), "{out}");
assert!(flat.contains("asyncfnsend_email"), "{out}");
assert!(!flat.contains("__config.max_attempts="), "{out}");
assert!(!flat.contains("SCHEDULE"), "{out}");
}
#[test]
fn test_expands_extractors_positionally() {
let out = expand_ok(
quote!(),
quote! {
pub async fn resize(args: Resize, s: JobState<Pool>, ctx: JobContext) -> Result<u32, Error> {
Ok(1)
}
},
);
let flat = compact(&out);
assert!(flat.contains("pubstructr#resize;"), "{out}");
assert!(flat.contains("<JobState<Pool>as::pgqueue::FromJobContext>::from_context(&__ctx)"), "{out}");
assert!(flat.contains("<JobContextas::pgqueue::FromJobContext>::from_context(&__ctx)"), "{out}");
assert!(flat.contains("resize(__args,__ext0,__ext1)"), "{out}");
assert!(flat.contains("pubasyncfncall(__arg0:Resize,__arg1:JobState<Pool>,__arg2:JobContext)"), "{out}");
}
#[test]
fn test_expands_all_config_attrs() {
let out = expand_ok(
quote!(
name = "custom",
max_attempts = 4,
timeout_ms = 30_000,
result_ttl_ms = 3_600_000,
retry_delay_ms = 500,
max_backoff_ms = 120_000,
priority = -1
),
quote! {
async fn j(_: ()) {}
},
);
let flat = compact(&out);
assert!(flat.contains("constNAME:&'staticstr=\"custom\";"), "{out}");
assert!(flat.contains("__config.max_attempts=4u32;"), "{out}");
assert!(
flat.contains(
"__config.timeout=::core::option::Option::Some(::core::time::Duration::from_millis(30000u64));"
),
"{out}"
);
assert!(
flat.contains("::pgqueue::JobRetention::For(::core::time::Duration::from_millis(3600000u64))"),
"{out}"
);
assert!(flat.contains("__config.retry_delay=::core::time::Duration::from_millis(500u64);"), "{out}");
assert!(flat.contains("::pgqueue::JobRetryBackoff::Exponential{max:::core::option::Option::Some"), "{out}");
assert!(flat.contains("__config.priority=-1i16;"), "{out}");
assert!(flat.contains("typeOutput=<()as::pgqueue::__private::IntoJobResult>::Output;"), "{out}");
}
#[test]
fn test_expands_zero_values() {
let out = expand_ok(
quote!(timeout_ms = 0),
quote! {
async fn j(_: ()) {}
},
);
let flat = compact(&out);
assert!(flat.contains("__config.timeout=::core::option::Option::None;"), "{out}");
assert!(!flat.contains("__config.retention="), "{out}");
assert!(!flat.contains("__config.backoff="), "{out}");
let out = expand_ok(
quote!(result_ttl_ms = 0),
quote!(
async fn j(_: ()) {}
),
);
assert!(compact(&out).contains("JobRetention::DeleteImmediately"), "{out}");
}
#[test]
fn test_keeps_doc_comments_on_the_struct() {
let out = expand_ok(
quote!(),
quote! {
async fn welcome(_: ()) {}
},
);
assert!(out.contains("Sends the welcome email."), "{out}");
}
#[test]
fn test_moves_deprecation_to_the_generated_job_type() {
let out = expand_ok(
quote!(),
quote! {
#[deprecated(note = "use the replacement")]
async fn legacy(_: ()) {}
},
);
let flat = compact(&out);
assert!(flat.contains("#[deprecated(note=\"usethereplacement\")]"), "{out}");
assert_eq!(flat.matches("#[deprecated(note=").count(), 1, "{out}");
assert!(!flat.contains("#[allow(deprecated)]const_:"), "{out}");
assert!(flat.contains("#[allow(deprecated)]impllegacy{"), "{out}");
assert!(flat.contains("#[allow(deprecated)]impl::pgqueue::JobTypeforlegacy"), "{out}");
assert!(flat.contains("#[allow(deprecated)]impl::pgqueue::JobDefinitionforlegacy"), "{out}");
assert_eq!(flat.matches("#[allow(deprecated)]").count(), 3, "{out}");
}
#[test]
fn test_omits_the_deprecated_allow_when_the_job_is_not_deprecated() {
let out = expand_ok(
quote!(),
quote! {
async fn current(_: ()) {}
},
);
assert!(!compact(&out).contains("#[allow(deprecated)]"), "{out}");
}
#[test]
fn test_copies_lint_attributes_onto_every_generated_item() {
let out = expand_ok(
quote!(),
quote! {
#[allow(missing_docs)]
#[deny(clippy::pedantic)]
#[tracing::instrument]
pub async fn undocumented(_: ()) {}
},
);
let flat = compact(&out);
for attr in ["#[allow(missing_docs)]", "#[deny(clippy::pedantic)]"] {
assert_eq!(flat.matches(attr).count(), 5, "{out}");
}
for item in [
"implundocumented{",
"impl::pgqueue::JobTypeforundocumented",
"impl::pgqueue::JobDefinitionforundocumented",
] {
assert!(
flat.contains(&format!("#[allow(missing_docs)]#[deny(clippy::pedantic)]{item}")),
"{item} must carry the user's lint attributes: {out}"
);
}
assert!(flat.contains("#[allow(missing_docs)]#[deny(clippy::pedantic)]#[derive("), "{out}");
assert!(!flat.contains("non_camel_case_types"), "{out}");
assert!(
flat.contains(
"#[allow(missing_docs)]#[deny(clippy::pedantic)]\
#[tracing::instrument]asyncfnundocumented"
),
"{out}"
);
assert_eq!(flat.matches("#[tracing::instrument]").count(), 1, "{out}");
assert!(flat.contains("#[tracing::instrument]asyncfnundocumented"), "{out}");
let out = expand_ok(
quote!(),
quote! {
#[deprecated(note = "use the replacement")]
#[allow(deprecated)]
pub async fn legacy(_: OldPayload) {}
},
);
let flat = compact(&out);
assert_eq!(flat.matches("Documentsthejob.").count(), 1, "{out}");
assert_eq!(flat.matches("#[deprecated(note=").count(), 1, "{out}");
assert_eq!(flat.matches("#[allow(deprecated)]#[allow(deprecated)]impl").count(), 3, "{out}");
}
#[test]
fn test_lint_attributes_reach_the_impls_that_name_the_user_s_types() {
for out in [
expand_ok(
quote!(),
quote!(
#[allow(deprecated)]
async fn j(_: OldPayload, s: JobState<OldState>) -> Result<OldOutput, Error> {}
),
),
expand_cron_ok(
quote!("* * * * *"),
quote!(
#[allow(deprecated)]
async fn c(s: JobState<OldState>) -> Result<OldOutput, Error> {}
),
),
] {
let flat = compact(&out);
assert!(flat.contains("#[allow(deprecated)]impl"), "{out}");
assert!(flat.contains("#[allow(deprecated)]impl::pgqueue::JobTypefor"), "{out}");
assert_eq!(flat.matches("#[allow(deprecated)]").count(), 5, "{out}");
}
}
#[test]
fn test_cfg_gated_parameters_are_refused() {
for gate in [
quote!(#[cfg(any())]),
quote!(#[cfg_attr(test, cfg(any()))]),
quote!(#[cfg_attr(true, cfg(false))]),
quote!(#[cfg_attr(false, cfg(any()))]),
quote!(#[cfg_attr(all(), cfg_attr(true, cfg(false)))]),
quote!(#[cfg_attr(test, cfg_attr(unix, cfg(any())))]),
quote!(#[cfg_attr(feature = "metrics", allow(unused_variables), cfg(any()))]),
] {
for result in [
expand_job(quote!(), quote!(async fn j(_: (), #gate _metrics: u32) {})),
expand_cron(quote!("* * * * *"), quote!(async fn c(#gate _metrics: u32) {})),
] {
let Err(err) = result else { panic!("accepted a parameter gated by {gate}") };
assert!(err.to_string().contains("cannot gate a parameter with `#[cfg]`"), "{gate}: {err}");
}
}
}
#[test]
fn test_parameter_cfg_attr_without_a_cfg_is_accepted() {
for gate in [
quote!(#[cfg_attr(test, allow(unused_variables))]),
quote!(#[cfg_attr(true, allow(unused_variables))]),
quote!(#[cfg_attr(all(unix, not(false)), cfg_attr(true, allow(unused_variables)))]),
] {
let out = expand_ok(quote!(), quote!(async fn j(_: (), #gate metrics: u32) {}));
assert_eq!(compact(&out).matches(&compact(&gate.to_string())).count(), 1, "{out}");
}
}
#[test]
fn test_malformed_parameter_cfg_attr_is_left_for_rustc() {
for gate in [
quote!(#[cfg_attr(, cfg(any()))]),
quote!(#[cfg_attr(all(), 1, cfg(any()))]),
] {
let out = expand_ok(quote!(), quote!(async fn j(_: (), #gate _metrics: u32) {}));
assert_eq!(compact(&out).matches(&compact(&gate.to_string())).count(), 1, "{out}");
}
}
#[test]
fn test_cfg_attr_stays_on_the_handler() {
let out = expand_ok(
quote!(),
quote! {
#[cfg_attr(test, tracing::instrument)]
pub async fn traced(_: ()) {}
},
);
let flat = compact(&out);
assert_eq!(flat.matches("#[cfg_attr(test,tracing::instrument)]").count(), 1, "{out}");
}
#[test]
fn test_lowers_expect_and_forbid_on_the_generated_impls() {
let out = expand_ok(
quote!(),
quote! {
#[expect(deprecated)]
#[forbid(unsafe_code)]
#[deprecated(note = "gone")]
pub async fn legacy(_: OldPayload) {}
},
);
let flat = compact(&out);
assert_eq!(flat.matches("#[expect(").count(), 0, "{out}");
assert_eq!(flat.matches("#[allow(unfulfilled_lint_expectations)]").count(), 0, "{out}");
assert!(flat.contains("#[allow(deprecated)]#[forbid(unsafe_code)]"), "{out}");
assert_eq!(flat.matches("#[forbid(unsafe_code)]").count(), 1, "{out}");
assert_eq!(
flat.matches("#[allow(deprecated)]#[deny(unsafe_code)]#[allow(deprecated)]impl").count(),
3,
"{out}"
);
}
#[test]
fn test_call_takes_the_handler_as_a_value_before_calling_it() {
let out = expand_ok(
quote!(),
quote!(
async fn notify((): ()) {}
),
);
assert!(compact(&out).contains("let__handler=notify;__handler(__arg0).await"), "{out}");
let out = expand_cron_ok(
quote!("0 * * * *"),
quote!(
async fn hourly() {}
),
);
assert!(compact(&out).contains("let__handler=hourly;__handler().await"), "{out}");
}
#[test]
fn test_hidden_handler_keeps_the_user_s_identifier() {
let out = expand_ok(
quote!(),
quote! {
#[tracing::instrument]
async fn send_email(args: SendEmail) -> anyhow::Result<()> {
Ok(())
}
},
);
let flat = compact(&out);
assert!(
!flat.contains("__pgqueue_inner"),
"the placeholder name is what `#[tracing::instrument]` would report: {out}"
);
assert!(flat.contains("#[tracing::instrument]asyncfnsend_email(args:SendEmail)"), "{out}");
assert!(flat.contains("send_email(__args)"), "{out}");
assert!(flat.contains("let__handler=send_email;__handler(__arg0).await"), "{out}");
let out = expand_cron_ok(
quote!("*/5 * * * *"),
quote! {
#[tracing::instrument]
async fn cleanup(ctx: JobContext) {}
},
);
let flat = compact(&out);
assert!(!flat.contains("__pgqueue_inner"), "{out}");
assert!(flat.contains("#[tracing::instrument]asyncfncleanup(ctx:JobContext)"), "{out}");
assert!(flat.contains("cleanup(__ext0)"), "{out}");
}
#[test]
fn test_generated_bindings_are_all_double_underscore_prefixed() {
for out in [
expand_ok(
quote!(max_attempts = 2, priority = 1),
quote!(
async fn j(_: u32) {}
),
),
expand_cron_ok(
quote!("* * * * *", max_attempts = 2),
quote!(
async fn c() {}
),
),
] {
let flat = compact(&out);
assert!(!flat.contains("letmutconfig="), "{out}");
assert!(!flat.contains("letconfig="), "{out}");
assert!(flat.contains("__config"), "{out}");
assert!(!flat.contains("(args:"), "{out}");
}
}
#[test]
fn test_config_binding_is_mutable_only_when_an_attribute_sets_it() {
let bare = compact(&expand_ok(
quote!(),
quote!(
async fn j(_: ()) {}
),
));
assert!(!bare.contains("#[allow(unused_mut)]"), "{bare}");
assert!(bare.contains("let__config="), "{bare}");
let configured = compact(&expand_ok(
quote!(max_attempts = 2),
quote!(
async fn j(_: ()) {}
),
));
assert!(!configured.contains("#[allow(unused_mut)]"), "{configured}");
assert!(configured.contains("letmut__config="), "{configured}");
}
#[test]
fn test_inner_attributes_stay_on_the_handler_body() {
let out = expand_ok(
quote!(),
quote! {
pub async fn work(_: ()) -> anyhow::Result<()> {
#![allow(unused_variables)]
Ok(())
}
},
);
let flat = compact(&out);
let struct_at = flat.find("pubstructr#work;").unwrap_or_else(|| panic!("{out}"));
assert_eq!(flat.matches("#![allow(unused_variables)]").count(), 1, "{out}");
let lint_at = flat.find("#![allow(unused_variables)]").unwrap_or_else(|| panic!("{out}"));
assert!(lint_at > struct_at, "an inner lint level must stay inside the hidden handler: {out}");
assert!(flat.contains("#[doc=r\"Innerdocumentationdescribesthejobtoo.\"]#[derive("), "{out}");
let outer_at = flat.find("Outerdocumentationdescribesthejob.").unwrap_or_else(|| panic!("{out}"));
let inner_doc_at = flat.find("Innerdocumentationdescribesthejobtoo.").unwrap_or_else(|| panic!("{out}"));
assert!(outer_at < inner_doc_at && inner_doc_at < struct_at, "{out}");
assert_eq!(flat.matches("Innerdocumentationdescribesthejobtoo.").count(), 2, "{out}");
assert!(flat.contains("#[allow(unused_variables)]#[doc=r\"Innerdocumentationdescribesthejobtoo.\"]"), "{out}");
}
#[test]
fn test_routes_inner_lint_attributes_like_outer_ones() {
for (out, struct_decl) in [
(
expand_ok(
quote!(),
quote! {
pub async fn f(_: ()) {
#![allow(missing_docs)]
#![expect(dead_code)]
#![forbid(unsafe_code)]
}
},
),
"pubstructr#f;",
),
(
expand_cron_ok(
quote!("* * * * *"),
quote! {
pub async fn c() {
#![allow(missing_docs)]
#![expect(dead_code)]
#![forbid(unsafe_code)]
}
},
),
"pubstructr#c;",
),
] {
let flat = compact(&out);
let levels = "#[allow(missing_docs)]#[allow(dead_code)]#[deny(unsafe_code)]";
assert!(flat.contains(&format!("{levels}#[derive(")), "{out}");
assert_eq!(flat.matches(&format!("{levels}impl")).count(), 3, "{out}");
let struct_at = flat.find(struct_decl).unwrap_or_else(|| panic!("{out}"));
assert!(!flat[..struct_at].contains("#!"), "{out}");
assert!(flat.contains("{#![allow(missing_docs)]#![allow(dead_code)]#![forbid(unsafe_code)]}"), "{out}");
assert!(!flat.contains("expect("), "{out}");
}
}
#[test]
fn test_moves_an_inner_deprecation_to_the_generated_job_type() {
for out in [
expand_ok(
quote!(),
quote! {
pub async fn legacy(_: ()) {
#![deprecated(note = "use the replacement")]
}
},
),
expand_cron_ok(
quote!("* * * * *"),
quote! {
pub async fn legacy() {
#![deprecated(note = "use the replacement")]
}
},
),
] {
let flat = compact(&out);
assert!(flat.contains("#[deprecated(note=\"usethereplacement\")]#[derive("), "{out}");
assert_eq!(flat.matches("deprecated(note=").count(), 1, "{out}");
assert_eq!(flat.matches("#[allow(deprecated)]impl").count(), 3, "{out}");
}
}
#[test]
fn test_lowers_a_forbid_to_a_deny_on_the_generated_struct() {
let out = expand_ok(
quote!(),
quote! {
#[forbid(non_camel_case_types)]
pub async fn forbidding(_: ()) {}
},
);
let flat = compact(&out);
assert!(flat.contains("#[deny(non_camel_case_types)]#[derive("), "{out}");
assert_eq!(flat.matches("#[forbid(non_camel_case_types)]").count(), 1, "{out}");
assert!(flat.contains("#[forbid(non_camel_case_types)]asyncfnforbidding"), "{out}");
}
#[test]
fn test_leaves_a_lint_level_naming_no_lints_where_it_was_written() {
let out = expand_ok(
quote!(),
quote! {
#[expect]
#[forbid = "nonsense"]
pub async fn odd(_: ()) {}
},
);
let flat = compact(&out);
assert_eq!(flat.matches("#[expect]").count(), 1, "{out}");
assert_eq!(flat.matches("#[forbid=\"nonsense\"]").count(), 1, "{out}");
assert!(flat.contains("#[expect]#[forbid=\"nonsense\"]asyncfnodd"), "{out}");
}
#[test]
fn test_lowers_an_expect_to_an_allow_on_the_generated_struct() {
let out = expand_ok(
quote!(),
quote! {
#[expect(missing_docs)]
pub async fn undocumented(_: ()) {}
},
);
let flat = compact(&out);
assert!(flat.contains("#[allow(missing_docs)]#[derive("), "{out}");
assert!(flat.contains("#[allow(missing_docs)]asyncfnundocumented"), "{out}");
assert_eq!(flat.matches("#[expect(").count(), 0, "{out}");
assert_eq!(flat.matches("unfulfilled_lint_expectations").count(), 0, "{out}");
}
#[test]
fn test_derives_the_job_name_from_the_unraw_function_name() {
let out = expand_ok(
quote!(),
quote! {
async fn r#type(_: ()) {}
},
);
assert!(compact(&out).contains("constNAME:&'staticstr=\"type\";"), "{out}");
}
#[test]
fn test_names_the_struct_with_a_raw_identifier() {
let name = format_ident!("gen");
for out in [
expand_ok(quote!(), quote!(async fn #name(_: ()) {})),
expand_cron_ok(quote!("* * * * *"), quote!(async fn #name() {})),
] {
let flat = compact(&out);
assert!(flat.contains("structr#gen;"), "{out}");
assert!(flat.contains("constNAME:&'staticstr=\"gen\";"), "{out}");
}
let out = expand_ok(
quote!(),
quote! {
async fn r#type(_: ()) {}
},
);
let flat = compact(&out);
assert!(flat.contains("structr#type;"), "{out}");
assert!(!flat.contains("r#r#"), "{out}");
}
#[test]
fn test_rejects_a_derived_job_name_longer_than_the_column_allows() {
let long = format_ident!("{}", "n".repeat(256));
let err = expand_job(quote!(), quote!(async fn #long(_: ()) {})).unwrap_err();
assert!(err.to_string().contains("256 bytes"), "{err}");
assert!(err.to_string().contains("job name must be 1..=255 bytes"), "{err}");
let ok = format_ident!("{}", "n".repeat(255));
assert!(expand_job(quote!(), quote!(async fn #ok(_: ()) {})).is_ok());
}
#[test]
fn test_cron_names_leave_room_for_the_derived_dedupe_key() {
let boundary = format_ident!("{}", "n".repeat(251));
let err = expand_cron(quote!("* * * * *"), quote!(async fn #boundary() {})).unwrap_err();
assert!(err.to_string().contains("251 bytes"), "{err}");
assert!(err.to_string().contains("job name must be 1..=250 bytes"), "{err}");
let ok = format_ident!("{}", "n".repeat(250));
assert!(expand_cron(quote!("* * * * *"), quote!(async fn #ok() {})).is_ok());
let long = "n".repeat(251);
let err = expand_cron(
quote!("* * * * *", name = #long),
quote!(
async fn c() {}
),
)
.unwrap_err();
assert_eq!(err.to_string(), "job name must be 1..=250 bytes and contain no NUL");
let job_name = "n".repeat(255);
assert!(
expand_job(
quote!(name = #job_name),
quote!(
async fn j(_: ()) {}
)
)
.is_ok()
);
}
#[test]
fn test_rejects_impl_trait_in_return_position() {
let err = expand_job(
quote!(),
quote!(
async fn j(_: ()) -> impl serde::Serialize {}
),
)
.unwrap_err();
assert_eq!(
err.to_string(),
"#[pgqueue::job] functions cannot use `impl Trait` in return position; \
use a concrete type"
);
let err = expand_cron(
quote!("* * * * *"),
quote!(
async fn c() -> impl serde::Serialize {}
),
)
.unwrap_err();
assert_eq!(
err.to_string(),
"#[pgqueue::cron] functions cannot use `impl Trait` in return position; \
use a concrete type"
);
}
#[test]
fn test_refuses_a_payload_that_borrows_anywhere() {
for payload in [
quote!(&str),
quote!(&'static str),
quote!(Option<&str>),
quote!(Vec<&mut u8>),
quote!((&str, u32)),
quote!([&str; 2]),
quote!(std::borrow::Cow<'_, str>),
quote!(Box<dyn std::fmt::Debug + '_>),
] {
let err = expand_job(quote!(), quote!(async fn j(_: #payload) {})).unwrap_err();
assert_eq!(
err.to_string(),
"#[pgqueue::job] functions cannot take a borrowed payload; it is deserialized \
from storage, so it must be owned",
"{payload}"
);
}
for (payload, target) in [
(quote!(Cow<str>), "str"),
(quote!(std::borrow::Cow<str>), "str"),
(quote!(Option<Cow<[u8]>>), "[u8]"),
(quote!(alloc::borrow::Cow<std::path::Path>), "std::path::Path"),
] {
let err = expand_job(quote!(), quote!(async fn j(_: #payload) {})).unwrap_err().to_string();
assert!(err.starts_with("#[pgqueue::job] functions cannot take a borrowed payload"), "{payload}: {err}");
assert!(err.contains(&format!("write `Cow<'static, {target}>`")), "{payload}: {err}");
assert!(
err.contains(&format!("`std::borrow::Cow<'static, {target}>` where `Cow` names an alias")),
"{err}"
);
}
for payload in [
quote!(std::borrow::Cow<'static, str>),
quote!(Cow<u32>),
quote!(farm::Cow<String>),
quote!(farm::Cow<str>),
quote!(Cow<String>),
quote!(Option<&'static str>),
quote!(fn(&str) -> &str),
quote!(Box<dyn Fn(&str) -> &str + Send>),
quote!(
[u8; {
let _: &str = "";
1
}]
),
] {
expand_ok(quote!(), quote!(async fn j(_: #payload) {}));
}
}
#[test]
fn test_refuses_an_output_that_borrows_anywhere() {
for output in [
quote!(&'static str),
quote!(anyhow::Result<&str>),
quote!(Result<Cow<'_, str>, Error>),
quote!(anyhow::Result<Cow<str>>),
] {
for err in [
expand_job(quote!(), quote!(async fn j(_: ()) -> #output {})).unwrap_err(),
expand_cron(quote!("* * * * *"), quote!(async fn c() -> #output {})).unwrap_err(),
] {
assert!(err.to_string().contains("functions cannot return a borrowed type"), "{output}: {err}");
}
}
for output in [
quote!(anyhow::Result<Cow<u32>>),
quote!(anyhow::Result<Vec<farm::Cow<u8>>>),
] {
expand_ok(quote!(), quote!(async fn j(_: ()) -> #output {}));
expand_cron_ok(quote!("* * * * *"), quote!(async fn c() -> #output {}));
}
expand_ok(
quote!(),
quote!(
async fn j(_: ()) -> anyhow::Result<Cow<'static, str>> {}
),
);
}
#[test]
fn test_accepts_borrowed_extractors() {
for extractor in [
quote!(&'static Config),
quote!(&Config),
quote!(JobState<&Config>),
quote!(JobState<std::borrow::Cow<'_, str>>),
] {
let out = expand_ok(quote!(), quote!(async fn j(_: (), config: #extractor) {}));
let written = compact(&extractor.to_string());
assert!(compact(&out).contains(&format!("<{written}as::pgqueue::FromJobContext>::from_context")), "{out}");
expand_cron_ok(quote!("* * * * *"), quote!(async fn c(config: #extractor) {}));
}
}
#[test]
fn test_refuses_an_extractor_in_the_payload_position() {
for (first, extractor) in [
(quote!(ctx: JobContext), "JobContext"),
(quote!(ctx: pgqueue::JobContext), "JobContext"),
(quote!(ctx: ::pgqueue::JobContext), "JobContext"),
(quote!(config: JobState<Config>), "JobState"),
(quote!(config: pgqueue::JobState<Config>), "JobState"),
] {
let err = expand_job(quote!(), quote!(async fn j(#first) {})).unwrap_err().to_string();
assert!(err.starts_with("the first parameter of a #[pgqueue::job] function is its payload"), "{err}");
assert!(err.contains(&format!("take the `{extractor}` after it")), "{err}");
assert!(err.contains(&format!("such as `crate::{extractor}`")), "{err}");
}
for payload in [
quote!(JobState),
quote!(app::JobContext),
quote!(crate::app::JobContext),
quote!(self::JobContext),
quote!(super::JobContext),
quote!(app::JobState<u32>),
] {
expand_ok(quote!(), quote!(async fn j(_: #payload) {}));
}
expand_ok(
quote!(),
quote!(
async fn j((): (), ctx: JobContext) {}
),
);
}
#[test]
fn test_job_config_default_is_absolutely_qualified() {
let out = expand_ok(
quote!(max_attempts = 3),
quote! {
async fn j(_: ()) {}
},
);
let flat = compact(&out);
assert!(flat.contains("<::pgqueue::JobConfigas::core::default::Default>::default()"), "{out}");
assert!(!flat.contains("JobConfig::default()"), "{out}");
}
#[test]
fn test_job_rejects_the_cron_only_revision_key() {
let err = expand_job(
quote!(revision = 1),
quote!(
async fn j(_: ()) {}
),
)
.unwrap_err();
assert_eq!(err.to_string(), "`revision` is only valid on #[pgqueue::cron]");
}
#[test]
fn test_rejects_invalid_functions() {
let cases: Vec<(TokenStream, &str)> = vec![
(
quote!(
fn j(_: ()) {}
),
"must be async",
),
(
quote!(
async fn j() {}
),
"need a payload",
),
(
quote!(
async fn j<T>(args: T) {}
),
"cannot be generic",
),
(
quote!(
async unsafe fn j(_: ()) {}
),
"cannot be unsafe",
),
(
quote! {
async fn j(args: u32) where u32: Copy {}
},
"cannot be generic",
),
(
quote!(
async fn j(self, args: u32) {}
),
"cannot take self",
),
(
quote!(
async fn j(args: Vec<impl serde::Serialize>) {}
),
"cannot use `impl Trait` in argument position",
),
(
quote!(
async fn j(_: (), _: ...) {}
),
"cannot be variadic",
),
];
for (item, expected) in cases {
let err = expand_job(quote!(), item.clone()).expect_err(&format!("should fail: {item}"));
assert!(err.to_string().contains(expected), "{item}: {err}");
}
}
#[test]
fn test_accepts_a_where_clause_with_no_predicates() {
let out = expand_ok(
quote!(),
quote! {
async fn j(args: u32) where {
let _ = args;
}
},
);
assert!(compact(&out).contains("structr#j;"), "{out}");
let err = expand_job(
quote!(),
quote! {
async fn j(args: u32) where u32: Copy {}
},
)
.unwrap_err();
assert!(err.to_string().contains("cannot be generic"), "{err}");
}
#[test]
fn test_attr_errors_propagate() {
let err = expand_job(
quote!(bogus = 1),
quote!(
async fn j(_: ()) {}
),
)
.unwrap_err();
assert!(err.to_string().contains("unknown attribute"), "{err}");
let err = expand_job(
quote!(),
quote!(
struct NotAFn;
),
)
.unwrap_err();
assert!(err.to_string().contains("expected"), "{err}");
}
#[test]
fn test_expands_cron_with_extractors_only() {
let out = expand_cron_ok(
quote!("*/5 * * * *"),
quote! {
pub async fn cleanup(ctx: JobContext, db: JobState<Pool>) -> anyhow::Result<u64> {
Ok(0)
}
},
);
let flat = compact(&out);
assert!(flat.contains("pubstructr#cleanup;"), "{out}");
assert!(flat.contains("typeArgs=();"), "{out}");
assert!(flat.contains("pubfnjob()->::pgqueue::JobBuilder<cleanup>"), "{out}");
assert!(flat.contains("::pgqueue::JobBuilder::new(())"), "{out}");
assert!(flat.contains("impl::pgqueue::CronDefinitionforcleanup"), "{out}");
assert!(flat.contains("constSCHEDULE:&'staticstr=\"*/5****\";"), "{out}");
assert!(flat.contains("<JobContextas::pgqueue::FromJobContext>::from_context(&__ctx)"), "{out}");
assert!(flat.contains("<JobState<Pool>as::pgqueue::FromJobContext>::from_context(&__ctx)"), "{out}");
assert!(flat.contains("cleanup(__ext0,__ext1)"), "{out}");
assert!(!flat.contains("decode_payload"), "{out}");
assert!(flat.contains("pubasyncfncall(__arg0:JobContext,__arg1:JobState<Pool>)"), "{out}");
}
#[test]
fn test_expands_cron_with_no_params_and_config() {
let out = expand_cron_ok(
quote!("*/5 * * * *", name = "tidy", max_attempts = 2, timeout_ms = 300_000),
quote! {
async fn cleanup() {}
},
);
let flat = compact(&out);
assert!(flat.contains("constNAME:&'staticstr=\"tidy\";"), "{out}");
assert!(flat.contains("__config.max_attempts=2u32;"), "{out}");
assert!(flat.contains("constSCHEDULE:&'staticstr=\"*/5****\";"), "{out}");
assert!(flat.contains("let__result:()=cleanup().await;"), "{out}");
assert!(flat.contains("encode_result(__result)"), "{out}");
}
#[test]
fn test_result_binding_is_ascribed_with_the_return_type() {
let out = expand_ok(
quote!(),
quote! {
async fn unit(_: ()) {}
},
);
assert!(compact(&out).contains("let__result:()=unit(__args).await;"), "{out}");
let out = expand_ok(
quote!(),
quote! {
async fn counted(_: ()) -> anyhow::Result<u32> {
Ok(1)
}
},
);
assert!(compact(&out).contains("let__result:anyhow::Result<u32>=counted(__args).await;"), "{out}");
}
#[test]
fn test_cron_rejects_bad_input() {
let err = expand_cron(
quote!(),
quote!(
async fn j() {}
),
)
.unwrap_err();
assert!(err.to_string().contains("cron expression"), "{err}");
let err = expand_cron(
quote!(42),
quote!(
async fn j() {}
),
)
.unwrap_err();
assert!(err.to_string().contains("cron expression"), "{err}");
let err = expand_cron(
quote!("99 * * * *"),
quote!(
async fn j() {}
),
)
.unwrap_err();
assert!(err.to_string().contains("invalid cron expression"), "{err}");
let err = expand_cron(
quote!("0 * * * * *"),
quote!(
async fn j() {}
),
)
.unwrap_err();
assert!(err.to_string().contains("expected 5"), "{err}");
let err = expand_cron(
quote!("* * * * *", bogus = 1),
quote!(
async fn j() {}
),
)
.unwrap_err();
assert!(err.to_string().contains("unknown attribute"), "{err}");
let err = expand_cron(
quote!("* * * * *"),
quote!(
fn j() {}
),
)
.unwrap_err();
assert!(err.to_string().contains("#[pgqueue::cron] functions must be async"), "{err}");
let err = expand_cron(
quote!("* * * * *"),
quote!(
async fn j(state: impl Send) {}
),
)
.unwrap_err();
assert!(err.to_string().contains("cannot use `impl Trait` in argument position"), "{err}");
let err = expand_cron(
quote!("* * * * *"),
quote!(
async unsafe fn j() {}
),
)
.unwrap_err();
assert!(err.to_string().contains("#[pgqueue::cron] functions cannot be unsafe"), "{err}");
}
}