refactor(spec): make UnboundPartitionField fields private and carry source-ids - #3267
Conversation
The four public fields are replaced by accessors, and the derived builder's visibility is limited to the crate, so an instance can only be built inside the crate or read out of a partition spec JSON. That is what lets the struct guarantee its own invariant: source_ids always holds at least one id. source_ids: Vec<i32> replaces source_id: i32. source_id() returns the single id, and an error for a multi-argument field rather than quietly handing back the first one. The serde module reads either spelling -- source-id, or source-ids for a v3 multi-argument transform -- and writes back the one that matches the field. Binding a multi-argument field now fails with a clear error. A bound PartitionField still carries a single source_id, so until that struct is converted there is nowhere to put the extra ids, and failing loudly beats dropping them on the way into TableCreation or TableUpdate::AddSpec.
Stefan-Dienst
left a comment
There was a problem hiding this comment.
Hi @moomindani ,
Thanks for the split PR. I did a quick pass, as I am interested in how this whole thing will land. Only flagged minor things. Looks good otherwise.
| (Some(source_id), Some(source_ids)) => { | ||
| // Tolerated for readers, but the two must agree |
There was a problem hiding this comment.
Why is this tolerated? Isn't source-id and source-ids mutually exclusive in v3?
There was a problem hiding this comment.
Good catch — nothing needs the tolerance. The spec only rules on what a writer emits, no writer emits both (Java still writes only source-id), and PyIceberg already rejects the pair as mutually exclusive, so accepting a matching pair only let the two readers disagree. 8fcd795 now rejects source-id and source-ids together, including when they agree.
| source_ids | ||
| } | ||
| (None, None) => { | ||
| return Err(invalid_data!("missing field `source-id`")); |
There was a problem hiding this comment.
nit: missing field "could also be" source-ids. Maybe rephrase to "Either source-id or source-ids must be present".
|
|
||
| /// Ensure that the transformation of the field is compatible with type of the field | ||
| /// in the schema. Implicitly also checks if the source field exists in the schema. | ||
| fn check_transform_compatibility(field: &UnboundPartitionField, schema: &Schema) -> Result<()> { |
There was a problem hiding this comment.
Shouldn't this be changed to check transform compatibility for all source ids?
There was a problem hiding this comment.
This is deliberate for now: field.source_id()? here is what makes binding a multi-argument field fail, which test_binding_a_multi_argument_field_fails_loudly pins. Checking every source id needs a multi-input Transform::result_type, and a bound PartitionField still holds one id, so that check belongs with the PartitionField conversion (#2802). I added a comment in 8fcd795 so the intent is visible at the call site.
PyIceberg already treats the two spellings as mutually exclusive, and no writer emits both, so accepting a matching pair only let the two readers disagree. Also name both spellings in the missing-id error and note why check_transform_compatibility rejects a multi-argument field. Co-authored-by: Isaac <no-reply@databricks.com>
blackmwk
left a comment
There was a problem hiding this comment.
Thanks @moomindani for this pr!
Address review: the UnboundPartitionField builder and with_field_id are public, since an unbound field is not expected to be validated yet, and UnboundPartitionSpecBuilder::add_partition_field now takes the field rather than a single source id, name and transform. With the builder public an empty source_ids can be built, so serializing one no longer panics and both spec builders reject it with a clear error. Co-authored-by: Isaac <no-reply@databricks.com>
|
@moomindani Thanks for the changes. Maybe we should take #3172 (comment) into account and first have a preceding PR that warns about the removal of the pub fields of |
| /// Being unbound, a field built through [`UnboundPartitionField::builder`] is not validated; | ||
| /// it is checked when added to an [`UnboundPartitionSpecBuilder`] and again when bound to a | ||
| /// schema. | ||
| #[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, TypedBuilder)] |
There was a problem hiding this comment.
I think this is one missing point: the builder's return type should be Result<UnboundPartitionField>, since we should not allow empty source ids. You can learn from what we did in FileScanTask to make TypedBuilder to return a Result and add check there.
There was a problem hiding this comment.
Done in 4e72bee, following FileScanTask: build() now returns Result<UnboundPartitionField> and rejects an empty source_ids, so an instance is always well formed again and the spec builders no longer carry their own check.
| fn try_from(value: UnboundPartitionFieldSerde) -> Result<Self, Error> { | ||
| let source_ids = match (value.source_id, value.source_ids) { | ||
| (Some(source_id), None) => vec![source_id], | ||
| (None, Some(source_ids)) if !source_ids.is_empty() => source_ids, |
There was a problem hiding this comment.
This check should be done in the builder I mentioned in https://github.com/apache/iceberg-rust/pull/3267/changes#r4132801710
There was a problem hiding this comment.
Done in 4e72bee — deserialization now builds through the same builder, so the empty check lives only there.
I replied in original issue, let's continue discussion there. |
Address review: the builder's build() now returns Result<UnboundPartitionField> and rejects an empty source_ids, following FileScanTask. Deserialization goes through the same builder, so the check lives in one place and the spec builders no longer need their own. Co-authored-by: Isaac <no-reply@databricks.com>
blackmwk
left a comment
There was a problem hiding this comment.
Thanks @moomindani for this pr!
apache#3267 made UnboundPartitionField's fields private and gave it source-ids, so UnboundPartitionSpecBuilder::add_partition_field now takes the field rather than its parts. The spec-evolution fixture built both of its specs the old way. Git merges the two changes without a word and only the compiler notices, so the port is part of this merge rather than a commit after it. Co-authored-by: Rexwell Minnis <rexminnis@gmail.com>
Which issue does this PR close?
Part of #3172 and #2801. First of the three per-struct PRs @blackmwk asked for in #2802, starting with
UnboundPartitionFieldas he suggested. #2802 stays open and will be narrowed toPartitionFieldonce this lands.What changes are included in this PR?
UnboundPartitionFieldgets the #3172 treatment — private fields with accessors — and with it the v3source-idsspelling.source_id() -> Result<i32>,source_ids() -> &[i32],field_id(),name()andtransform()accessors. The derivedTypedBuilderis public and, followingFileScanTask, itsbuild()returnsResultand rejects an emptysource_ids;with_field_idis public too.source_ids: Vec<i32>replacessource_id: i32and always holds at least one id.source_id()returns the single id, and an error for a multi-argument field rather than quietly handing back the first one._serdemodule reads either spelling —source-id, orsource-idsfor a v3 multi-argument transform — and writes back the one that matches the field. An emptysource-ids, a missing id, and a field that carries bothsource-idandsource-idsare all rejected.PartitionFieldstill carries a singlesource_id, so until that struct is converted there is nowhere to put the extra ids, and failing loudly beats dropping them on the way intoTableCreationorTableUpdate::AddSpec.check_for_redundant_partitionscompares the whole id list instead of one id.UnboundPartitionSpecBuilder::add_partition_fieldtakes anUnboundPartitionFieldinstead of a source id, name and transform, so it can carry a multi-argument field.add_partition_field_internalis folded into it.Not in this PR, and not lost:
PartitionField(#2802, to be narrowed once this lands) andSortFieldget the same treatment, and with them the multi-argument read support and theTransform::Unknownmapping that #2801 is about.Are these changes tested?
Yes — five new unit tests in
spec/partition.rs: reading asource-ids-only field and writing it back, the single-id round trip, the three malformed-id rejections, binding a multi-argument field failing loudly, and the builder rejecting an emptysource_ids.cargo test -p iceberg --libpasses (1769 tests), withcargo fmt,cargo clippy --workspace --all-targets --all-features -- -D warningsand a full workspace build clean.crates/iceberg/public-api.txtis regenerated: four public fields out, five accessors andwith_field_idin,builder()still public,From<UnboundPartitionField> for Result<UnboundPartitionField>added, and the newadd_partition_fieldsignature.AI Disclosure
This pull request and its description were written by Isaac.