Skip to main content

iceberg/spec/
partition.rs

1// Licensed to the Apache Software Foundation (ASF) under one
2// or more contributor license agreements.  See the NOTICE file
3// distributed with this work for additional information
4// regarding copyright ownership.  The ASF licenses this file
5// to you under the Apache License, Version 2.0 (the
6// "License"); you may not use this file except in compliance
7// with the License.  You may obtain a copy of the License at
8//
9//   http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing,
12// software distributed under the License is distributed on an
13// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14// KIND, either express or implied.  See the License for the
15// specific language governing permissions and limitations
16// under the License.
17
18/*!
19 * Partitioning
20 */
21use std::sync::Arc;
22
23use itertools::Itertools;
24use serde::{Deserialize, Serialize};
25use typed_builder::TypedBuilder;
26
27use super::transform::Transform;
28use super::{NestedField, PrimitiveType, Schema, SchemaRef, StructType};
29use crate::Result;
30use crate::error::invalid_data;
31use crate::spec::Struct;
32
33pub(crate) const UNPARTITIONED_LAST_ASSIGNED_ID: i32 = 999;
34pub(crate) const DEFAULT_PARTITION_SPEC_ID: i32 = 0;
35
36/// Partition fields capture the transform from table data to partition values.
37#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, TypedBuilder)]
38#[serde(rename_all = "kebab-case")]
39pub struct PartitionField {
40    /// A source column id from the table’s schema
41    pub source_id: i32,
42    /// A partition field id that is used to identify a partition field and is unique within a partition spec.
43    /// In v2 table metadata, it is unique across all partition specs.
44    pub field_id: i32,
45    /// A partition name.
46    pub name: String,
47    /// A transform that is applied to the source column to produce a partition value.
48    pub transform: Transform,
49}
50
51impl PartitionField {
52    /// To unbound partition field
53    pub fn into_unbound(self) -> UnboundPartitionField {
54        self.into()
55    }
56}
57
58/// Reference to [`PartitionSpec`].
59pub type PartitionSpecRef = Arc<PartitionSpec>;
60/// Partition spec that defines how to produce a tuple of partition values from a record.
61///
62/// A [`PartitionSpec`] is originally obtained by binding an [`UnboundPartitionSpec`] to a schema and is
63/// only guaranteed to be valid for that schema. The main difference between [`PartitionSpec`] and
64/// [`UnboundPartitionSpec`] is that the former has field ids assigned,
65/// while field ids are optional for [`UnboundPartitionSpec`].
66#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone)]
67#[serde(rename_all = "kebab-case")]
68pub struct PartitionSpec {
69    /// Identifier for PartitionSpec
70    spec_id: i32,
71    /// Details of the partition spec
72    fields: Vec<PartitionField>,
73}
74
75impl PartitionSpec {
76    /// Create a new partition spec builder with the given schema.
77    pub fn builder(schema: impl Into<SchemaRef>) -> PartitionSpecBuilder {
78        PartitionSpecBuilder::new(schema)
79    }
80
81    /// Fields of the partition spec
82    pub fn fields(&self) -> &[PartitionField] {
83        &self.fields
84    }
85
86    /// Spec id of the partition spec
87    pub fn spec_id(&self) -> i32 {
88        self.spec_id
89    }
90
91    /// Get a new unpartitioned partition spec
92    pub fn unpartition_spec() -> Self {
93        Self {
94            spec_id: DEFAULT_PARTITION_SPEC_ID,
95            fields: vec![],
96        }
97    }
98
99    /// Returns if the partition spec is unpartitioned.
100    ///
101    /// A [`PartitionSpec`] is unpartitioned if it has no fields or all fields are [`Transform::Void`] transform.
102    pub fn is_unpartitioned(&self) -> bool {
103        self.fields.is_empty() || self.fields.iter().all(|f| f.transform == Transform::Void)
104    }
105
106    /// Returns the partition type of this partition spec.
107    /// If a source column is absent, preserves fixed transform result types and uses
108    /// unknown for result types that depend on the source type.
109    pub fn partition_type(&self, schema: &Schema) -> Result<StructType> {
110        let mut struct_fields = Vec::with_capacity(self.fields.len());
111        for partition_field in &self.fields {
112            let res_type = match schema.field_by_id(partition_field.source_id) {
113                Some(field) => partition_field.transform.result_type(&field.field_type)?,
114                // Historical specs may reference dropped source columns. Retain every
115                // field's position and any result type that is independent of its source.
116                None => match partition_field.transform {
117                    Transform::Bucket(_) | Transform::Year | Transform::Month | Transform::Hour => {
118                        PrimitiveType::Int.into()
119                    }
120                    Transform::Day => PrimitiveType::Date.into(),
121                    Transform::Unknown => PrimitiveType::String.into(),
122                    Transform::Identity | Transform::Truncate(_) | Transform::Void => {
123                        PrimitiveType::Unknown.into()
124                    }
125                },
126            };
127            struct_fields.push(
128                NestedField::optional(partition_field.field_id, &partition_field.name, res_type)
129                    .into(),
130            );
131        }
132        Ok(StructType::new(struct_fields))
133    }
134
135    /// Convert to unbound partition spec
136    pub fn into_unbound(self) -> UnboundPartitionSpec {
137        self.into()
138    }
139
140    /// Change the spec id of the partition spec
141    pub fn with_spec_id(self, spec_id: i32) -> Self {
142        Self { spec_id, ..self }
143    }
144
145    /// Check if this partition spec has sequential partition ids.
146    /// Sequential ids start from 1000 and increment by 1 for each field.
147    /// This is required for spec version 1
148    pub fn has_sequential_ids(&self) -> bool {
149        has_sequential_ids(self.fields.iter().map(|f| f.field_id))
150    }
151
152    /// Get the highest field id in the partition spec.
153    pub fn highest_field_id(&self) -> Option<i32> {
154        self.fields.iter().map(|f| f.field_id).max()
155    }
156
157    /// Check if this partition spec is compatible with another partition spec.
158    ///
159    /// Returns true if the partition spec is equal to the other spec with partition field ids ignored and
160    /// spec_id ignored. The following must be identical:
161    /// * The number of fields
162    /// * Field order
163    /// * Field names
164    /// * Source column ids
165    /// * Transforms
166    pub fn is_compatible_with(&self, other: &PartitionSpec) -> bool {
167        if self.fields.len() != other.fields.len() {
168            return false;
169        }
170
171        for (this_field, other_field) in self.fields.iter().zip(other.fields.iter()) {
172            if this_field.source_id != other_field.source_id
173                || this_field.name != other_field.name
174                || this_field.transform != other_field.transform
175            {
176                return false;
177            }
178        }
179
180        true
181    }
182
183    /// Returns partition path string containing partition type and partition
184    /// value as key-value pairs.
185    pub fn partition_to_path(&self, data: &Struct, schema: SchemaRef) -> String {
186        let partition_type = self.partition_type(&schema).unwrap();
187        let field_types = partition_type.fields();
188
189        self.fields
190            .iter()
191            .enumerate()
192            .map(|(i, field)| {
193                let value = data[i].as_ref();
194                form_urlencoded::Serializer::new(String::new())
195                    .append_pair(
196                        &field.name,
197                        &field
198                            .transform
199                            .to_human_string(&field_types[i].field_type, value),
200                    )
201                    .finish()
202            })
203            .join("/")
204    }
205}
206
207/// A partition key represents a specific partition in a table, containing the partition spec,
208/// schema, and the actual partition values.
209#[derive(Clone, Debug)]
210pub struct PartitionKey {
211    /// The partition spec that contains the partition fields.
212    spec: PartitionSpec,
213    /// The schema to which the partition spec is bound.
214    schema: SchemaRef,
215    /// Partition fields' values in struct.
216    data: Struct,
217}
218
219impl PartitionKey {
220    /// Creates a new partition key with the given spec, schema, and data.
221    pub fn new(spec: PartitionSpec, schema: SchemaRef, data: Struct) -> Self {
222        Self { spec, schema, data }
223    }
224
225    /// Creates a new partition key from another partition key, with a new data field.
226    pub fn copy_with_data(&self, data: Struct) -> Self {
227        Self {
228            spec: self.spec.clone(),
229            schema: self.schema.clone(),
230            data,
231        }
232    }
233
234    /// Generates a partition path based on the partition values.
235    pub fn to_path(&self) -> String {
236        self.spec.partition_to_path(&self.data, self.schema.clone())
237    }
238
239    /// Returns `true` if the partition key is absent (`None`)
240    /// or represents an unpartitioned spec.
241    pub fn is_effectively_none(partition_key: Option<&PartitionKey>) -> bool {
242        match partition_key {
243            None => true,
244            Some(pk) => pk.spec.is_unpartitioned(),
245        }
246    }
247
248    /// Returns the associated [`PartitionSpec`].
249    pub fn spec(&self) -> &PartitionSpec {
250        &self.spec
251    }
252
253    /// Returns the associated [`SchemaRef`].
254    pub fn schema(&self) -> &SchemaRef {
255        &self.schema
256    }
257
258    /// Returns the associated [`Struct`].
259    pub fn data(&self) -> &Struct {
260        &self.data
261    }
262}
263
264/// Reference to [`UnboundPartitionSpec`].
265pub type UnboundPartitionSpecRef = Arc<UnboundPartitionSpec>;
266/// Unbound partition field can be built without a schema and later bound to a schema.
267///
268/// The fields are private so that an instance is always well formed: in particular
269/// `source_ids` holds at least one id, which [`UnboundPartitionField::builder`] checks when the
270/// field is built.
271#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, TypedBuilder)]
272#[serde(
273    try_from = "self::_serde::UnboundPartitionFieldSerde",
274    into = "self::_serde::UnboundPartitionFieldSerde"
275)]
276#[builder(build_method(into = Result<UnboundPartitionField>))]
277pub struct UnboundPartitionField {
278    /// The source column ids from the table’s schema. A single-argument transform reads one
279    /// id; a v3 multi-argument transform reads several.
280    source_ids: Vec<i32>,
281    /// A partition field id that is used to identify a partition field and is unique within a partition spec.
282    /// In v2 table metadata, it is unique across all partition specs.
283    #[builder(default, setter(strip_option(fallback = field_id_opt)))]
284    field_id: Option<i32>,
285    /// A partition name.
286    #[builder(setter(into))]
287    name: String,
288    /// A transform that is applied to the source column to produce a partition value.
289    transform: Transform,
290}
291
292impl UnboundPartitionField {
293    /// The single source column id this field reads.
294    ///
295    /// Returns an error for a multi-argument field, which reads several columns and therefore
296    /// has no single source id. Use [`Self::source_ids`] to handle both shapes.
297    pub fn source_id(&self) -> Result<i32> {
298        match self.source_ids.as_slice() {
299            [source_id] => Ok(*source_id),
300            source_ids => Err(invalid_data!(
301                "Partition field '{}' reads {} source columns and has no single source id",
302                self.name,
303                source_ids.len()
304            )),
305        }
306    }
307
308    /// The source column ids this field reads, in order. Never empty.
309    pub fn source_ids(&self) -> &[i32] {
310        &self.source_ids
311    }
312
313    /// The partition field id, when one was assigned.
314    pub fn field_id(&self) -> Option<i32> {
315        self.field_id
316    }
317
318    /// The partition name.
319    pub fn name(&self) -> &str {
320        &self.name
321    }
322
323    /// The transform applied to the source columns to produce a partition value.
324    pub fn transform(&self) -> Transform {
325        self.transform
326    }
327
328    /// Return this field with the given partition field id assigned.
329    pub fn with_field_id(self, field_id: i32) -> Self {
330        Self {
331            field_id: Some(field_id),
332            ..self
333        }
334    }
335
336    fn validate(&self) -> Result<()> {
337        if self.source_ids.is_empty() {
338            return Err(invalid_data!("Empty source-ids is not allowed"));
339        }
340        Ok(())
341    }
342}
343
344impl From<UnboundPartitionField> for Result<UnboundPartitionField> {
345    fn from(field: UnboundPartitionField) -> Self {
346        field.validate()?;
347        Ok(field)
348    }
349}
350
351mod _serde {
352    use serde::{Deserialize, Serialize};
353
354    use super::UnboundPartitionField;
355    use crate::Error;
356    use crate::error::invalid_data;
357    use crate::spec::Transform;
358
359    /// Per the spec a single-argument field carries `source-id` and a multi-argument field
360    /// carries `source-ids`. Either spelling is read, but not both at once; the one that
361    /// matches the field is written.
362    #[derive(Serialize, Deserialize)]
363    #[serde(rename_all = "kebab-case")]
364    pub(super) struct UnboundPartitionFieldSerde {
365        #[serde(default, skip_serializing_if = "Option::is_none")]
366        source_id: Option<i32>,
367        #[serde(default, skip_serializing_if = "Option::is_none")]
368        source_ids: Option<Vec<i32>>,
369        #[serde(default, skip_serializing_if = "Option::is_none")]
370        field_id: Option<i32>,
371        name: String,
372        transform: Transform,
373    }
374
375    impl TryFrom<UnboundPartitionFieldSerde> for UnboundPartitionField {
376        type Error = Error;
377
378        fn try_from(value: UnboundPartitionFieldSerde) -> Result<Self, Error> {
379            let source_ids = match (value.source_id, value.source_ids) {
380                (Some(source_id), None) => vec![source_id],
381                (None, Some(source_ids)) => source_ids,
382                (Some(_), Some(_)) => {
383                    return Err(invalid_data!(
384                        "source-id and source-ids are mutually exclusive"
385                    ));
386                }
387                (None, None) => {
388                    return Err(invalid_data!(
389                        "Either `source-id` or `source-ids` must be present"
390                    ));
391                }
392            };
393
394            UnboundPartitionField::builder()
395                .source_ids(source_ids)
396                .field_id_opt(value.field_id)
397                .name(value.name)
398                .transform(value.transform)
399                .build()
400        }
401    }
402
403    impl From<UnboundPartitionField> for UnboundPartitionFieldSerde {
404        fn from(value: UnboundPartitionField) -> Self {
405            let (source_id, source_ids) = match value.source_ids.as_slice() {
406                [source_id] => (Some(*source_id), None),
407                _ => (None, Some(value.source_ids)),
408            };
409            Self {
410                source_id,
411                source_ids,
412                field_id: value.field_id,
413                name: value.name,
414                transform: value.transform,
415            }
416        }
417    }
418}
419
420/// Unbound partition spec can be built without a schema and later bound to a schema.
421/// They are used to transport schema information as part of the REST specification.
422/// The main difference to [`PartitionSpec`] is that the field ids are optional.
423#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Default)]
424#[serde(rename_all = "kebab-case")]
425pub struct UnboundPartitionSpec {
426    /// Identifier for PartitionSpec
427    #[serde(skip_serializing_if = "Option::is_none")]
428    pub(crate) spec_id: Option<i32>,
429    /// Details of the partition spec
430    pub(crate) fields: Vec<UnboundPartitionField>,
431}
432
433impl UnboundPartitionSpec {
434    /// Create unbound partition spec builder
435    pub fn builder() -> UnboundPartitionSpecBuilder {
436        UnboundPartitionSpecBuilder::default()
437    }
438
439    /// Bind this unbound partition spec to a schema.
440    ///
441    /// Unlike [`PartitionSpecBuilder::add_unbound_field`], this accepts redundant fields, such
442    /// as two time granularities of one column, because spec evolution produces them.
443    pub fn bind(self, schema: impl Into<SchemaRef>) -> Result<PartitionSpec> {
444        PartitionSpecBuilder::new_from_unbound(self, schema)?.build()
445    }
446
447    /// Spec id of the partition spec
448    pub fn spec_id(&self) -> Option<i32> {
449        self.spec_id
450    }
451
452    /// Fields of the partition spec
453    pub fn fields(&self) -> &[UnboundPartitionField] {
454        &self.fields
455    }
456
457    /// Change the spec id of the partition spec
458    pub fn with_spec_id(self, spec_id: i32) -> Self {
459        Self {
460            spec_id: Some(spec_id),
461            ..self
462        }
463    }
464}
465
466fn has_sequential_ids(field_ids: impl Iterator<Item = i32>) -> bool {
467    for (index, field_id) in field_ids.enumerate() {
468        let expected_id = (UNPARTITIONED_LAST_ASSIGNED_ID as i64)
469            .checked_add(1)
470            .and_then(|id| id.checked_add(index as i64))
471            .unwrap_or(i64::MAX);
472
473        if field_id as i64 != expected_id {
474            return false;
475        }
476    }
477
478    true
479}
480
481impl From<PartitionField> for UnboundPartitionField {
482    fn from(field: PartitionField) -> Self {
483        UnboundPartitionField {
484            source_ids: vec![field.source_id],
485            field_id: Some(field.field_id),
486            name: field.name,
487            transform: field.transform,
488        }
489    }
490}
491
492impl From<PartitionSpec> for UnboundPartitionSpec {
493    fn from(spec: PartitionSpec) -> Self {
494        UnboundPartitionSpec {
495            spec_id: Some(spec.spec_id),
496            fields: spec.fields.into_iter().map(Into::into).collect(),
497        }
498    }
499}
500
501/// Create a new UnboundPartitionSpec
502#[derive(Debug, Default)]
503pub struct UnboundPartitionSpecBuilder {
504    spec_id: Option<i32>,
505    fields: Vec<UnboundPartitionField>,
506}
507
508impl UnboundPartitionSpecBuilder {
509    /// Create a new partition spec builder with the given schema.
510    pub fn new() -> Self {
511        Self {
512            spec_id: None,
513            fields: vec![],
514        }
515    }
516
517    /// Set the spec id for the partition spec.
518    pub fn with_spec_id(mut self, spec_id: i32) -> Self {
519        self.spec_id = Some(spec_id);
520        self
521    }
522
523    /// Add a new partition field to the partition spec from an unbound partition field.
524    pub fn add_partition_field(mut self, field: UnboundPartitionField) -> Result<Self> {
525        self.check_name_set_and_unique(&field.name)?;
526        self.check_for_redundant_partitions(&field.source_ids, &field.transform)?;
527        if let Some(partition_field_id) = field.field_id {
528            self.check_partition_id_unique(partition_field_id)?;
529        }
530        self.fields.push(field);
531        Ok(self)
532    }
533
534    /// Add multiple partition fields to the partition spec.
535    pub fn add_partition_fields(
536        self,
537        fields: impl IntoIterator<Item = UnboundPartitionField>,
538    ) -> Result<Self> {
539        let mut builder = self;
540        for field in fields {
541            builder = builder.add_partition_field(field)?;
542        }
543        Ok(builder)
544    }
545
546    /// Build the unbound partition spec.
547    pub fn build(self) -> UnboundPartitionSpec {
548        UnboundPartitionSpec {
549            spec_id: self.spec_id,
550            fields: self.fields,
551        }
552    }
553}
554
555/// Create valid partition specs for a given schema.
556#[derive(Debug)]
557pub struct PartitionSpecBuilder {
558    spec_id: Option<i32>,
559    last_assigned_field_id: i32,
560    fields: Vec<UnboundPartitionField>,
561    schema: SchemaRef,
562}
563
564impl PartitionSpecBuilder {
565    /// Create a new partition spec builder with the given schema.
566    pub fn new(schema: impl Into<SchemaRef>) -> Self {
567        Self {
568            spec_id: None,
569            fields: vec![],
570            last_assigned_field_id: UNPARTITIONED_LAST_ASSIGNED_ID,
571            schema: schema.into(),
572        }
573    }
574
575    /// Create a new partition spec builder from an existing unbound partition spec.
576    pub fn new_from_unbound(
577        unbound: UnboundPartitionSpec,
578        schema: impl Into<SchemaRef>,
579    ) -> Result<Self> {
580        let mut builder =
581            Self::new(schema).with_spec_id(unbound.spec_id.unwrap_or(DEFAULT_PARTITION_SPEC_ID));
582
583        for field in unbound.fields {
584            builder = builder.add_field_allowing_redundancy(field)?;
585        }
586        Ok(builder)
587    }
588
589    /// Set the last assigned field id for the partition spec.
590    ///
591    /// Set this field when a new partition spec is created for an existing TableMetaData.
592    /// As `field_id` must be unique in V2 metadata, this should be set to
593    /// the highest field id used previously.
594    pub fn with_last_assigned_field_id(mut self, last_assigned_field_id: i32) -> Self {
595        self.last_assigned_field_id = last_assigned_field_id;
596        self
597    }
598
599    /// Set the spec id for the partition spec.
600    pub fn with_spec_id(mut self, spec_id: i32) -> Self {
601        self.spec_id = Some(spec_id);
602        self
603    }
604
605    /// Add a new partition field to the partition spec.
606    pub fn add_partition_field(
607        self,
608        source_name: impl AsRef<str>,
609        target_name: impl Into<String>,
610        transform: Transform,
611    ) -> Result<Self> {
612        let source_id = self
613            .schema
614            .field_by_name(source_name.as_ref())
615            .ok_or_else(|| {
616                invalid_data!(
617                    "Cannot find source column with name: {} in schema",
618                    source_name.as_ref()
619                )
620            })?
621            .id;
622        let field = UnboundPartitionField {
623            source_ids: vec![source_id],
624            field_id: None,
625            name: target_name.into(),
626            transform,
627        };
628
629        self.add_unbound_field(field)
630    }
631
632    /// Add a new partition field to the partition spec.
633    ///
634    /// If partition field id is set, it is used as the field id.
635    /// Otherwise, a new `field_id` is assigned.
636    ///
637    /// Rejects a field that is redundant with an existing one, such as a second time
638    /// granularity of the same column. [`UnboundPartitionSpec::bind`] accepts such fields.
639    pub fn add_unbound_field(self, field: UnboundPartitionField) -> Result<Self> {
640        self.check_name_set_and_unique(&field.name)?;
641        self.check_for_redundant_partitions(&field.source_ids, &field.transform)?;
642        self.add_field_allowing_redundancy(field)
643    }
644
645    /// Same as [`Self::add_unbound_field`] without the redundancy check, as Java's
646    /// `PartitionSpec.Builder.add` does. Used to bind persisted specs, where evolution can leave
647    /// several time granularities or void fields for one column.
648    fn add_field_allowing_redundancy(mut self, field: UnboundPartitionField) -> Result<Self> {
649        self.check_name_set_and_unique(&field.name)?;
650        Self::check_name_does_not_collide_with_schema(&field, &self.schema)?;
651        Self::check_transform_compatibility(&field, &self.schema)?;
652        if let Some(partition_field_id) = field.field_id {
653            self.check_partition_id_unique(partition_field_id)?;
654        }
655
656        // Non-fallible from here
657        self.fields.push(field);
658        Ok(self)
659    }
660
661    /// Wrapper around `with_unbound_fields` to add multiple partition fields.
662    pub fn add_unbound_fields(
663        self,
664        fields: impl IntoIterator<Item = UnboundPartitionField>,
665    ) -> Result<Self> {
666        let mut builder = self;
667        for field in fields {
668            builder = builder.add_unbound_field(field)?;
669        }
670        Ok(builder)
671    }
672
673    /// Build a bound partition spec with the given schema.
674    pub fn build(self) -> Result<PartitionSpec> {
675        let fields = Self::set_field_ids(self.fields, self.last_assigned_field_id)?;
676        Ok(PartitionSpec {
677            spec_id: self.spec_id.unwrap_or(DEFAULT_PARTITION_SPEC_ID),
678            fields,
679        })
680    }
681
682    fn set_field_ids(
683        fields: Vec<UnboundPartitionField>,
684        last_assigned_field_id: i32,
685    ) -> Result<Vec<PartitionField>> {
686        let mut last_assigned_field_id = last_assigned_field_id;
687        // Already assigned partition ids. If we see one of these during iteration,
688        // we skip it.
689        let assigned_ids = fields
690            .iter()
691            .filter_map(|f| f.field_id)
692            .collect::<std::collections::HashSet<_>>();
693
694        fn _check_add_1(prev: i32) -> Result<i32> {
695            prev.checked_add(1)
696                .ok_or_else(|| invalid_data!("Cannot assign more partition ids. Overflow."))
697        }
698
699        let mut bound_fields = Vec::with_capacity(fields.len());
700        for field in fields.into_iter() {
701            let partition_field_id = if let Some(partition_field_id) = field.field_id {
702                last_assigned_field_id = std::cmp::max(last_assigned_field_id, partition_field_id);
703                partition_field_id
704            } else {
705                last_assigned_field_id = _check_add_1(last_assigned_field_id)?;
706                while assigned_ids.contains(&last_assigned_field_id) {
707                    last_assigned_field_id = _check_add_1(last_assigned_field_id)?;
708                }
709                last_assigned_field_id
710            };
711
712            bound_fields.push(PartitionField {
713                source_id: field.source_id()?,
714                field_id: partition_field_id,
715                name: field.name,
716                transform: field.transform,
717            })
718        }
719
720        Ok(bound_fields)
721    }
722
723    /// Ensure that the partition name is unique among columns in the schema.
724    /// Duplicate names are allowed if:
725    /// 1. The column is sourced from the column with the same name.
726    /// 2. AND [`Transform::may_share_name_with_source`] holds for the transform.
727    fn check_name_does_not_collide_with_schema(
728        field: &UnboundPartitionField,
729        schema: &Schema,
730    ) -> Result<()> {
731        match schema.field_by_name(field.name.as_str()) {
732            Some(schema_collision) => {
733                if field.transform.may_share_name_with_source() {
734                    if schema_collision.id == field.source_id()? {
735                        Ok(())
736                    } else {
737                        Err(invalid_data!(
738                            "Cannot create identity or void partition sourced from different field in schema. Field name '{}' has id `{}` in schema but partition source id is `{}`",
739                            field.name,
740                            schema_collision.id,
741                            field.source_id()?
742                        ))
743                    }
744                } else {
745                    Err(invalid_data!(
746                        "Cannot create partition with name: '{}' that conflicts with schema field and is not an identity or void transform.",
747                        field.name
748                    ))
749                }
750            }
751            None => Ok(()),
752        }
753    }
754
755    /// Ensure that the transformation of the field is compatible with type of the field
756    /// in the schema. Implicitly also checks if the source field exists in the schema.
757    fn check_transform_compatibility(field: &UnboundPartitionField, schema: &Schema) -> Result<()> {
758        // A multi-argument field is rejected here on purpose: a bound `PartitionField` has
759        // no place for more than one source id yet.
760        let source_id = field.source_id()?;
761        let schema_field = schema.field_by_id(source_id).ok_or_else(|| {
762            invalid_data!("Cannot find partition source field with id `{source_id}` in schema")
763        })?;
764
765        if field.transform != Transform::Void {
766            if !schema_field.field_type.is_primitive() {
767                return Err(invalid_data!(
768                    "Cannot partition by non-primitive source field: '{}'.",
769                    schema_field.field_type
770                ));
771            }
772
773            if field
774                .transform
775                .result_type(&schema_field.field_type)
776                .is_err()
777            {
778                return Err(invalid_data!(
779                    "Invalid source type: '{}' for transform: '{}'.",
780                    schema_field.field_type,
781                    field.transform.dedup_name()
782                ));
783            }
784        }
785
786        Ok(())
787    }
788}
789
790/// Contains checks that are common to both PartitionSpecBuilder and UnboundPartitionSpecBuilder
791trait CorePartitionSpecValidator {
792    /// Ensure that the partition name is unique among the partition fields and is not empty.
793    fn check_name_set_and_unique(&self, name: &str) -> Result<()> {
794        if name.is_empty() {
795            return Err(invalid_data!("Cannot use empty partition name"));
796        }
797
798        if self.fields().iter().any(|f| f.name == name) {
799            return Err(invalid_data!(
800                "Cannot use partition name more than once: {name}"
801            ));
802        }
803        Ok(())
804    }
805
806    /// For a single source-column transformations must be unique.
807    fn check_for_redundant_partitions(
808        &self,
809        source_ids: &[i32],
810        transform: &Transform,
811    ) -> Result<()> {
812        let collision = self.fields().iter().find(|f| {
813            f.source_ids == source_ids && f.transform.dedup_name() == transform.dedup_name()
814        });
815
816        if let Some(collision) = collision {
817            Err(invalid_data!(
818                "Cannot add redundant partition with source ids `{:?}` and transform `{}`. A partition with the same source ids and transform already exists with name `{}`",
819                source_ids,
820                transform.dedup_name(),
821                collision.name
822            ))
823        } else {
824            Ok(())
825        }
826    }
827
828    /// Check field / partition_id unique within the partition spec if set
829    fn check_partition_id_unique(&self, field_id: i32) -> Result<()> {
830        if self.fields().iter().any(|f| f.field_id == Some(field_id)) {
831            return Err(invalid_data!(
832                "Cannot use field id more than once in one PartitionSpec: {field_id}"
833            ));
834        }
835
836        Ok(())
837    }
838
839    fn fields(&self) -> &Vec<UnboundPartitionField>;
840}
841
842impl CorePartitionSpecValidator for PartitionSpecBuilder {
843    fn fields(&self) -> &Vec<UnboundPartitionField> {
844        &self.fields
845    }
846}
847
848impl CorePartitionSpecValidator for UnboundPartitionSpecBuilder {
849    fn fields(&self) -> &Vec<UnboundPartitionField> {
850        &self.fields
851    }
852}
853
854#[cfg(test)]
855mod tests {
856    use super::*;
857    use crate::ErrorKind;
858    use crate::spec::{Literal, PrimitiveType, Type};
859
860    #[test]
861    fn test_partition_spec() {
862        let spec = r#"
863        {
864        "spec-id": 1,
865        "fields": [ {
866            "source-id": 4,
867            "field-id": 1000,
868            "name": "ts_day",
869            "transform": "day"
870            }, {
871            "source-id": 1,
872            "field-id": 1001,
873            "name": "id_bucket",
874            "transform": "bucket[16]"
875            }, {
876            "source-id": 2,
877            "field-id": 1002,
878            "name": "id_truncate",
879            "transform": "truncate[4]"
880            } ]
881        }
882        "#;
883
884        let partition_spec: PartitionSpec = serde_json::from_str(spec).unwrap();
885        assert_eq!(4, partition_spec.fields[0].source_id);
886        assert_eq!(1000, partition_spec.fields[0].field_id);
887        assert_eq!("ts_day", partition_spec.fields[0].name);
888        assert_eq!(Transform::Day, partition_spec.fields[0].transform);
889
890        assert_eq!(1, partition_spec.fields[1].source_id);
891        assert_eq!(1001, partition_spec.fields[1].field_id);
892        assert_eq!("id_bucket", partition_spec.fields[1].name);
893        assert_eq!(Transform::Bucket(16), partition_spec.fields[1].transform);
894
895        assert_eq!(2, partition_spec.fields[2].source_id);
896        assert_eq!(1002, partition_spec.fields[2].field_id);
897        assert_eq!("id_truncate", partition_spec.fields[2].name);
898        assert_eq!(Transform::Truncate(4), partition_spec.fields[2].transform);
899    }
900
901    #[test]
902    fn test_is_unpartitioned() {
903        let schema = Schema::builder()
904            .with_fields(vec![
905                NestedField::required(1, "id", Type::Primitive(PrimitiveType::Int)).into(),
906                NestedField::required(2, "name", Type::Primitive(PrimitiveType::String)).into(),
907            ])
908            .build()
909            .unwrap();
910        let partition_spec = PartitionSpec::builder(schema.clone())
911            .with_spec_id(1)
912            .build()
913            .unwrap();
914        assert!(
915            partition_spec.is_unpartitioned(),
916            "Empty partition spec should be unpartitioned"
917        );
918
919        let partition_spec = PartitionSpec::builder(schema.clone())
920            .add_unbound_fields(vec![
921                UnboundPartitionField::builder()
922                    .source_ids(vec![1])
923                    .name("id".to_string())
924                    .transform(Transform::Identity)
925                    .build()
926                    .unwrap(),
927                UnboundPartitionField::builder()
928                    .source_ids(vec![2])
929                    .name("name_string".to_string())
930                    .transform(Transform::Void)
931                    .build()
932                    .unwrap(),
933            ])
934            .unwrap()
935            .with_spec_id(1)
936            .build()
937            .unwrap();
938        assert!(
939            !partition_spec.is_unpartitioned(),
940            "Partition spec with one non void transform should not be unpartitioned"
941        );
942
943        let partition_spec = PartitionSpec::builder(schema.clone())
944            .with_spec_id(1)
945            .add_unbound_fields(vec![
946                UnboundPartitionField::builder()
947                    .source_ids(vec![1])
948                    .name("id_void".to_string())
949                    .transform(Transform::Void)
950                    .build()
951                    .unwrap(),
952                UnboundPartitionField::builder()
953                    .source_ids(vec![2])
954                    .name("name_void".to_string())
955                    .transform(Transform::Void)
956                    .build()
957                    .unwrap(),
958            ])
959            .unwrap()
960            .build()
961            .unwrap();
962        assert!(
963            partition_spec.is_unpartitioned(),
964            "Partition spec with all void field should be unpartitioned"
965        );
966    }
967
968    #[test]
969    fn test_unbound_partition_spec() {
970        let spec = r#"
971		{
972		"spec-id": 1,
973		"fields": [ {
974			"source-id": 4,
975			"field-id": 1000,
976			"name": "ts_day",
977			"transform": "day"
978			}, {
979			"source-id": 1,
980			"field-id": 1001,
981			"name": "id_bucket",
982			"transform": "bucket[16]"
983			}, {
984			"source-id": 2,
985			"field-id": 1002,
986			"name": "id_truncate",
987			"transform": "truncate[4]"
988			} ]
989		}
990		"#;
991
992        let partition_spec: UnboundPartitionSpec = serde_json::from_str(spec).unwrap();
993        assert_eq!(Some(1), partition_spec.spec_id);
994
995        assert_eq!(4, partition_spec.fields[0].source_id().unwrap());
996        assert_eq!(Some(1000), partition_spec.fields[0].field_id);
997        assert_eq!("ts_day", partition_spec.fields[0].name);
998        assert_eq!(Transform::Day, partition_spec.fields[0].transform);
999
1000        assert_eq!(1, partition_spec.fields[1].source_id().unwrap());
1001        assert_eq!(Some(1001), partition_spec.fields[1].field_id);
1002        assert_eq!("id_bucket", partition_spec.fields[1].name);
1003        assert_eq!(Transform::Bucket(16), partition_spec.fields[1].transform);
1004
1005        assert_eq!(2, partition_spec.fields[2].source_id().unwrap());
1006        assert_eq!(Some(1002), partition_spec.fields[2].field_id);
1007        assert_eq!("id_truncate", partition_spec.fields[2].name);
1008        assert_eq!(Transform::Truncate(4), partition_spec.fields[2].transform);
1009
1010        let spec = r#"
1011		{
1012		"fields": [ {
1013			"source-id": 4,
1014			"name": "ts_day",
1015			"transform": "day"
1016			} ]
1017		}
1018		"#;
1019        let partition_spec: UnboundPartitionSpec = serde_json::from_str(spec).unwrap();
1020        assert_eq!(None, partition_spec.spec_id);
1021
1022        assert_eq!(4, partition_spec.fields[0].source_id().unwrap());
1023        assert_eq!(None, partition_spec.fields[0].field_id);
1024        assert_eq!("ts_day", partition_spec.fields[0].name);
1025        assert_eq!(Transform::Day, partition_spec.fields[0].transform);
1026    }
1027
1028    #[test]
1029    fn test_unbound_partition_spec_serialization_skips_none_fields() {
1030        let spec = UnboundPartitionSpec::builder()
1031            .add_partition_field(
1032                UnboundPartitionField::builder()
1033                    .source_ids(vec![4])
1034                    .name("ts_day")
1035                    .transform(Transform::Day)
1036                    .build()
1037                    .unwrap(),
1038            )
1039            .unwrap()
1040            .build();
1041
1042        let value = serde_json::to_value(&spec).unwrap();
1043        let object = value.as_object().unwrap();
1044        assert!(!object.contains_key("spec-id"));
1045        let field = object["fields"][0].as_object().unwrap();
1046        assert!(!field.contains_key("field-id"));
1047
1048        let value = serde_json::to_value(spec.with_spec_id(1)).unwrap();
1049        let object = value.as_object().unwrap();
1050        assert_eq!(Some(&serde_json::json!(1)), object.get("spec-id"));
1051
1052        // Explicit nulls must still deserialize to `None` for backwards
1053        // compatibility.
1054        let spec: UnboundPartitionSpec = serde_json::from_str(
1055            r#"{
1056                "spec-id": null,
1057                "fields": [
1058                    {"source-id": 4, "name": "ts_day", "transform": "day", "field-id": null}
1059                ]
1060            }"#,
1061        )
1062        .unwrap();
1063        assert_eq!(None, spec.spec_id);
1064        assert_eq!(None, spec.fields[0].field_id);
1065    }
1066
1067    #[test]
1068    fn test_new_unpartition() {
1069        let schema = Schema::builder()
1070            .with_fields(vec![
1071                NestedField::required(1, "id", Type::Primitive(PrimitiveType::Int)).into(),
1072                NestedField::required(2, "name", Type::Primitive(PrimitiveType::String)).into(),
1073            ])
1074            .build()
1075            .unwrap();
1076        let partition_spec = PartitionSpec::builder(schema.clone())
1077            .with_spec_id(0)
1078            .build()
1079            .unwrap();
1080        let partition_type = partition_spec.partition_type(&schema).unwrap();
1081        assert_eq!(0, partition_type.fields().len());
1082
1083        let unpartition_spec = PartitionSpec::unpartition_spec();
1084        assert_eq!(partition_spec, unpartition_spec);
1085    }
1086
1087    #[test]
1088    fn test_partition_type() {
1089        let spec = r#"
1090            {
1091            "spec-id": 1,
1092            "fields": [ {
1093                "source-id": 4,
1094                "field-id": 1000,
1095                "name": "ts_day",
1096                "transform": "day"
1097                }, {
1098                "source-id": 1,
1099                "field-id": 1001,
1100                "name": "id_bucket",
1101                "transform": "bucket[16]"
1102                }, {
1103                "source-id": 2,
1104                "field-id": 1002,
1105                "name": "id_truncate",
1106                "transform": "truncate[4]"
1107                } ]
1108            }
1109            "#;
1110
1111        let partition_spec: PartitionSpec = serde_json::from_str(spec).unwrap();
1112        let schema = Schema::builder()
1113            .with_fields(vec![
1114                NestedField::required(1, "id", Type::Primitive(PrimitiveType::Int)).into(),
1115                NestedField::required(2, "name", Type::Primitive(PrimitiveType::String)).into(),
1116                NestedField::required(3, "ts", Type::Primitive(PrimitiveType::Timestamp)).into(),
1117                NestedField::required(4, "ts_day", Type::Primitive(PrimitiveType::Timestamp))
1118                    .into(),
1119                NestedField::required(5, "id_bucket", Type::Primitive(PrimitiveType::Int)).into(),
1120                NestedField::required(6, "id_truncate", Type::Primitive(PrimitiveType::Int)).into(),
1121            ])
1122            .build()
1123            .unwrap();
1124
1125        let partition_type = partition_spec.partition_type(&schema).unwrap();
1126        assert_eq!(3, partition_type.fields().len());
1127        assert_eq!(
1128            *partition_type.fields()[0],
1129            NestedField::optional(
1130                partition_spec.fields[0].field_id,
1131                &partition_spec.fields[0].name,
1132                Type::Primitive(PrimitiveType::Date)
1133            )
1134        );
1135        assert_eq!(
1136            *partition_type.fields()[1],
1137            NestedField::optional(
1138                partition_spec.fields[1].field_id,
1139                &partition_spec.fields[1].name,
1140                Type::Primitive(PrimitiveType::Int)
1141            )
1142        );
1143        assert_eq!(
1144            *partition_type.fields()[2],
1145            NestedField::optional(
1146                partition_spec.fields[2].field_id,
1147                &partition_spec.fields[2].name,
1148                Type::Primitive(PrimitiveType::String)
1149            )
1150        );
1151    }
1152
1153    #[test]
1154    fn test_partition_empty() {
1155        let spec = r#"
1156            {
1157            "spec-id": 1,
1158            "fields": []
1159            }
1160            "#;
1161
1162        let partition_spec: PartitionSpec = serde_json::from_str(spec).unwrap();
1163        let schema = Schema::builder()
1164            .with_fields(vec![
1165                NestedField::required(1, "id", Type::Primitive(PrimitiveType::Int)).into(),
1166                NestedField::required(2, "name", Type::Primitive(PrimitiveType::String)).into(),
1167                NestedField::required(3, "ts", Type::Primitive(PrimitiveType::Timestamp)).into(),
1168                NestedField::required(4, "ts_day", Type::Primitive(PrimitiveType::Timestamp))
1169                    .into(),
1170                NestedField::required(5, "id_bucket", Type::Primitive(PrimitiveType::Int)).into(),
1171                NestedField::required(6, "id_truncate", Type::Primitive(PrimitiveType::Int)).into(),
1172            ])
1173            .build()
1174            .unwrap();
1175
1176        let partition_type = partition_spec.partition_type(&schema).unwrap();
1177        assert_eq!(0, partition_type.fields().len());
1178    }
1179
1180    #[test]
1181    fn test_partition_type_with_dropped_source_column() {
1182        let spec = r#"
1183        {
1184        "spec-id": 1,
1185        "fields": [ {
1186            "source-id": 4,
1187            "field-id": 1000,
1188            "name": "ts_day",
1189            "transform": "day"
1190            }, {
1191            "source-id": 1,
1192            "field-id": 1001,
1193            "name": "id_bucket",
1194            "transform": "bucket[16]"
1195            }, {
1196            "source-id": 2,
1197            "field-id": 1002,
1198            "name": "id_truncate",
1199            "transform": "truncate[4]"
1200            } ]
1201        }
1202        "#;
1203
1204        let partition_spec: PartitionSpec = serde_json::from_str(spec).unwrap();
1205        let schema = Schema::builder()
1206            .with_fields(vec![
1207                NestedField::required(1, "id", Type::Primitive(PrimitiveType::Int)).into(),
1208                NestedField::required(2, "name", Type::Primitive(PrimitiveType::String)).into(),
1209            ])
1210            .build()
1211            .unwrap();
1212
1213        assert_eq!(
1214            partition_spec.partition_type(&schema).unwrap(),
1215            StructType::new(vec![
1216                NestedField::optional(1000, "ts_day", PrimitiveType::Date).into(),
1217                NestedField::optional(1001, "id_bucket", PrimitiveType::Int).into(),
1218                NestedField::optional(1002, "id_truncate", PrimitiveType::String).into(),
1219            ])
1220        );
1221
1222        // Dropping all sources must retain every partition field in its original position.
1223        let empty_schema = Schema::builder().build().unwrap();
1224        assert_eq!(
1225            partition_spec.partition_type(&empty_schema).unwrap(),
1226            StructType::new(vec![
1227                NestedField::optional(1000, "ts_day", PrimitiveType::Date).into(),
1228                NestedField::optional(1001, "id_bucket", PrimitiveType::Int).into(),
1229                NestedField::optional(1002, "id_truncate", PrimitiveType::Unknown).into(),
1230            ])
1231        );
1232
1233        // Missing sources must not suppress validation of transforms on remaining sources.
1234        let incompatible_schema = Schema::builder()
1235            .with_fields(vec![
1236                NestedField::required(1, "id", PrimitiveType::Boolean).into(),
1237            ])
1238            .build()
1239            .unwrap();
1240        let err = partition_spec
1241            .partition_type(&incompatible_schema)
1242            .unwrap_err();
1243        assert_eq!(err.kind(), ErrorKind::DataInvalid);
1244        assert_eq!(
1245            err.message(),
1246            "boolean is not a valid input type of bucket transform"
1247        );
1248    }
1249
1250    #[test]
1251    fn test_partition_type_without_source_type() {
1252        let schema = Schema::builder().build().unwrap();
1253        for (transform, expected_type) in [
1254            (Transform::Identity, PrimitiveType::Unknown),
1255            (Transform::Truncate(4), PrimitiveType::Unknown),
1256            (Transform::Void, PrimitiveType::Unknown),
1257            (Transform::Bucket(16), PrimitiveType::Int),
1258            (Transform::Year, PrimitiveType::Int),
1259            (Transform::Month, PrimitiveType::Int),
1260            (Transform::Day, PrimitiveType::Date),
1261            (Transform::Hour, PrimitiveType::Int),
1262            (Transform::Unknown, PrimitiveType::String),
1263        ] {
1264            let spec = PartitionSpec {
1265                spec_id: 0,
1266                fields: vec![PartitionField {
1267                    source_id: 1,
1268                    field_id: 1000,
1269                    name: "partition".to_string(),
1270                    transform,
1271                }],
1272            };
1273            assert_eq!(
1274                spec.partition_type(&schema).unwrap(),
1275                StructType::new(vec![
1276                    NestedField::optional(1000, "partition", expected_type).into(),
1277                ])
1278            );
1279        }
1280    }
1281
1282    #[test]
1283    fn test_builder_disallow_duplicate_names() {
1284        UnboundPartitionSpec::builder()
1285            .add_partition_field(
1286                UnboundPartitionField::builder()
1287                    .source_ids(vec![1])
1288                    .name("ts_day")
1289                    .transform(Transform::Day)
1290                    .build()
1291                    .unwrap(),
1292            )
1293            .unwrap()
1294            .add_partition_field(
1295                UnboundPartitionField::builder()
1296                    .source_ids(vec![2])
1297                    .name("ts_day")
1298                    .transform(Transform::Day)
1299                    .build()
1300                    .unwrap(),
1301            )
1302            .unwrap_err();
1303    }
1304
1305    #[test]
1306    fn test_builder_disallow_duplicate_field_ids() {
1307        let schema = Schema::builder()
1308            .with_fields(vec![
1309                NestedField::required(1, "id", Type::Primitive(PrimitiveType::Int)).into(),
1310                NestedField::required(2, "name", Type::Primitive(PrimitiveType::String)).into(),
1311            ])
1312            .build()
1313            .unwrap();
1314        PartitionSpec::builder(schema.clone())
1315            .add_unbound_field(UnboundPartitionField {
1316                source_ids: vec![1],
1317                field_id: Some(1000),
1318                name: "id".to_string(),
1319                transform: Transform::Identity,
1320            })
1321            .unwrap()
1322            .add_unbound_field(UnboundPartitionField {
1323                source_ids: vec![2],
1324                field_id: Some(1000),
1325                name: "id_bucket".to_string(),
1326                transform: Transform::Bucket(16),
1327            })
1328            .unwrap_err();
1329    }
1330
1331    #[test]
1332    fn test_builder_auto_assign_field_ids() {
1333        let schema = Schema::builder()
1334            .with_fields(vec![
1335                NestedField::required(1, "id", Type::Primitive(PrimitiveType::Int)).into(),
1336                NestedField::required(2, "name", Type::Primitive(PrimitiveType::String)).into(),
1337                NestedField::required(3, "ts", Type::Primitive(PrimitiveType::Timestamp)).into(),
1338            ])
1339            .build()
1340            .unwrap();
1341        let spec = PartitionSpec::builder(schema.clone())
1342            .with_spec_id(1)
1343            .add_unbound_field(UnboundPartitionField {
1344                source_ids: vec![1],
1345                name: "id".to_string(),
1346                transform: Transform::Identity,
1347                field_id: Some(1012),
1348            })
1349            .unwrap()
1350            .add_unbound_field(UnboundPartitionField {
1351                source_ids: vec![2],
1352                name: "name_void".to_string(),
1353                transform: Transform::Void,
1354                field_id: None,
1355            })
1356            .unwrap()
1357            // Should keep its ID even if its lower
1358            .add_unbound_field(UnboundPartitionField {
1359                source_ids: vec![3],
1360                name: "year".to_string(),
1361                transform: Transform::Year,
1362                field_id: Some(1),
1363            })
1364            .unwrap()
1365            .build()
1366            .unwrap();
1367
1368        assert_eq!(1012, spec.fields[0].field_id);
1369        assert_eq!(1013, spec.fields[1].field_id);
1370        assert_eq!(1, spec.fields[2].field_id);
1371    }
1372
1373    #[test]
1374    fn test_builder_valid_schema() {
1375        let schema = Schema::builder()
1376            .with_fields(vec![
1377                NestedField::required(1, "id", Type::Primitive(PrimitiveType::Int)).into(),
1378                NestedField::required(2, "name", Type::Primitive(PrimitiveType::String)).into(),
1379            ])
1380            .build()
1381            .unwrap();
1382
1383        PartitionSpec::builder(schema.clone())
1384            .with_spec_id(1)
1385            .build()
1386            .unwrap();
1387
1388        let spec = PartitionSpec::builder(schema.clone())
1389            .with_spec_id(1)
1390            .add_partition_field("id", "id_bucket[16]", Transform::Bucket(16))
1391            .unwrap()
1392            .build()
1393            .unwrap();
1394
1395        assert_eq!(spec, PartitionSpec {
1396            spec_id: 1,
1397            fields: vec![PartitionField {
1398                source_id: 1,
1399                field_id: 1000,
1400                name: "id_bucket[16]".to_string(),
1401                transform: Transform::Bucket(16),
1402            }],
1403        });
1404        assert_eq!(
1405            spec.partition_type(&schema).unwrap(),
1406            StructType::new(vec![
1407                NestedField::optional(1000, "id_bucket[16]", Type::Primitive(PrimitiveType::Int))
1408                    .into()
1409            ])
1410        )
1411    }
1412
1413    #[test]
1414    fn test_collision_with_schema_name() {
1415        let schema = Schema::builder()
1416            .with_fields(vec![
1417                NestedField::required(1, "id", Type::Primitive(PrimitiveType::Int)).into(),
1418            ])
1419            .build()
1420            .unwrap();
1421
1422        PartitionSpec::builder(schema.clone())
1423            .with_spec_id(1)
1424            .build()
1425            .unwrap();
1426
1427        let err = PartitionSpec::builder(schema)
1428            .with_spec_id(1)
1429            .add_unbound_field(UnboundPartitionField {
1430                source_ids: vec![1],
1431                field_id: None,
1432                name: "id".to_string(),
1433                transform: Transform::Bucket(16),
1434            })
1435            .unwrap_err();
1436        assert!(err.message().contains("conflicts with schema"))
1437    }
1438
1439    #[test]
1440    fn test_builder_collision_is_ok_for_identity_transforms() {
1441        let schema = Schema::builder()
1442            .with_fields(vec![
1443                NestedField::required(1, "id", Type::Primitive(PrimitiveType::Int)).into(),
1444                NestedField::required(2, "number", Type::Primitive(PrimitiveType::Int)).into(),
1445            ])
1446            .build()
1447            .unwrap();
1448
1449        PartitionSpec::builder(schema.clone())
1450            .with_spec_id(1)
1451            .build()
1452            .unwrap();
1453
1454        PartitionSpec::builder(schema.clone())
1455            .with_spec_id(1)
1456            .add_unbound_field(UnboundPartitionField {
1457                source_ids: vec![1],
1458                field_id: None,
1459                name: "id".to_string(),
1460                transform: Transform::Identity,
1461            })
1462            .unwrap()
1463            .build()
1464            .unwrap();
1465
1466        // Not OK for different source id
1467        PartitionSpec::builder(schema)
1468            .with_spec_id(1)
1469            .add_unbound_field(UnboundPartitionField {
1470                source_ids: vec![2],
1471                field_id: None,
1472                name: "id".to_string(),
1473                transform: Transform::Identity,
1474            })
1475            .unwrap_err();
1476    }
1477
1478    #[test]
1479    fn test_builder_all_source_ids_must_exist() {
1480        let schema = Schema::builder()
1481            .with_fields(vec![
1482                NestedField::required(1, "id", Type::Primitive(PrimitiveType::Int)).into(),
1483                NestedField::required(2, "name", Type::Primitive(PrimitiveType::String)).into(),
1484                NestedField::required(3, "ts", Type::Primitive(PrimitiveType::Timestamp)).into(),
1485            ])
1486            .build()
1487            .unwrap();
1488
1489        // Valid
1490        PartitionSpec::builder(schema.clone())
1491            .with_spec_id(1)
1492            .add_unbound_fields(vec![
1493                UnboundPartitionField {
1494                    source_ids: vec![1],
1495                    field_id: None,
1496                    name: "id_bucket".to_string(),
1497                    transform: Transform::Bucket(16),
1498                },
1499                UnboundPartitionField {
1500                    source_ids: vec![2],
1501                    field_id: None,
1502                    name: "name".to_string(),
1503                    transform: Transform::Identity,
1504                },
1505            ])
1506            .unwrap()
1507            .build()
1508            .unwrap();
1509
1510        // Invalid
1511        PartitionSpec::builder(schema)
1512            .with_spec_id(1)
1513            .add_unbound_fields(vec![
1514                UnboundPartitionField {
1515                    source_ids: vec![1],
1516                    field_id: None,
1517                    name: "id_bucket".to_string(),
1518                    transform: Transform::Bucket(16),
1519                },
1520                UnboundPartitionField {
1521                    source_ids: vec![4],
1522                    field_id: None,
1523                    name: "name".to_string(),
1524                    transform: Transform::Identity,
1525                },
1526            ])
1527            .unwrap_err();
1528    }
1529
1530    #[test]
1531    fn test_builder_disallows_variant_source() {
1532        let schema = Schema::builder()
1533            .with_fields(vec![
1534                NestedField::required(1, "id", Type::Primitive(PrimitiveType::Int)).into(),
1535                NestedField::optional(2, "v", Type::Variant(crate::spec::VariantType)).into(),
1536            ])
1537            .build()
1538            .unwrap();
1539
1540        let err = PartitionSpec::builder(schema)
1541            .with_spec_id(1)
1542            .add_unbound_fields(vec![UnboundPartitionField {
1543                source_ids: vec![2],
1544                field_id: None,
1545                name: "v_part".to_string(),
1546                transform: Transform::Identity,
1547            }])
1548            .expect_err("variant must not be allowed as a partition source");
1549
1550        assert_eq!(
1551            err.message(),
1552            "Cannot partition by non-primitive source field: 'variant'."
1553        );
1554    }
1555
1556    #[test]
1557    fn test_builder_disallows_redundant() {
1558        let err = UnboundPartitionSpec::builder()
1559            .with_spec_id(1)
1560            .add_partition_field(
1561                UnboundPartitionField::builder()
1562                    .source_ids(vec![1])
1563                    .name("id_bucket[16]")
1564                    .transform(Transform::Bucket(16))
1565                    .build()
1566                    .unwrap(),
1567            )
1568            .unwrap()
1569            .add_partition_field(
1570                UnboundPartitionField::builder()
1571                    .source_ids(vec![1])
1572                    .name("id_bucket_with_other_name")
1573                    .transform(Transform::Bucket(16))
1574                    .build()
1575                    .unwrap(),
1576            )
1577            .unwrap_err();
1578        assert!(err.message().contains("redundant partition"));
1579    }
1580
1581    fn ts_schema() -> Schema {
1582        Schema::builder()
1583            .with_fields(vec![
1584                NestedField::required(1, "ts", Type::Primitive(PrimitiveType::Timestamptz)).into(),
1585                NestedField::required(2, "id", Type::Primitive(PrimitiveType::Long)).into(),
1586            ])
1587            .build()
1588            .unwrap()
1589    }
1590
1591    fn ts_field(field_id: i32, name: &str, transform: Transform) -> UnboundPartitionField {
1592        UnboundPartitionField {
1593            source_ids: vec![1],
1594            field_id: Some(field_id),
1595            name: name.to_string(),
1596            transform,
1597        }
1598    }
1599
1600    #[test]
1601    fn test_add_unbound_field_rejects_second_time_granularity() {
1602        let err = PartitionSpec::builder(ts_schema())
1603            .add_unbound_field(ts_field(1000, "ts_day", Transform::Day))
1604            .unwrap()
1605            .add_unbound_field(ts_field(1001, "ts_hour", Transform::Hour))
1606            .unwrap_err();
1607        assert!(err.message().contains("redundant partition"));
1608    }
1609
1610    #[test]
1611    fn test_bind_accepts_evolved_spec_fields() {
1612        let fields = vec![
1613            ts_field(1000, "ts_day", Transform::Day),
1614            ts_field(1001, "ts_hour", Transform::Hour),
1615            ts_field(1002, "ts", Transform::Void),
1616            ts_field(1003, "ts_null", Transform::Void),
1617        ];
1618        let unbound = UnboundPartitionSpec {
1619            spec_id: Some(1),
1620            fields: fields.clone(),
1621        };
1622
1623        let spec = unbound.bind(ts_schema()).unwrap();
1624
1625        let expected = PartitionSpec {
1626            spec_id: 1,
1627            fields: fields
1628                .into_iter()
1629                .map(|f| PartitionField {
1630                    source_id: 1,
1631                    field_id: f.field_id.unwrap(),
1632                    name: f.name,
1633                    transform: f.transform,
1634                })
1635                .collect(),
1636        };
1637        assert_eq!(spec, expected);
1638    }
1639
1640    #[test]
1641    fn test_bind_rejects_void_field_named_after_other_column() {
1642        let unbound = UnboundPartitionSpec {
1643            spec_id: Some(1),
1644            fields: vec![ts_field(1000, "id", Transform::Void)],
1645        };
1646
1647        let err = unbound.bind(ts_schema()).unwrap_err();
1648
1649        assert_eq!(err.kind(), ErrorKind::DataInvalid);
1650        assert!(err.message().contains("Field name 'id'"));
1651    }
1652
1653    #[test]
1654    fn test_bind_accepts_identical_fields_with_different_names() {
1655        let bucket_field = |field_id: i32, name: &str| UnboundPartitionField {
1656            source_ids: vec![2],
1657            field_id: Some(field_id),
1658            name: name.to_string(),
1659            transform: Transform::Bucket(16),
1660        };
1661        let unbound = UnboundPartitionSpec {
1662            spec_id: Some(1),
1663            fields: vec![
1664                bucket_field(1000, "id_bucket"),
1665                bucket_field(1001, "id_bucket_2"),
1666            ],
1667        };
1668
1669        let spec = unbound.bind(ts_schema()).unwrap();
1670
1671        let expected = PartitionSpec {
1672            spec_id: 1,
1673            fields: vec![
1674                PartitionField {
1675                    source_id: 2,
1676                    field_id: 1000,
1677                    name: "id_bucket".to_string(),
1678                    transform: Transform::Bucket(16),
1679                },
1680                PartitionField {
1681                    source_id: 2,
1682                    field_id: 1001,
1683                    name: "id_bucket_2".to_string(),
1684                    transform: Transform::Bucket(16),
1685                },
1686            ],
1687        };
1688        assert_eq!(spec, expected);
1689    }
1690
1691    #[test]
1692    fn test_bind_rejects_repeated_partition_name() {
1693        let unbound = UnboundPartitionSpec {
1694            spec_id: Some(1),
1695            fields: vec![
1696                ts_field(1000, "ts_part", Transform::Day),
1697                ts_field(1001, "ts_part", Transform::Hour),
1698            ],
1699        };
1700
1701        let err = unbound.bind(ts_schema()).unwrap_err();
1702
1703        assert!(
1704            err.message()
1705                .contains("Cannot use partition name more than once")
1706        );
1707    }
1708
1709    #[test]
1710    fn test_add_unbound_field_checks_name_before_redundancy() {
1711        let err = PartitionSpec::builder(ts_schema())
1712            .add_unbound_field(ts_field(1000, "ts_part", Transform::Day))
1713            .unwrap()
1714            .add_unbound_field(ts_field(1001, "ts_part", Transform::Hour))
1715            .unwrap_err();
1716
1717        assert!(
1718            err.message()
1719                .contains("Cannot use partition name more than once")
1720        );
1721    }
1722
1723    #[test]
1724    fn test_builder_incompatible_transforms_disallowed() {
1725        let schema = Schema::builder()
1726            .with_fields(vec![
1727                NestedField::required(1, "id", Type::Primitive(PrimitiveType::Int)).into(),
1728            ])
1729            .build()
1730            .unwrap();
1731
1732        PartitionSpec::builder(schema)
1733            .with_spec_id(1)
1734            .add_unbound_field(UnboundPartitionField {
1735                source_ids: vec![1],
1736                field_id: None,
1737                name: "id_year".to_string(),
1738                transform: Transform::Year,
1739            })
1740            .unwrap_err();
1741    }
1742
1743    #[test]
1744    fn test_build_unbound_specs_without_partition_id() {
1745        let spec = UnboundPartitionSpec::builder()
1746            .with_spec_id(1)
1747            .add_partition_fields(vec![UnboundPartitionField {
1748                source_ids: vec![1],
1749                field_id: None,
1750                name: "id_bucket[16]".to_string(),
1751                transform: Transform::Bucket(16),
1752            }])
1753            .unwrap()
1754            .build();
1755
1756        assert_eq!(spec, UnboundPartitionSpec {
1757            spec_id: Some(1),
1758            fields: vec![UnboundPartitionField {
1759                source_ids: vec![1],
1760                field_id: None,
1761                name: "id_bucket[16]".to_string(),
1762                transform: Transform::Bucket(16),
1763            }]
1764        });
1765    }
1766
1767    #[test]
1768    fn test_is_compatible_with() {
1769        let schema = Schema::builder()
1770            .with_fields(vec![
1771                NestedField::required(1, "id", Type::Primitive(PrimitiveType::Int)).into(),
1772                NestedField::required(2, "name", Type::Primitive(PrimitiveType::String)).into(),
1773            ])
1774            .build()
1775            .unwrap();
1776
1777        let partition_spec_1 = PartitionSpec::builder(schema.clone())
1778            .with_spec_id(1)
1779            .add_unbound_field(UnboundPartitionField {
1780                source_ids: vec![1],
1781                field_id: None,
1782                name: "id_bucket".to_string(),
1783                transform: Transform::Bucket(16),
1784            })
1785            .unwrap()
1786            .build()
1787            .unwrap();
1788
1789        let partition_spec_2 = PartitionSpec::builder(schema)
1790            .with_spec_id(1)
1791            .add_unbound_field(UnboundPartitionField {
1792                source_ids: vec![1],
1793                field_id: None,
1794                name: "id_bucket".to_string(),
1795                transform: Transform::Bucket(16),
1796            })
1797            .unwrap()
1798            .build()
1799            .unwrap();
1800
1801        assert!(partition_spec_1.is_compatible_with(&partition_spec_2));
1802    }
1803
1804    #[test]
1805    fn test_not_compatible_with_transform_different() {
1806        let schema = Schema::builder()
1807            .with_fields(vec![
1808                NestedField::required(1, "id", Type::Primitive(PrimitiveType::Int)).into(),
1809            ])
1810            .build()
1811            .unwrap();
1812
1813        let partition_spec_1 = PartitionSpec::builder(schema.clone())
1814            .with_spec_id(1)
1815            .add_unbound_field(UnboundPartitionField {
1816                source_ids: vec![1],
1817                field_id: None,
1818                name: "id_bucket".to_string(),
1819                transform: Transform::Bucket(16),
1820            })
1821            .unwrap()
1822            .build()
1823            .unwrap();
1824
1825        let partition_spec_2 = PartitionSpec::builder(schema)
1826            .with_spec_id(1)
1827            .add_unbound_field(UnboundPartitionField {
1828                source_ids: vec![1],
1829                field_id: None,
1830                name: "id_bucket".to_string(),
1831                transform: Transform::Bucket(32),
1832            })
1833            .unwrap()
1834            .build()
1835            .unwrap();
1836
1837        assert!(!partition_spec_1.is_compatible_with(&partition_spec_2));
1838    }
1839
1840    #[test]
1841    fn test_not_compatible_with_source_id_different() {
1842        let schema = Schema::builder()
1843            .with_fields(vec![
1844                NestedField::required(1, "id", Type::Primitive(PrimitiveType::Int)).into(),
1845                NestedField::required(2, "name", Type::Primitive(PrimitiveType::String)).into(),
1846            ])
1847            .build()
1848            .unwrap();
1849
1850        let partition_spec_1 = PartitionSpec::builder(schema.clone())
1851            .with_spec_id(1)
1852            .add_unbound_field(UnboundPartitionField {
1853                source_ids: vec![1],
1854                field_id: None,
1855                name: "id_bucket".to_string(),
1856                transform: Transform::Bucket(16),
1857            })
1858            .unwrap()
1859            .build()
1860            .unwrap();
1861
1862        let partition_spec_2 = PartitionSpec::builder(schema)
1863            .with_spec_id(1)
1864            .add_unbound_field(UnboundPartitionField {
1865                source_ids: vec![2],
1866                field_id: None,
1867                name: "id_bucket".to_string(),
1868                transform: Transform::Bucket(16),
1869            })
1870            .unwrap()
1871            .build()
1872            .unwrap();
1873
1874        assert!(!partition_spec_1.is_compatible_with(&partition_spec_2));
1875    }
1876
1877    #[test]
1878    fn test_not_compatible_with_order_different() {
1879        let schema = Schema::builder()
1880            .with_fields(vec![
1881                NestedField::required(1, "id", Type::Primitive(PrimitiveType::Int)).into(),
1882                NestedField::required(2, "name", Type::Primitive(PrimitiveType::String)).into(),
1883            ])
1884            .build()
1885            .unwrap();
1886
1887        let partition_spec_1 = PartitionSpec::builder(schema.clone())
1888            .with_spec_id(1)
1889            .add_unbound_field(UnboundPartitionField {
1890                source_ids: vec![1],
1891                field_id: None,
1892                name: "id_bucket".to_string(),
1893                transform: Transform::Bucket(16),
1894            })
1895            .unwrap()
1896            .add_unbound_field(UnboundPartitionField {
1897                source_ids: vec![2],
1898                field_id: None,
1899                name: "name".to_string(),
1900                transform: Transform::Identity,
1901            })
1902            .unwrap()
1903            .build()
1904            .unwrap();
1905
1906        let partition_spec_2 = PartitionSpec::builder(schema)
1907            .with_spec_id(1)
1908            .add_unbound_field(UnboundPartitionField {
1909                source_ids: vec![2],
1910                field_id: None,
1911                name: "name".to_string(),
1912                transform: Transform::Identity,
1913            })
1914            .unwrap()
1915            .add_unbound_field(UnboundPartitionField {
1916                source_ids: vec![1],
1917                field_id: None,
1918                name: "id_bucket".to_string(),
1919                transform: Transform::Bucket(16),
1920            })
1921            .unwrap()
1922            .build()
1923            .unwrap();
1924
1925        assert!(!partition_spec_1.is_compatible_with(&partition_spec_2));
1926    }
1927
1928    #[test]
1929    fn test_highest_field_id_unpartitioned() {
1930        let spec = PartitionSpec::builder(Schema::builder().with_fields(vec![]).build().unwrap())
1931            .with_spec_id(1)
1932            .build()
1933            .unwrap();
1934
1935        assert!(spec.highest_field_id().is_none());
1936    }
1937
1938    #[test]
1939    fn test_highest_field_id() {
1940        let schema = Schema::builder()
1941            .with_fields(vec![
1942                NestedField::required(1, "id", Type::Primitive(PrimitiveType::Int)).into(),
1943                NestedField::required(2, "name", Type::Primitive(PrimitiveType::String)).into(),
1944            ])
1945            .build()
1946            .unwrap();
1947
1948        let spec = PartitionSpec::builder(schema)
1949            .with_spec_id(1)
1950            .add_unbound_field(UnboundPartitionField {
1951                source_ids: vec![1],
1952                field_id: Some(1001),
1953                name: "id".to_string(),
1954                transform: Transform::Identity,
1955            })
1956            .unwrap()
1957            .add_unbound_field(UnboundPartitionField {
1958                source_ids: vec![2],
1959                field_id: Some(1000),
1960                name: "name".to_string(),
1961                transform: Transform::Identity,
1962            })
1963            .unwrap()
1964            .build()
1965            .unwrap();
1966
1967        assert_eq!(Some(1001), spec.highest_field_id());
1968    }
1969
1970    #[test]
1971    fn test_has_sequential_ids() {
1972        let schema = Schema::builder()
1973            .with_fields(vec![
1974                NestedField::required(1, "id", Type::Primitive(PrimitiveType::Int)).into(),
1975                NestedField::required(2, "name", Type::Primitive(PrimitiveType::String)).into(),
1976            ])
1977            .build()
1978            .unwrap();
1979
1980        let spec = PartitionSpec::builder(schema)
1981            .with_spec_id(1)
1982            .add_unbound_field(UnboundPartitionField {
1983                source_ids: vec![1],
1984                field_id: Some(1000),
1985                name: "id".to_string(),
1986                transform: Transform::Identity,
1987            })
1988            .unwrap()
1989            .add_unbound_field(UnboundPartitionField {
1990                source_ids: vec![2],
1991                field_id: Some(1001),
1992                name: "name".to_string(),
1993                transform: Transform::Identity,
1994            })
1995            .unwrap()
1996            .build()
1997            .unwrap();
1998
1999        assert_eq!(1000, spec.fields[0].field_id);
2000        assert_eq!(1001, spec.fields[1].field_id);
2001        assert!(spec.has_sequential_ids());
2002    }
2003
2004    #[test]
2005    fn test_sequential_ids_must_start_at_1000() {
2006        let schema = Schema::builder()
2007            .with_fields(vec![
2008                NestedField::required(1, "id", Type::Primitive(PrimitiveType::Int)).into(),
2009                NestedField::required(2, "name", Type::Primitive(PrimitiveType::String)).into(),
2010            ])
2011            .build()
2012            .unwrap();
2013
2014        let spec = PartitionSpec::builder(schema)
2015            .with_spec_id(1)
2016            .add_unbound_field(UnboundPartitionField {
2017                source_ids: vec![1],
2018                field_id: Some(999),
2019                name: "id".to_string(),
2020                transform: Transform::Identity,
2021            })
2022            .unwrap()
2023            .add_unbound_field(UnboundPartitionField {
2024                source_ids: vec![2],
2025                field_id: Some(1000),
2026                name: "name".to_string(),
2027                transform: Transform::Identity,
2028            })
2029            .unwrap()
2030            .build()
2031            .unwrap();
2032
2033        assert_eq!(999, spec.fields[0].field_id);
2034        assert_eq!(1000, spec.fields[1].field_id);
2035        assert!(!spec.has_sequential_ids());
2036    }
2037
2038    #[test]
2039    fn test_sequential_ids_must_have_no_gaps() {
2040        let schema = Schema::builder()
2041            .with_fields(vec![
2042                NestedField::required(1, "id", Type::Primitive(PrimitiveType::Int)).into(),
2043                NestedField::required(2, "name", Type::Primitive(PrimitiveType::String)).into(),
2044            ])
2045            .build()
2046            .unwrap();
2047
2048        let spec = PartitionSpec::builder(schema)
2049            .with_spec_id(1)
2050            .add_unbound_field(UnboundPartitionField {
2051                source_ids: vec![1],
2052                field_id: Some(1000),
2053                name: "id".to_string(),
2054                transform: Transform::Identity,
2055            })
2056            .unwrap()
2057            .add_unbound_field(UnboundPartitionField {
2058                source_ids: vec![2],
2059                field_id: Some(1002),
2060                name: "name".to_string(),
2061                transform: Transform::Identity,
2062            })
2063            .unwrap()
2064            .build()
2065            .unwrap();
2066
2067        assert_eq!(1000, spec.fields[0].field_id);
2068        assert_eq!(1002, spec.fields[1].field_id);
2069        assert!(!spec.has_sequential_ids());
2070    }
2071
2072    #[test]
2073    fn test_partition_to_path_with_dropped_source_column() {
2074        for (source_type, transform, value, expected) in [
2075            (
2076                PrimitiveType::Int,
2077                Transform::Identity,
2078                Literal::int(1),
2079                "p=1",
2080            ),
2081            (
2082                PrimitiveType::Timestamp,
2083                Transform::Identity,
2084                Literal::long(1),
2085                "p=1",
2086            ),
2087            (
2088                PrimitiveType::Int,
2089                Transform::Truncate(10),
2090                Literal::int(10),
2091                "p=10",
2092            ),
2093        ] {
2094            let schema = Schema::builder()
2095                .with_fields(vec![
2096                    NestedField::required(1, "kept", PrimitiveType::Int).into(),
2097                    NestedField::required(2, "dropped", source_type).into(),
2098                ])
2099                .build()
2100                .unwrap();
2101            let spec = PartitionSpec::builder(schema)
2102                .add_partition_field("dropped", "p", transform)
2103                .unwrap()
2104                .build()
2105                .unwrap();
2106            let current_schema = Schema::builder()
2107                .with_fields(vec![
2108                    NestedField::required(1, "kept", PrimitiveType::Int).into(),
2109                ])
2110                .build()
2111                .unwrap();
2112            assert_eq!(
2113                spec.partition_to_path(&Struct::from_iter([Some(value)]), current_schema.into()),
2114                expected
2115            );
2116        }
2117    }
2118
2119    #[test]
2120    fn test_partition_to_path() {
2121        let schema = Schema::builder()
2122            .with_fields(vec![
2123                NestedField::required(1, "id", Type::Primitive(PrimitiveType::Int)).into(),
2124                NestedField::required(2, "name", Type::Primitive(PrimitiveType::String)).into(),
2125                NestedField::required(3, "timestamp", Type::Primitive(PrimitiveType::Timestamp))
2126                    .into(),
2127                NestedField::required(4, "empty", Type::Primitive(PrimitiveType::String)).into(),
2128            ])
2129            .build()
2130            .unwrap();
2131
2132        let spec = PartitionSpec::builder(schema.clone())
2133            .add_partition_field("id", "id", Transform::Identity)
2134            .unwrap()
2135            .add_partition_field("name", "name", Transform::Identity)
2136            .unwrap()
2137            .add_partition_field("timestamp", "ts_hour", Transform::Hour)
2138            .unwrap()
2139            .add_partition_field("empty", "empty_void", Transform::Void)
2140            .unwrap()
2141            .build()
2142            .unwrap();
2143
2144        let data = Struct::from_iter([
2145            Some(Literal::int(42)),
2146            Some(Literal::string("alice")),
2147            Some(Literal::int(1000)),
2148            Some(Literal::string("empty")),
2149        ]);
2150
2151        assert_eq!(
2152            spec.partition_to_path(&data, schema.into()),
2153            "id=42/name=alice/ts_hour=1970-02-11-16/empty_void=null"
2154        );
2155    }
2156
2157    #[test]
2158    fn test_partition_to_path_float_matches_java() {
2159        let schema = Schema::builder()
2160            .with_fields(vec![
2161                NestedField::required(1, "d", Type::Primitive(PrimitiveType::Double)).into(),
2162                NestedField::required(2, "f", Type::Primitive(PrimitiveType::Float)).into(),
2163            ])
2164            .build()
2165            .unwrap();
2166
2167        let spec = PartitionSpec::builder(schema.clone())
2168            .add_partition_field("d", "d", Transform::Identity)
2169            .unwrap()
2170            .add_partition_field("f", "f", Transform::Identity)
2171            .unwrap()
2172            .build()
2173            .unwrap();
2174
2175        let data = Struct::from_iter([Some(Literal::double(1.0)), Some(Literal::float(1.0e7_f32))]);
2176
2177        assert_eq!(
2178            spec.partition_to_path(&data, schema.into()),
2179            "d=1.0/f=1.0E7"
2180        );
2181    }
2182
2183    #[test]
2184    fn test_partition_to_path_escaped_strings() {
2185        let schema = Schema::builder()
2186            .with_fields(vec![
2187                NestedField::required(1, "\"esc\"#1", Type::Primitive(PrimitiveType::String))
2188                    .into(),
2189                NestedField::required(2, "data", Type::Primitive(PrimitiveType::String)).into(),
2190            ])
2191            .build()
2192            .unwrap();
2193
2194        let spec = PartitionSpec::builder(schema.clone())
2195            .add_partition_field("\"esc\"#1", "\"esc\"#1", Transform::Identity)
2196            .unwrap()
2197            .build()
2198            .unwrap();
2199
2200        let data = Struct::from_iter([
2201            Some(Literal::string("a/b/c/d")),
2202            Some(Literal::string("val#1")),
2203        ]);
2204
2205        assert_eq!(
2206            spec.partition_to_path(&data, schema.into()),
2207            "%22esc%22%231=a%2Fb%2Fc%2Fd"
2208        );
2209    }
2210
2211    #[test]
2212    fn test_partition_to_path_escaped_field_name() {
2213        let schema = Schema::builder()
2214            .with_fields(vec![
2215                NestedField::required(1, "\"esc\"#1", Type::Primitive(PrimitiveType::String))
2216                    .into(),
2217                NestedField::required(2, "data", Type::Primitive(PrimitiveType::String)).into(),
2218            ])
2219            .build()
2220            .unwrap();
2221
2222        let spec = PartitionSpec::builder(schema.clone())
2223            .add_partition_field("data", "data", Transform::Identity)
2224            .unwrap()
2225            .add_partition_field("data", "data_truc_10", Transform::Truncate(10))
2226            .unwrap()
2227            .build()
2228            .unwrap();
2229
2230        let data = Struct::from_iter([
2231            Some(Literal::string("a/b/c/d")),
2232            Some(Literal::string("a/b/c/d")),
2233        ]);
2234
2235        assert_eq!(
2236            spec.partition_to_path(&data, schema.into()),
2237            "data=a%2Fb%2Fc%2Fd/data_truc_10=a%2Fb%2Fc%2Fd"
2238        );
2239    }
2240
2241    #[test]
2242    fn test_unbound_partition_field_reads_source_ids_only() {
2243        let field: UnboundPartitionField = serde_json::from_str(
2244            r#"{"source-ids": [1, 2], "name": "m", "transform": "bucket[4]"}"#,
2245        )
2246        .unwrap();
2247
2248        assert_eq!([1, 2], field.source_ids());
2249        // a multi-argument field has no single source id
2250        assert!(
2251            field
2252                .source_id()
2253                .unwrap_err()
2254                .to_string()
2255                .contains("has no single source id")
2256        );
2257
2258        let serialized = serde_json::to_value(&field).unwrap();
2259        assert_eq!(
2260            Some(&serde_json::json!([1, 2])),
2261            serialized.get("source-ids")
2262        );
2263        assert!(serialized.get("source-id").is_none());
2264    }
2265
2266    #[test]
2267    fn test_unbound_partition_field_single_source_id_round_trip() {
2268        let field: UnboundPartitionField =
2269            serde_json::from_str(r#"{"source-id": 1, "name": "m", "transform": "identity"}"#)
2270                .unwrap();
2271
2272        assert_eq!(1, field.source_id().unwrap());
2273        assert_eq!([1], field.source_ids());
2274
2275        // a single-argument field keeps writing source-id
2276        let serialized = serde_json::to_value(&field).unwrap();
2277        assert_eq!(Some(&serde_json::json!(1)), serialized.get("source-id"));
2278        assert!(serialized.get("source-ids").is_none());
2279    }
2280
2281    #[test]
2282    fn test_unbound_partition_field_rejects_malformed_source_ids() {
2283        for (input, expected) in [
2284            (
2285                r#"{"source-ids": [], "name": "m", "transform": "identity"}"#,
2286                "Empty source-ids is not allowed",
2287            ),
2288            (
2289                r#"{"name": "m", "transform": "identity"}"#,
2290                "Either `source-id` or `source-ids` must be present",
2291            ),
2292            (
2293                r#"{"source-id": 1, "source-ids": [1], "name": "m", "transform": "identity"}"#,
2294                "mutually exclusive",
2295            ),
2296        ] {
2297            let err = serde_json::from_str::<UnboundPartitionField>(input).unwrap_err();
2298            assert!(
2299                err.to_string().contains(expected),
2300                "unexpected error for {input}: {err}"
2301            );
2302        }
2303    }
2304
2305    #[test]
2306    fn test_binding_a_multi_argument_field_fails_loudly() {
2307        let schema = Schema::builder()
2308            .with_fields(vec![
2309                NestedField::required(1, "a", Type::Primitive(PrimitiveType::Int)).into(),
2310                NestedField::required(2, "b", Type::Primitive(PrimitiveType::Int)).into(),
2311            ])
2312            .build()
2313            .unwrap();
2314
2315        let spec: UnboundPartitionSpec = serde_json::from_str(
2316            r#"{"spec-id": 1, "fields": [{"source-ids": [1, 2], "field-id": 1000, "name": "m", "transform": "bucket[4]"}]}"#,
2317        )
2318        .unwrap();
2319
2320        // A bound PartitionField still carries a single source id, so binding has to refuse
2321        // rather than quietly keep the first one.
2322        let err = spec.bind(schema).unwrap_err();
2323        assert!(
2324            err.to_string().contains("has no single source id"),
2325            "unexpected error: {err}"
2326        );
2327    }
2328
2329    #[test]
2330    fn test_unbound_partition_field_builder_rejects_empty_source_ids() {
2331        let err = UnboundPartitionField::builder()
2332            .source_ids(vec![])
2333            .name("m")
2334            .transform(Transform::Identity)
2335            .build()
2336            .unwrap_err();
2337        assert!(
2338            err.to_string().contains("Empty source-ids is not allowed"),
2339            "unexpected error: {err}"
2340        );
2341    }
2342}