Skip to content
13 changes: 12 additions & 1 deletion crates/iceberg/src/arrow/reader/pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ use crate::arrow::record_batch_transformer::RecordBatchTransformerBuilder;
use crate::arrow::scan_metrics::{CountingFileRead, ScanMetrics, ScanResult};
use crate::error::Result;
use crate::io::{FileIO, FileMetadata, FileRead};
use crate::metadata_columns::{RESERVED_FIELD_ID_FILE, is_metadata_field};
use crate::metadata_columns::{
RESERVED_FIELD_ID_FILE, RESERVED_FIELD_ID_SPEC_ID, is_metadata_field,
};
use crate::scan::{ArrowRecordBatchStream, FileScanTask, FileScanTaskStream};
use crate::spec::Datum;
use crate::{Error, ErrorKind};
Expand Down Expand Up @@ -248,6 +250,15 @@ impl FileScanTaskReader {
record_batch_transformer_builder.with_constant(RESERVED_FIELD_ID_FILE, file_datum);
}

if task
.project_field_ids()
.contains(&RESERVED_FIELD_ID_SPEC_ID)
{
let spec_id_datum = Datum::int(task.partition_spec.clone().unwrap().spec_id());
record_batch_transformer_builder = record_batch_transformer_builder

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Routing _spec_id through with_constant makes it RunEndEncoded (metadata-field constants get datum_to_arrow_type_with_ree), same as _file. The spec types _spec_id as a plain int (https://iceberg.apache.org/spec/#reserved-field-ids). For the Comet/Spark consumer, does REE get decoded cheaply downstream, or would a plain Int32 avoid a cast? Not necessarily a change, just confirming the consumer is happy with REE here as it is for _file.

.with_constant(RESERVED_FIELD_ID_SPEC_ID, spec_id_datum);
}

if let (Some(partition_spec), Some(partition_data)) =
(task.partition_spec.clone(), task.partition.clone())
{
Expand Down
105 changes: 54 additions & 51 deletions crates/iceberg/src/arrow/record_batch_transformer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -359,49 +359,29 @@ impl RecordBatchTransformer {
let fields: Result<Vec<_>> = projected_iceberg_field_ids
.iter()
.map(|field_id| {
// Check if this is a constant field
if constant_fields.contains_key(field_id) {
// For metadata/virtual fields (like _file), get name from metadata_columns
// For partition fields, get name from schema (they exist in schema)
if let Ok(iceberg_field) = get_metadata_field(*field_id) {
// This is a metadata/virtual field - convert Iceberg field to Arrow
let datum = constant_fields.get(field_id).ok_or(Error::new(
ErrorKind::Unexpected,
"constant field not found",
))?;
let arrow_type = datum_to_arrow_type_with_ree(datum);
let arrow_field =
Field::new(&iceberg_field.name, arrow_type, !iceberg_field.required)
.with_metadata(HashMap::from([(
PARQUET_FIELD_ID_META_KEY.to_string(),
iceberg_field.id.to_string(),
)]));
Ok(Arc::new(arrow_field))
} else {
// This is a partition constant field (exists in schema but uses constant value)
let field = &field_id_to_mapped_schema_map
.get(field_id)
.ok_or(Error::new(ErrorKind::Unexpected, "field not found"))?
.0;
let datum = constant_fields.get(field_id).ok_or(Error::new(
ErrorKind::Unexpected,
"constant field not found",
))?;
let arrow_type = datum_to_arrow_type_with_ree(datum);
// Use the type from constant_fields (REE for constants)
let constant_field =
Field::new(field.name(), arrow_type, field.is_nullable())
.with_metadata(field.metadata().clone());
Ok(Arc::new(constant_field))
}
} else {
// Regular field - use schema as-is
Ok(field_id_to_mapped_schema_map
.get(field_id)
.ok_or(Error::new(ErrorKind::Unexpected, "field not found"))?
.0
.clone())
// Metadata/virtual fields (like _file, _spec_id) don't exist in the table
// schema, so build their Arrow field from the metadata column definition and
// the pre-computed constant's type.
if let Some(datum) = constant_fields.get(field_id)
&& let Ok(iceberg_field) = get_metadata_field(*field_id)
{
let arrow_type = datum_to_arrow_type_with_ree(datum);
let arrow_field =
Field::new(&iceberg_field.name, arrow_type, !iceberg_field.required)
.with_metadata(HashMap::from([(
PARQUET_FIELD_ID_META_KEY.to_string(),
iceberg_field.id.to_string(),
)]));
return Ok(Arc::new(arrow_field));
}

// Regular fields and identity-partitioned constant fields both exist in the
// table schema, so use the mapped Arrow field as-is.
Ok(field_id_to_mapped_schema_map
.get(field_id)
.ok_or(Error::new(ErrorKind::Unexpected, "field not found"))?
.0
.clone())
})
.collect();

Expand Down Expand Up @@ -481,16 +461,39 @@ impl RecordBatchTransformer {
projected_iceberg_field_ids
.iter()
.map(|field_id| {
// Check if this is a constant field (metadata/virtual or identity-partitioned)
// Constant fields always use their pre-computed constant values, regardless of whether
// they exist in the Parquet file. This is per Iceberg spec rule #1: partition metadata
// is authoritative and should be preferred over file data.
// Check if this is a constant field (metadata/virtual or identity-partitioned).
//
// Metadata/virtual fields (like _file, _spec_id) never exist in the data file,
// so they always use their pre-computed constant value.
//
// For identity-partitioned fields, the Iceberg spec's "Column Projection" rules
// only apply to "field ids which are not present in a data file". When the column
// IS present in the Parquet file, it must be read from the file; the partition
// metadata constant is only a fallback for when the column is absent (e.g. add_files).
if let Some(datum) = constant_fields.get(field_id) {
let arrow_type = datum_to_arrow_type_with_ree(datum);
return Ok(ColumnSource::Add {
value: Some(datum.literal().clone()),
target_type: arrow_type,
});
let is_metadata_field = get_metadata_field(*field_id).is_ok();
let present_in_file = field_id_to_source_schema_map.contains_key(field_id);

if is_metadata_field || !present_in_file {
let arrow_type = if is_metadata_field {
datum_to_arrow_type_with_ree(datum)
} else {
field_id_to_mapped_schema_map
.get(field_id)
.ok_or(Error::new(
ErrorKind::Unexpected,
"could not find field in schema",
))?
.0
.data_type()
.clone()
};

return Ok(ColumnSource::Add {
value: Some(datum.literal().clone()),
target_type: arrow_type,
});
}
}

let (target_field, _) =
Expand Down
5 changes: 4 additions & 1 deletion crates/iceberg/src/arrow/value.rs
Original file line number Diff line number Diff line change
Expand Up @@ -834,6 +834,9 @@ pub(crate) fn create_primitive_array_repeated(
let vals: Vec<Option<i32>> = vec![None; num_rows];
Arc::new(Date32Array::from(vals))
}
(DataType::Int64, Some(PrimitiveLiteral::Int(value))) => {
Arc::new(Int64Array::from(vec![i64::from(*value); num_rows]))
}
(DataType::Int64, Some(PrimitiveLiteral::Long(value))) => {
Arc::new(Int64Array::from(vec![*value; num_rows]))
}
Expand Down Expand Up @@ -969,7 +972,7 @@ pub(crate) fn create_primitive_array_repeated(
(dt, _) => {
return Err(Error::new(
ErrorKind::Unexpected,
format!("unexpected target column type {dt}"),
format!("unexpected target column type {dt}, prim_lit {prim_lit:?}"),
));
}
})
Expand Down
15 changes: 11 additions & 4 deletions crates/iceberg/src/scan/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ use crate::scan::{
PartitionFilterCache,
};
use crate::spec::{
ManifestContentType, ManifestEntryRef, ManifestFile, ManifestList, NameMapping, SchemaRef,
SnapshotRef, TableMetadataRef,
ManifestContentType, ManifestEntryRef, ManifestFile, ManifestList, NameMapping,
PartitionSpecRef, SchemaRef, SnapshotRef, TableMetadataRef,
};
use crate::{Error, ErrorKind, Result};

Expand All @@ -48,6 +48,7 @@ pub(crate) struct ManifestFileContext {
delete_file_index: DeleteFileIndex,
name_mapping: Option<Arc<NameMapping>>,
case_sensitive: bool,
partition_spec: Option<PartitionSpecRef>,
}

/// Wraps a [`ManifestEntryRef`] alongside the objects that are needed
Expand All @@ -63,6 +64,7 @@ pub(crate) struct ManifestEntryContext {
pub delete_file_index: DeleteFileIndex,
pub name_mapping: Option<Arc<NameMapping>>,
pub case_sensitive: bool,
pub partition_spec: Option<PartitionSpecRef>,
}

impl ManifestFileContext {
Expand All @@ -80,6 +82,7 @@ impl ManifestFileContext {
delete_file_index,
name_mapping,
case_sensitive,
partition_spec,
} = self;

let manifest = object_cache.get_manifest(&manifest_file).await?;
Expand All @@ -96,6 +99,7 @@ impl ManifestFileContext {
delete_file_index: delete_file_index.clone(),
name_mapping: name_mapping.clone(),
case_sensitive,
partition_spec: partition_spec.clone(),
};

sender
Expand Down Expand Up @@ -135,8 +139,7 @@ impl ManifestEntryContext {
)
.with_deletes(deletes)
.with_partition(Some(self.manifest_entry.data_file.partition.clone()))
// TODO: Pass actual PartitionSpec through context chain for native flow
.with_partition_spec(None)
.with_partition_spec(self.partition_spec.clone())
.with_name_mapping(self.name_mapping)
.with_case_sensitive(self.case_sensitive)
.build())
Expand Down Expand Up @@ -284,6 +287,10 @@ impl PlanContext {
delete_file_index,
name_mapping: self.name_mapping.clone(),
case_sensitive: self.case_sensitive,
partition_spec: self
.table_metadata
.partition_spec_by_id(manifest_file.partition_spec_id)
.cloned(),
}
}
}
Loading
Loading