1use 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
38pub 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 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#[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
117impl 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 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 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 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 let arrow_schema = Arc::new(ArrowSchema::new(vec![
496 Field::new("id", DataType::Int32, false),
497 Field::new("different_name", DataType::Utf8, false), ]));
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 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}