This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-rust.git
The following commit(s) were added to refs/heads/main by this push:
new deab0692f refactor(spec): make UnboundPartitionField fields private
and carry source-ids (#3267)
deab0692f is described below
commit deab0692f6df211a396d6fc0ad21811f8b6c8491
Author: Noritaka Sekiyama <[email protected]>
AuthorDate: Tue Sep 29 11:49:43 2026 +0000
refactor(spec): make UnboundPartitionField fields private and carry
source-ids (#3267)
* Make UnboundPartitionField fields private and carry source-ids
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.
* Reject source-id and source-ids together in UnboundPartitionField
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 <[email protected]>
* Make UnboundPartitionField buildable and pass it to add_partition_field
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 <[email protected]>
* Validate UnboundPartitionField when it is built
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 <[email protected]>
---------
Co-authored-by: Isaac <[email protected]>
---
crates/catalog/rest/src/catalog.rs | 11 +-
crates/iceberg/public-api.txt | 15 +-
.../src/arrow/record_batch_partition_splitter.rs | 28 +-
crates/iceberg/src/catalog/mod.rs | 31 +-
.../src/expr/visitors/expression_evaluator.rs | 5 +-
.../expr/visitors/inclusive_metrics_evaluator.rs | 5 +-
.../src/expr/visitors/inclusive_projection.rs | 60 +--
crates/iceberg/src/partitioning.rs | 88 ++--
crates/iceberg/src/scan/cache.rs | 11 +-
crates/iceberg/src/spec/partition.rs | 461 ++++++++++++++++-----
crates/iceberg/src/spec/snapshot_summary.rs | 10 +-
crates/iceberg/src/spec/table_metadata.rs | 108 +++--
crates/iceberg/src/spec/table_metadata_builder.rs | 304 ++++++++++----
13 files changed, 821 insertions(+), 316 deletions(-)
diff --git a/crates/catalog/rest/src/catalog.rs
b/crates/catalog/rest/src/catalog.rs
index aa36a074e..b7d3b3163 100644
--- a/crates/catalog/rest/src/catalog.rs
+++ b/crates/catalog/rest/src/catalog.rs
@@ -3832,13 +3832,14 @@ mod tests {
.properties(HashMap::from([("owner".to_string(),
"testx".to_string())]))
.partition_spec(
UnboundPartitionSpec::builder()
- .add_partition_fields(vec![
+ .add_partition_field(
UnboundPartitionField::builder()
- .source_id(1)
+ .source_ids(vec![1])
+ .name("id")
.transform(Transform::Truncate(3))
- .name("id".to_string())
- .build(),
- ])
+ .build()
+ .unwrap(),
+ )
.unwrap()
.build(),
)
diff --git a/crates/iceberg/public-api.txt b/crates/iceberg/public-api.txt
index e2e78073e..58c7db7dd 100644
--- a/crates/iceberg/public-api.txt
+++ b/crates/iceberg/public-api.txt
@@ -2911,10 +2911,13 @@ pub fn
iceberg::spec::TableProperties<'properties>::write_target_file_size_bytes
impl<'properties> core::fmt::Debug for
iceberg::spec::TableProperties<'properties>
pub fn iceberg::spec::TableProperties<'properties>::fmt(&self, f: &mut
core::fmt::Formatter<'_>) -> core::fmt::Result
pub struct iceberg::spec::UnboundPartitionField
-pub iceberg::spec::UnboundPartitionField::field_id: core::option::Option<i32>
-pub iceberg::spec::UnboundPartitionField::name: alloc::string::String
-pub iceberg::spec::UnboundPartitionField::source_id: i32
-pub iceberg::spec::UnboundPartitionField::transform: iceberg::spec::Transform
+impl iceberg::spec::UnboundPartitionField
+pub fn iceberg::spec::UnboundPartitionField::field_id(&self) ->
core::option::Option<i32>
+pub fn iceberg::spec::UnboundPartitionField::name(&self) -> &str
+pub fn iceberg::spec::UnboundPartitionField::source_id(&self) ->
iceberg::Result<i32>
+pub fn iceberg::spec::UnboundPartitionField::source_ids(&self) -> &[i32]
+pub fn iceberg::spec::UnboundPartitionField::transform(&self) ->
iceberg::spec::Transform
+pub fn iceberg::spec::UnboundPartitionField::with_field_id(self, field_id:
i32) -> Self
impl core::clone::Clone for iceberg::spec::UnboundPartitionField
pub fn iceberg::spec::UnboundPartitionField::clone(&self) ->
iceberg::spec::UnboundPartitionField
impl core::cmp::Eq for iceberg::spec::UnboundPartitionField
@@ -2922,6 +2925,8 @@ impl core::cmp::PartialEq for
iceberg::spec::UnboundPartitionField
pub fn iceberg::spec::UnboundPartitionField::eq(&self, other:
&iceberg::spec::UnboundPartitionField) -> bool
impl core::convert::From<iceberg::spec::PartitionField> for
iceberg::spec::UnboundPartitionField
pub fn iceberg::spec::UnboundPartitionField::from(field:
iceberg::spec::PartitionField) -> Self
+impl core::convert::From<iceberg::spec::UnboundPartitionField> for
iceberg::Result<iceberg::spec::UnboundPartitionField>
+pub fn iceberg::Result<iceberg::spec::UnboundPartitionField>::from(field:
iceberg::spec::UnboundPartitionField) -> Self
impl core::fmt::Debug for iceberg::spec::UnboundPartitionField
pub fn iceberg::spec::UnboundPartitionField::fmt(&self, f: &mut
core::fmt::Formatter<'_>) -> core::fmt::Result
impl core::marker::StructuralPartialEq for iceberg::spec::UnboundPartitionField
@@ -2956,7 +2961,7 @@ impl<'de> serde_core::de::Deserialize<'de> for
iceberg::spec::UnboundPartitionSp
pub fn iceberg::spec::UnboundPartitionSpec::deserialize<__D>(__deserializer:
__D) -> core::result::Result<Self, <__D as
serde_core::de::Deserializer>::Error> where __D:
serde_core::de::Deserializer<'de>
pub struct iceberg::spec::UnboundPartitionSpecBuilder
impl iceberg::spec::UnboundPartitionSpecBuilder
-pub fn iceberg::spec::UnboundPartitionSpecBuilder::add_partition_field(self,
source_id: i32, target_name: impl alloc::string::ToString, transformation:
iceberg::spec::Transform) -> iceberg::Result<Self>
+pub fn iceberg::spec::UnboundPartitionSpecBuilder::add_partition_field(self,
field: iceberg::spec::UnboundPartitionField) -> iceberg::Result<Self>
pub fn iceberg::spec::UnboundPartitionSpecBuilder::add_partition_fields(self,
fields: impl core::iter::traits::collect::IntoIterator<Item =
iceberg::spec::UnboundPartitionField>) -> iceberg::Result<Self>
pub fn iceberg::spec::UnboundPartitionSpecBuilder::build(self) ->
iceberg::spec::UnboundPartitionSpec
pub fn iceberg::spec::UnboundPartitionSpecBuilder::new() -> Self
diff --git a/crates/iceberg/src/arrow/record_batch_partition_splitter.rs
b/crates/iceberg/src/arrow/record_batch_partition_splitter.rs
index 6b3aa73d2..0b679040c 100644
--- a/crates/iceberg/src/arrow/record_batch_partition_splitter.rs
+++ b/crates/iceberg/src/arrow/record_batch_partition_splitter.rs
@@ -244,12 +244,14 @@ mod tests {
let partition_spec = Arc::new(
PartitionSpecBuilder::new(schema.clone())
.with_spec_id(1)
- .add_unbound_field(UnboundPartitionField {
- source_id: 1,
- field_id: None,
- name: "id_bucket".to_string(),
- transform: Transform::Identity,
- })
+ .add_unbound_field(
+ UnboundPartitionField::builder()
+ .source_ids(vec![1])
+ .name("id_bucket".to_string())
+ .transform(Transform::Identity)
+ .build()
+ .unwrap(),
+ )
.unwrap()
.build()
.unwrap(),
@@ -358,12 +360,14 @@ mod tests {
let partition_spec = Arc::new(
PartitionSpecBuilder::new(schema.clone())
.with_spec_id(1)
- .add_unbound_field(UnboundPartitionField {
- source_id: 1,
- field_id: None,
- name: "id_bucket".to_string(),
- transform: Transform::Identity,
- })
+ .add_unbound_field(
+ UnboundPartitionField::builder()
+ .source_ids(vec![1])
+ .name("id_bucket".to_string())
+ .transform(Transform::Identity)
+ .build()
+ .unwrap(),
+ )
.unwrap()
.build()
.unwrap(),
diff --git a/crates/iceberg/src/catalog/mod.rs
b/crates/iceberg/src/catalog/mod.rs
index cf7a94d20..88681040c 100644
--- a/crates/iceberg/src/catalog/mod.rs
+++ b/crates/iceberg/src/catalog/mod.rs
@@ -1103,8 +1103,8 @@ mod tests {
PartitionStatisticsFile, PrimitiveType, Schema, Snapshot,
SnapshotReference,
SnapshotRetention, SortDirection, SortField, SortOrder,
SqlViewRepresentation,
StatisticsFile, Summary, TableMetadata, TableMetadataBuilder,
Transform, Type,
- UnboundPartitionSpec, ViewFormatVersion, ViewRepresentation,
ViewRepresentations,
- ViewVersion,
+ UnboundPartitionField, UnboundPartitionSpec, ViewFormatVersion,
ViewRepresentation,
+ ViewRepresentations, ViewVersion,
};
use crate::table::Table;
use crate::test_utils::test_runtime;
@@ -1659,11 +1659,32 @@ mod tests {
"#,
TableUpdate::AddSpec {
spec: UnboundPartitionSpec::builder()
- .add_partition_field(4, "ts_day".to_string(),
Transform::Day)
+ .add_partition_field(
+ UnboundPartitionField::builder()
+ .source_ids(vec![4])
+ .name("ts_day")
+ .transform(Transform::Day)
+ .build()
+ .unwrap(),
+ )
.unwrap()
- .add_partition_field(1, "id_bucket".to_string(),
Transform::Bucket(16))
+ .add_partition_field(
+ UnboundPartitionField::builder()
+ .source_ids(vec![1])
+ .name("id_bucket")
+ .transform(Transform::Bucket(16))
+ .build()
+ .unwrap(),
+ )
.unwrap()
- .add_partition_field(2, "id_truncate".to_string(),
Transform::Truncate(4))
+ .add_partition_field(
+ UnboundPartitionField::builder()
+ .source_ids(vec![2])
+ .name("id_truncate")
+ .transform(Transform::Truncate(4))
+ .build()
+ .unwrap(),
+ )
.unwrap()
.build(),
},
diff --git a/crates/iceberg/src/expr/visitors/expression_evaluator.rs
b/crates/iceberg/src/expr/visitors/expression_evaluator.rs
index 122f19589..31ce94a99 100644
--- a/crates/iceberg/src/expr/visitors/expression_evaluator.rs
+++ b/crates/iceberg/src/expr/visitors/expression_evaluator.rs
@@ -280,11 +280,12 @@ mod tests {
.with_spec_id(1)
.add_unbound_field(
UnboundPartitionField::builder()
- .source_id(1)
+ .source_ids(vec![1])
.name("a".to_string())
.field_id(1)
.transform(Transform::Identity)
- .build(),
+ .build()
+ .unwrap(),
)
.unwrap()
.build()
diff --git a/crates/iceberg/src/expr/visitors/inclusive_metrics_evaluator.rs
b/crates/iceberg/src/expr/visitors/inclusive_metrics_evaluator.rs
index 06c92ab3e..8ccd1364d 100644
--- a/crates/iceberg/src/expr/visitors/inclusive_metrics_evaluator.rs
+++ b/crates/iceberg/src/expr/visitors/inclusive_metrics_evaluator.rs
@@ -1661,11 +1661,12 @@ mod test {
.with_spec_id(1)
.add_unbound_fields(vec![
UnboundPartitionField::builder()
- .source_id(1)
+ .source_ids(vec![1])
.name("a".to_string())
.field_id(1)
.transform(Transform::Identity)
- .build(),
+ .build()
+ .unwrap(),
])
.unwrap()
.build()
diff --git a/crates/iceberg/src/expr/visitors/inclusive_projection.rs
b/crates/iceberg/src/expr/visitors/inclusive_projection.rs
index d9544e4c4..88508af84 100644
--- a/crates/iceberg/src/expr/visitors/inclusive_projection.rs
+++ b/crates/iceberg/src/expr/visitors/inclusive_projection.rs
@@ -304,11 +304,12 @@ mod tests {
.with_spec_id(1)
.add_unbound_field(
UnboundPartitionField::builder()
- .source_id(1)
+ .source_ids(vec![1])
.name("a".to_string())
.field_id(1)
.transform(Transform::Identity)
- .build(),
+ .build()
+ .unwrap(),
)
.unwrap()
.build()
@@ -339,12 +340,15 @@ mod tests {
let partition_spec = PartitionSpec::builder(arc_schema.clone())
.with_spec_id(1)
- .add_unbound_fields(vec![UnboundPartitionField {
- source_id: 2,
- name: "year".to_string(),
- field_id: Some(1000),
- transform: Transform::Year,
- }])
+ .add_unbound_fields(vec![
+ UnboundPartitionField::builder()
+ .source_ids(vec![2])
+ .field_id(1000)
+ .name("year".to_string())
+ .transform(Transform::Year)
+ .build()
+ .unwrap(),
+ ])
.unwrap()
.build()
.unwrap();
@@ -374,12 +378,15 @@ mod tests {
let partition_spec = PartitionSpec::builder(arc_schema.clone())
.with_spec_id(1)
- .add_unbound_fields(vec![UnboundPartitionField {
- source_id: 2,
- name: "month".to_string(),
- field_id: Some(1000),
- transform: Transform::Month,
- }])
+ .add_unbound_fields(vec![
+ UnboundPartitionField::builder()
+ .source_ids(vec![2])
+ .field_id(1000)
+ .name("month".to_string())
+ .transform(Transform::Month)
+ .build()
+ .unwrap(),
+ ])
.unwrap()
.build()
.unwrap();
@@ -409,12 +416,15 @@ mod tests {
let partition_spec = PartitionSpec::builder(arc_schema.clone())
.with_spec_id(1)
- .add_unbound_fields(vec![UnboundPartitionField {
- source_id: 2,
- name: "day".to_string(),
- field_id: Some(1000),
- transform: Transform::Day,
- }])
+ .add_unbound_fields(vec![
+ UnboundPartitionField::builder()
+ .source_ids(vec![2])
+ .field_id(1000)
+ .name("day".to_string())
+ .transform(Transform::Day)
+ .build()
+ .unwrap(),
+ ])
.unwrap()
.build()
.unwrap();
@@ -446,11 +456,12 @@ mod tests {
.with_spec_id(1)
.add_unbound_field(
UnboundPartitionField::builder()
- .source_id(3)
+ .source_ids(vec![3])
.name("name_truncate".to_string())
.field_id(3)
.transform(Transform::Truncate(4))
- .build(),
+ .build()
+ .unwrap(),
)
.unwrap()
.build()
@@ -486,11 +497,12 @@ mod tests {
.with_spec_id(1)
.add_unbound_field(
UnboundPartitionField::builder()
- .source_id(1)
+ .source_ids(vec![1])
.name("a_bucket[7]".to_string())
.field_id(1)
.transform(Transform::Bucket(7))
- .build(),
+ .build()
+ .unwrap(),
)
.unwrap()
.build()
diff --git a/crates/iceberg/src/partitioning.rs
b/crates/iceberg/src/partitioning.rs
index ef230ef9e..6ac62c68c 100644
--- a/crates/iceberg/src/partitioning.rs
+++ b/crates/iceberg/src/partitioning.rs
@@ -177,7 +177,9 @@ mod tests {
use std::sync::Arc;
use super::*;
- use crate::spec::{NestedField, PrimitiveType, Transform, Type,
UnboundPartitionSpec};
+ use crate::spec::{
+ NestedField, PrimitiveType, Transform, Type, UnboundPartitionField,
UnboundPartitionSpec,
+ };
fn test_schema() -> Schema {
Schema::builder()
@@ -199,7 +201,14 @@ mod tests {
let mut builder =
UnboundPartitionSpec::builder().with_spec_id(spec_id);
for (source_id, name, transform) in fields {
builder = builder
- .add_partition_field(source_id, name, transform)
+ .add_partition_field(
+ UnboundPartitionField::builder()
+ .source_ids(vec![source_id])
+ .name(name)
+ .transform(transform)
+ .build()
+ .unwrap(),
+ )
.unwrap();
}
builder.build().bind(schema.clone()).unwrap()
@@ -261,12 +270,15 @@ mod tests {
// Spec 0: old name
let spec_v0 = PartitionSpec::builder(Arc::new(schema.clone()))
.with_spec_id(0)
- .add_unbound_field(crate::spec::UnboundPartitionField {
- source_id: 4,
- field_id: Some(1000),
- name: "cat_old".to_string(),
- transform: Transform::Identity,
- })
+ .add_unbound_field(
+ UnboundPartitionField::builder()
+ .source_ids(vec![4])
+ .field_id(1000)
+ .name("cat_old".to_string())
+ .transform(Transform::Identity)
+ .build()
+ .unwrap(),
+ )
.unwrap()
.build()
.unwrap();
@@ -274,12 +286,15 @@ mod tests {
// Spec 1: newer name, same field_id
let spec_v1 = PartitionSpec::builder(Arc::new(schema.clone()))
.with_spec_id(1)
- .add_unbound_field(crate::spec::UnboundPartitionField {
- source_id: 4,
- field_id: Some(1000),
- name: "cat_new".to_string(),
- transform: Transform::Identity,
- })
+ .add_unbound_field(
+ UnboundPartitionField::builder()
+ .source_ids(vec![4])
+ .field_id(1000)
+ .name("cat_new".to_string())
+ .transform(Transform::Identity)
+ .build()
+ .unwrap(),
+ )
.unwrap()
.build()
.unwrap();
@@ -297,12 +312,15 @@ mod tests {
// Spec 0 (older): category partitioned by identity
let spec_v0 = PartitionSpec::builder(Arc::new(schema.clone()))
.with_spec_id(0)
- .add_unbound_field(crate::spec::UnboundPartitionField {
- source_id: 4,
- field_id: Some(1000),
- name: "category".to_string(),
- transform: Transform::Identity,
- })
+ .add_unbound_field(
+ UnboundPartitionField::builder()
+ .source_ids(vec![4])
+ .field_id(1000)
+ .name("category".to_string())
+ .transform(Transform::Identity)
+ .build()
+ .unwrap(),
+ )
.unwrap()
.build()
.unwrap();
@@ -310,12 +328,15 @@ mod tests {
// Spec 1 (newer): same field_id voided (partition dropped)
let spec_v1 = PartitionSpec::builder(Arc::new(schema.clone()))
.with_spec_id(1)
- .add_unbound_field(crate::spec::UnboundPartitionField {
- source_id: 4,
- field_id: Some(1000),
- name: "category_v2".to_string(),
- transform: Transform::Void,
- })
+ .add_unbound_field(
+ UnboundPartitionField::builder()
+ .source_ids(vec![4])
+ .field_id(1000)
+ .name("category_v2".to_string())
+ .transform(Transform::Void)
+ .build()
+ .unwrap(),
+ )
.unwrap()
.build()
.unwrap();
@@ -371,12 +392,15 @@ mod tests {
// Spec 1: partition by category + ts_year
let spec_v1 = PartitionSpec::builder(Arc::new(schema.clone()))
.with_spec_id(1)
- .add_unbound_field(crate::spec::UnboundPartitionField {
- source_id: 4,
- field_id: Some(spec_v0.fields()[0].field_id),
- name: "category".to_string(),
- transform: Transform::Identity,
- })
+ .add_unbound_field(
+ UnboundPartitionField::builder()
+ .source_ids(vec![4])
+ .field_id(spec_v0.fields()[0].field_id)
+ .name("category".to_string())
+ .transform(Transform::Identity)
+ .build()
+ .unwrap(),
+ )
.unwrap()
.add_partition_field("ts", "ts_year", Transform::Year)
.unwrap()
diff --git a/crates/iceberg/src/scan/cache.rs b/crates/iceberg/src/scan/cache.rs
index b5eb3c4ce..b87786e6c 100644
--- a/crates/iceberg/src/scan/cache.rs
+++ b/crates/iceberg/src/scan/cache.rs
@@ -270,7 +270,7 @@ mod tests {
use crate::expr::{Bind, BoundPredicate, Reference};
use crate::spec::{
Datum, FormatVersion, NestedField, PrimitiveType, Schema, SortOrder,
TableMetadataBuilder,
- Transform, Type, UnboundPartitionSpec,
+ Transform, Type, UnboundPartitionField, UnboundPartitionSpec,
};
/// A historical spec whose source column was dropped from the current
schema resolves to an
@@ -288,7 +288,14 @@ mod tests {
// Spec 0 partitions on `part` (id 2).
let spec = UnboundPartitionSpec::builder()
.with_spec_id(0)
- .add_partition_field(2, "part", Transform::Identity)
+ .add_partition_field(
+ UnboundPartitionField::builder()
+ .source_ids(vec![2])
+ .name("part")
+ .transform(Transform::Identity)
+ .build()
+ .unwrap(),
+ )
.unwrap()
.build();
diff --git a/crates/iceberg/src/spec/partition.rs
b/crates/iceberg/src/spec/partition.rs
index 3999c0d01..17aa3de96 100644
--- a/crates/iceberg/src/spec/partition.rs
+++ b/crates/iceberg/src/spec/partition.rs
@@ -264,20 +264,157 @@ impl PartitionKey {
/// Reference to [`UnboundPartitionSpec`].
pub type UnboundPartitionSpecRef = Arc<UnboundPartitionSpec>;
/// Unbound partition field can be built without a schema and later bound to a
schema.
+///
+/// The fields are private so that an instance is always well formed: in
particular
+/// `source_ids` holds at least one id, which
[`UnboundPartitionField::builder`] checks when the
+/// field is built.
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, TypedBuilder)]
-#[serde(rename_all = "kebab-case")]
+#[serde(
+ try_from = "self::_serde::UnboundPartitionFieldSerde",
+ into = "self::_serde::UnboundPartitionFieldSerde"
+)]
+#[builder(build_method(into = Result<UnboundPartitionField>))]
pub struct UnboundPartitionField {
- /// A source column id from the table’s schema
- pub source_id: i32,
+ /// The source column ids from the table’s schema. A single-argument
transform reads one
+ /// id; a v3 multi-argument transform reads several.
+ source_ids: Vec<i32>,
/// A partition field id that is used to identify a partition field and is
unique within a partition spec.
/// In v2 table metadata, it is unique across all partition specs.
#[builder(default, setter(strip_option(fallback = field_id_opt)))]
- #[serde(skip_serializing_if = "Option::is_none")]
- pub field_id: Option<i32>,
+ field_id: Option<i32>,
/// A partition name.
- pub name: String,
+ #[builder(setter(into))]
+ name: String,
/// A transform that is applied to the source column to produce a
partition value.
- pub transform: Transform,
+ transform: Transform,
+}
+
+impl UnboundPartitionField {
+ /// The single source column id this field reads.
+ ///
+ /// Returns an error for a multi-argument field, which reads several
columns and therefore
+ /// has no single source id. Use [`Self::source_ids`] to handle both
shapes.
+ pub fn source_id(&self) -> Result<i32> {
+ match self.source_ids.as_slice() {
+ [source_id] => Ok(*source_id),
+ source_ids => Err(invalid_data!(
+ "Partition field '{}' reads {} source columns and has no
single source id",
+ self.name,
+ source_ids.len()
+ )),
+ }
+ }
+
+ /// The source column ids this field reads, in order. Never empty.
+ pub fn source_ids(&self) -> &[i32] {
+ &self.source_ids
+ }
+
+ /// The partition field id, when one was assigned.
+ pub fn field_id(&self) -> Option<i32> {
+ self.field_id
+ }
+
+ /// The partition name.
+ pub fn name(&self) -> &str {
+ &self.name
+ }
+
+ /// The transform applied to the source columns to produce a partition
value.
+ pub fn transform(&self) -> Transform {
+ self.transform
+ }
+
+ /// Return this field with the given partition field id assigned.
+ pub fn with_field_id(self, field_id: i32) -> Self {
+ Self {
+ field_id: Some(field_id),
+ ..self
+ }
+ }
+
+ fn validate(&self) -> Result<()> {
+ if self.source_ids.is_empty() {
+ return Err(invalid_data!("Empty source-ids is not allowed"));
+ }
+ Ok(())
+ }
+}
+
+impl From<UnboundPartitionField> for Result<UnboundPartitionField> {
+ fn from(field: UnboundPartitionField) -> Self {
+ field.validate()?;
+ Ok(field)
+ }
+}
+
+mod _serde {
+ use serde::{Deserialize, Serialize};
+
+ use super::UnboundPartitionField;
+ use crate::Error;
+ use crate::error::invalid_data;
+ use crate::spec::Transform;
+
+ /// Per the spec a single-argument field carries `source-id` and a
multi-argument field
+ /// carries `source-ids`. Either spelling is read, but not both at once;
the one that
+ /// matches the field is written.
+ #[derive(Serialize, Deserialize)]
+ #[serde(rename_all = "kebab-case")]
+ pub(super) struct UnboundPartitionFieldSerde {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ source_id: Option<i32>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ source_ids: Option<Vec<i32>>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ field_id: Option<i32>,
+ name: String,
+ transform: Transform,
+ }
+
+ impl TryFrom<UnboundPartitionFieldSerde> for UnboundPartitionField {
+ type Error = Error;
+
+ 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)) => source_ids,
+ (Some(_), Some(_)) => {
+ return Err(invalid_data!(
+ "source-id and source-ids are mutually exclusive"
+ ));
+ }
+ (None, None) => {
+ return Err(invalid_data!(
+ "Either `source-id` or `source-ids` must be present"
+ ));
+ }
+ };
+
+ UnboundPartitionField::builder()
+ .source_ids(source_ids)
+ .field_id_opt(value.field_id)
+ .name(value.name)
+ .transform(value.transform)
+ .build()
+ }
+ }
+
+ impl From<UnboundPartitionField> for UnboundPartitionFieldSerde {
+ fn from(value: UnboundPartitionField) -> Self {
+ let (source_id, source_ids) = match value.source_ids.as_slice() {
+ [source_id] => (Some(*source_id), None),
+ _ => (None, Some(value.source_ids)),
+ };
+ Self {
+ source_id,
+ source_ids,
+ field_id: value.field_id,
+ name: value.name,
+ transform: value.transform,
+ }
+ }
+ }
}
/// Unbound partition spec can be built without a schema and later bound to a
schema.
@@ -341,7 +478,7 @@ fn has_sequential_ids(field_ids: impl Iterator<Item = i32>)
-> bool {
impl From<PartitionField> for UnboundPartitionField {
fn from(field: PartitionField) -> Self {
UnboundPartitionField {
- source_id: field.source_id,
+ source_ids: vec![field.source_id],
field_id: Some(field.field_id),
name: field.name,
transform: field.transform,
@@ -381,19 +518,14 @@ impl UnboundPartitionSpecBuilder {
}
/// Add a new partition field to the partition spec from an unbound
partition field.
- pub fn add_partition_field(
- self,
- source_id: i32,
- target_name: impl ToString,
- transformation: Transform,
- ) -> Result<Self> {
- let field = UnboundPartitionField {
- source_id,
- field_id: None,
- name: target_name.to_string(),
- transform: transformation,
- };
- self.add_partition_field_internal(field)
+ pub fn add_partition_field(mut self, field: UnboundPartitionField) ->
Result<Self> {
+ self.check_name_set_and_unique(&field.name)?;
+ self.check_for_redundant_partitions(&field.source_ids,
&field.transform)?;
+ if let Some(partition_field_id) = field.field_id {
+ self.check_partition_id_unique(partition_field_id)?;
+ }
+ self.fields.push(field);
+ Ok(self)
}
/// Add multiple partition fields to the partition spec.
@@ -403,21 +535,11 @@ impl UnboundPartitionSpecBuilder {
) -> Result<Self> {
let mut builder = self;
for field in fields {
- builder = builder.add_partition_field_internal(field)?;
+ builder = builder.add_partition_field(field)?;
}
Ok(builder)
}
- fn add_partition_field_internal(mut self, field: UnboundPartitionField) ->
Result<Self> {
- self.check_name_set_and_unique(&field.name)?;
- self.check_for_redundant_partitions(field.source_id,
&field.transform)?;
- if let Some(partition_field_id) = field.field_id {
- self.check_partition_id_unique(partition_field_id)?;
- }
- self.fields.push(field);
- Ok(self)
- }
-
/// Build the unbound partition spec.
pub fn build(self) -> UnboundPartitionSpec {
UnboundPartitionSpec {
@@ -495,7 +617,7 @@ impl PartitionSpecBuilder {
})?
.id;
let field = UnboundPartitionField {
- source_id,
+ source_ids: vec![source_id],
field_id: None,
name: target_name.into(),
transform,
@@ -510,7 +632,7 @@ impl PartitionSpecBuilder {
/// Otherwise, a new `field_id` is assigned.
pub fn add_unbound_field(mut self, field: UnboundPartitionField) ->
Result<Self> {
self.check_name_set_and_unique(&field.name)?;
- self.check_for_redundant_partitions(field.source_id,
&field.transform)?;
+ self.check_for_redundant_partitions(&field.source_ids,
&field.transform)?;
Self::check_name_does_not_collide_with_schema(&field, &self.schema)?;
Self::check_transform_compatibility(&field, &self.schema)?;
if let Some(partition_field_id) = field.field_id {
@@ -574,7 +696,7 @@ impl PartitionSpecBuilder {
};
bound_fields.push(PartitionField {
- source_id: field.source_id,
+ source_id: field.source_id()?,
field_id: partition_field_id,
name: field.name,
transform: field.transform,
@@ -595,14 +717,14 @@ impl PartitionSpecBuilder {
match schema.field_by_name(field.name.as_str()) {
Some(schema_collision) => {
if field.transform == Transform::Identity {
- if schema_collision.id == field.source_id {
+ if schema_collision.id == field.source_id()? {
Ok(())
} else {
Err(invalid_data!(
"Cannot create identity partition sourced from
different field in schema. Field name '{}' has id `{}` in schema but partition
source id is `{}`",
field.name,
schema_collision.id,
- field.source_id
+ field.source_id()?
))
}
} else {
@@ -619,11 +741,11 @@ impl PartitionSpecBuilder {
/// 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<()> {
- let schema_field = schema.field_by_id(field.source_id).ok_or_else(|| {
- invalid_data!(
- "Cannot find partition source field with id `{}` in schema",
- field.source_id
- )
+ // A multi-argument field is rejected here on purpose: a bound
`PartitionField` has
+ // no place for more than one source id yet.
+ let source_id = field.source_id()?;
+ let schema_field = schema.field_by_id(source_id).ok_or_else(|| {
+ invalid_data!("Cannot find partition source field with id
`{source_id}` in schema")
})?;
if field.transform != Transform::Void {
@@ -668,15 +790,19 @@ trait CorePartitionSpecValidator {
}
/// For a single source-column transformations must be unique.
- fn check_for_redundant_partitions(&self, source_id: i32, transform:
&Transform) -> Result<()> {
+ fn check_for_redundant_partitions(
+ &self,
+ source_ids: &[i32],
+ transform: &Transform,
+ ) -> Result<()> {
let collision = self.fields().iter().find(|f| {
- f.source_id == source_id && f.transform.dedup_name() ==
transform.dedup_name()
+ f.source_ids == source_ids && f.transform.dedup_name() ==
transform.dedup_name()
});
if let Some(collision) = collision {
Err(invalid_data!(
- "Cannot add redundant partition with source id `{}` and
transform `{}`. A partition with the same source id and transform already
exists with name `{}`",
- source_id,
+ "Cannot add redundant partition with source ids `{:?}` and
transform `{}`. A partition with the same source ids and transform already
exists with name `{}`",
+ source_ids,
transform.dedup_name(),
collision.name
))
@@ -779,15 +905,17 @@ mod tests {
let partition_spec = PartitionSpec::builder(schema.clone())
.add_unbound_fields(vec![
UnboundPartitionField::builder()
- .source_id(1)
+ .source_ids(vec![1])
.name("id".to_string())
.transform(Transform::Identity)
- .build(),
+ .build()
+ .unwrap(),
UnboundPartitionField::builder()
- .source_id(2)
+ .source_ids(vec![2])
.name("name_string".to_string())
.transform(Transform::Void)
- .build(),
+ .build()
+ .unwrap(),
])
.unwrap()
.with_spec_id(1)
@@ -802,15 +930,17 @@ mod tests {
.with_spec_id(1)
.add_unbound_fields(vec![
UnboundPartitionField::builder()
- .source_id(1)
+ .source_ids(vec![1])
.name("id_void".to_string())
.transform(Transform::Void)
- .build(),
+ .build()
+ .unwrap(),
UnboundPartitionField::builder()
- .source_id(2)
+ .source_ids(vec![2])
.name("name_void".to_string())
.transform(Transform::Void)
- .build(),
+ .build()
+ .unwrap(),
])
.unwrap()
.build()
@@ -848,17 +978,17 @@ mod tests {
let partition_spec: UnboundPartitionSpec =
serde_json::from_str(spec).unwrap();
assert_eq!(Some(1), partition_spec.spec_id);
- assert_eq!(4, partition_spec.fields[0].source_id);
+ assert_eq!(4, partition_spec.fields[0].source_id().unwrap());
assert_eq!(Some(1000), partition_spec.fields[0].field_id);
assert_eq!("ts_day", partition_spec.fields[0].name);
assert_eq!(Transform::Day, partition_spec.fields[0].transform);
- assert_eq!(1, partition_spec.fields[1].source_id);
+ assert_eq!(1, partition_spec.fields[1].source_id().unwrap());
assert_eq!(Some(1001), partition_spec.fields[1].field_id);
assert_eq!("id_bucket", partition_spec.fields[1].name);
assert_eq!(Transform::Bucket(16), partition_spec.fields[1].transform);
- assert_eq!(2, partition_spec.fields[2].source_id);
+ assert_eq!(2, partition_spec.fields[2].source_id().unwrap());
assert_eq!(Some(1002), partition_spec.fields[2].field_id);
assert_eq!("id_truncate", partition_spec.fields[2].name);
assert_eq!(Transform::Truncate(4), partition_spec.fields[2].transform);
@@ -875,7 +1005,7 @@ mod tests {
let partition_spec: UnboundPartitionSpec =
serde_json::from_str(spec).unwrap();
assert_eq!(None, partition_spec.spec_id);
- assert_eq!(4, partition_spec.fields[0].source_id);
+ assert_eq!(4, partition_spec.fields[0].source_id().unwrap());
assert_eq!(None, partition_spec.fields[0].field_id);
assert_eq!("ts_day", partition_spec.fields[0].name);
assert_eq!(Transform::Day, partition_spec.fields[0].transform);
@@ -884,7 +1014,14 @@ mod tests {
#[test]
fn test_unbound_partition_spec_serialization_skips_none_fields() {
let spec = UnboundPartitionSpec::builder()
- .add_partition_field(4, "ts_day".to_string(), Transform::Day)
+ .add_partition_field(
+ UnboundPartitionField::builder()
+ .source_ids(vec![4])
+ .name("ts_day")
+ .transform(Transform::Day)
+ .build()
+ .unwrap(),
+ )
.unwrap()
.build();
@@ -1131,9 +1268,23 @@ mod tests {
#[test]
fn test_builder_disallow_duplicate_names() {
UnboundPartitionSpec::builder()
- .add_partition_field(1, "ts_day".to_string(), Transform::Day)
+ .add_partition_field(
+ UnboundPartitionField::builder()
+ .source_ids(vec![1])
+ .name("ts_day")
+ .transform(Transform::Day)
+ .build()
+ .unwrap(),
+ )
.unwrap()
- .add_partition_field(2, "ts_day".to_string(), Transform::Day)
+ .add_partition_field(
+ UnboundPartitionField::builder()
+ .source_ids(vec![2])
+ .name("ts_day")
+ .transform(Transform::Day)
+ .build()
+ .unwrap(),
+ )
.unwrap_err();
}
@@ -1148,14 +1299,14 @@ mod tests {
.unwrap();
PartitionSpec::builder(schema.clone())
.add_unbound_field(UnboundPartitionField {
- source_id: 1,
+ source_ids: vec![1],
field_id: Some(1000),
name: "id".to_string(),
transform: Transform::Identity,
})
.unwrap()
.add_unbound_field(UnboundPartitionField {
- source_id: 2,
+ source_ids: vec![2],
field_id: Some(1000),
name: "id_bucket".to_string(),
transform: Transform::Bucket(16),
@@ -1176,14 +1327,14 @@ mod tests {
let spec = PartitionSpec::builder(schema.clone())
.with_spec_id(1)
.add_unbound_field(UnboundPartitionField {
- source_id: 1,
+ source_ids: vec![1],
name: "id".to_string(),
transform: Transform::Identity,
field_id: Some(1012),
})
.unwrap()
.add_unbound_field(UnboundPartitionField {
- source_id: 2,
+ source_ids: vec![2],
name: "name_void".to_string(),
transform: Transform::Void,
field_id: None,
@@ -1191,7 +1342,7 @@ mod tests {
.unwrap()
// Should keep its ID even if its lower
.add_unbound_field(UnboundPartitionField {
- source_id: 3,
+ source_ids: vec![3],
name: "year".to_string(),
transform: Transform::Year,
field_id: Some(1),
@@ -1262,7 +1413,7 @@ mod tests {
let err = PartitionSpec::builder(schema)
.with_spec_id(1)
.add_unbound_field(UnboundPartitionField {
- source_id: 1,
+ source_ids: vec![1],
field_id: None,
name: "id".to_string(),
transform: Transform::Bucket(16),
@@ -1289,7 +1440,7 @@ mod tests {
PartitionSpec::builder(schema.clone())
.with_spec_id(1)
.add_unbound_field(UnboundPartitionField {
- source_id: 1,
+ source_ids: vec![1],
field_id: None,
name: "id".to_string(),
transform: Transform::Identity,
@@ -1302,7 +1453,7 @@ mod tests {
PartitionSpec::builder(schema)
.with_spec_id(1)
.add_unbound_field(UnboundPartitionField {
- source_id: 2,
+ source_ids: vec![2],
field_id: None,
name: "id".to_string(),
transform: Transform::Identity,
@@ -1326,13 +1477,13 @@ mod tests {
.with_spec_id(1)
.add_unbound_fields(vec![
UnboundPartitionField {
- source_id: 1,
+ source_ids: vec![1],
field_id: None,
name: "id_bucket".to_string(),
transform: Transform::Bucket(16),
},
UnboundPartitionField {
- source_id: 2,
+ source_ids: vec![2],
field_id: None,
name: "name".to_string(),
transform: Transform::Identity,
@@ -1347,13 +1498,13 @@ mod tests {
.with_spec_id(1)
.add_unbound_fields(vec![
UnboundPartitionField {
- source_id: 1,
+ source_ids: vec![1],
field_id: None,
name: "id_bucket".to_string(),
transform: Transform::Bucket(16),
},
UnboundPartitionField {
- source_id: 4,
+ source_ids: vec![4],
field_id: None,
name: "name".to_string(),
transform: Transform::Identity,
@@ -1375,7 +1526,7 @@ mod tests {
let err = PartitionSpec::builder(schema)
.with_spec_id(1)
.add_unbound_fields(vec![UnboundPartitionField {
- source_id: 2,
+ source_ids: vec![2],
field_id: None,
name: "v_part".to_string(),
transform: Transform::Identity,
@@ -1392,12 +1543,22 @@ mod tests {
fn test_builder_disallows_redundant() {
let err = UnboundPartitionSpec::builder()
.with_spec_id(1)
- .add_partition_field(1, "id_bucket[16]".to_string(),
Transform::Bucket(16))
+ .add_partition_field(
+ UnboundPartitionField::builder()
+ .source_ids(vec![1])
+ .name("id_bucket[16]")
+ .transform(Transform::Bucket(16))
+ .build()
+ .unwrap(),
+ )
.unwrap()
.add_partition_field(
- 1,
- "id_bucket_with_other_name".to_string(),
- Transform::Bucket(16),
+ UnboundPartitionField::builder()
+ .source_ids(vec![1])
+ .name("id_bucket_with_other_name")
+ .transform(Transform::Bucket(16))
+ .build()
+ .unwrap(),
)
.unwrap_err();
assert!(err.message().contains("redundant partition"));
@@ -1415,7 +1576,7 @@ mod tests {
PartitionSpec::builder(schema)
.with_spec_id(1)
.add_unbound_field(UnboundPartitionField {
- source_id: 1,
+ source_ids: vec![1],
field_id: None,
name: "id_year".to_string(),
transform: Transform::Year,
@@ -1428,7 +1589,7 @@ mod tests {
let spec = UnboundPartitionSpec::builder()
.with_spec_id(1)
.add_partition_fields(vec![UnboundPartitionField {
- source_id: 1,
+ source_ids: vec![1],
field_id: None,
name: "id_bucket[16]".to_string(),
transform: Transform::Bucket(16),
@@ -1439,7 +1600,7 @@ mod tests {
assert_eq!(spec, UnboundPartitionSpec {
spec_id: Some(1),
fields: vec![UnboundPartitionField {
- source_id: 1,
+ source_ids: vec![1],
field_id: None,
name: "id_bucket[16]".to_string(),
transform: Transform::Bucket(16),
@@ -1460,7 +1621,7 @@ mod tests {
let partition_spec_1 = PartitionSpec::builder(schema.clone())
.with_spec_id(1)
.add_unbound_field(UnboundPartitionField {
- source_id: 1,
+ source_ids: vec![1],
field_id: None,
name: "id_bucket".to_string(),
transform: Transform::Bucket(16),
@@ -1472,7 +1633,7 @@ mod tests {
let partition_spec_2 = PartitionSpec::builder(schema)
.with_spec_id(1)
.add_unbound_field(UnboundPartitionField {
- source_id: 1,
+ source_ids: vec![1],
field_id: None,
name: "id_bucket".to_string(),
transform: Transform::Bucket(16),
@@ -1496,7 +1657,7 @@ mod tests {
let partition_spec_1 = PartitionSpec::builder(schema.clone())
.with_spec_id(1)
.add_unbound_field(UnboundPartitionField {
- source_id: 1,
+ source_ids: vec![1],
field_id: None,
name: "id_bucket".to_string(),
transform: Transform::Bucket(16),
@@ -1508,7 +1669,7 @@ mod tests {
let partition_spec_2 = PartitionSpec::builder(schema)
.with_spec_id(1)
.add_unbound_field(UnboundPartitionField {
- source_id: 1,
+ source_ids: vec![1],
field_id: None,
name: "id_bucket".to_string(),
transform: Transform::Bucket(32),
@@ -1533,7 +1694,7 @@ mod tests {
let partition_spec_1 = PartitionSpec::builder(schema.clone())
.with_spec_id(1)
.add_unbound_field(UnboundPartitionField {
- source_id: 1,
+ source_ids: vec![1],
field_id: None,
name: "id_bucket".to_string(),
transform: Transform::Bucket(16),
@@ -1545,7 +1706,7 @@ mod tests {
let partition_spec_2 = PartitionSpec::builder(schema)
.with_spec_id(1)
.add_unbound_field(UnboundPartitionField {
- source_id: 2,
+ source_ids: vec![2],
field_id: None,
name: "id_bucket".to_string(),
transform: Transform::Bucket(16),
@@ -1570,14 +1731,14 @@ mod tests {
let partition_spec_1 = PartitionSpec::builder(schema.clone())
.with_spec_id(1)
.add_unbound_field(UnboundPartitionField {
- source_id: 1,
+ source_ids: vec![1],
field_id: None,
name: "id_bucket".to_string(),
transform: Transform::Bucket(16),
})
.unwrap()
.add_unbound_field(UnboundPartitionField {
- source_id: 2,
+ source_ids: vec![2],
field_id: None,
name: "name".to_string(),
transform: Transform::Identity,
@@ -1589,14 +1750,14 @@ mod tests {
let partition_spec_2 = PartitionSpec::builder(schema)
.with_spec_id(1)
.add_unbound_field(UnboundPartitionField {
- source_id: 2,
+ source_ids: vec![2],
field_id: None,
name: "name".to_string(),
transform: Transform::Identity,
})
.unwrap()
.add_unbound_field(UnboundPartitionField {
- source_id: 1,
+ source_ids: vec![1],
field_id: None,
name: "id_bucket".to_string(),
transform: Transform::Bucket(16),
@@ -1631,14 +1792,14 @@ mod tests {
let spec = PartitionSpec::builder(schema)
.with_spec_id(1)
.add_unbound_field(UnboundPartitionField {
- source_id: 1,
+ source_ids: vec![1],
field_id: Some(1001),
name: "id".to_string(),
transform: Transform::Identity,
})
.unwrap()
.add_unbound_field(UnboundPartitionField {
- source_id: 2,
+ source_ids: vec![2],
field_id: Some(1000),
name: "name".to_string(),
transform: Transform::Identity,
@@ -1663,14 +1824,14 @@ mod tests {
let spec = PartitionSpec::builder(schema)
.with_spec_id(1)
.add_unbound_field(UnboundPartitionField {
- source_id: 1,
+ source_ids: vec![1],
field_id: Some(1000),
name: "id".to_string(),
transform: Transform::Identity,
})
.unwrap()
.add_unbound_field(UnboundPartitionField {
- source_id: 2,
+ source_ids: vec![2],
field_id: Some(1001),
name: "name".to_string(),
transform: Transform::Identity,
@@ -1697,14 +1858,14 @@ mod tests {
let spec = PartitionSpec::builder(schema)
.with_spec_id(1)
.add_unbound_field(UnboundPartitionField {
- source_id: 1,
+ source_ids: vec![1],
field_id: Some(999),
name: "id".to_string(),
transform: Transform::Identity,
})
.unwrap()
.add_unbound_field(UnboundPartitionField {
- source_id: 2,
+ source_ids: vec![2],
field_id: Some(1000),
name: "name".to_string(),
transform: Transform::Identity,
@@ -1731,14 +1892,14 @@ mod tests {
let spec = PartitionSpec::builder(schema)
.with_spec_id(1)
.add_unbound_field(UnboundPartitionField {
- source_id: 1,
+ source_ids: vec![1],
field_id: Some(1000),
name: "id".to_string(),
transform: Transform::Identity,
})
.unwrap()
.add_unbound_field(UnboundPartitionField {
- source_id: 2,
+ source_ids: vec![2],
field_id: Some(1002),
name: "name".to_string(),
transform: Transform::Identity,
@@ -1847,4 +2008,106 @@ mod tests {
"data=a%2Fb%2Fc%2Fd/data_truc_10=a%2Fb%2Fc%2Fd"
);
}
+
+ #[test]
+ fn test_unbound_partition_field_reads_source_ids_only() {
+ let field: UnboundPartitionField = serde_json::from_str(
+ r#"{"source-ids": [1, 2], "name": "m", "transform": "bucket[4]"}"#,
+ )
+ .unwrap();
+
+ assert_eq!([1, 2], field.source_ids());
+ // a multi-argument field has no single source id
+ assert!(
+ field
+ .source_id()
+ .unwrap_err()
+ .to_string()
+ .contains("has no single source id")
+ );
+
+ let serialized = serde_json::to_value(&field).unwrap();
+ assert_eq!(
+ Some(&serde_json::json!([1, 2])),
+ serialized.get("source-ids")
+ );
+ assert!(serialized.get("source-id").is_none());
+ }
+
+ #[test]
+ fn test_unbound_partition_field_single_source_id_round_trip() {
+ let field: UnboundPartitionField =
+ serde_json::from_str(r#"{"source-id": 1, "name": "m", "transform":
"identity"}"#)
+ .unwrap();
+
+ assert_eq!(1, field.source_id().unwrap());
+ assert_eq!([1], field.source_ids());
+
+ // a single-argument field keeps writing source-id
+ let serialized = serde_json::to_value(&field).unwrap();
+ assert_eq!(Some(&serde_json::json!(1)), serialized.get("source-id"));
+ assert!(serialized.get("source-ids").is_none());
+ }
+
+ #[test]
+ fn test_unbound_partition_field_rejects_malformed_source_ids() {
+ for (input, expected) in [
+ (
+ r#"{"source-ids": [], "name": "m", "transform": "identity"}"#,
+ "Empty source-ids is not allowed",
+ ),
+ (
+ r#"{"name": "m", "transform": "identity"}"#,
+ "Either `source-id` or `source-ids` must be present",
+ ),
+ (
+ r#"{"source-id": 1, "source-ids": [1], "name": "m",
"transform": "identity"}"#,
+ "mutually exclusive",
+ ),
+ ] {
+ let err =
serde_json::from_str::<UnboundPartitionField>(input).unwrap_err();
+ assert!(
+ err.to_string().contains(expected),
+ "unexpected error for {input}: {err}"
+ );
+ }
+ }
+
+ #[test]
+ fn test_binding_a_multi_argument_field_fails_loudly() {
+ let schema = Schema::builder()
+ .with_fields(vec![
+ NestedField::required(1, "a",
Type::Primitive(PrimitiveType::Int)).into(),
+ NestedField::required(2, "b",
Type::Primitive(PrimitiveType::Int)).into(),
+ ])
+ .build()
+ .unwrap();
+
+ let spec: UnboundPartitionSpec = serde_json::from_str(
+ r#"{"spec-id": 1, "fields": [{"source-ids": [1, 2], "field-id":
1000, "name": "m", "transform": "bucket[4]"}]}"#,
+ )
+ .unwrap();
+
+ // A bound PartitionField still carries a single source id, so binding
has to refuse
+ // rather than quietly keep the first one.
+ let err = spec.bind(schema).unwrap_err();
+ assert!(
+ err.to_string().contains("has no single source id"),
+ "unexpected error: {err}"
+ );
+ }
+
+ #[test]
+ fn test_unbound_partition_field_builder_rejects_empty_source_ids() {
+ let err = UnboundPartitionField::builder()
+ .source_ids(vec![])
+ .name("m")
+ .transform(Transform::Identity)
+ .build()
+ .unwrap_err();
+ assert!(
+ err.to_string().contains("Empty source-ids is not allowed"),
+ "unexpected error: {err}"
+ );
+ }
}
diff --git a/crates/iceberg/src/spec/snapshot_summary.rs
b/crates/iceberg/src/spec/snapshot_summary.rs
index a48a5abdd..f8e69c4c8 100644
--- a/crates/iceberg/src/spec/snapshot_summary.rs
+++ b/crates/iceberg/src/spec/snapshot_summary.rs
@@ -828,10 +828,11 @@ mod tests {
PartitionSpec::builder(schema.clone())
.add_unbound_fields(vec![
UnboundPartitionField::builder()
- .source_id(2)
+ .source_ids(vec![2])
.name("year".to_string())
.transform(Transform::Identity)
- .build(),
+ .build()
+ .unwrap(),
])
.unwrap()
.with_spec_id(1)
@@ -978,10 +979,11 @@ mod tests {
PartitionSpec::builder(schema.clone())
.add_unbound_fields(vec![
UnboundPartitionField::builder()
- .source_id(2)
+ .source_ids(vec![2])
.name("year".to_string())
.transform(Transform::Identity)
- .build(),
+ .build()
+ .unwrap(),
])
.unwrap()
.with_spec_id(1)
diff --git a/crates/iceberg/src/spec/table_metadata.rs
b/crates/iceberg/src/spec/table_metadata.rs
index 24f10355b..83468602c 100644
--- a/crates/iceberg/src/spec/table_metadata.rs
+++ b/crates/iceberg/src/spec/table_metadata.rs
@@ -1721,12 +1721,15 @@ mod tests {
let partition_spec = PartitionSpec::builder(schema.clone())
.with_spec_id(0)
- .add_unbound_field(UnboundPartitionField {
- name: "ts_day".to_string(),
- transform: Transform::Day,
- source_id: 4,
- field_id: Some(1000),
- })
+ .add_unbound_field(
+ UnboundPartitionField::builder()
+ .source_ids(vec![4])
+ .field_id(1000)
+ .name("ts_day".to_string())
+ .transform(Transform::Day)
+ .build()
+ .unwrap(),
+ )
.unwrap()
.build()
.unwrap();
@@ -1869,12 +1872,15 @@ mod tests {
let partition_spec = PartitionSpec::builder(schema.clone())
.with_spec_id(0)
- .add_unbound_field(UnboundPartitionField {
- name: "ts_day".to_string(),
- transform: Transform::Day,
- source_id: 4,
- field_id: Some(1000),
- })
+ .add_unbound_field(
+ UnboundPartitionField::builder()
+ .source_ids(vec![4])
+ .field_id(1000)
+ .name("ts_day".to_string())
+ .transform(Transform::Day)
+ .build()
+ .unwrap(),
+ )
.unwrap()
.build()
.unwrap();
@@ -2945,12 +2951,15 @@ mod tests {
let partition_spec = PartitionSpec::builder(schema.clone())
.with_spec_id(0)
- .add_unbound_field(UnboundPartitionField {
- name: "x".to_string(),
- transform: Transform::Identity,
- source_id: 1,
- field_id: Some(1000),
- })
+ .add_unbound_field(
+ UnboundPartitionField::builder()
+ .source_ids(vec![1])
+ .field_id(1000)
+ .name("x".to_string())
+ .transform(Transform::Identity)
+ .build()
+ .unwrap(),
+ )
.unwrap()
.build()
.unwrap();
@@ -3042,12 +3051,15 @@ mod tests {
let partition_spec = PartitionSpec::builder(schema2.clone())
.with_spec_id(0)
- .add_unbound_field(UnboundPartitionField {
- name: "x".to_string(),
- transform: Transform::Identity,
- source_id: 1,
- field_id: Some(1000),
- })
+ .add_unbound_field(
+ UnboundPartitionField::builder()
+ .source_ids(vec![1])
+ .field_id(1000)
+ .name("x".to_string())
+ .transform(Transform::Identity)
+ .build()
+ .unwrap(),
+ )
.unwrap()
.build()
.unwrap();
@@ -3171,12 +3183,15 @@ mod tests {
let partition_spec = PartitionSpec::builder(schema.clone())
.with_spec_id(0)
- .add_unbound_field(UnboundPartitionField {
- name: "x".to_string(),
- transform: Transform::Identity,
- source_id: 1,
- field_id: Some(1000),
- })
+ .add_unbound_field(
+ UnboundPartitionField::builder()
+ .source_ids(vec![1])
+ .field_id(1000)
+ .name("x".to_string())
+ .transform(Transform::Identity)
+ .build()
+ .unwrap(),
+ )
.unwrap()
.build()
.unwrap();
@@ -3257,12 +3272,15 @@ mod tests {
let partition_spec = PartitionSpec::builder(schema.clone())
.with_spec_id(0)
- .add_unbound_field(UnboundPartitionField {
- name: "x".to_string(),
- transform: Transform::Identity,
- source_id: 1,
- field_id: Some(1000),
- })
+ .add_unbound_field(
+ UnboundPartitionField::builder()
+ .source_ids(vec![1])
+ .field_id(1000)
+ .name("x".to_string())
+ .transform(Transform::Identity)
+ .build()
+ .unwrap(),
+ )
.unwrap()
.build()
.unwrap();
@@ -4481,7 +4499,14 @@ mod tests {
schema.clone(),
UnboundPartitionSpec::builder()
.with_spec_id(0)
- .add_partition_field(2, "y", Transform::Identity)
+ .add_partition_field(
+ UnboundPartitionField::builder()
+ .source_ids(vec![2])
+ .name("y")
+ .transform(Transform::Identity)
+ .build()
+ .unwrap(),
+ )
.unwrap()
.build(),
SortOrder::unsorted_order(),
@@ -4496,7 +4521,14 @@ mod tests {
.into_builder(None)
.add_partition_spec(
UnboundPartitionSpec::builder()
- .add_partition_field(3, "z", Transform::Identity)
+ .add_partition_field(
+ UnboundPartitionField::builder()
+ .source_ids(vec![3])
+ .name("z")
+ .transform(Transform::Identity)
+ .build()
+ .unwrap(),
+ )
.unwrap()
.build(),
)
diff --git a/crates/iceberg/src/spec/table_metadata_builder.rs
b/crates/iceberg/src/spec/table_metadata_builder.rs
index 3ed848d2d..9a8206cb2 100644
--- a/crates/iceberg/src/spec/table_metadata_builder.rs
+++ b/crates/iceberg/src/spec/table_metadata_builder.rs
@@ -741,7 +741,7 @@ impl TableMetadataBuilder {
for partition_field in unbound_spec.fields() {
let exists_in_any_schema = self
.metadata
- .name_exists_in_any_schema(&partition_field.name);
+ .name_exists_in_any_schema(partition_field.name());
// Skip if partition field name doesn't conflict with any schema
field
if !exists_in_any_schema {
@@ -749,15 +749,15 @@ impl TableMetadataBuilder {
}
// If name exists in schemas, validate against current schema rules
- if let Some(schema_field) =
current_schema.field_by_name(&partition_field.name) {
+ if let Some(schema_field) =
current_schema.field_by_name(partition_field.name()) {
let is_identity_transform =
- partition_field.transform ==
crate::spec::Transform::Identity;
- let has_matching_source_id = schema_field.id ==
partition_field.source_id;
+ partition_field.transform() ==
crate::spec::Transform::Identity;
+ let has_matching_source_id = schema_field.id ==
partition_field.source_id()?;
if !is_identity_transform {
return Err(invalid_data!(
"Cannot create partition with name '{}' that conflicts
with schema field and is not an identity transform.",
- partition_field.name
+ partition_field.name()
));
}
@@ -765,9 +765,9 @@ impl TableMetadataBuilder {
return Err(invalid_data!(
"Cannot create identity partition sourced from
different field in schema. \
Field name '{}' has id `{}` in schema but
partition source id is `{}`",
- partition_field.name,
+ partition_field.name(),
schema_field.id,
- partition_field.source_id
+ partition_field.source_id()?
));
}
}
@@ -850,21 +850,22 @@ impl TableMetadataBuilder {
.partition_specs
.values()
.flat_map(|spec| spec.fields())
- .map(|field| ((field.source_id, &field.transform), field.field_id))
+ .map(|field| ((vec![field.source_id], field.transform),
field.field_id))
.collect();
// Create new fields with reused field IDs where possible
let fields = unbound_spec
.fields
.into_iter()
- .map(|mut field| {
- if field.field_id.is_none()
+ .map(|field| {
+ if field.field_id().is_none()
&& let Some(&existing_field_id) =
- equivalent_field_ids.get(&(field.source_id,
&field.transform))
+
equivalent_field_ids.get(&(field.source_ids().to_vec(), field.transform()))
{
- field.field_id = Some(existing_field_id);
+ field.with_field_id(existing_field_id)
+ } else {
+ field
}
- field
})
.collect();
@@ -1228,15 +1229,19 @@ impl TableMetadataBuilder {
// Re-build partition spec with new ids
let mut fresh_spec = PartitionSpecBuilder::new(fresh_schema.clone());
for field in spec.fields() {
- let source_field_name =
previous_id_to_name.get(&field.source_id).ok_or_else(|| {
+ let source_id = field.source_id()?;
+ let source_field_name =
previous_id_to_name.get(&source_id).ok_or_else(|| {
invalid_data!(
"Cannot find source column with id {} for partition column
{} in schema.",
- field.source_id,
- field.name
+ source_id,
+ field.name()
)
})?;
- fresh_spec =
- fresh_spec.add_partition_field(source_field_name, &field.name,
field.transform)?;
+ fresh_spec = fresh_spec.add_partition_field(
+ source_field_name,
+ field.name(),
+ field.transform(),
+ )?;
}
let fresh_spec = fresh_spec.build()?;
@@ -1432,7 +1437,14 @@ mod tests {
fn partition_spec() -> UnboundPartitionSpec {
UnboundPartitionSpec::builder()
.with_spec_id(0)
- .add_partition_field(2, "y", Transform::Identity)
+ .add_partition_field(
+ UnboundPartitionField::builder()
+ .source_ids(vec![2])
+ .name("y")
+ .transform(Transform::Identity)
+ .build()
+ .unwrap(),
+ )
.unwrap()
.build()
}
@@ -1666,12 +1678,15 @@ mod tests {
// partition_spec() has None set for field-id
spec: PartitionSpec::builder(schema())
.with_spec_id(0)
- .add_unbound_field(UnboundPartitionField {
- name: "y".to_string(),
- transform: Transform::Identity,
- source_id: 2,
- field_id: Some(1000)
- })
+ .add_unbound_field(
+ UnboundPartitionField::builder()
+ .source_ids(vec![2])
+ .field_id(1000)
+ .name("y".to_string())
+ .transform(Transform::Identity)
+ .build()
+ .unwrap()
+ )
.unwrap()
.build()
.unwrap()
@@ -1739,20 +1754,21 @@ mod tests {
let added_spec = UnboundPartitionSpec::builder()
.with_spec_id(10)
.add_partition_fields(vec![
- UnboundPartitionField {
- // The previous field - has field_id set
- name: "y".to_string(),
- transform: Transform::Identity,
- source_id: 2,
- field_id: Some(1000),
- },
- UnboundPartitionField {
- // A new field without field id - should still be without
field id in changes
- name: "z".to_string(),
- transform: Transform::Identity,
- source_id: 3,
- field_id: None,
- },
+ // The previous field - has field_id set
+ UnboundPartitionField::builder()
+ .source_ids(vec![2])
+ .field_id(1000)
+ .name("y".to_string())
+ .transform(Transform::Identity)
+ .build()
+ .unwrap(),
+ // A new field without field id - should still be without
field id in changes
+ UnboundPartitionField::builder()
+ .source_ids(vec![3])
+ .name("z".to_string())
+ .transform(Transform::Identity)
+ .build()
+ .unwrap(),
])
.unwrap()
.build();
@@ -1767,19 +1783,25 @@ mod tests {
let expected_change = added_spec.with_spec_id(1);
let expected_spec = PartitionSpec::builder(schema())
.with_spec_id(1)
- .add_unbound_field(UnboundPartitionField {
- name: "y".to_string(),
- transform: Transform::Identity,
- source_id: 2,
- field_id: Some(1000),
- })
+ .add_unbound_field(
+ UnboundPartitionField::builder()
+ .source_ids(vec![2])
+ .field_id(1000)
+ .name("y".to_string())
+ .transform(Transform::Identity)
+ .build()
+ .unwrap(),
+ )
.unwrap()
- .add_unbound_field(UnboundPartitionField {
- name: "z".to_string(),
- transform: Transform::Identity,
- source_id: 3,
- field_id: Some(1001),
- })
+ .add_unbound_field(
+ UnboundPartitionField::builder()
+ .source_ids(vec![3])
+ .field_id(1001)
+ .name("z".to_string())
+ .transform(Transform::Identity)
+ .build()
+ .unwrap(),
+ )
.unwrap()
.build()
.unwrap();
@@ -1817,7 +1839,14 @@ mod tests {
let schema = builder.get_current_schema().unwrap().clone();
let added_spec = UnboundPartitionSpec::builder()
.with_spec_id(10)
- .add_partition_field(1, "y_bucket[2]", Transform::Bucket(2))
+ .add_partition_field(
+ UnboundPartitionField::builder()
+ .source_ids(vec![1])
+ .name("y_bucket[2]")
+ .transform(Transform::Bucket(2))
+ .build()
+ .unwrap(),
+ )
.unwrap()
.build();
@@ -1831,12 +1860,15 @@ mod tests {
let expected_spec = PartitionSpec::builder(schema)
.with_spec_id(1)
- .add_unbound_field(UnboundPartitionField {
- name: "y_bucket[2]".to_string(),
- transform: Transform::Bucket(2),
- source_id: 1,
- field_id: Some(1001),
- })
+ .add_unbound_field(
+ UnboundPartitionField::builder()
+ .source_ids(vec![1])
+ .field_id(1001)
+ .name("y_bucket[2]".to_string())
+ .transform(Transform::Bucket(2))
+ .build()
+ .unwrap(),
+ )
.unwrap()
.build()
.unwrap();
@@ -2401,18 +2433,20 @@ mod tests {
let added_spec = UnboundPartitionSpec::builder()
.with_spec_id(10)
.add_partition_fields(vec![
- UnboundPartitionField {
- name: "y".to_string(),
- transform: Transform::Identity,
- source_id: 2,
- field_id: Some(1000),
- },
- UnboundPartitionField {
- name: "z".to_string(),
- transform: Transform::Identity,
- source_id: 3,
- field_id: Some(1002),
- },
+ UnboundPartitionField::builder()
+ .source_ids(vec![2])
+ .field_id(1000)
+ .name("y".to_string())
+ .transform(Transform::Identity)
+ .build()
+ .unwrap(),
+ UnboundPartitionField::builder()
+ .source_ids(vec![3])
+ .field_id(1002)
+ .name("z".to_string())
+ .transform(Transform::Identity)
+ .build()
+ .unwrap(),
])
.unwrap()
.build();
@@ -2737,7 +2771,14 @@ mod tests {
let partition_spec_with_bucket = UnboundPartitionSpec::builder()
.with_spec_id(0)
- .add_partition_field(1, "bucket_data", Transform::Bucket(16))
+ .add_partition_field(
+ UnboundPartitionField::builder()
+ .source_ids(vec![1])
+ .name("bucket_data")
+ .transform(Transform::Bucket(16))
+ .build()
+ .unwrap(),
+ )
.unwrap()
.build();
@@ -2796,7 +2837,14 @@ mod tests {
let partition_spec = UnboundPartitionSpec::builder()
.with_spec_id(0)
- .add_partition_field(1, "partition_col", Transform::Bucket(16))
+ .add_partition_field(
+ UnboundPartitionField::builder()
+ .source_ids(vec![1])
+ .name("partition_col")
+ .transform(Transform::Bucket(16))
+ .build()
+ .unwrap(),
+ )
.unwrap()
.build();
@@ -2845,7 +2893,14 @@ mod tests {
let partition_spec = UnboundPartitionSpec::builder()
.with_spec_id(0)
- .add_partition_field(1, "data_bucket", Transform::Bucket(16))
+ .add_partition_field(
+ UnboundPartitionField::builder()
+ .source_ids(vec![1])
+ .name("data_bucket")
+ .transform(Transform::Bucket(16))
+ .build()
+ .unwrap(),
+ )
.unwrap()
.build();
@@ -2868,7 +2923,14 @@ mod tests {
let conflicting_partition_spec = UnboundPartitionSpec::builder()
.with_spec_id(1)
- .add_partition_field(1, "existing_field", Transform::Bucket(8))
+ .add_partition_field(
+ UnboundPartitionField::builder()
+ .source_ids(vec![1])
+ .name("existing_field")
+ .transform(Transform::Bucket(8))
+ .build()
+ .unwrap(),
+ )
.unwrap()
.build();
@@ -2898,7 +2960,14 @@ mod tests {
let partition_spec = UnboundPartitionSpec::builder()
.with_spec_id(0)
- .add_partition_field(1, "bucket_data", Transform::Bucket(16))
+ .add_partition_field(
+ UnboundPartitionField::builder()
+ .source_ids(vec![1])
+ .name("bucket_data")
+ .transform(Transform::Bucket(16))
+ .build()
+ .unwrap(),
+ )
.unwrap()
.build();
@@ -2994,7 +3063,14 @@ mod tests {
let partition_spec = UnboundPartitionSpec::builder()
.with_spec_id(0)
- .add_partition_field(2, "partition_data", Transform::Identity)
+ .add_partition_field(
+ UnboundPartitionField::builder()
+ .source_ids(vec![2])
+ .name("partition_data")
+ .transform(Transform::Identity)
+ .build()
+ .unwrap(),
+ )
.unwrap()
.build();
@@ -3043,7 +3119,14 @@ mod tests {
let partition_spec = UnboundPartitionSpec::builder()
.with_spec_id(0)
- .add_partition_field(1, "bucket_data", Transform::Bucket(16))
+ .add_partition_field(
+ UnboundPartitionField::builder()
+ .source_ids(vec![1])
+ .name("bucket_data")
+ .transform(Transform::Bucket(16))
+ .build()
+ .unwrap(),
+ )
.unwrap()
.build();
@@ -3093,7 +3176,14 @@ mod tests {
let partition_spec = UnboundPartitionSpec::builder()
.with_spec_id(0)
- .add_partition_field(1, "data_bucket", Transform::Bucket(16))
+ .add_partition_field(
+ UnboundPartitionField::builder()
+ .source_ids(vec![1])
+ .name("data_bucket")
+ .transform(Transform::Bucket(16))
+ .build()
+ .unwrap(),
+ )
.unwrap()
.build();
@@ -3117,7 +3207,14 @@ mod tests {
// Try to add a partition spec with a field name that does NOT
conflict with existing schema fields
let non_conflicting_partition_spec = UnboundPartitionSpec::builder()
.with_spec_id(1)
- .add_partition_field(2, "new_partition_field",
Transform::Bucket(8))
+ .add_partition_field(
+ UnboundPartitionField::builder()
+ .source_ids(vec![2])
+ .name("new_partition_field")
+ .transform(Transform::Bucket(8))
+ .build()
+ .unwrap(),
+ )
.unwrap()
.build();
@@ -3518,7 +3615,14 @@ mod tests {
// Create initial table with spec 0: identity(id) -> field_id = 1000
let initial_spec = UnboundPartitionSpec::builder()
- .add_partition_field(1, "id", Transform::Identity)
+ .add_partition_field(
+ UnboundPartitionField::builder()
+ .source_ids(vec![1])
+ .name("id")
+ .transform(Transform::Identity)
+ .build()
+ .unwrap(),
+ )
.unwrap()
.build();
@@ -3537,7 +3641,14 @@ mod tests {
// Add spec 1: bucket(data) -> field_id = 1001
let spec1 = UnboundPartitionSpec::builder()
- .add_partition_field(2, "data_bucket", Transform::Bucket(10))
+ .add_partition_field(
+ UnboundPartitionField::builder()
+ .source_ids(vec![2])
+ .name("data_bucket")
+ .transform(Transform::Bucket(10))
+ .build()
+ .unwrap(),
+ )
.unwrap()
.build();
let builder =
metadata.into_builder(Some("s3://bucket/table/metadata/v1.json".to_string()));
@@ -3547,11 +3658,32 @@ mod tests {
// Add spec 2: identity(id) + bucket(data) + year(timestamp)
// Should reuse field_id 1000 for identity(id) and 1001 for
bucket(data)
let spec2 = UnboundPartitionSpec::builder()
- .add_partition_field(1, "id", Transform::Identity) // Should reuse
1000
- .unwrap()
- .add_partition_field(2, "data_bucket", Transform::Bucket(10)) //
Should reuse 1001
- .unwrap()
- .add_partition_field(3, "year", Transform::Year) // Should get new
1002
+ .add_partition_field(
+ UnboundPartitionField::builder()
+ .source_ids(vec![1])
+ .name("id")
+ .transform(Transform::Identity)
+ .build()
+ .unwrap(),
+ ) // Should reuse 1000
+ .unwrap()
+ .add_partition_field(
+ UnboundPartitionField::builder()
+ .source_ids(vec![2])
+ .name("data_bucket")
+ .transform(Transform::Bucket(10))
+ .build()
+ .unwrap(),
+ ) // Should reuse 1001
+ .unwrap()
+ .add_partition_field(
+ UnboundPartitionField::builder()
+ .source_ids(vec![3])
+ .name("year")
+ .transform(Transform::Year)
+ .build()
+ .unwrap(),
+ ) // Should get new 1002
.unwrap()
.build();
let builder =
metadata.into_builder(Some("s3://bucket/table/metadata/v2.json".to_string()));