Initial commit: duck derive macros
This commit is contained in:
16
crates/libduck-derive/Cargo.toml
Normal file
16
crates/libduck-derive/Cargo.toml
Normal file
@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "libduck-derive"
|
||||
version = { workspace = true }
|
||||
edition = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[lib]
|
||||
proc-macro = true
|
||||
|
||||
[dependencies]
|
||||
syn = { workspace = true }
|
||||
quote = { workspace = true }
|
||||
proc-macro2 = { workspace = true }
|
||||
darling = { workspace = true }
|
64
crates/libduck-derive/src/duck_value.rs
Normal file
64
crates/libduck-derive/src/duck_value.rs
Normal file
@ -0,0 +1,64 @@
|
||||
use quote::{ToTokens, quote};
|
||||
use syn::Fields;
|
||||
|
||||
use crate::util::{DuckFieldMode, parse_attrs};
|
||||
|
||||
pub fn duck_value(name: &proc_macro2::Ident, fields: &Fields) -> proc_macro2::TokenStream {
|
||||
let mut columns = Vec::new();
|
||||
|
||||
for f in fields {
|
||||
let ident = &f.ident;
|
||||
let ty = &f.ty;
|
||||
let ident_str = ident.clone().to_token_stream().to_string();
|
||||
|
||||
let attrs = match parse_attrs(&f.attrs, ty) {
|
||||
Err(x) => return x,
|
||||
Ok(x) => x,
|
||||
};
|
||||
|
||||
match attrs.mode {
|
||||
DuckFieldMode::Json => {
|
||||
if let Some(inty) = attrs.optional_inner {
|
||||
columns.push(quote! {
|
||||
(
|
||||
#ident_str.to_owned(),
|
||||
<
|
||||
Option<::libduck::special::DuckJson<#inty>>
|
||||
as ::libduck::DuckValue
|
||||
>::duck_type()
|
||||
)
|
||||
});
|
||||
} else {
|
||||
columns.push(quote! {
|
||||
(
|
||||
#ident_str.to_owned(),
|
||||
<
|
||||
::libduck::special::DuckJson<#ty>
|
||||
as ::libduck::DuckValue
|
||||
>::duck_type()
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
DuckFieldMode::Normal => {
|
||||
columns.push(quote! {
|
||||
(
|
||||
#ident_str.to_owned(),
|
||||
<#ty as ::libduck::DuckValue>::duck_type()
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return quote! {
|
||||
impl ::libduck::DuckValue for #name {
|
||||
fn duck_type() -> ::libduck::duckdb::types::Type {
|
||||
::libduck::duckdb::types::Type::Struct(
|
||||
vec![#(#columns),*]
|
||||
)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
99
crates/libduck-derive/src/from_duck.rs
Normal file
99
crates/libduck-derive/src/from_duck.rs
Normal file
@ -0,0 +1,99 @@
|
||||
use quote::{quote, ToTokens};
|
||||
use syn::Fields;
|
||||
|
||||
use crate::util::{parse_attrs, DuckFieldMode};
|
||||
|
||||
pub fn from_duck(name: &proc_macro2::Ident, fields: &Fields) -> proc_macro2::TokenStream {
|
||||
let mut lines = Vec::new();
|
||||
let mut idents = Vec::new();
|
||||
|
||||
for f in fields {
|
||||
let ident = &f.ident;
|
||||
let ty = &f.ty;
|
||||
let ident_str = ident.clone().to_token_stream().to_string();
|
||||
|
||||
let attrs = match parse_attrs(&f.attrs, ty) {
|
||||
Err(x) => return x,
|
||||
Ok(x) => x,
|
||||
};
|
||||
|
||||
match attrs.mode {
|
||||
DuckFieldMode::Json => {
|
||||
if let Some(inty) = attrs.optional_inner {
|
||||
lines.push(quote! {
|
||||
path_to_field.push(#ident_str.to_owned());
|
||||
let #ident = {
|
||||
let value = stx.get(&#ident_str.to_owned()).cloned();
|
||||
<
|
||||
Option<::libduck::special::DuckJson<#inty>>
|
||||
as ::libduck::FromDuck
|
||||
>::from_duck_with_path(value, &path_to_field)?.map(|x| x.0)
|
||||
};
|
||||
path_to_field.pop();
|
||||
});
|
||||
} else {
|
||||
lines.push(quote! {
|
||||
path_to_field.push(#ident_str.to_owned());
|
||||
let #ident = {
|
||||
let value = stx.get(&#ident_str.to_owned()).cloned();
|
||||
<
|
||||
::libduck::special::DuckJson<#ty>
|
||||
as ::libduck::FromDuck
|
||||
>::from_duck_with_path(value, &path_to_field)?.0
|
||||
};
|
||||
path_to_field.pop();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
DuckFieldMode::Normal => {
|
||||
lines.push(quote! {
|
||||
path_to_field.push(#ident_str.to_owned());
|
||||
let #ident = {
|
||||
|
||||
// TODO: no clone
|
||||
let value = stx.get(&#ident_str.to_owned()).cloned();
|
||||
<#ty as ::libduck::FromDuck>::from_duck_with_path(value, &path_to_field)?
|
||||
};
|
||||
path_to_field.pop();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
idents.push(ident);
|
||||
}
|
||||
|
||||
return quote! {
|
||||
impl ::libduck::FromDuck for #name {
|
||||
fn from_duck_with_path(value: Option<::libduck::duckdb::types::Value>, path_to_field: &[String]) -> Result<Self, ::libduck::FromDuckError> {
|
||||
let mut path_to_field: Vec<_> = path_to_field.into();
|
||||
|
||||
match value {
|
||||
Some(::libduck::duckdb::types::Value::Struct(stx)) => {
|
||||
#(#lines)*
|
||||
|
||||
return Ok(Self {
|
||||
#(#idents),*
|
||||
});
|
||||
}
|
||||
|
||||
Some(x) => {
|
||||
return Err(::libduck::FromDuckError::InvalidType {
|
||||
path_to_field,
|
||||
expected: <Self as ::libduck::DuckValue>::duck_type(),
|
||||
got: ::libduck::_infer_value_type(&x),
|
||||
});
|
||||
}
|
||||
|
||||
None => {
|
||||
return Err(::libduck::FromDuckError::InvalidType {
|
||||
path_to_field,
|
||||
expected: <Self as ::libduck::DuckValue>::duck_type(),
|
||||
got: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
240
crates/libduck-derive/src/lib.rs
Normal file
240
crates/libduck-derive/src/lib.rs
Normal file
@ -0,0 +1,240 @@
|
||||
//! Procedural macros for automatically deriving duck traits.
|
||||
//! Do not use this crate directly, always import it using `libduck`'s re-export.
|
||||
//!
|
||||
//! This crate provides derive macros that implement [`FromDuck`], and [`ToDuck`], and [`DuckValue`].
|
||||
//!
|
||||
//! See [`FromDuck`] for detailed docs.
|
||||
|
||||
use quote::quote;
|
||||
use syn::{parse_macro_input, DeriveInput};
|
||||
|
||||
mod duck_value;
|
||||
mod from_duck;
|
||||
mod to_duck;
|
||||
mod util;
|
||||
|
||||
/// This macro derives [`FromDuck`] for structs and for enums with no fields.
|
||||
/// - [`FromDuck`] requires [`DuckValue`], and you'll likely want to derive that too.
|
||||
/// - Be careful with binary data. [`Vec<u8>`] maps to `TINYINT[]` in duckdb, while [`DuckBlob`] maps to `BLOB`.
|
||||
///
|
||||
///
|
||||
/// # Struct example
|
||||
///
|
||||
/// ```ignore
|
||||
/// #[derive(Debug, Clone, DuckValue, FromDuck, ToDuck)]
|
||||
/// pub struct User {
|
||||
/// // These fields may not be null
|
||||
/// pub uuid: Uuid,
|
||||
/// pub name: String,
|
||||
/// pub created_at: chrono::DateTime<Utc>,
|
||||
///
|
||||
/// // This field is optional, and may be null
|
||||
/// pub bio: Option<String>,
|
||||
///
|
||||
/// // This field is stored as a json string
|
||||
/// #[duck(json)]
|
||||
/// pub extra: HashMap<String, serde_json::Value>
|
||||
///
|
||||
/// // This field is stored as a `MAP(TEXT, TEXT)`
|
||||
/// pub tags: HashMap<String, String>
|
||||
///
|
||||
/// // This field is a binary blob.
|
||||
/// //
|
||||
/// // The `Arc` has no effect on the type of this field
|
||||
/// // in duckdb, and is transparently (de)serialized.
|
||||
/// pub avatar: Option<Arc<DuckBlob>>
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
///
|
||||
/// # Enum example
|
||||
///
|
||||
/// [`FromDuck`], [`ToDuck`], and [`DuckValue`] can only be derived for enums whose variants have no fields.
|
||||
/// They are stored as TEXT in duckdb, and we use the enum's implementation of [`ToString`] and [`std::str::FromStr`]
|
||||
/// to convert variants to/from strings.
|
||||
///
|
||||
/// You may use [strum](https://docs.rs/strum/latest/strum) to derive [`ToString`] and [`std::str::FromStr`]
|
||||
/// for string enums like this.
|
||||
///
|
||||
/// ```ignore
|
||||
/// #[derive(DuckValue, FromDuck, ToDuck)]
|
||||
/// pub enum State {
|
||||
/// A,
|
||||
/// B,
|
||||
/// C
|
||||
/// }
|
||||
/// ``
|
||||
#[proc_macro_derive(FromDuck, attributes(duck))]
|
||||
pub fn derive_fromduck(item: proc_macro::TokenStream) -> proc_macro::TokenStream {
|
||||
let input = parse_macro_input!(item as DeriveInput);
|
||||
let name = input.ident.clone();
|
||||
|
||||
match &input.data {
|
||||
syn::Data::Struct(data) => return from_duck::from_duck(&name, &data.fields).into(),
|
||||
|
||||
syn::Data::Enum(data) => {
|
||||
if let Some(f) = data
|
||||
.variants
|
||||
.iter()
|
||||
.map(|x| x.fields.iter().next())
|
||||
.find(|x| x.is_some())
|
||||
.flatten()
|
||||
{
|
||||
let error = syn::Error::new_spanned(
|
||||
f,
|
||||
"FromDuck can only be derived for enums with no fields",
|
||||
);
|
||||
return error.to_compile_error().into();
|
||||
}
|
||||
|
||||
let name_str = name.to_string();
|
||||
|
||||
return quote! {
|
||||
impl ::libduck::FromDuck for #name {
|
||||
fn from_duck_with_path(
|
||||
value: Option<::libduck::duckdb::types::Value>,
|
||||
path_to_field: &[String]
|
||||
) -> Result<Self, ::libduck::FromDuckError> {
|
||||
use std::str::FromStr;
|
||||
|
||||
let v = match value {
|
||||
Some(::libduck::duckdb::types::Value::Text(s)) => match <#name as FromStr>::from_str(&s) {
|
||||
Ok(x) => x,
|
||||
Err(error) => {
|
||||
return Err(::libduck::FromDuckError::ParseError {
|
||||
path_to_field: path_to_field.into(),
|
||||
string: s.into(),
|
||||
msg: Some(format!("invalid {}", #name_str)),
|
||||
parent: Some(Box::new(error))
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
Some(x) => {
|
||||
return Err(::libduck::FromDuckError::InvalidType {
|
||||
path_to_field: path_to_field.into(),
|
||||
expected: <Self as ::libduck::DuckValue>::duck_type(),
|
||||
got: ::libduck::_infer_value_type(&x),
|
||||
});
|
||||
}
|
||||
|
||||
None => {
|
||||
return Err(::libduck::FromDuckError::InvalidType {
|
||||
path_to_field: path_to_field.into(),
|
||||
expected: <Self as ::libduck::DuckValue>::duck_type(),
|
||||
got: None,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return Ok(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
.into();
|
||||
}
|
||||
|
||||
_ => {
|
||||
let error = syn::Error::new_spanned(
|
||||
&input,
|
||||
"FromDuck can only be derived for structs and enums",
|
||||
);
|
||||
return error.to_compile_error().into();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// This macro derives [`ToDuck`] for structs and for enums with no fields.
|
||||
/// - [`ToDuck`] requires [`DuckValue`], and you'll likely want to derive that too.
|
||||
///
|
||||
/// This macro works exactly like [`FromDuck`].
|
||||
/// Refer to that macro for detailed docs.
|
||||
#[proc_macro_derive(ToDuck, attributes(duck))]
|
||||
pub fn derive_toduck(item: proc_macro::TokenStream) -> proc_macro::TokenStream {
|
||||
let input = parse_macro_input!(item as DeriveInput);
|
||||
let name = input.ident.clone();
|
||||
|
||||
match &input.data {
|
||||
syn::Data::Struct(data) => return to_duck::to_duck(&name, &data.fields).into(),
|
||||
|
||||
syn::Data::Enum(data) => {
|
||||
if let Some(f) = data
|
||||
.variants
|
||||
.iter()
|
||||
.map(|x| x.fields.iter().next())
|
||||
.find(|x| x.is_some())
|
||||
.flatten()
|
||||
{
|
||||
let error = syn::Error::new_spanned(
|
||||
f,
|
||||
"ToDuck can only be derived for enums with no fields",
|
||||
);
|
||||
return error.to_compile_error().into();
|
||||
}
|
||||
|
||||
return quote! {
|
||||
impl ::libduck::ToDuck for #name {
|
||||
fn to_duck_with_path(
|
||||
&self,
|
||||
path_to_field: &[String]
|
||||
) -> Result<::libduck::duckdb::types::Value, ::libduck::ToDuckError> {
|
||||
Ok(::libduck::duckdb::types::Value::Text(<Self as ToString>::to_string(self)))
|
||||
}
|
||||
}
|
||||
}
|
||||
.into();
|
||||
}
|
||||
|
||||
_ => {
|
||||
let error =
|
||||
syn::Error::new_spanned(&input, "ToDuck can only be derived for structs and enums");
|
||||
return error.to_compile_error().into();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// This macro derives [`DuckValue`] for structs and for enums with no fields.
|
||||
///
|
||||
/// Refer to [`FromDuck`] for detailed docs.
|
||||
#[proc_macro_derive(DuckValue, attributes(duck))]
|
||||
pub fn derive_duckvalue(item: proc_macro::TokenStream) -> proc_macro::TokenStream {
|
||||
let input = parse_macro_input!(item as DeriveInput);
|
||||
let name = input.ident.clone();
|
||||
|
||||
match &input.data {
|
||||
syn::Data::Struct(data) => return duck_value::duck_value(&name, &data.fields).into(),
|
||||
|
||||
syn::Data::Enum(data) => {
|
||||
if let Some(f) = data
|
||||
.variants
|
||||
.iter()
|
||||
.map(|x| x.fields.iter().next())
|
||||
.find(|x| x.is_some())
|
||||
.flatten()
|
||||
{
|
||||
let error = syn::Error::new_spanned(
|
||||
f,
|
||||
"DuckValue can only be derived for enums with no fields",
|
||||
);
|
||||
return error.to_compile_error().into();
|
||||
}
|
||||
|
||||
return quote! {
|
||||
impl ::libduck::DuckValue for #name {
|
||||
fn duck_type() -> ::libduck::duckdb::types::Type {
|
||||
::libduck::duckdb::types::Type::Text
|
||||
}
|
||||
}
|
||||
}
|
||||
.into();
|
||||
}
|
||||
|
||||
_ => {
|
||||
let error = syn::Error::new_spanned(
|
||||
&input,
|
||||
"DuckValue can only be derived for structs and enums",
|
||||
);
|
||||
return error.to_compile_error().into();
|
||||
}
|
||||
};
|
||||
}
|
81
crates/libduck-derive/src/to_duck.rs
Normal file
81
crates/libduck-derive/src/to_duck.rs
Normal file
@ -0,0 +1,81 @@
|
||||
use quote::{ToTokens, quote};
|
||||
use syn::Fields;
|
||||
|
||||
use crate::util::{DuckFieldMode, parse_attrs};
|
||||
|
||||
pub fn to_duck(name: &proc_macro2::Ident, fields: &Fields) -> proc_macro2::TokenStream {
|
||||
let mut lines = Vec::new();
|
||||
|
||||
for f in fields {
|
||||
let ident = &f.ident;
|
||||
let ty = &f.ty;
|
||||
let ident_str = ident.clone().to_token_stream().to_string();
|
||||
|
||||
let attrs = match parse_attrs(&f.attrs, ty) {
|
||||
Err(x) => return x,
|
||||
Ok(x) => x,
|
||||
};
|
||||
|
||||
match attrs.mode {
|
||||
DuckFieldMode::Json => {
|
||||
// Note how we use refs here to prevent cloning
|
||||
|
||||
if let Some(inty) = attrs.optional_inner {
|
||||
lines.push(quote! {
|
||||
{
|
||||
path_to_field.push(#ident_str.to_owned());
|
||||
|
||||
let val = self.#ident.as_ref().map(::libduck::special::DuckJson);
|
||||
let #ident = <
|
||||
Option<::libduck::special::DuckJson<&#inty>>
|
||||
as ::libduck::ToDuck
|
||||
>::to_duck_with_path(&val, &path_to_field)?;
|
||||
|
||||
fields.push((#ident_str.to_owned(), #ident));
|
||||
path_to_field.pop();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
lines.push(quote! {
|
||||
{
|
||||
path_to_field.push(#ident_str.to_owned());
|
||||
let val = ::libduck::special::DuckJson(&self.#ident);
|
||||
let #ident = <
|
||||
::libduck::special::DuckJson<&#ty>
|
||||
as ::libduck::ToDuck
|
||||
>::to_duck_with_path(&val, &path_to_field)?;
|
||||
|
||||
fields.push((#ident_str.to_owned(), #ident));
|
||||
path_to_field.pop();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
DuckFieldMode::Normal => {
|
||||
lines.push(quote! {
|
||||
{
|
||||
path_to_field.push(#ident_str.to_owned());
|
||||
let #ident = <#ty as ::libduck::ToDuck>::to_duck_with_path(&self.#ident, &path_to_field)?;
|
||||
fields.push((#ident_str.to_owned(), #ident));
|
||||
path_to_field.pop();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return quote! {
|
||||
impl ::libduck::ToDuck for #name {
|
||||
fn to_duck_with_path(&self, path_to_field: &[String]) -> Result<::libduck::duckdb::types::Value, ::libduck::ToDuckError> {
|
||||
let mut path_to_field: Vec<_> = path_to_field.into();
|
||||
let mut fields = Vec::new();
|
||||
|
||||
#(#lines);*
|
||||
|
||||
let x = ::libduck::duckdb::types::OrderedMap::from(fields);
|
||||
return Ok(::libduck::duckdb::types::Value::Struct(x));
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
128
crates/libduck-derive/src/util.rs
Normal file
128
crates/libduck-derive/src/util.rs
Normal file
@ -0,0 +1,128 @@
|
||||
use darling::FromMeta;
|
||||
use quote::ToTokens;
|
||||
use syn::{AttrStyle, Attribute, MacroDelimiter, Meta, MetaList, Path, Type};
|
||||
|
||||
pub enum DuckFieldMode {
|
||||
Normal,
|
||||
Json,
|
||||
}
|
||||
|
||||
pub struct DuckAttrs {
|
||||
pub mode: DuckFieldMode,
|
||||
|
||||
/// If some, this type is optional,
|
||||
/// and the inner type is inside.
|
||||
///
|
||||
/// We use this to strip the first option from fields
|
||||
/// annotated with `#[duck(json)]`, since the option needs
|
||||
/// to be placed _before_ `DuckJson`.
|
||||
pub optional_inner: Option<Type>,
|
||||
}
|
||||
|
||||
/// Given tokens for `Option<T>`, return `T`.
|
||||
/// Returns `None` if this is not an `Option`.
|
||||
pub fn option_inner(ty: &Type) -> Option<&Type> {
|
||||
let syn::Type::Path(ty) = ty else { return None };
|
||||
if ty.qself.is_some() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let ty = &ty.path;
|
||||
|
||||
#[expect(clippy::unwrap_used)] // We check length
|
||||
if ty.segments.is_empty() || ty.segments.last().unwrap().ident != "Option" {
|
||||
return None;
|
||||
}
|
||||
|
||||
if !(ty.segments.len() == 1
|
||||
|| (ty.segments.len() == 3
|
||||
&& ["core", "std"].contains(&ty.segments[0].ident.to_string().as_str())
|
||||
&& ty.segments[1].ident == "option"))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
#[expect(clippy::unwrap_used)] // We checked length
|
||||
let last_segment = ty.segments.last().unwrap();
|
||||
|
||||
let syn::PathArguments::AngleBracketed(generics) = &last_segment.arguments else {
|
||||
return None;
|
||||
};
|
||||
if generics.args.len() != 1 {
|
||||
return None;
|
||||
}
|
||||
let syn::GenericArgument::Type(inner_type) = &generics.args[0] else {
|
||||
return None;
|
||||
};
|
||||
|
||||
Some(inner_type)
|
||||
}
|
||||
|
||||
/// Parse the `#[duck]` attribute
|
||||
pub fn parse_attrs(
|
||||
attrs: &Vec<Attribute>,
|
||||
ty: &Type,
|
||||
) -> Result<DuckAttrs, proc_macro2::TokenStream> {
|
||||
#[derive(Debug, Default, FromMeta, PartialEq, Eq)]
|
||||
#[darling(derive_syn_parse)]
|
||||
struct RawDuckAttr {
|
||||
#[darling(default)]
|
||||
json: bool,
|
||||
}
|
||||
|
||||
impl RawDuckAttr {
|
||||
fn merge(&mut self, rhs: Self) {
|
||||
self.json |= rhs.json;
|
||||
}
|
||||
}
|
||||
|
||||
let mut rda = RawDuckAttr::default();
|
||||
|
||||
for a in attrs {
|
||||
match a {
|
||||
Attribute {
|
||||
style: AttrStyle::Outer,
|
||||
meta:
|
||||
Meta::List(MetaList {
|
||||
delimiter: MacroDelimiter::Paren(_),
|
||||
|
||||
tokens,
|
||||
path: Path {
|
||||
leading_colon: None,
|
||||
segments,
|
||||
},
|
||||
}),
|
||||
..
|
||||
} => {
|
||||
// Not our token
|
||||
if segments.to_token_stream().to_string() != "duck" {
|
||||
continue;
|
||||
}
|
||||
|
||||
let attr: RawDuckAttr = match syn::parse(tokens.clone().into()) {
|
||||
Ok(x) => x,
|
||||
Err(err) => {
|
||||
let error = syn::Error::new_spanned(a, format!("{err}"));
|
||||
return Err(error.to_compile_error());
|
||||
}
|
||||
};
|
||||
|
||||
rda.merge(attr);
|
||||
}
|
||||
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
|
||||
let optional_inner = option_inner(ty).cloned();
|
||||
|
||||
let mode = match rda.json {
|
||||
true => DuckFieldMode::Json,
|
||||
false => DuckFieldMode::Normal,
|
||||
};
|
||||
|
||||
return Ok(DuckAttrs {
|
||||
mode,
|
||||
optional_inner,
|
||||
});
|
||||
}
|
Reference in New Issue
Block a user