Skip to content

Add an annotation framework for typed table properties - #2955

Draft
blackmwk wants to merge 15 commits into
apache:mainfrom
blackmwk:ir-2877
Draft

Add an annotation framework for typed table properties#2955
blackmwk wants to merge 15 commits into
apache:mainfrom
blackmwk:ir-2877

Conversation

@blackmwk

@blackmwk blackmwk commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

What changes are included in this PR?

  • Add the iceberg-property-macro crate with a Properties derive macro.
  • Define table-property keys, defaults, documentation, and custom converters using field annotations.
  • Generate Default, public getters, string-map parsing, and JSON serialization/deserialization.
  • Keep TableProperties fields private.
  • Change write.format.default from String to the typed DataFileFormat.
  • Update existing consumers to use the generated getters.
  • Update the public API snapshot.

Are these changes tested?

Yes.

  • Added unit tests for default and overridden property values.
  • Added JSON serialization and deserialization round-trip tests.
  • Added coverage for optional properties and custom parsing behavior.
  • Ran the Iceberg and DataFusion unit test suites.
  • Ran Clippy with warnings denied.
  • Verified formatting and the public API snapshot.

Comment thread crates/property-macro/src/lib.rs Outdated
Comment thread crates/iceberg/src/spec/table_properties.rs Outdated
Comment thread crates/iceberg/src/spec/table_properties.rs Outdated
Comment thread crates/iceberg/src/spec/table_properties.rs Outdated
@blackmwk
blackmwk marked this pull request as draft August 4, 2026 09:49
Comment thread crates/property-macro/Cargo.toml
Comment thread crates/iceberg/src/spec/table_props.rs
Comment thread crates/iceberg/src/spec/parsed_table_prop.rs Outdated
Comment thread crates/iceberg/src/spec/parsed_table_prop.rs Outdated
Comment thread crates/iceberg/src/spec/parsed_table_prop.rs Outdated
Comment thread crates/iceberg/src/spec/parsed_table_prop.rs Outdated
Comment thread crates/iceberg/src/spec/parsed_table_prop.rs Outdated
Comment thread crates/iceberg/src/spec/parsed_table_prop.rs Outdated
Comment thread crates/iceberg/src/spec/parsed_table_prop.rs Outdated
Comment thread crates/iceberg/src/spec/parsed_table_prop.rs Outdated
Comment thread crates/iceberg/src/spec/parsed_table_prop.rs Outdated
Comment thread crates/iceberg/src/spec/parsed_table_prop.rs Outdated
Comment thread crates/iceberg/src/spec/parsed_table_prop.rs Outdated
Comment thread crates/iceberg/src/spec/table_props.rs Outdated
Comment thread crates/iceberg/src/spec/parsed_table_prop.rs Outdated
Comment thread crates/iceberg/src/spec/parsed_table_prop.rs Outdated
Comment thread crates/iceberg/src/spec/parsed_table_prop.rs Outdated
Comment thread crates/iceberg/src/spec/parsed_table_prop.rs Outdated
Comment thread crates/iceberg/src/spec/parsed_table_prop.rs Outdated
Comment thread crates/iceberg/src/spec/parsed_table_prop.rs Outdated
Comment thread crates/iceberg/src/spec/table_props.rs Outdated
Comment thread crates/iceberg/src/spec/table_props.rs Outdated
Comment thread crates/iceberg/src/spec/parsed_table_prop.rs Outdated
Comment thread crates/iceberg/src/spec/parsed_table_prop.rs Outdated
Comment thread crates/iceberg/src/spec/parsed_table_prop.rs Outdated
Comment thread crates/iceberg/src/spec/table_props.rs
Comment thread crates/iceberg/src/spec/table_props.rs
Comment thread crates/iceberg/src/spec/parsed_table_prop.rs Outdated
Comment thread crates/iceberg/src/spec/parsed_table_prop.rs Outdated
Comment thread crates/iceberg/src/spec/parsed_table_prop.rs Outdated
Comment thread crates/iceberg/src/spec/parsed_table_prop.rs Outdated
Comment thread crates/iceberg/src/compression.rs Outdated
Comment thread crates/iceberg/src/compression.rs Outdated
Comment thread crates/iceberg/src/compression.rs Outdated
Comment thread crates/iceberg/src/lib.rs Outdated
Comment thread crates/iceberg/src/spec/parsed_table_prop.rs Outdated
Comment thread crates/iceberg/src/spec/parsed_table_prop.rs Outdated
Comment thread crates/iceberg/src/spec/parsed_table_prop.rs Outdated
@blackmwk
blackmwk marked this pull request as ready for review August 5, 2026 11:36
@blackmwk

