-
Notifications
You must be signed in to change notification settings - Fork 539
Add derive properties macro #2970
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
894637b
a1fd056
0068cce
4e35b2c
5896eea
e9b6b6f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| # Licensed to the Apache Software Foundation (ASF) under one | ||
| # or more contributor license agreements. See the NOTICE file | ||
| # distributed with this work for additional information | ||
| # regarding copyright ownership. The ASF licenses this file | ||
| # to you under the Apache License, Version 2.0 (the | ||
| # "License"); you may not use this file except in compliance | ||
| # with the License. You may obtain a copy of the License at | ||
| # | ||
| # http://www.apache.org/licenses/LICENSE-2.0 | ||
| # | ||
| # Unless required by applicable law or agreed to in writing, | ||
| # software distributed under the License is distributed on an | ||
| # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | ||
| # KIND, either express or implied. See the License for the | ||
| # specific language governing permissions and limitations | ||
| # under the License. | ||
|
|
||
| [package] | ||
| edition = { workspace = true } | ||
| homepage = { workspace = true } | ||
| name = "iceberg-property-macro" | ||
| publish = true | ||
| readme = "README.md" | ||
| rust-version = { workspace = true } | ||
| version = { workspace = true } | ||
|
|
||
| license = { workspace = true } | ||
| repository = { workspace = true } | ||
|
|
||
| categories = ["database"] | ||
| description = "Property derive macro for Apache Iceberg Rust" | ||
| keywords = ["iceberg"] | ||
|
|
||
| [lib] | ||
| proc-macro = true | ||
|
|
||
| [dependencies] | ||
| proc-macro2 = "1" | ||
| quote = "1" | ||
| syn = { version = "2", features = ["full"] } | ||
|
|
||
| [dev-dependencies] | ||
| serde = { workspace = true, features = ["derive"] } | ||
| serde_json = { workspace = true } | ||
|
|
||
| [lints] | ||
| workspace = true | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,240 @@ | ||
| <!-- | ||
| Licensed to the Apache Software Foundation (ASF) under one | ||
| or more contributor license agreements. See the NOTICE file | ||
| distributed with this work for additional information | ||
| regarding copyright ownership. The ASF licenses this file | ||
| to you under the Apache License, Version 2.0 (the | ||
| "License"); you may not use this file except in compliance | ||
| with the License. You may obtain a copy of the License at | ||
|
|
||
| http://www.apache.org/licenses/LICENSE-2.0 | ||
|
|
||
| Unless required by applicable law or agreed to in writing, | ||
| software distributed under the License is distributed on an | ||
| "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | ||
| KIND, either express or implied. See the License for the | ||
| specific language governing permissions and limitations | ||
| under the License. | ||
| --> | ||
|
|
||
| # Iceberg property derive macro | ||
|
|
||
| `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. | ||
|
|
||
| ## Generated API | ||
|
|
||
| For every annotated struct, `#[derive(Properties)]` generates this inherent | ||
| constructor: | ||
|
|
||
| ```text | ||
| impl MyProperties { | ||
| pub fn from_properties( | ||
| properties: &HashMap<String, String>, | ||
| ) -> Result<Self, String>; | ||
| } | ||
| ``` | ||
|
|
||
| `from_properties` borrows the source map, parses every modeled property, and | ||
| uses its annotated default when a property is absent. Unknown keys are ignored. | ||
| An invalid value returns an error containing its primary property key. | ||
|
|
||
| Adding `pub(getter)` to a field generates an immutable accessor with the field | ||
| name. Structurally known `Copy` types return `T`; other types return `&T`. | ||
| Documentation attributes on the field are copied to the generated getter. The | ||
| macro generates no setters, backing fields, or conversion back to a property | ||
| map. | ||
|
|
||
| ## Complete example | ||
|
|
||
| This example covers exact keys and defaults, optional values, case-insensitive | ||
| booleans, prefixed maps, nested groups, custom single-value parsing, custom | ||
| multi-key parsing, lists of additional keys, read-only getters, ignored unknown | ||
| keys, and contextual errors. | ||
|
|
||
| ```rust | ||
| use std::collections::HashMap; | ||
|
|
||
| use iceberg_property_macro::Properties; | ||
|
|
||
| const RETRIES: &str = "commit.retry.num-retries"; | ||
| const OWNER: &str = "owner"; | ||
| const FANOUT: &str = "write.fanout.enabled"; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't think Since this README is teaching material in Iceberg's own repo, I'd use the real keys so nobody bakes a nonexistent one into a config. Worth a note that |
||
| const COLUMN_FPP_PREFIX: &str = "write.parquet.bloom-filter-fpp.column."; | ||
| const LOCATION: &str = "write.data.path"; | ||
| const WIDTH: &str = "dimensions.width"; | ||
| const HEIGHT: &str = "dimensions.height"; | ||
| const DEPTH: &str = "dimensions.depth"; | ||
|
|
||
| fn parse_location(value: &str) -> Result<String, &'static str> { | ||
| let location = value.trim().trim_end_matches('/'); | ||
| if location.is_empty() { | ||
| Err("location must not be empty") | ||
| } else { | ||
| Ok(location.to_string()) | ||
| } | ||
| } | ||
|
|
||
| fn parse_dimensions( | ||
| properties: &HashMap<String, String>, | ||
| width_key: &str, | ||
| additional_keys: &[&str], | ||
| default: (u64, u64, u64), | ||
| ) -> Result<(u64, u64, u64), String> { | ||
| if additional_keys.len() != 2 { | ||
| return Err("dimensions require height and depth keys".to_string()); | ||
| } | ||
| let parse = |key: &str, default| { | ||
| properties | ||
| .get(key) | ||
| .map(|value| value.parse::<u64>().map_err(|error| error.to_string())) | ||
| .transpose() | ||
| .map(|value| value.unwrap_or(default)) | ||
| }; | ||
|
|
||
| Ok(( | ||
| parse(width_key, default.0)?, | ||
| parse(additional_keys[0], default.1)?, | ||
| parse(additional_keys[1], default.2)?, | ||
| )) | ||
| } | ||
|
|
||
| #[derive(Debug, Properties)] | ||
| struct CommitProperties { | ||
| /// Maximum number of times to retry a commit. | ||
| #[property(key = RETRIES, default = 4, pub(getter))] | ||
| retries: usize, | ||
| } | ||
|
|
||
| #[derive(Debug, Properties)] | ||
| struct TableLikeProperties { | ||
| /// Nested groups parse from the same flat property map. | ||
| #[property(nested, pub(getter))] | ||
| commit: CommitProperties, | ||
|
|
||
| /// Option<T> distinguishes an absent property from a present value. | ||
| #[property(key = OWNER, default = None, pub(getter))] | ||
| owner: Option<String>, | ||
|
|
||
| /// Boolean values are parsed case-insensitively. | ||
| #[property(key = FANOUT, default = true, pub(getter))] | ||
| fanout_enabled: bool, | ||
|
|
||
| /// A prefix captures suffix/value pairs into a typed map. | ||
| #[property(prefix = COLUMN_FPP_PREFIX, default = HashMap::new(), pub(getter))] | ||
| column_fpp: HashMap<String, f64>, | ||
|
|
||
| /// A single-key parser can validate and normalize a property value. | ||
| #[property( | ||
| key = LOCATION, | ||
| default = "warehouse", | ||
| parse_with = parse_location, | ||
| pub(getter) | ||
| )] | ||
| location: String, | ||
|
|
||
| /// A full-map parser can model one field with multiple property keys. | ||
| #[property( | ||
| key = WIDTH, | ||
| additional_keys = [HEIGHT, DEPTH], | ||
| default = (640, 480, 320), | ||
| parse_properties_with = parse_dimensions, | ||
| pub(getter) | ||
| )] | ||
| dimensions: (u64, u64, u64), | ||
| } | ||
|
|
||
| fn main() -> Result<(), String> { | ||
| let defaults = TableLikeProperties::from_properties(&HashMap::new())?; | ||
| assert_eq!(defaults.commit().retries(), 4); | ||
| assert_eq!(defaults.owner(), &None); | ||
| assert!(defaults.fanout_enabled()); | ||
| assert!(defaults.column_fpp().is_empty()); | ||
| assert_eq!(defaults.location(), "warehouse"); | ||
| assert_eq!(defaults.dimensions(), (640, 480, 320)); | ||
|
|
||
| let raw = HashMap::from([ | ||
| (RETRIES.to_string(), "8".to_string()), | ||
| (OWNER.to_string(), "iceberg".to_string()), | ||
| (FANOUT.to_string(), "FALSE".to_string()), | ||
| (format!("{COLUMN_FPP_PREFIX}id"), "0.01".to_string()), | ||
| (LOCATION.to_string(), " s3://bucket/table/ ".to_string()), | ||
| (WIDTH.to_string(), "1920".to_string()), | ||
| (HEIGHT.to_string(), "1080".to_string()), | ||
| (DEPTH.to_string(), "720".to_string()), | ||
| ("unmodeled".to_string(), "ignored".to_string()), | ||
| ]); | ||
|
|
||
| let properties = TableLikeProperties::from_properties(&raw)?; | ||
| assert_eq!(properties.commit().retries(), 8); | ||
| assert_eq!(properties.owner().as_deref(), Some("iceberg")); | ||
| assert!(!properties.fanout_enabled()); | ||
| assert_eq!(properties.column_fpp()["id"], 0.01); | ||
| assert_eq!(properties.location(), "s3://bucket/table"); | ||
| assert_eq!(properties.dimensions(), (1920, 1080, 720)); | ||
|
|
||
| let error = TableLikeProperties::from_properties(&HashMap::from([( | ||
| LOCATION.to_string(), | ||
| "/".to_string(), | ||
| )])) | ||
| .unwrap_err(); | ||
| assert!(error.contains(LOCATION)); | ||
|
|
||
| Ok(()) | ||
| } | ||
| ``` | ||
|
|
||
| ## Using ordinary derives together | ||
|
|
||
| `Properties` does not implicitly derive other traits, so `Default`, | ||
| `Serialize`, and `Deserialize` can be selected independently and behave like | ||
| ordinary Rust derives: | ||
|
|
||
| ```rust | ||
| use std::collections::HashMap; | ||
|
|
||
| use iceberg_property_macro::Properties; | ||
| use serde::{Deserialize, Serialize}; | ||
|
|
||
| #[derive(Debug, Default, Serialize, Deserialize, Properties)] | ||
| struct ReadProperties { | ||
| #[property( | ||
| key = "commit.retry.num-retries", | ||
| default = 4, | ||
| pub(getter) | ||
| )] | ||
| retries: u64, | ||
| } | ||
|
|
||
| fn main() -> Result<(), Box<dyn std::error::Error>> { | ||
| // The property annotation supplies the default used by from_properties. | ||
| let properties = ReadProperties::from_properties(&HashMap::new())?; | ||
| assert_eq!(properties.retries(), 4); | ||
|
|
||
| // The ordinary Default derive uses the field's Rust default instead. | ||
| let defaults = ReadProperties::default(); | ||
| assert_eq!(defaults.retries(), 0); | ||
|
|
||
| // Ordinary Serde derives use Rust field names, not property keys. | ||
| let json = serde_json::to_string(&properties)?; | ||
| assert_eq!(json, r#"{"retries":4}"#); | ||
| let decoded: ReadProperties = serde_json::from_str(r#"{"retries":7}"#)?; | ||
| assert_eq!(decoded.retries(), 7); | ||
| Ok(()) | ||
| } | ||
| ``` | ||
|
|
||
| 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. | ||
|
|
||
| The `prefix` setting requires `HashMap<String, T>`. `nested` embeds another | ||
| `Properties` struct while reading the same flat map. `parse_with` customizes | ||
| parsing for one exact-key field. `parse_properties_with` receives the complete | ||
| property map, and `additional_keys` supplies its list of secondary keys. | ||
|
|
||
| Boolean values are parsed case-insensitively. Other values require `FromStr` | ||
| unless a custom parser is supplied. String-literal and path defaults are | ||
| converted into their field type with `Into`. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| pub mod iceberg_property_macro | ||
| pub proc macro iceberg_property_macro::#[derive(Properties)] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| // Licensed to the Apache Software Foundation (ASF) under one | ||
| // or more contributor license agreements. See the NOTICE file | ||
| // distributed with this work for additional information | ||
| // regarding copyright ownership. The ASF licenses this file | ||
| // to you under the Apache License, Version 2.0 (the | ||
| // "License"); you may not use this file except in compliance | ||
| // with the License. You may obtain a copy of the License at | ||
| // | ||
| // http://www.apache.org/licenses/LICENSE-2.0 | ||
| // | ||
| // Unless required by applicable law or agreed to in writing, | ||
| // software distributed under the License is distributed on an | ||
| // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | ||
| // KIND, either express or implied. See the License for the | ||
| // specific language governing permissions and limitations | ||
| // under the License. | ||
|
|
||
| #![doc = include_str!("../README.md")] | ||
|
|
||
| use proc_macro::TokenStream; | ||
| use syn::{DeriveInput, parse_macro_input}; | ||
|
|
||
| mod properties; | ||
|
|
||
| /// Derives property-map parsing and opt-in read-only accessors for a struct. | ||
| #[proc_macro_derive(Properties, attributes(property))] | ||
| pub fn derive_properties(input: TokenStream) -> TokenStream { | ||
| let input = parse_macro_input!(input as DeriveInput); | ||
|
|
||
| match properties::expand_properties(input) { | ||
| Ok(tokens) => tokens.into(), | ||
| Err(error) => error.into_compile_error().into(), | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is the split I asked for on #2955 — thanks for pulling the macro out. It went a bit further than the 1:1 port though: the
TablePropertiesport that would prove the DSL fits our real property structs isn't here yet, and the crate ispublish = trueat 0.10.0 with nothing consuming it. Validating the macro against the existing properties was the whole point of porting first.I'd want the port alongside this — or
publish = falseplus a tracking issue until it lands, before the API locks intopublic-api.txt. #2955 linked #2877; this PR's body is the empty template, so it'd help to link an issue here too. wdyt?