Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 84 additions & 8 deletions crates/property-macro/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,16 @@
under the License.
-->

# Iceberg property derive macro
# Iceberg property macros
Comment thread
blackmwk marked this conversation as resolved.

`Properties` parses a typed struct from a flat `HashMap<String, String>` and
can generate opt-in read-only getters. It deliberately does not generate
property-map serialization or implement `Default`, `Serialize`, `Deserialize`,
or any other trait.
## `Properties` derive macro

## Generated API
`#[derive(Properties)]` parses an owned typed struct from a flat
`HashMap<String, String>` and can generate opt-in read-only getters. It
deliberately does not generate property-map serialization or implement
`Default`, `Serialize`, `Deserialize`, or any other trait.

### Generated API

For every annotated struct, `#[derive(Properties)]` generates this inherent
constructor:
Expand Down Expand Up @@ -54,7 +56,7 @@ map.
corresponding types from `std`. The macro recognizes those field shapes
syntactically and generates code using the standard-library variants.

## Complete example
### Complete example

This example covers exact keys and defaults, optional values, case-insensitive
booleans, prefixed maps, nested groups, custom single-value parsing, custom
Expand Down Expand Up @@ -211,7 +213,7 @@ fn main() -> iceberg::Result<()> {
}
```

## Using ordinary derives together
### Using ordinary derives together

`Properties` does not implicitly derive other traits, so `Default`,
`Serialize`, and `Deserialize` can be selected independently and behave like
Expand Down Expand Up @@ -251,6 +253,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
```

### Property declarations

All field settings must be grouped under `#[property(...)]`. This keeps `key`,
`default`, `prefix`, `nested`, parser hooks, additional keys, and getter
generation in one attribute and avoids collisions with ordinary Rust derives.
Expand All @@ -271,3 +275,75 @@ converted into their field type with `Into`.
`from_properties` and both custom parser hooks use `iceberg::Result`. Generated
`FromStr` failures use `ErrorKind::DataInvalid`. The macro preserves errors from
custom parsers and adds the primary property key as error context.

## `properties_view!` function-like macro

`properties_view!` independently defines a lightweight borrowed view over a
flat property map. Its struct-shaped fields are declarations used to generate
getters rather than stored fields. Constructing the view does not parse
anything; each generated getter parses and returns only its declared property.
Missing properties use their annotated defaults, while invalid configured
values return an error from the corresponding getter.

```rust
use std::collections::HashMap;

use iceberg_property_macro::properties_view;

properties_view! {
#[derive(Debug)]
pub struct WriteProperties {
// `getter` makes a generated getter public. Without it, the getter is
// private, regardless of the declaration's visibility modifier.

/// Number of times to retry a commit.
#[property(key = "commit.retry.num-retries", default = 4, getter)]
commit_num_retries: usize,

/// Optional configured base directory for metadata files.
#[property(key = "write.metadata.path", default = None)]
raw_write_metadata_path: Option<String>,
}
}

impl WriteProperties<'_> {
/// Returns the configured metadata path or the table's metadata directory.
pub fn write_metadata_path(&self, table_path: &str) -> iceberg::Result<String> {
Ok(self.raw_write_metadata_path()?.unwrap_or_else(|| {
format!("{}/metadata", table_path.trim_end_matches('/'))
}))
}
}

fn read(properties: &HashMap<String, String>) -> iceberg::Result<()> {
let view = WriteProperties::new(properties);
let _retries = view.commit_num_retries()?;
let _metadata_path = view.write_metadata_path("s3://bucket/table")?;
Ok(())
}
```

For this declaration, the macro generates
`impl<'properties> WriteProperties<'properties>` with a
`pub fn new(properties: &'properties HashMap<String, String>) -> Self`
constructor. The constructor has the same visibility as the view declaration:
`pub` here, `pub(crate)` for a `pub(crate)` view, and private for a private view.
It only stores the map reference and does not parse any properties.

The generated type has one lifetime parameter and contains only a reference to
Comment thread
blackmwk marked this conversation as resolved.
the source `HashMap`; no declared field is stored in the view. Every declaration
generates a getter, but only the `getter` option makes that method public. Field
visibility modifiers are ignored, consistent with how `Properties` uses
`getter` to control generated accessors. Omitting `getter` makes it possible to
keep a raw generated method private and expose a public method that adds
application-specific behavior, as shown by `write_metadata_path` above.

Non-`Copy` values are returned by value because the view does not retain parsed
results, so calling the same getter repeatedly reparses that field.

The view supports exact keys, optional values, case-insensitive booleans,
prefixed maps, nested views, `parse_with`, and `parse_properties_with` using the
same property declaration syntax as `Properties`. A nested field's type must be
another view type, such as `CommitProperties<'_>`. The nested getter returns a
view tied directly to the source map's lifetime, so it remains usable after the
parent view is dropped.
1 change: 1 addition & 0 deletions crates/property-macro/public-api.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
pub mod iceberg_property_macro
pub proc macro iceberg_property_macro::#[derive(Properties)]
pub proc macro iceberg_property_macro::properties_view!()
14 changes: 13 additions & 1 deletion crates/property-macro/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,10 @@
#![doc = include_str!("../README.md")]

