ruststream-sqlx-macros 0.7.0

Procedural macros for ruststream-sqlx, the SQL database crate of the RustStream messaging framework.
Documentation
//! sqlx's own attributes, as sqlx reads them: `rename_all` on the struct, and `rename`, `skip`,
//! `flatten`, `json` and `try_from` on a field.

use proc_macro2::TokenTree;
use syn::ext::IdentExt;
use syn::parse::ParseStream;
use syn::{Attribute, Ident, LitStr, Token, Type};

use crate::naming::RenameAll;

pub(crate) fn rename_all(attrs: &[Attribute]) -> syn::Result<Option<RenameAll>> {
    let mut casing = None;
    for attr in attrs.iter().filter(|attr| attr.path().is_ident("sqlx")) {
        attr.parse_nested_meta(|meta| {
            if meta.path.is_ident("rename_all") {
                casing = Some(RenameAll::parse(&meta.value()?.parse()?)?);
            } else {
                skip_value(meta.input)?;
            }
            Ok(())
        })?;
    }
    Ok(casing)
}

/// What `#[sqlx(..)]` says about one field.
#[derive(Default)]
pub(crate) struct SqlxField {
    pub(crate) rename: Option<LitStr>,
    pub(crate) skip: bool,
    pub(crate) flatten: bool,
    pub(crate) json: bool,
    pub(crate) try_from: Option<Box<Type>>,
}

/// The column sqlx reads for a field: its `rename` as written, else its name without `r#`,
/// recased by the struct's `rename_all`.
pub(crate) fn column_name(
    ident: &Ident,
    rename: Option<&LitStr>,
    rename_all: Option<RenameAll>,
) -> syn::Result<String> {
    if let Some(rename) = rename {
        let name = rename.value();
        if name.is_empty() {
            return Err(syn::Error::new(rename.span(), "the column name is empty"));
        }
        return Ok(name);
    }
    let name = ident.unraw().to_string();
    Ok(match rename_all {
        Some(casing) => casing.apply(&name),
        None => name,
    })
}

pub(crate) fn sqlx_field(attrs: &[Attribute]) -> syn::Result<SqlxField> {
    let mut sqlx = SqlxField::default();
    for attr in attrs.iter().filter(|attr| attr.path().is_ident("sqlx")) {
        attr.parse_nested_meta(|meta| {
            if meta.path.is_ident("rename") {
                sqlx.rename = Some(meta.value()?.parse()?);
            } else if meta.path.is_ident("skip") {
                sqlx.skip = true;
            } else if meta.path.is_ident("flatten") {
                sqlx.flatten = true;
            } else if meta.path.is_ident("json") {
                sqlx.json = true;
                skip_value(meta.input)?;
            } else if meta.path.is_ident("try_from") {
                let decoded: LitStr = meta.value()?.parse()?;
                sqlx.try_from = Some(Box::new(decoded.parse()?));
            } else {
                skip_value(meta.input)?;
            }
            Ok(())
        })?;
    }
    Ok(sqlx)
}

/// Consumes what follows a key of `#[sqlx(..)]` this derive does not read (`json(nullable)`,
/// `default`), so sqlx's own options pass through untouched.
fn skip_value(input: ParseStream<'_>) -> syn::Result<()> {
    while !input.is_empty() && !input.peek(Token![,]) {
        input.parse::<TokenTree>()?;
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use quote::ToTokens;
    use syn::{DeriveInput, parse_quote};

    use crate::parse::{Inbox, Storage, inbox};

    fn columns(inbox: &Inbox<'_>) -> Vec<Option<String>> {
        inbox
            .fields
            .iter()
            .map(|field| field.column().map(|column| column.name.clone()))
            .collect()
    }

    #[test]
    fn column_names_follow_sqlx() -> syn::Result<()> {
        let input: DeriveInput = parse_quote! {
            #[inbox(table = "email_jobs")]
            #[sqlx(rename_all = "camelCase")]
            struct SendEmail {
                #[field(id)]
                job_id: i64,
                #[sqlx(rename = "queue_name")]
                #[field(group)]
                group_name: String,
                r#type: String,
                #[sqlx(skip)]
                cache: Vec<u8>,
                #[sqlx(flatten)]
                extra: Extra,
                #[sqlx(json(nullable), try_from = "i64", default)]
                attachments: Vec<String>,
            }
        };
        assert_eq!(
            columns(&inbox(&input)?),
            [
                Some("jobId".to_owned()),
                Some("queue_name".to_owned()),
                Some("type".to_owned()),
                None,
                None,
                Some("attachments".to_owned()),
            ]
        );
        let inbox = inbox(&input)?;
        assert!(matches!(inbox.fields[3].storage, Storage::Skipped));
        assert!(matches!(inbox.fields[4].storage, Storage::Flattened));
        assert!(inbox.flattens());
        // The type sqlx decodes a column as before it converts it, for a column read alone.
        let decoded = inbox.fields[5]
            .column()
            .and_then(|column| column.try_from.as_ref())
            .map(|ty| ty.to_token_stream().to_string());
        assert_eq!(decoded.as_deref(), Some("i64"));
        Ok(())
    }

    #[test]
    fn a_json_field_is_marked_for_the_insert() -> syn::Result<()> {
        let input: DeriveInput = parse_quote! {
            #[inbox(table = "jobs")]
            struct Job { #[field(id)] id: i64, #[sqlx(json(nullable))] body: Option<Body>, other: String }
        };
        let json: Vec<_> = inbox(&input)?
            .columns()
            .map(|(_, column)| column.json)
            .collect();
        assert_eq!(json, [false, true, false]);
        Ok(())
    }
}