Skip to main content

iceberg_datafusion/physical_plan/
project.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//! Partition value projection for Iceberg tables.
19
20use std::sync::Arc;
21
22use datafusion::arrow::array::RecordBatch;
23use datafusion::arrow::datatypes::{DataType, Schema as ArrowSchema};
24use datafusion::common::{DataFusionError, Result as DFResult};
25use datafusion::physical_expr::PhysicalExpr;
26use datafusion::physical_expr::expressions::Column;
27use datafusion::physical_plan::projection::ProjectionExec;
28use datafusion::physical_plan::{ColumnarValue, ExecutionPlan};
29use iceberg::arrow::{
30    PROJECTED_PARTITION_VALUE_COLUMN, PartitionValueCalculator, schema_to_arrow_schema,
31    strip_metadata_from_schema,
32};
33use iceberg::spec::PartitionSpec;
34use iceberg::table::Table;
35
36use crate::to_datafusion_error;
37
38/// Extends an ExecutionPlan with partition value calculations for Iceberg tables.
39///
40/// This function takes an input ExecutionPlan and extends it with an additional column
41/// containing calculated partition values based on the table's partition specification.
42/// For unpartitioned tables, returns the original plan unchanged.
43///
44/// # Arguments
45/// * `input` - The input ExecutionPlan to extend
46/// * `table` - The Iceberg table with partition specification
47///
48/// # Returns
49/// * `Ok(Arc<dyn ExecutionPlan>)` - Extended plan with partition values column
50/// * `Err` - If partition spec is not found or transformation fails
51pub fn project_with_partition(
52    input: Arc<dyn ExecutionPlan>,
53    table: &Table,
54) -> DFResult<Arc<dyn ExecutionPlan>> {
55    let metadata = table.metadata();
56    let partition_spec = metadata.default_partition_spec();
57    let table_schema = metadata.current_schema();
58
59    if partition_spec.is_unpartitioned() {
60        return Ok(input);
61    }
62
63    let input_schema = input.schema();
64
65    // Validate that input_schema matches the Iceberg table schema
66    // Strip metadata from both schemas before comparison to ignore metadata differences
67    let expected_arrow_schema =
68        schema_to_arrow_schema(table_schema.as_ref()).map_err(to_datafusion_error)?;
69    let input_schema_cleaned =
70        strip_metadata_from_schema(&input_schema).map_err(to_datafusion_error)?;
71    let expected_schema_cleaned =
72        strip_metadata_from_schema(&expected_arrow_schema).map_err(to_datafusion_error)?;
73
74    if input_schema_cleaned != expected_schema_cleaned {
75        return Err(DataFusionError::Plan(format!(
76            "Input schema does not match Iceberg table schema.\n\
77             Expected schema: {expected_schema_cleaned}\n\
78             Input schema: {input_schema_cleaned}"
79        )));
80    }
81
82    let calculator =
83        PartitionValueCalculator::try_new(partition_spec.as_ref(), table_schema.as_ref())
84            .map_err(to_datafusion_error)?;
85
86    let mut projection_exprs: Vec<(Arc<dyn PhysicalExpr>, String)> =
87        Vec::with_capacity(input_schema.fields().len() + 1);
88
89    for (index, field) in input_schema.fields().iter().enumerate() {
90        let column_expr = Arc::new(Column::new(field.name(), index));
91        projection_exprs.push((column_expr, field.name().clone()));
92    }
93
94    let partition_expr = Arc::new(PartitionExpr::new(calculator, partition_spec.clone()));
95    projection_exprs.push((partition_expr, PROJECTED_PARTITION_VALUE_COLUMN.to_string()));
96
97    let projection = ProjectionExec::try_new(projection_exprs, input)?;
98    Ok(Arc::new(projection))
99}
100
101/// PhysicalExpr implementation for partition value calculation
102#[derive(Debug, Clone)]
103struct PartitionExpr {
104    calculator: Arc<PartitionValueCalculator>,
105    partition_spec: Arc<PartitionSpec>,
106}
107
108impl PartitionExpr {
109    fn new(calculator: PartitionValueCalculator, partition_spec: Arc<PartitionSpec>) -> Self {
110        Self {
111            calculator: Arc::new(calculator),
112            partition_spec,
113        }
114    }
115}
116
117// Manual PartialEq/Eq implementations for pointer-based equality
118// (two PartitionExpr are equal if they share the same calculator and partition_spec instances)
119impl PartialEq for PartitionExpr {
120    fn eq(&self, other: &Self) -> bool {
121        Arc::ptr_eq(&self.calculator, &other.calculator)
122            && Arc::ptr_eq(&self.partition_spec, &other.partition_spec)
123    }
124}
125
126impl Eq for PartitionExpr {}
127
128impl PhysicalExpr for PartitionExpr {
129    fn data_type(&self, _input_schema: &ArrowSchema) -> DFResult<DataType> {
130        Ok(self.calculator.partition_arrow_type().clone())
131    }
132
133    fn nullable(&self, _input_schema: &ArrowSchema) -> DFResult<bool> {
134        Ok(false)
135    }
136
137    fn evaluate(&self, batch: &RecordBatch) -> DFResult<ColumnarValue> {
138        let array = self
139            .calculator
140            .calculate(batch)
141            .map_err(to_datafusion_error)?;
142        Ok(ColumnarValue::Array(array))
143    }
144
145    fn children(&self) -> Vec<&Arc<dyn PhysicalExpr>> {
146        vec![]
147    }
148
149    fn with_new_children(
150        self: Arc<Self>,
151        _children: Vec<Arc<dyn PhysicalExpr>>,
152    ) -> DFResult<Arc<dyn PhysicalExpr>> {
153        Ok(self)
154    }
155
156    fn fmt_sql(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
157        let field_names: Vec<String> = self
158            .partition_spec
159            .fields()
160            .iter()
161            .map(|pf| format!("{}({})", pf.transform, pf.name))
162            .collect();
163        write!(f, "iceberg_partition_values[{}]", field_names.join(", "))
164    }
165}
166
167impl std::fmt::Display for PartitionExpr {
168    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
169        let field_names: Vec<&str> = self
170            .partition_spec
171            .fields()
172            .iter()
173            .map(|pf| pf.name.as_str())
174            .collect();
175        write!(f, "iceberg_partition_values({})", field_names.join(", "))
176    }
177}
178
179impl std::hash::Hash for PartitionExpr {
180    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
181        // Two PartitionExpr are equal if they share the same calculator and partition_spec Arcs
182        Arc::as_ptr(&self.calculator).hash(state);
183        Arc::as_ptr(&self.partition_spec).hash(state);
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use datafusion::arrow::array::{ArrayRef, Int32Array, StructArray};
190    use datafusion::arrow::datatypes::{DataType, Field, Fields};
191    use datafusion::physical_plan::empty::EmptyExec;
192    use iceberg::spec::{NestedField, PrimitiveType, Schema, StructType, Transform, Type};
193    use iceberg::test_utils::test_runtime;
194
195    use super::*;
196
197    #[test]
198    fn test_partition_calculator_basic() {
199        let table_schema = Schema::builder()
200            .with_schema_id(0)
201            .with_fields(vec![
202                NestedField::required(1, "id", Type::Primitive(PrimitiveType::Int)).into(),
203                NestedField::required(2, "name", Type::Primitive(PrimitiveType::String)).into(),
204            ])
205            .build()
206            .unwrap();
207
208        let partition_spec = PartitionSpec::builder(Arc::new(table_schema.clone()))
209            .add_partition_field("id", "id_partition", Transform::Identity)
210            .unwrap()
211            .build()
212            .unwrap();
213
214        let calculator = PartitionValueCalculator::try_new(&partition_spec, &table_schema).unwrap();
215
216        // Verify partition type
217        assert_eq!(calculator.partition_type().fields().len(), 1);
218        assert_eq!(calculator.partition_type().fields()[0].name, "id_partition");
219    }
220
221    #[test]
222    fn test_partition_expr_with_projection() {
223        let table_schema = Schema::builder()
224            .with_schema_id(0)
225            .with_fields(vec![
226                NestedField::required(1, "id", Type::Primitive(PrimitiveType::Int)).into(),
227                NestedField::required(2, "name", Type::Primitive(PrimitiveType::String)).into(),
228            ])
229            .build()
230            .unwrap();
231
232        let partition_spec = Arc::new(
233            PartitionSpec::builder(Arc::new(table_schema.clone()))
234                .add_partition_field("id", "id_partition", Transform::Identity)
235                .unwrap()
236                .build()
237                .unwrap(),
238        );
239
240        let arrow_schema = Arc::new(ArrowSchema::new(vec![
241            Field::new("id", DataType::Int32, false),
242            Field::new("name", DataType::Utf8, false),
243        ]));
244
245        let input = Arc::new(EmptyExec::new(arrow_schema.clone()));
246
247        let calculator = PartitionValueCalculator::try_new(&partition_spec, &table_schema).unwrap();
248
249        let mut projection_exprs: Vec<(Arc<dyn PhysicalExpr>, String)> =
250            Vec::with_capacity(arrow_schema.fields().len() + 1);
251        for (i, field) in arrow_schema.fields().iter().enumerate() {
252            let column_expr = Arc::new(Column::new(field.name(), i));
253            projection_exprs.push((column_expr, field.name().clone()));
254        }
255
256        let partition_expr = Arc::new(PartitionExpr::new(calculator, partition_spec));
257        projection_exprs.push((partition_expr, PROJECTED_PARTITION_VALUE_COLUMN.to_string()));
258
259        let projection = ProjectionExec::try_new(projection_exprs, input).unwrap();
260        let result = Arc::new(projection);
261
262        assert_eq!(result.schema().fields().len(), 3);
263        assert_eq!(result.schema().field(0).name(), "id");
264        assert_eq!(result.schema().field(1).name(), "name");
265        assert_eq!(result.schema().field(2).name(), "_partition");
266    }
267
268    #[test]
269    fn test_partition_expr_evaluate() {
270        let table_schema = Schema::builder()
271            .with_schema_id(0)
272            .with_fields(vec![
273                NestedField::required(1, "id", Type::Primitive(PrimitiveType::Int)).into(),
274                NestedField::required(2, "data", Type::Primitive(PrimitiveType::String)).into(),
275            ])
276            .build()
277            .unwrap();
278
279        let partition_spec = PartitionSpec::builder(Arc::new(table_schema.clone()))
280            .add_partition_field("id", "id_partition", Transform::Identity)
281            .unwrap()
282            .build()
283            .unwrap();
284
285        let arrow_schema = Arc::new(ArrowSchema::new(vec![
286            Field::new("id", DataType::Int32, false),
287            Field::new("data", DataType::Utf8, false),
288        ]));
289
290        let batch = RecordBatch::try_new(arrow_schema.clone(), vec![
291            Arc::new(Int32Array::from(vec![10, 20, 30])),
292            Arc::new(datafusion::arrow::array::StringArray::from(vec![
293                "a", "b", "c",
294            ])),
295        ])
296        .unwrap();
297
298        let partition_spec = Arc::new(partition_spec);
299        let calculator = PartitionValueCalculator::try_new(&partition_spec, &table_schema).unwrap();
300        let partition_type = calculator.partition_arrow_type().clone();
301        let expr = PartitionExpr::new(calculator, partition_spec);
302
303        assert_eq!(expr.data_type(&arrow_schema).unwrap(), partition_type);
304        assert!(!expr.nullable(&arrow_schema).unwrap());
305
306        let result = expr.evaluate(&batch).unwrap();
307        match result {
308            ColumnarValue::Array(array) => {
309                let struct_array = array.as_any().downcast_ref::<StructArray>().unwrap();
310                let id_partition = struct_array
311                    .column_by_name("id_partition")
312                    .unwrap()
313                    .as_any()
314                    .downcast_ref::<Int32Array>()
315                    .unwrap();
316                assert_eq!(id_partition.value(0), 10);
317                assert_eq!(id_partition.value(1), 20);
318                assert_eq!(id_partition.value(2), 30);
319            }
320            _ => panic!("Expected array result"),
321        }
322    }
323
324    #[test]
325    fn test_nested_partition() {
326        let address_struct = StructType::new(vec![
327            NestedField::required(3, "street", Type::Primitive(PrimitiveType::String)).into(),
328            NestedField::required(4, "city", Type::Primitive(PrimitiveType::String)).into(),
329        ]);
330
331        let table_schema = Schema::builder()
332            .with_schema_id(0)
333            .with_fields(vec![
334                NestedField::required(1, "id", Type::Primitive(PrimitiveType::Int)).into(),
335                NestedField::required(2, "address", Type::Struct(address_struct)).into(),
336            ])
337            .build()
338            .unwrap();
339
340        let partition_spec = PartitionSpec::builder(Arc::new(table_schema.clone()))
341            .add_partition_field("address.city", "city_partition", Transform::Identity)
342            .unwrap()
343            .build()
344            .unwrap();
345
346        let struct_fields = Fields::from(vec![
347            Field::new("street", DataType::Utf8, false),
348            Field::new("city", DataType::Utf8, false),
349        ]);
350
351        let arrow_schema = Arc::new(ArrowSchema::new(vec![
352            Field::new("id", DataType::Int32, false),
353            Field::new("address", DataType::Struct(struct_fields), false),
354        ]));
355
356        let street_array = Arc::new(datafusion::arrow::array::StringArray::from(vec![
357            "123 Main St",
358            "456 Oak Ave",
359        ]));
360        let city_array = Arc::new(datafusion::arrow::array::StringArray::from(vec![
361            "New York",
362            "Los Angeles",
363        ]));
364
365        let struct_array = StructArray::from(vec![
366            (
367                Arc::new(Field::new("street", DataType::Utf8, false)),
368                street_array as ArrayRef,
369            ),
370            (
371                Arc::new(Field::new("city", DataType::Utf8, false)),
372                city_array as ArrayRef,
373            ),
374        ]);
375
376        let batch = RecordBatch::try_new(arrow_schema.clone(), vec![
377            Arc::new(Int32Array::from(vec![1, 2])),
378            Arc::new(struct_array),
379        ])
380        .unwrap();
381
382        let calculator = PartitionValueCalculator::try_new(&partition_spec, &table_schema).unwrap();
383        let array = calculator.calculate(&batch).unwrap();
384
385        let struct_array = array.as_any().downcast_ref::<StructArray>().unwrap();
386        let city_partition = struct_array
387            .column_by_name("city_partition")
388            .unwrap()
389            .as_any()
390            .downcast_ref::<datafusion::arrow::array::StringArray>()
391            .unwrap();
392
393        assert_eq!(city_partition.value(0), "New York");
394        assert_eq!(city_partition.value(1), "Los Angeles");
395    }
396
397    #[test]
398    fn test_schema_validation_matching_schemas() {
399        use iceberg::TableIdent;
400        use iceberg::io::FileIO;
401        use iceberg::spec::{FormatVersion, NestedField, PrimitiveType, Schema, Type};
402
403        let table_schema = Arc::new(
404            Schema::builder()
405                .with_fields(vec![
406                    NestedField::required(1, "id", Type::Primitive(PrimitiveType::Int)).into(),
407                    NestedField::required(2, "name", Type::Primitive(PrimitiveType::String)).into(),
408                ])
409                .build()
410                .unwrap(),
411        );
412
413        let partition_spec = PartitionSpec::builder(table_schema.clone())
414            .add_partition_field("id", "id_partition", Transform::Identity)
415            .unwrap()
416            .build()
417            .unwrap();
418
419        let sort_order = iceberg::spec::SortOrder::builder()
420            .build(&table_schema)
421            .unwrap();
422
423        let table_metadata_builder = iceberg::spec::TableMetadataBuilder::new(
424            (*table_schema).clone(),
425            partition_spec,
426            sort_order,
427            "/test/table".to_string(),
428            FormatVersion::V2,
429            std::collections::HashMap::new(),
430        )
431        .unwrap();
432
433        let table_metadata = table_metadata_builder.build().unwrap();
434
435        // Create Arrow schema matching the table schema
436        let arrow_schema = Arc::new(ArrowSchema::new(vec![
437            Field::new("id", DataType::Int32, false),
438            Field::new("name", DataType::Utf8, false),
439        ]));
440
441        let input = Arc::new(EmptyExec::new(arrow_schema));
442
443        let table = Table::builder()
444            .metadata(table_metadata.metadata)
445            .identifier(TableIdent::from_strs(["test", "table"]).unwrap())
446            .file_io(FileIO::new_with_fs())
447            .metadata_location("/test/metadata.json")
448            .runtime(test_runtime())
449            .build()
450            .unwrap();
451
452        let result = project_with_partition(input, &table);
453        assert!(result.is_ok(), "Schema validation should pass");
454    }
455
456    #[test]
457    fn test_schema_validation_mismatched_schemas() {
458        use iceberg::TableIdent;
459        use iceberg::io::FileIO;
460        use iceberg::spec::{FormatVersion, NestedField, PrimitiveType, Schema, Type};
461
462        let table_schema = Arc::new(
463            Schema::builder()
464                .with_fields(vec![
465                    NestedField::required(1, "id", Type::Primitive(PrimitiveType::Int)).into(),
466                    NestedField::required(2, "name", Type::Primitive(PrimitiveType::String)).into(),
467                ])
468                .build()
469                .unwrap(),
470        );
471
472        let partition_spec = PartitionSpec::builder(table_schema.clone())
473            .add_partition_field("id", "id_partition", Transform::Identity)
474            .unwrap()
475            .build()
476            .unwrap();
477
478        let sort_order = iceberg::spec::SortOrder::builder()
479            .build(&table_schema)
480            .unwrap();
481
482        let table_metadata_builder = iceberg::spec::TableMetadataBuilder::new(
483            (*table_schema).clone(),
484            partition_spec,
485            sort_order,
486            "/test/table".to_string(),
487            FormatVersion::V2,
488            std::collections::HashMap::new(),
489        )
490        .unwrap();
491
492        let table_metadata = table_metadata_builder.build().unwrap();
493
494        // Create Arrow schema with different field name (mismatched)
495        let arrow_schema = Arc::new(ArrowSchema::new(vec![
496            Field::new("id", DataType::Int32, false),
497            Field::new("different_name", DataType::Utf8, false), // Wrong field name
498        ]));
499
500        let input = Arc::new(EmptyExec::new(arrow_schema));
501
502        let table = Table::builder()
503            .metadata(table_metadata.metadata)
504            .identifier(TableIdent::from_strs(["test", "table"]).unwrap())
505            .file_io(FileIO::new_with_fs())
506            .metadata_location("/test/metadata.json")
507            .runtime(test_runtime())
508            .build()
509            .unwrap();
510
511        let result = project_with_partition(input, &table);
512        assert!(
513            result.is_err(),
514            "Schema validation should fail for mismatched schemas"
515        );
516        assert!(
517            result
518                .unwrap_err()
519                .to_string()
520                .contains("Input schema does not match Iceberg table schema")
521        );
522    }
523
524    #[test]
525    fn test_schema_validation_with_metadata_differences() {
526        use std::collections::HashMap;
527
528        use iceberg::TableIdent;
529        use iceberg::io::FileIO;
530        use iceberg::spec::{FormatVersion, NestedField, PrimitiveType, Schema, Type};
531
532        let table_schema = Arc::new(
533            Schema::builder()
534                .with_fields(vec![
535                    NestedField::required(1, "id", Type::Primitive(PrimitiveType::Int)).into(),
536                    NestedField::required(2, "name", Type::Primitive(PrimitiveType::String)).into(),
537                ])
538                .build()
539                .unwrap(),
540        );
541
542        let partition_spec = PartitionSpec::builder(table_schema.clone())
543            .add_partition_field("id", "id_partition", Transform::Identity)
544            .unwrap()
545            .build()
546            .unwrap();
547
548        let sort_order = iceberg::spec::SortOrder::builder()
549            .build(&table_schema)
550            .unwrap();
551
552        let table_metadata_builder = iceberg::spec::TableMetadataBuilder::new(
553            (*table_schema).clone(),
554            partition_spec,
555            sort_order,
556            "/test/table".to_string(),
557            FormatVersion::V2,
558            HashMap::new(),
559        )
560        .unwrap();
561
562        let table_metadata = table_metadata_builder.build().unwrap();
563
564        // Create Arrow schema with metadata (should be ignored in comparison)
565        let mut metadata = HashMap::new();
566        metadata.insert("extra".to_string(), "metadata".to_string());
567
568        let arrow_schema = Arc::new(ArrowSchema::new(vec![
569            Field::new("id", DataType::Int32, false).with_metadata(metadata.clone()),
570            Field::new("name", DataType::Utf8, false).with_metadata(metadata),
571        ]));
572
573        let input = Arc::new(EmptyExec::new(arrow_schema));
574
575        let table = Table::builder()
576            .metadata(table_metadata.metadata)
577            .identifier(TableIdent::from_strs(["test", "table"]).unwrap())
578            .file_io(FileIO::new_with_fs())
579            .metadata_location("/test/metadata.json")
580            .runtime(test_runtime())
581            .build()
582            .unwrap();
583
584        let result = project_with_partition(input, &table);
585        assert!(
586            result.is_ok(),
587            "Schema validation should pass even with metadata differences"
588        );
589    }
590}