Skip to content

feat: add support for _partition metadata column - #2668

Merged
CTTY merged 6 commits into
apache:mainfrom
parthchandra:metadata-columns
Jul 29, 2026
Merged

feat: add support for _partition metadata column#2668
CTTY merged 6 commits into
apache:mainfrom
parthchandra:metadata-columns

Conversation

@parthchandra

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

What changes are included in this PR?

Implements the _partition metadata column for table scans. This is a struct column whose type is the union of all partition fields across all partition specs (handling partition evolution). Each row gets the
partition values for its data file.

  • Adds compute_unified_partition_type() to compute the union of partition fields across all specs (equivalent to Java's Partitioning.partitionType())
  • Adds PartitionColumnConstant and build_partition_column_constant() for pre-computing the struct values per file
  • Adds ColumnSource::AddStructConstant variant to RecordBatchTransformer for materializing struct columns
  • Threads the unified partition type through scan planning and populates the constant in FileScanTask
  • Pipeline detects RESERVED_FIELD_ID_PARTITION in projected fields and injects the struct constant

Are these changes tested?

Because we do not have write support yet, I made the corresponding change to comet and then tested by adding tests in Comet which uses iceberg-java to write files and then iceberg-rust to read them back.
https://github.com/parthchandra/datafusion-comet/blob/iceberg-metadata-columns/spark/src/test/resources/sql-tests/iceberg/metadata_column_partition.sql

@parthchandra
parthchandra marked this pull request as draft June 18, 2026 00:34
@parthchandra

Copy link
Copy Markdown
Contributor Author

@advancedxy fyi

@parthchandra
parthchandra marked this pull request as ready for review June 18, 2026 01:46
Comment thread crates/iceberg/src/metadata_columns.rs Outdated
/// # Arguments
/// * `partition_specs` - Iterator over all partition specs in the table
/// * `schema` - The current table schema (needed to determine result types of transforms)
pub fn compute_unified_partition_type<'a>(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

nit: it's a bit of odd that this function is added in metadata_column.rs. Do you think it's a good idea to create partitioning.rs and put this function into partitioning.rs?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Created crates/iceberg/src/partitioning.rs, moved the function there, registered as pub mod partitioning in lib.rs

Comment thread crates/iceberg/src/metadata_columns.rs Outdated
let mut seen_field_ids = std::collections::HashSet::new();
let mut struct_fields: Vec<NestedFieldRef> = Vec::new();

for spec in partition_specs {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I notice some inconsistent with java's impl:

  1. unknown specs are rejected in java.
  2. specs are sorted by spec id first(in reverse order), which means newer partition spec's field name will be picked fist.
  3. V1 table's void transform field is also handled in java: the partition field that was dropped later.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good point!
Updated the implementation (partitioning.rs) with -

  • Sort specs by spec_id descending (newer field names take precedence)
  • Skips Transform::Void fields (dropped partition columns)
  • Deduplicates by field_id

Comment thread crates/iceberg/src/scan/context.rs Outdated
Comment on lines +129 to +146
let partition_column_constant =
if let Some(ref unified_partition_type) = self.unified_partition_type {
let partition_spec = self
.table_metadata
.partition_spec_by_id(self.partition_spec_id);
if let Some(spec) = partition_spec {
let constant = build_partition_column_constant(
unified_partition_type,
spec,
&self.manifest_entry.data_file.partition,
)?;
Some(Arc::new(constant))
} else {
None
}
} else {
None
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I'm not sure this is a good idea to calculate the partition column in the plan/scan phase and it adds build_partition_column_constant dep from record_batch_transformer' mod into the scan side.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fair point. Removed build_partition_column_constant from scan/context.rs. The scan phase only passes unified_partition_type through to the task. The actual struct constant is computed lazily at read time in pipeline.rs.

Comment thread crates/iceberg/src/scan/task.rs Outdated
#[serde(serialize_with = "serialize_not_implemented")]
#[serde(deserialize_with = "deserialize_not_implemented")]
#[builder(default)]
pub partition_column_constant: Option<Arc<PartitionColumnConstant>>,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Like the comment in https://github.com/apache/iceberg-rust/pull/2668/changes#r3448373215, I think it's better to pass the unified partition type here rather than passing the actual unified partition value. It would be easier for comet to pooling the type rather than the actual value.

BTW, this would be unnecessary if we can rebuild/access the table/metadata when reading on the executor side. Java archives this by SerializableTable and with Spark's broadcast. We don't have similar thing on the rust yet.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

FileScanTask now carries unified_partition_type: Option<Arc<StructType>> instead of partition_column_constant: Option<Arc<PartitionColumnConstant>>. The reader computes the value from type + spec + partition data.
Also removed the table_metadata field from ManifestEntryContext and ManifestFileContext (it was only used for the old precomputation path)

Comment on lines 118 to 121
let mut ids: Vec<i32> = value.identifier_field_ids.into_iter().collect();
ids.sort_unstable();
Some(ids)
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

the changes in this file seem unrelated? And I don't think the spec requires sorting identifier field ids.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

You're right, this got left over by accident. Removed.

Comment thread crates/iceberg/src/arrow/value.rs Outdated
Comment thread crates/iceberg/src/arrow/record_batch_transformer.rs
@advancedxy

Copy link
Copy Markdown

@parthchandra thanks for pinging me and working on this. I think I'm concerned that the unified partition value is carried in the file scan task, which seems a bit of odd.

Comment thread crates/iceberg/src/partitioning.rs Outdated
@advancedxy

Copy link
Copy Markdown

@parthchandra I did another round and left some follow-up comments. I think it's in good shape overall.

@mbutrovich

Copy link
Copy Markdown
Collaborator

Comet generates the FileScanTasks from Iceberg Java. Does Iceberg Java provide unified_partition_type, otherwise we need to be sure Comet can generate these if you want Comet to work with this.

@parthchandra

Copy link
Copy Markdown
Contributor Author

Comet generates the FileScanTasks from Iceberg Java. Does Iceberg Java provide unified_partition_type, otherwise we need to be sure Comet can generate these if you want Comet to work with this.

Good point! Iceberg Java doesn't provide unified_partition_type on individual FileScanTasks — Java computes it at a higher level via Partitioning.partitionType(table)

We can do the equivalent in Comet - https://github.com/parthchandra/datafusion-comet/blob/iceberg-metadata-columns/native/core/src/execution/planner.rs#L3409-L3440

@mbutrovich
mbutrovich self-requested a review June 24, 2026 17:41
{
let struct_type = DataType::Struct(pc.fields.clone());
let nullable = pc.fields.is_empty();
let arrow_field = Field::new("_partition", struct_type, nullable)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

nit: should use RESERVED_COL_NAME_PARTITION in metadata_columns directly.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

fixed

Comment thread crates/iceberg/src/partitioning.rs Outdated
}

// Skip void transforms (dropped partition columns) and unknown transforms
if matches!(field.transform, Transform::Void | Transform::Unknown) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

hmm. I don't think this is consistent with iceberg-java.

Unknown transform should be checked rather than ignored.

Considering a new transform introduced in iceberg-spec but not adapted by iceberg-rust yet should be considered as unknown transform.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

fixed

@mbutrovich

Copy link
Copy Markdown
Collaborator

We can do the equivalent in Comet - https://github.com/parthchandra/datafusion-comet/blob/iceberg-metadata-columns/native/core/src/execution/planner.rs#L3409-L3440

Are you able to demonstrate this feature working in Comet? Usually when I bring an enhancement to iceberg-rust motivated by Comet usage, I create a draft PR on the Comet repo with tests and the feature built out (using the PR branch here) to demonstrate it works with the Iceberg Java suites.

@parthchandra

Copy link
Copy Markdown
Contributor Author

Thanks @advancedxy @mbutrovich I'm on the road atm, will address these in a few days.

@mbutrovich mbutrovich left a comment

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.

Thanks for this @parthchandra! Computing the unified type once per scan and threading it through the context is a nice shape, and matching Java's "empty partition struct becomes null" choice is a good call for engine compatibility. A few things I'd like to talk through before it lands, mostly around matching Partitioning.partitionType exactly and trimming some surface area:

  1. There looks to be an ordering dependency on #2695 (the partition_spec wiring). Could you confirm? See the context.rs note.
  2. Two spots where I think we may diverge from Partitioning.partitionType (dropped source column; result field ordering), flagged as questions inline.
  3. Some of the array construction could lean on arrow-rs helpers, plus a couple of API-surface trims.
  4. Coverage is identity-transform-only right now; I listed the cases I'd find most reassuring.

Re: tests:

The two new unit tests are identity-only. Cases that would most increase my confidence:

  • non-identity transform (bucket/truncate), confirming the transformed partition value lands in _partition, consistent with PartitionUtil.constantsMap reading partitionData.get(pos);
  • unpartitioned table end-to-end (exercises the empty-struct-to-null branch);
  • explicit null partition value (distinct from evolution-missing);
  • _partition selected alongside _file/_pos/_spec_id in one scan, since they share the projection/transform path.

Comment thread crates/iceberg/src/partitioning.rs Outdated

seen_field_ids.insert(field.field_id);

let source_field = schema.field_by_id(field.source_id).ok_or_else(|| {

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.

Java's Partitioning.partitionType only considers allActiveFieldIds, which filters out fields whose source column is gone (schema.findField(field.sourceId()) != null in Partitioning.java). Here we return ErrorKind::Unexpected instead. If I'm reading both right, a table that dropped a column it used to partition on (allowed under https://iceberg.apache.org/spec/#partition-evolution) would error the whole scan when _partition is projected. Would continue-ing on a missing source column match Java's intent better? A test that drops a partition source column would settle it either way.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Changed to skip fields where the source column gone so this should match Java's behavior. However, proving it with a test turned out to be tricky. The test failed; turns out in Spark/Iceberg DDL PartitionSpec.bind() rejects specs referencing dropped columns during writes so the condition cannot arise.

Comment thread crates/iceberg/src/partitioning.rs
Comment thread crates/iceberg/src/partitioning.rs Outdated
Comment thread crates/iceberg/src/arrow/value.rs
}
}

fn create_struct_column(

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.

create_struct_column is hand-rolled struct assembly that arrow-rs already provides: the empty/all-null path is StructArray::new_null(fields, len) / new_null_array (arrow-array/src/array/struct_array.rs:192), and the populated path is StructArray::try_new(fields, arrays, nulls), which validates fields.len() == arrays.len() and child-length agreement (struct_array.rs:106). I'd reuse those rather than maintain our own constructor: try_new also enforces the fields/child_values length invariant that the current zip silently truncates. Same principle as the value.rs note: prefer the library's validated constructors over duplicating them here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done. Thanks for pointing this one out!

Comment thread crates/iceberg/src/partitioning.rs Outdated
partition_specs: impl Iterator<Item = &'a PartitionSpec>,
schema: &Schema,
) -> Result<StructType> {
let mut seen_field_ids = std::collections::HashSet::new();

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.

Small consistency thing: use std::collections::HashSet; / use std::cmp::Reverse; up top matches the surrounding style.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

changed

let struct_type = DataType::Struct(pc.fields.clone());
let nullable = pc.fields.is_empty();
let arrow_field =
Field::new(RESERVED_COL_NAME_PARTITION, struct_type, nullable)

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.

This exact block recurs several times across these PRs. A small fn field_with_id(name, dt, nullable, field_id) -> Arc<Field> helper would remove the repetition and keep the field-id metadata format in one place.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done. Created a new field_with_id helper function

Comment thread crates/iceberg/src/scan/context.rs Outdated
@@ -138,6 +142,7 @@ impl ManifestEntryContext {
// TODO: Pass actual PartitionSpec through context chain for native flow
.with_partition_spec(None)

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.

build_partition_column_constant needs task.partition_spec to be Some for partitioned tables, but this PR keeps partition_spec(None); #2695 is the one that wires the real spec through context.rs. Is the plan to rebase on #2695? If so, an end-to-end test asserting non-null partition values would confirm the wiring once rebased.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The plan is for this PR to go in first and have #2695 rebase. I'll add end to end tests for all metadata fields in the Comet PR

Comment thread crates/iceberg/src/scan/mod.rs
Comment thread crates/iceberg/src/scan/task.rs
@parthchandra

Copy link
Copy Markdown
Contributor Author

We can do the equivalent in Comet - https://github.com/parthchandra/datafusion-comet/blob/iceberg-metadata-columns/native/core/src/execution/planner.rs#L3409-L3440

Are you able to demonstrate this feature working in Comet? Usually when I bring an enhancement to iceberg-rust motivated by Comet usage, I create a draft PR on the Comet repo with tests and the feature built out (using the PR branch here) to demonstrate it works with the Iceberg Java suites.

I've opened a draft PR in Comet: apache/datafusion-comet#4752 based on the same branch I referenced above. All existing tests pass.
The new tests covering this PR are in https://github.com/parthchandra/datafusion-comet/blob/iceberg-metadata-columns/spark/src/test/resources/sql-tests/iceberg/metadata_column_partition.sql

Working on addressing your newer comments (thanks for the detailed review!).

@parthchandra

Copy link
Copy Markdown
Contributor Author

@mbutrovich @advancedxy addressed all your review comments.
Sync'd with @hsiang-c and will rebase this on top of #2695 (it will be easier)

@mbutrovich
mbutrovich self-requested a review July 10, 2026 17:06
@parthchandra

Copy link
Copy Markdown
Contributor Author

Comet (draft) PR with @hsiang-c 's PRs included. CI is green. apache/datafusion-comet#4752
(branch with all the PRs merged - https://github.com/parthchandra/iceberg-rust/tree/metadata-columns-unified )

@parthchandra

Copy link
Copy Markdown
Contributor Author

@mbutrovich could you re-review once @hsiang-c 's PRs are merged and I rebase on top of them?

@advancedxy

Copy link
Copy Markdown

@parthchandra since other prs are merged, would you like to rebase the PR on the master and let's continue.

@parthchandra

Copy link
Copy Markdown
Contributor Author

@advancedxy, @mbutrovich, @CTTY rebased this PR after #2695 and
#2746 merged.

The corresponding PR in Comet: apache/datafusion-comet#4752 adds tests as well. (Note the CI failure is an issue in Comet main and not related to this)

PTAL

@advancedxy advancedxy left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Left some style comments, the code should be correct as far as I can see.

Comment thread crates/iceberg/src/scan/mod.rs Outdated

// Compute unified partition type if _partition is projected
let unified_partition_type = if field_ids.contains(&RESERVED_FIELD_ID_PARTITION) {
let upt = compute_unified_partition_type(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

nit: upt seems a bit of unusual in the iceberg-rust repo. how about simply call it partition_type or unified_type?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Changed.

snapshot_schema: Arc<IcebergSchema>,
projected_iceberg_field_ids: Vec<i32>,
constant_fields: HashMap<i32, Datum>,
metadata_columns: HashMap<i32, MetadataColumnSource>,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

hmm, I'd prefer to use constant fields here, which aligns with java's concept. Although, the constant fields are almost always metadata columns. But the identity partition source field is also injected into the constant map.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

changed. Also renamed the enum from MetadataColumnSource to ColumnConstant since the values aren't exclusively metadata


/// Pre-computed data for the _partition struct constant.
#[derive(Debug, Clone, PartialEq)]
pub struct PartitionColumnConstant {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

this could be called as StructConstant? It could cover other struct constant if needed, not just the partition column.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

changed

let struct_type = DataType::Struct(pc.fields().clone());
let nullable = pc.fields().is_empty();
let arrow_field = field_with_id(
RESERVED_COL_NAME_PARTITION,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I think this branch specialized/assumed the MetadataColumnSource::Struct must come from the _partition field, which is true for current code but might change when the spec evolves. It would be better to check the field_id is indeed the partition col field first.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

added a check for field id. Also for unexpected struct constants, we now return an error.

@advancedxy advancedxy left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

LGTM now, thanks for the effort, it's great work.

@parthchandra

Copy link
Copy Markdown
Contributor Author

Thanks @advancedxy. @CTTY could we get a committer review/approval?

@hsiang-c hsiang-c left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thank you @parthchandra LGTM

@CTTY CTTY left a comment

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.

Hi, thanks for your contribution!

Some(ColumnConstant::Struct(_)) => {
return Err(Error::new(
ErrorKind::Unexpected,
format!("Unexpected struct constant for field id {field_id}"),

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.

nit: can we make this error message more explicit? Like

format!(
            "Struct column constants are only supported for the `_partition` \
             metadata column (field id {RESERVED_FIELD_ID_PARTITION}), but one \
             was set for field id {field_id}"
        )

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

done

Some(ColumnConstant::Struct(_)) => {
return Err(Error::new(
ErrorKind::Unexpected,
format!("Unexpected struct constant for field id {field_id}"),

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.

nit: same here, the error message is a bit fuzzy

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

changed

/// For each field in the unified partition type:
/// - If it corresponds to a field in this file's partition spec, use the value from partition_data
/// - Otherwise (partition evolution), use null
pub fn build_partition_column_constant(

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.

nit: similarly, build_partition_constant could be better. We can make the naming consistent

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.

does this really need to be pub? I think a pub(crate) is more suitable but not sure if I overlooked anything.

Having 3 loosely coupled arguments for a pub function makes it easier for users mistakes

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

fixed

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.

This feels unneeded since we now have field_with_id

/// Covers both scalar constants (metadata columns like `_file` and `_spec_id`,
/// as well as identity partition source fields) and the struct `_partition` column.
#[derive(Debug, Clone, PartialEq)]
pub enum ColumnConstant {

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.

This should be pub(crate)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

fixed


/// Pre-computed data for a struct constant column.
#[derive(Debug, Clone, PartialEq)]
pub struct StructConstant {

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.

This should be pub(crate)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

fixed

}

/// Set a pre-computed _partition column constant directly.
pub(crate) fn with_partition_column_precomputed(

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.

nit: I think with_partition_constant is more straightforward

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

fixed

Comment thread crates/iceberg/src/partitioning.rs Outdated

for spec in specs {
for field in spec.fields() {
if seen_field_ids.contains(&field.field_id) {

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.

Can we revisit this? Just read the java impl (Partitioning::buildPartitionProjectionType), and my understanding for the void transform handling is:

  1. If it's new field, we record its id, type, and name. If we have an existing newer field and the current field is the older field, go to 2
  2. We check if the newer field is Void transform AND if the older field is not Void transform, which implies that the field was dropped. In this case, java would preserve the type of the older field. But I don't see this happening in the current function?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Great point @CTTY. This did not match the java version. Redid this to match java's buildPartitionProjectionType. so that when a newer spec marks a field Void but an older spec has it with a real transform, the older spec's type is preserved while the newer spec's name is kept. Introduced all_active_field_ids helper matching Java's allActiveFieldIds.

/// # Arguments
/// * `partition_specs` - Iterator over all partition specs in the table
/// * `schema` - The current table schema (needed to determine result types of transforms)
pub fn compute_unified_partition_type<'a>(

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.

We should add unit tests for this function

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

added unit tests -
single spec identity,
year transform,
unpartitioned,
multiple fields sorted by id,
newer name precedence, void replaced by older non-void,
dropped source column skipped, and partition evolution adding new fields

@parthchandra parthchandra left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@CTTY thank you for your review. Addressed your comments. Also, added some tests to cover compute_unified_partition_type in the Comet tests (draft PR: apache/datafusion-comet#4752) which compare with Spark results

/// # Arguments
/// * `partition_specs` - Iterator over all partition specs in the table
/// * `schema` - The current table schema (needed to determine result types of transforms)
pub fn compute_unified_partition_type<'a>(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

added unit tests -
single spec identity,
year transform,
unpartitioned,
multiple fields sorted by id,
newer name precedence, void replaced by older non-void,
dropped source column skipped, and partition evolution adding new fields

Comment thread crates/iceberg/src/partitioning.rs Outdated

for spec in specs {
for field in spec.fields() {
if seen_field_ids.contains(&field.field_id) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Great point @CTTY. This did not match the java version. Redid this to match java's buildPartitionProjectionType. so that when a newer spec marks a field Void but an older spec has it with a real transform, the older spec's type is preserved while the newer spec's name is kept. Introduced all_active_field_ids helper matching Java's allActiveFieldIds.

/// Covers both scalar constants (metadata columns like `_file` and `_spec_id`,
/// as well as identity partition source fields) and the struct `_partition` column.
#[derive(Debug, Clone, PartialEq)]
pub enum ColumnConstant {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

fixed


/// Pre-computed data for a struct constant column.
#[derive(Debug, Clone, PartialEq)]
pub struct StructConstant {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

fixed

}

/// Set a pre-computed _partition column constant directly.
pub(crate) fn with_partition_column_precomputed(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

fixed

Some(ColumnConstant::Struct(_)) => {
return Err(Error::new(
ErrorKind::Unexpected,
format!("Unexpected struct constant for field id {field_id}"),

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

done

Some(ColumnConstant::Struct(_)) => {
return Err(Error::new(
ErrorKind::Unexpected,
format!("Unexpected struct constant for field id {field_id}"),

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

changed

/// For each field in the unified partition type:
/// - If it corresponds to a field in this file's partition spec, use the value from partition_data
/// - Otherwise (partition evolution), use null
pub fn build_partition_column_constant(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

fixed

@CTTY

CTTY commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Hi @parthchandra , looks like the code is not updated yet. Maybe you have commits that are not pushed to the remote?

@parthchandra

Copy link
Copy Markdown
Contributor Author

@CTTY 6bf928a has the changes but they don't seem to be showing up. Let me rebase and push again.

@parthchandra

Copy link
Copy Markdown
Contributor Author

@CTTY updated (had to force push)

@parthchandra

Copy link
Copy Markdown
Contributor Author

Comet's apache/datafusion-comet#4752 CI is green with this PR.
Comet Iceberg tests for metadata columns are in CometIcebergNativeSuite.scala

@CTTY CTTY left a comment

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.

Thanks for working on this! Just took another pass

// 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).

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.

this comment still holds true, can we keep this?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added back on the scalar constant arm

Comment thread crates/iceberg/src/partitioning.rs Outdated

let active_field_ids = all_active_field_ids(specs.iter().copied(), schema);

let mut field_map: HashMap<i32, &crate::spec::PartitionField> = HashMap::new();

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.

nit: we can just import this

Suggested change
let mut field_map: HashMap<i32, &crate::spec::PartitionField> = HashMap::new();
let mut field_map: HashMap<i32, &PartitionField> = HashMap::new();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done

Comment thread crates/iceberg/src/partitioning.rs Outdated

if matches!(field.transform, Transform::Unknown) {
return Err(Error::new(
ErrorKind::FeatureUnsupported,

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.

I'd say it's data invalid error. We can't support unknown transform since we don't know the result type

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fair point. Changed to ErrorKind::DataInvalid

name_map.insert(field_id, field.name.clone());
}
Some(existing) => {
if is_void_transform(existing) && !is_void_transform(field) {

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.

Java does a compatibility check before this to compare the existing field and the new field, and we need this because V1 doesn't ensure the field id is unique across specs: https://github.com/apache/iceberg/blob/main/core/src/main/java/org/apache/iceberg/Partitioning.java#L295

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thank you for catching this! Added the compatibility check.

continue;
}

if matches!(field.transform, Transform::Unknown) {

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.

This check should be moved before the continue above, otherwise it may be skipped

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done. moved

(Some(spec), Some(data)) => (spec.clone(), data.clone()),
// Unpartitioned table or missing spec: build_partition_constant
// handles the empty unified_type case with an early return.
_ => (Arc::new(PartitionSpec::unpartition_spec()), Struct::empty()),

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.

We should fail if !unified_type.fields().is_empty()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done. Now errors if the unified type has fields but spec/data is missing.

Comment thread crates/iceberg/src/partitioning.rs Outdated
Comment on lines +108 to +109
let name = &name_map[&fid];
let ty = type_map.remove(&fid).unwrap();

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.

I understand that this is unlikely to fail due to the way it was constructed. But I still think we should not unwrap here, how about the below?

let struct_fields = field_ids
    .into_iter()
    .map(|fid| -> Result<NestedFieldRef> {
        let name = name_map.get(&fid).ok_or_else(|| {
            Error::new(
                ErrorKind::Unexpected,
                format!("Missing name for partition field {fid}"),
            )
        })?;

        let ty = type_map.remove(&fid).ok_or_else(|| {
            Error::new(
                ErrorKind::Unexpected,
                format!("Missing type for partition field {fid}"),
            )
        })?;

        Ok(NestedField::optional(fid, name, ty).into())
    })
    .collect::<Result<Vec<_>>>()?;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fair enough. Replaced with ok_or_else.

@parthchandra parthchandra left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thank you for the very thorough review @CTTY!

(Some(spec), Some(data)) => (spec.clone(), data.clone()),
// Unpartitioned table or missing spec: build_partition_constant
// handles the empty unified_type case with an early return.
_ => (Arc::new(PartitionSpec::unpartition_spec()), Struct::empty()),

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done. Now errors if the unified type has fields but spec/data is missing.

Comment thread crates/iceberg/src/partitioning.rs Outdated

let active_field_ids = all_active_field_ids(specs.iter().copied(), schema);

let mut field_map: HashMap<i32, &crate::spec::PartitionField> = HashMap::new();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done

continue;
}

if matches!(field.transform, Transform::Unknown) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done. moved

Comment thread crates/iceberg/src/partitioning.rs Outdated

if matches!(field.transform, Transform::Unknown) {
return Err(Error::new(
ErrorKind::FeatureUnsupported,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fair point. Changed to ErrorKind::DataInvalid

name_map.insert(field_id, field.name.clone());
}
Some(existing) => {
if is_void_transform(existing) && !is_void_transform(field) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thank you for catching this! Added the compatibility check.

Comment thread crates/iceberg/src/partitioning.rs Outdated
Comment on lines +108 to +109
let name = &name_map[&fid];
let ty = type_map.remove(&fid).unwrap();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fair enough. Replaced with ok_or_else.

// 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).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added back on the scalar constant arm

@CTTY CTTY left a comment

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.

LGTM! Thanks for contributing this feature

@CTTY
CTTY merged commit 3d84c81 into apache:main Jul 29, 2026
21 checks passed
@parthchandra

Copy link
Copy Markdown
Contributor Author

Thank you @CTTY @advancedxy @mbutrovich @hsiang-c !

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support for projecting metadata columns _pos, _spec_id, and _partition in table scan

5 participants