blackmwk commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

I think this pr is ready for review, cc @kevinjqliu @CTTY

TableProperties is now easier maintain, and we could expand it to typed catalog configs later.

@kevinjqliu kevinjqliu left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks great. I iterated with codex on the PR review.

One thing that stood out to me for the new implementation is a lack of custom validators. For example, write.metadata.path previous implementation rejected empty values or trims trailing slashes.

I also feel like we might benefit from breaking this PR up into smaller chunks so we can more easily refactor the table properties in groups


#[test]
fn test_metadata_location_trims_trailing_slash() {
// A configured path with a trailing slash must not yield a doubled separator

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this behavior change intentional?

previous parser (parse_location_property) had custom logic to strip out the slashes.

And this test verifies that behavior

Comment thread crates/iceberg/Cargo.toml
fnv = { workspace = true }
form_urlencoded = { workspace = true }
futures = { workspace = true }
iceberg-property-macro = { version = "0.10.0", path = "../property-macro" }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think we have to publish the new crate, otherwise the iceberg crate cannot be packaged. 😭

cargo package -p iceberg --no-verify


/// Returns the codec name used by an Iceberg table property.
pub(crate) fn property_value(&self) -> String {
self.name().to_string()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This round trip changes a valid Parquet property into an invalid one:

write.parquet.compression-codec=uncompressed
→ CompressionCodec::None
→ write.parquet.compression-codec=none

Parquet expects uncompressed, not none.

Should serialization preserve the format-specific value?

}
}

