Skip to main content

pylon_derive/
lib.rs

1//
2// This source file is part of the Pylon open source project.
3//
4// Copyright (c) 2026 Jaldis B.V.
5//
6// Licensed under the MIT OR Apache-2.0 license (the "License");
7// you may not use this file except in compliance with the License.
8// You may obtain a copy of the License at
9//
10//     https://opensource.org/licenses/MIT
11//     https://www.apache.org/licenses/LICENSE-2.0
12//
13// Unless required by applicable law or agreed to in writing, software
14// distributed under the License is distributed on an "AS IS" BASIS,
15// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16// See the License for the specific language governing permissions and
17// limitations under the License.
18//
19
20//! `#[derive(Queryable)]` — populates a struct or enum from a query result,
21//! so a caller writes `client.query::<Row, _>(pyql, &args)` instead of
22//! walking a generic [`Value`](pylon_client::Value) by hand.
23//!
24//! Deliberately conventional in its surface (the usual derive name, the usual
25//! `rename`/`json`/`crate_path` attributes under a `pylon` namespace), so a
26//! project arriving from another Rust client keeps its row structs as-is. The one
27//! semantic difference is spelled out on
28//! [`Queryable`](pylon_client::Queryable): fields are matched **by name**
29//! rather than by shape position, because Pylon's decoded objects are
30//! name-keyed.
31
32use proc_macro::TokenStream;
33use quote::quote;
34use syn::spanned::Spanned;
35use syn::{Data, DeriveInput, Fields, Ident, LitStr, Path, parse_macro_input};
36
37/// Attributes accepted on the container, a field or an enum variant.
38#[derive(Default)]
39struct Attrs {
40    rename: Option<LitStr>,
41    json: bool,
42    crate_path: Option<Path>,
43}
44
45fn parse_attrs(attrs: &[syn::Attribute]) -> syn::Result<Attrs> {
46    let mut parsed = Attrs::default();
47    for attr in attrs.iter().filter(|a| a.path().is_ident("pylon")) {
48        attr.parse_nested_meta(|meta| {
49            if meta.path.is_ident("json") {
50                parsed.json = true;
51                Ok(())
52            } else if meta.path.is_ident("rename") {
53                parsed.rename = Some(meta.value()?.parse()?);
54                Ok(())
55            } else if meta.path.is_ident("crate_path") {
56                parsed.crate_path = Some(meta.value()?.parse()?);
57                Ok(())
58            } else {
59                Err(meta.error("unknown `pylon` attribute; expected `json`, `rename` or `crate_path`"))
60            }
61        })?;
62    }
63    Ok(parsed)
64}
65
66#[proc_macro_derive(Queryable, attributes(pylon))]
67pub fn queryable(input: TokenStream) -> TokenStream {
68    let input = parse_macro_input!(input as DeriveInput);
69    expand(input).unwrap_or_else(|error| error.to_compile_error()).into()
70}
71
72fn expand(input: DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
73    let container = parse_attrs(&input.attrs)?;
74    let crate_path = container
75        .crate_path
76        .clone()
77        .unwrap_or_else(|| syn::parse_quote!(::pylon_client));
78    let name = &input.ident;
79    let name_literal = name.to_string();
80    let (impl_generics, type_generics, where_clause) = input.generics.split_for_impl();
81
82    // A container-level `json` deserializes the whole value with serde and
83    // never looks at the Rust shape at all, so it applies to structs and
84    // enums alike.
85    let body = if container.json {
86        quote! { #crate_path::queryable::derive::from_json(value, #name_literal) }
87    } else {
88        match &input.data {
89            Data::Struct(data) => struct_body(&crate_path, &name_literal, &data.fields)?,
90            Data::Enum(data) => enum_body(&crate_path, name, &name_literal, data)?,
91            Data::Union(_) => {
92                return Err(syn::Error::new(
93                    input.span(),
94                    "Queryable cannot be derived for a union; use a struct or an enum",
95                ));
96            }
97        }
98    };
99
100    Ok(quote! {
101        impl #impl_generics #crate_path::Queryable for #name #type_generics #where_clause {
102            fn decode(
103                value: &#crate_path::Value,
104            ) -> ::core::result::Result<Self, #crate_path::DecodeError> {
105                #body
106            }
107        }
108    })
109}
110
111fn struct_body(crate_path: &Path, name_literal: &str, fields: &Fields) -> syn::Result<proc_macro2::TokenStream> {
112    let Fields::Named(named) = fields else {
113        return Err(syn::Error::new(
114            fields.span(),
115            "Queryable needs named fields — a query shape is a set of named pointers, \
116             so a tuple or unit struct has nothing to match them against",
117        ));
118    };
119
120    let mut initializers = Vec::new();
121    for field in &named.named {
122        let attrs = parse_attrs(&field.attrs)?;
123        // `ident` is always `Some` inside `Fields::Named`.
124        let ident = field.ident.as_ref().expect("named field has an identifier");
125        let shape_name = attrs
126            .rename
127            .as_ref()
128            .map(LitStr::value)
129            .unwrap_or_else(|| ident.to_string());
130        let helper = if attrs.json { quote!(json_field) } else { quote!(field) };
131        initializers.push(quote! {
132            #ident: #crate_path::queryable::derive::#helper(object, #name_literal, #shape_name)?
133        });
134    }
135
136    Ok(quote! {
137        let object = #crate_path::queryable::derive::object(value, #name_literal)?;
138        ::core::result::Result::Ok(Self { #(#initializers),* })
139    })
140}
141
142fn enum_body(
143    crate_path: &Path,
144    name: &Ident,
145    name_literal: &str,
146    data: &syn::DataEnum,
147) -> syn::Result<proc_macro2::TokenStream> {
148    let mut arms = Vec::new();
149    for variant in &data.variants {
150        if !matches!(variant.fields, Fields::Unit) {
151            return Err(syn::Error::new(
152                variant.span(),
153                "Queryable only supports unit variants — a schema enum's members carry no payload",
154            ));
155        }
156        let attrs = parse_attrs(&variant.attrs)?;
157        if attrs.json {
158            return Err(syn::Error::new(
159                variant.span(),
160                "`json` is not valid on a variant; put it on the container or on a struct field",
161            ));
162        }
163        let ident = &variant.ident;
164        let label = attrs
165            .rename
166            .as_ref()
167            .map(LitStr::value)
168            .unwrap_or_else(|| ident.to_string());
169        arms.push(quote! { #label => ::core::result::Result::Ok(#name::#ident) });
170    }
171
172    Ok(quote! {
173        match #crate_path::queryable::derive::enum_label(value, #name_literal)? {
174            #(#arms,)*
175            other => ::core::result::Result::Err(
176                #crate_path::queryable::derive::unknown_variant(#name_literal, other),
177            ),
178        }
179    })
180}