Initial commit: duck derive macros

This commit is contained in:
2025-07-17 22:53:27 -07:00
commit 40cdd9a4c2
18 changed files with 4333 additions and 0 deletions

View 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 }

View 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),*]
)
}
}
};
}

View 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,
});
}
}
}
}
};
}

View 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();
}
};
}

View 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));
}
}
};
}

View 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,
});
}