use proc_macro::TokenStream;
use syn::{DeriveInput, parse_macro_input};
use syn::{DeriveInput, ItemStruct, parse_macro_input};

mod properties;
mod properties_view;

/// Derives property-map parsing and opt-in read-only accessors for a struct.
#[proc_macro_derive(Properties, attributes(property))]
Expand All @@ -32,3 +33,14 @@ pub fn derive_properties(input: TokenStream) -> TokenStream {
Err(error) => error.into_compile_error().into(),
}
}

/// Defines a borrowed view that parses properties independently through generated getters.
#[proc_macro]
pub fn properties_view(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as ItemStruct);

match properties_view::expand_properties_view(input) {
Ok(tokens) => tokens.into(),
Err(error) => error.into_compile_error().into(),
}
}
62 changes: 39 additions & 23 deletions crates/property-macro/src/properties.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,20 +24,20 @@ use syn::{
Ident, Lit, Path, PathArguments, Token, Type,
};

struct PropertyField {
ident: Ident,
ty: Type,
pub(crate) struct PropertyField {
pub(crate) ident: Ident,
pub(crate) ty: Type,
key: Option<Expr>,
additional_keys: Option<Vec<Expr>>,
prefix: Option<Expr>,
nested: bool,
pub(crate) nested: bool,
default: Option<Expr>,
parse_with: Option<Path>,
parse_properties_with: Option<Path>,
option_inner_type: Option<Type>,
map_value_type: Option<Type>,
public_getter: bool,
doc_attributes: Vec<Attribute>,
pub(crate) public_getter: bool,
pub(crate) doc_attributes: Vec<Attribute>,
}

enum PropertyOption {
Expand All @@ -52,7 +52,7 @@ enum PropertyOption {
}

#[derive(Default)]
struct PropertyOptions {
pub(crate) struct PropertyOptions {
key: Option<Expr>,
additional_keys: Option<Vec<Expr>>,
prefix: Option<Expr>,
Expand Down Expand Up @@ -143,7 +143,7 @@ pub(crate) fn expand_properties(input: DeriveInput) -> syn::Result<TokenStream2>
})
}

fn parse_property_field(
pub(crate) fn parse_property_field(
field: &Field,
property_options: PropertyOptions,
) -> syn::Result<PropertyField> {
Expand Down Expand Up @@ -244,7 +244,7 @@ fn parse_property_field(
})
}

fn property_options(field: &Field) -> syn::Result<PropertyOptions> {
pub(crate) fn property_options(field: &Field) -> syn::Result<PropertyOptions> {
let Some(attribute) = find_attribute(&field.attrs, "property")? else {
return Err(Error::new_spanned(
field,
Expand Down Expand Up @@ -394,9 +394,27 @@ fn find_attribute<'a>(

fn parse_field(field: &PropertyField) -> syn::Result<TokenStream2> {
let ident = &field.ident;
let parse = parse_field_value(field, ParseTarget::Owned)?;
Ok(quote!(#ident: #parse))
}

#[derive(Clone, Copy)]
pub(crate) enum ParseTarget {
Owned,
View,
}

pub(crate) fn parse_field_value(
field: &PropertyField,
target: ParseTarget,
) -> syn::Result<TokenStream2> {
if field.nested {
let ty = &field.ty;
return Ok(quote!(#ident: <#ty>::from_properties(properties)?));
let constructor = match target {
ParseTarget::Owned => quote!(<#ty>::from_properties(properties)?),
ParseTarget::View => quote!(<#ty>::new(properties)),
};
return Ok(constructor);
}

let ty = &field.ty;
Expand All @@ -415,17 +433,15 @@ fn parse_field(field: &PropertyField) -> syn::Result<TokenStream2> {
)
})?;
let default = typed_default(field)?;
return Ok(quote! {
#ident: {
let parsed: ::iceberg::Result<#ty> = #parse_properties_with(
properties,
#key,
&[#(#additional_keys),*],
#default,
);
parsed.map_err(|error| error.with_context("property", #key))?
}
});
return Ok(quote! {{
let parsed: ::iceberg::Result<#ty> = #parse_properties_with(
properties,
#key,
&[#(#additional_keys),*],
#default,
);
parsed.map_err(|error| error.with_context("property", #key))?
}});
}

if let Some(prefix) = &field.prefix {
Expand All @@ -441,7 +457,7 @@ fn parse_field(field: &PropertyField) -> syn::Result<TokenStream2> {
quote!(value.parse::<#value_type>())
};
return Ok(quote! {
#ident: properties
properties
.iter()
.filter_map(|(key, value)| {
key.strip_prefix(#prefix).map(|suffix| {
Expand Down Expand Up @@ -512,7 +528,7 @@ fn parse_field(field: &PropertyField) -> syn::Result<TokenStream2> {
};

Ok(quote! {
#ident: match properties.get(#key) {
match properties.get(#key) {
Some(value) => #parse,
None => #default,
}
Expand Down
Loading
Loading