impl FromStr for NameMapping {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit this NameMapping change seems unrelated to the PR

/// Returns an error if the compression codec property has an invalid value.
pub fn metadata_compression_codec(&self) -> Result<CompressionCodec> {
parse_metadata_file_compression(&self.properties)
Ok(self.table_properties()?.write_metadata_compression_codec)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

self.table_properties()? parses every modeled property.
parse_metadata_file_compression only parsed what it needs.

An invalid unrelated value can therefore make this accessor fail. Parse only the required property here, or make typed access lazy.

Comment thread crates/iceberg/src/spec/table_props.rs Outdated
Comment on lines +278 to +287
// Write properties.
#[key = "write.format.default"]
#[default(DataFileFormat::Parquet)]
#[doc = "Default data file format: Parquet, Avro, or ORC."]
pub write_format_default: DataFileFormat,

#[key = "write.delete.format.default"]
#[default(DataFileFormat::Parquet)]
#[doc = "Default delete file format: Parquet, Avro, or ORC."]
pub write_delete_format_default: DataFileFormat,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

write.delete.format.default should inherit write.format.default when absent, not always default to Parquet.

With only write.format.default=orc, the PR returns write_delete_format_default == Parquet

upstream documents the default as “data file format.”

Comment thread crates/iceberg/src/spec/table_props.rs Outdated
pub write_wap_enabled: bool,

#[key = "write.distribution-mode"]
#[default(DistributionMode::None)]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Iceberg leaves these distribution properties unset so engines can choose their defaults.

Using DistributionMode::None conflates absence with an explicit "none" and imposes behavior that is not standardized. Consider Option<DistributionMode> to preserve that distinction.

Comment thread crates/iceberg/src/spec/table_props.rs Outdated
#[key = "write.format.default"]
#[default(DataFileFormat::Parquet)]
#[doc = "Default data file format: Parquet, Avro, or ORC."]
pub write_format_default: DataFileFormat,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The DataFileFormat enum is broader than this property’s valid domain.
For example, the parser currently accepts write.format.default=puffin, although only Parquet, Avro, and ORC are allowed.

The same applies to CompressionCodec, which permits invalid format/codec combinations. Consider property-specific validation.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This refactor changes existing behavior and drops several regression tests from table_properties.rs.

Behavior lost or changed:

  • write.metadata.path no longer rejects empty values or trims trailing slashes.
  • Every call to table_properties() eagerly parses the expanded set of modeled properties, so an invalid unrelated property can make an operation fail. Some previous paths, such as metadata compression, parsed only the required key.
  • Metadata compression now accepts uncompressed; the previous allowlist was "", none, and gzip.

Missing regression coverage:

  • Empty and trailing-slash metadata paths.
  • Metadata-compression defaults, case-insensitive values, and invalid codecs.
  • Invalid numeric and boolean values.
  • CDC defaults, partial overrides, negative normalization levels, and parsing errors.

Could we preserve the previous behavior and port these regression tests as part of the refactor?

@blackmwk
blackmwk marked this pull request as draft August 6, 2026 06:25

@laskoviymishka laskoviymishka left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kevin’s threads already cover the property-level behavior changes, so I mostly looked at the API introduced by this revision.

The main issue is that the generated setters don’t enforce the same validation as the parse path.

For example, set_write_format_default(DataFileFormat::Puffin) compiles, but parse_table_file_format rejects Puffin. So the setter can create a TableProperties value that from_properties would refuse to build, and that invalid value can then be serialized back into a property map.

Same problem with things like:

  • set_write_parquet_compression_codec(CompressionCodec::Zlib)
  • set_write_avro_compression_codec(CompressionCodec::Brotli)

Those are outside the allowed codec sets for those formats.

Now that the fields are private, setters are the main write API, so I think this should be resolved before public-api.txt locks in all 116 of them.

A few options seem reasonable:

  • make setters fallible for fields using #[parse_with] / #[parse_properties_with], and reuse the same validator
  • keep those setters pub(crate) and expose validated builder methods instead
  • or, at minimum, document clearly that setters do not validate and callers are responsible for preserving invariants

A few other things I’d still look at:

  • getters return &T even for Copy fields, so call sites end up doing things like *properties.gc_enabled(). Returning primitives by value in field_accessors would be nicer. Fine as a follow-up if you don’t want more churn here.
  • the macro docs should spell out the four hook signatures. In particular, the parse hook gets the default by value while the write hook gets it by reference, and that isn’t obvious from the current docs.
  • #[write_properties_with] without #[parse_properties_with] should probably fail at compile time. If that asymmetry is intentional, the docs should say what parse path is used instead.

I’m also still in favor of splitting the PR.

This revision makes the case pretty well: table_props.rs grew from 1129 to 1682 lines, while the macro crate itself stayed fairly straightforward. Most of the review effort is going into the property definitions and defaults, not the macro mechanism.

I’d be more comfortable landing the macro plus the existing properties as a 1:1 port first, with the bar being “no observable behavior changes,” then adding the new properties in smaller groups and checking their defaults against Java.

The macro itself looks close.

let setter_doc = format!("Sets `{ident}`.");
quote! {
#[doc = #setter_doc]
pub fn #setter_ident(&mut self, value: #ty) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd make the setters for validated fields fallible, since as generated they bypass the validation the parse path enforces.

The setter is a bare assignment, so set_write_format_default(DataFileFormat::Puffin) compiles and leaves the struct in a state from_properties would have rejected — parse_table_file_format (table_props.rs:188) explicitly errors on Puffin. Same shape for set_write_parquet_compression_codec(CompressionCodec::Zlib) and set_write_avro_compression_codec(CompressionCodec::Brotli), neither of which is in the respective allowlist. So the value round-trips out to a property map that this crate itself won't read back.

For fields carrying a #[parse_with] / #[parse_properties_with] validator I'd have the setter call the same validator and return Result<()>. If keeping them infallible matters more, then narrowing them to pub(crate) and exposing validated builder methods would work too — or at minimum a doc line saying the setter is unvalidated so callers know the invariant is theirs to hold. wdyt?

/// the declared prefix. `nested` embeds another `Properties` struct while keeping its serialized
/// property map flat. `parse_with` may be used for exact-key property types that do not implement
/// `FromStr` or need validation. `serialize_with` supplies their string representation in JSON.
/// `parse_properties_with` and `write_properties_with` provide access to the complete property map

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd put the actual hook signatures in this doc block. It says write hooks are passed the field default and that additional_key is passed after the primary key, but never the shapes, so implementing a hook for a new field means reading parse_field and write_field to recover them.

From the generated call sites at :481 and :652:

// parse_properties_with, with additional_key
fn(&HashMap<String, String>, key: &str, additional_key: &str, default: T) -> Result<T, impl Display>
// without additional_key: fn(&HashMap<String, String>, key: &str, default: T) -> Result<T, impl Display>

// write_properties_with, with additional_key
fn(&T, &mut HashMap<String, String>, key: &str, additional_key: &str, default: &T)
// without additional_key: fn(&T, &mut HashMap<String, String>, key: &str, default: &T)

Two of these aren't guessable: default arrives by value in the parse hook but by reference in the write hook, and parse_with always receives &str even when the field is Option<T> — whereas serialize_with on an Option<T> field receives &Option<T> (see the other thread on :675). Getting any of them wrong surfaces as E0308 at the #[derive(Properties)] line with no pointer back to the attribute, so a few /// lines here would save a round trip through the macro source. A doctest exercising one hook would be even better, since it can't drift.

let getter = field.public_getter.then(|| {
quote! {
#(#docs)*
pub fn #ident(&self) -> &#ty {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd return T rather than &T for the Copy fields here. The template is pub fn #ident(&self) -> &#ty for every field, so gc_enabled() hands back &bool and commit_retry_num_retries() hands back &usize.

The cost shows up at every call site in this PR: *properties.gc_enabled() in catalog/utils.rs, *props.write_target_file_size_bytes() in parquet_writer.rs, and the same in transaction/mod.rs and expire_snapshots.rs. None of these fields carry a lifetime and the struct is Clone, so the reference buys nothing.

Gating on the primitive types in field_accessorsbool, the integer types, f64 — and emitting -> #ty / self.#ident for those would drop the derefs while leaving String, Option<T> and HashMap getters as they are. Happy to see this as a follow-up if you'd rather not churn the call sites in this PR, but it's cheaper to settle before the getter names land in public-api.txt.

let serialize_with = attribute_path_value(&field.attrs, "serialize_with")?;
let parse_properties_with = attribute_path_value(&field.attrs, "parse_properties_with")?;
let write_properties_with = attribute_path_value(&field.attrs, "write_properties_with")?;
if additional_key.is_some()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This gate catches #[additional_key] with neither hook, but not either hook without the other, so both asymmetric combinations compile silently.

#[write_properties_with(f)] on its own serializes through f and parses through the standard FromStr branch at :490, so the read and write paths end up enforcing different invariants — for exactly the multi-key fields these hooks exist to handle. The reverse pairing writes via ToString and loses whichever key the hook meant to set. Every use in table_props.rs today pairs them, so nothing is broken yet.

I'd reject an unpaired annotation here. If the asymmetric case is meant to be allowed, a note in the doc block naming which path the unhooked side falls back to would cover it.

let key = field.key.as_ref().expect("exact-key fields have a key");
if field.option_inner_type.is_some() {
let value = match &field.serialize_with {
Some(serialize_with) => quote!(#serialize_with(&self.#ident)),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For an Option<T> field serialize_with gets &Option<T>, while the bare-T branch at :687 gets &T — and the is_some() guard at :681 means the hook can only ever be called with Some(_).

That asymmetry is already costing something concrete: serialize_name_mapping (table_props.rs:404) has to take &Option<NameMapping> and carry .expect("checked is_some before serialization") for a case the generated code has ruled out. The signature encodes an invariant the type system isn't being told about.

Unwrapping before the call would make both branches hand over &T and let that expect go away:

Some(serialize_with) => quote!(#serialize_with(
    self.#ident.as_ref().expect("checked is_some above")
)),

One hook uses this today, so it's about as cheap to change as it will ever be. Documenting the asymmetry instead would also be fine, as long as the next person writing an Option hook finds out before the compiler tells them at the derive site.

@blackmwk

blackmwk commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

Hi, @laskoviymishka I have create a separate pr for the framework, see #2970

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[EPIC] Type safe TableProperties parsing.

3 participants