This is an automated email from the ASF dual-hosted git repository.

parthchandra pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/datafusion-comet.git


The following commit(s) were added to refs/heads/main by this push:
     new 60413eb52c feat: support Iceberg metadata columns _pos, _spec, _file, 
and _partition (#4752)
60413eb52c is described below

commit 60413eb52c1f6dda98965e328598ac3b90948b6a
Author: Parth Chandra <[email protected]>
AuthorDate: Fri Jul 31 18:10:00 2026 -0700

    feat: support Iceberg metadata columns _pos, _spec, _file, and _partition 
(#4752)
    
    * feat: [iceberg] support iceberg metadata columns _pos, _spec, _file, 
_partition
---
 dev/diffs/iceberg/1.10.0.diff                      |  14 +
 dev/diffs/iceberg/1.11.0.diff                      |  14 +
 dev/diffs/iceberg/1.8.1.diff                       |  14 +
 dev/diffs/iceberg/1.9.1.diff                       |  14 +
 native/Cargo.lock                                  |   4 +-
 native/Cargo.toml                                  |   4 +-
 .../core/src/execution/operators/iceberg_scan.rs   |   1 +
 native/core/src/execution/planner.rs               | 262 +++++++
 native/proto/src/proto/operator.proto              |   6 +-
 .../apache/comet/iceberg/IcebergReflection.scala   |  84 ++
 .../org/apache/comet/rules/CometScanRule.scala     |  87 ++-
 .../serde/operator/CometIcebergNativeScan.scala    | 291 ++++---
 .../iceberg/metadata_column_partition.sql          | 854 +++++++++++++++++++++
 .../org/apache/comet/CometIcebergNativeSuite.scala | 670 +++++++++++++++-
 14 files changed, 2159 insertions(+), 160 deletions(-)

diff --git a/dev/diffs/iceberg/1.10.0.diff b/dev/diffs/iceberg/1.10.0.diff
index b7c5ce8a84..775f190e99 100644
--- a/dev/diffs/iceberg/1.10.0.diff
+++ b/dev/diffs/iceberg/1.10.0.diff
@@ -1519,6 +1519,20 @@ index 4c1a509591..b22f3f9ad9 100644
              .enableHiveSupport()
              .getOrCreate();
  
+diff --git 
a/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/SparkRowLevelOperationsTestBase.java
 
b/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/SparkRowLevelOperationsTestBase.java
+index 893f9931cf..17e5fef217 100644
+--- 
a/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/SparkRowLevelOperationsTestBase.java
++++ 
b/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/SparkRowLevelOperationsTestBase.java
+@@ -460,7 +460,8 @@ public abstract class SparkRowLevelOperationsTestBase 
extends ExtensionsTestBase
+ 
+   protected void assertAllBatchScansVectorized(SparkPlan plan) {
+     List<SparkPlan> batchScans = SparkPlanUtil.collectBatchScans(plan);
+-    
assertThat(batchScans).hasSizeGreaterThan(0).allMatch(SparkPlan::supportsColumnar);
++    // When Comet is enabled, its native scan replaces BatchScanExec nodes 
entirely
++    assertThat(batchScans).allMatch(SparkPlan::supportsColumnar);
+   }
+ 
+   protected void createTableWithDeleteGranularity(
 diff --git 
a/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestCallStatementParser.java
 
b/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestCallStatementParser.java
 index ecf9e6f8a5..23dbcf7af2 100644
 --- 
a/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestCallStatementParser.java
diff --git a/dev/diffs/iceberg/1.11.0.diff b/dev/diffs/iceberg/1.11.0.diff
index ca56ab9b93..a8dc4d3150 100644
--- a/dev/diffs/iceberg/1.11.0.diff
+++ b/dev/diffs/iceberg/1.11.0.diff
@@ -57,6 +57,20 @@ index f766fbb79a..c5e31185a9 100644
              .config(TestBase.DISABLE_UI)
              .enableHiveSupport()
              .getOrCreate();
+diff --git 
a/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/SparkRowLevelOperationsTestBase.java
 
b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/SparkRowLevelOperationsTestBase.java
+index b5d6415763..0763a723d3 100644
+--- 
a/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/SparkRowLevelOperationsTestBase.java
++++ 
b/spark/v4.1/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/SparkRowLevelOperationsTestBase.java
+@@ -448,7 +448,8 @@ public abstract class SparkRowLevelOperationsTestBase 
extends ExtensionsTestBase
+ 
+   protected void assertAllBatchScansVectorized(SparkPlan plan) {
+     List<SparkPlan> batchScans = SparkPlanUtil.collectBatchScans(plan);
+-    
assertThat(batchScans).hasSizeGreaterThan(0).allMatch(SparkPlan::supportsColumnar);
++    // When Comet is enabled, its native scan replaces BatchScanExec nodes 
entirely
++    assertThat(batchScans).allMatch(SparkPlan::supportsColumnar);
+   }
+ 
+   protected void createTableWithDeleteGranularity(
 diff --git 
a/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/action/DeleteOrphanFilesBenchmark.java
 
b/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/action/DeleteOrphanFilesBenchmark.java
 index 3fd84553f0..350b4a7561 100644
 --- 
a/spark/v4.1/spark/src/jmh/java/org/apache/iceberg/spark/action/DeleteOrphanFilesBenchmark.java
diff --git a/dev/diffs/iceberg/1.8.1.diff b/dev/diffs/iceberg/1.8.1.diff
index 7c951c7f5b..7456bb745e 100644
--- a/dev/diffs/iceberg/1.8.1.diff
+++ b/dev/diffs/iceberg/1.8.1.diff
@@ -78,6 +78,20 @@ index 4f137f5b8d..145ef2d00c 100644
              .getOrCreate();
  
      SparkTestBase.catalog =
+diff --git 
a/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/SparkRowLevelOperationsTestBase.java
 
b/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/SparkRowLevelOperationsTestBase.java
+index 5fa76f9da6..9184aaba83 100644
+--- 
a/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/SparkRowLevelOperationsTestBase.java
++++ 
b/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/SparkRowLevelOperationsTestBase.java
+@@ -452,7 +452,8 @@ public abstract class SparkRowLevelOperationsTestBase 
extends SparkExtensionsTes
+ 
+   protected void assertAllBatchScansVectorized(SparkPlan plan) {
+     List<SparkPlan> batchScans = SparkPlanUtil.collectBatchScans(plan);
+-    
assertThat(batchScans).hasSizeGreaterThan(0).allMatch(SparkPlan::supportsColumnar);
++    // When Comet is enabled, its native scan replaces BatchScanExec nodes 
entirely
++    assertThat(batchScans).allMatch(SparkPlan::supportsColumnar);
+   }
+ 
+   protected void createTableWithDeleteGranularity(
 diff --git 
a/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestCallStatementParser.java
 
b/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestCallStatementParser.java
 index 55a413063e..a1895c2faf 100644
 --- 
a/spark/v3.4/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestCallStatementParser.java
diff --git a/dev/diffs/iceberg/1.9.1.diff b/dev/diffs/iceberg/1.9.1.diff
index e086b43a30..32c4e693fc 100644
--- a/dev/diffs/iceberg/1.9.1.diff
+++ b/dev/diffs/iceberg/1.9.1.diff
@@ -1631,6 +1631,20 @@ index 578845e3da..3cc1598035 100644
              .enableHiveSupport()
              .getOrCreate();
  
+diff --git 
a/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/SparkRowLevelOperationsTestBase.java
 
b/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/SparkRowLevelOperationsTestBase.java
+index b5d6415763..0763a723d3 100644
+--- 
a/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/SparkRowLevelOperationsTestBase.java
++++ 
b/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/SparkRowLevelOperationsTestBase.java
+@@ -448,7 +448,8 @@ public abstract class SparkRowLevelOperationsTestBase 
extends ExtensionsTestBase
+ 
+   protected void assertAllBatchScansVectorized(SparkPlan plan) {
+     List<SparkPlan> batchScans = SparkPlanUtil.collectBatchScans(plan);
+-    
assertThat(batchScans).hasSizeGreaterThan(0).allMatch(SparkPlan::supportsColumnar);
++    // When Comet is enabled, its native scan replaces BatchScanExec nodes 
entirely
++    assertThat(batchScans).allMatch(SparkPlan::supportsColumnar);
+   }
+ 
+   protected void createTableWithDeleteGranularity(
 diff --git 
a/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestCallStatementParser.java
 
b/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestCallStatementParser.java
 index ecf9e6f8a5..23dbcf7af2 100644
 --- 
a/spark/v3.5/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestCallStatementParser.java
diff --git a/native/Cargo.lock b/native/Cargo.lock
index a231b50010..a920991922 100644
--- a/native/Cargo.lock
+++ b/native/Cargo.lock
@@ -3497,7 +3497,7 @@ dependencies = [
 [[package]]
 name = "iceberg"
 version = "0.10.0"
-source = 
"git+https://github.com/apache/iceberg-rust?rev=4532da8#4532da80f3930cdbda1cf2effe05b50165da9875";
+source = 
"git+https://github.com/apache/iceberg-rust?rev=3d84c81353b1b23b6e4ae8eea8f8a021cc6927a7#3d84c81353b1b23b6e4ae8eea8f8a021cc6927a7";
 dependencies = [
  "aes-gcm",
  "anyhow",
@@ -3553,7 +3553,7 @@ dependencies = [
 [[package]]
 name = "iceberg-storage-opendal"
 version = "0.10.0"
-source = 
"git+https://github.com/apache/iceberg-rust?rev=4532da8#4532da80f3930cdbda1cf2effe05b50165da9875";
+source = 
"git+https://github.com/apache/iceberg-rust?rev=3d84c81353b1b23b6e4ae8eea8f8a021cc6927a7#3d84c81353b1b23b6e4ae8eea8f8a021cc6927a7";
 dependencies = [
  "anyhow",
  "async-trait",
diff --git a/native/Cargo.toml b/native/Cargo.toml
index fd7d7d3a66..e41b3a1265 100644
--- a/native/Cargo.toml
+++ b/native/Cargo.toml
@@ -58,8 +58,8 @@ object_store = { version = "0.13.2", features = ["gcp", 
"azure", "aws", "http"]
 url = "2.2"
 aws-config = "1.8.18"
 aws-credential-types = "1.2.13"
-iceberg = { git = "https://github.com/apache/iceberg-rust";, rev = "4532da8" }
-iceberg-storage-opendal = { git = "https://github.com/apache/iceberg-rust";, 
rev = "4532da8", features = ["opendal-memory", "opendal-fs", "opendal-s3", 
"opendal-gcs", "opendal-oss", "opendal-azdls"] }
+iceberg = { git = "https://github.com/apache/iceberg-rust";, rev = 
"3d84c81353b1b23b6e4ae8eea8f8a021cc6927a7" }
+iceberg-storage-opendal = { git = "https://github.com/apache/iceberg-rust";, 
rev = "3d84c81353b1b23b6e4ae8eea8f8a021cc6927a7", features = ["opendal-memory", 
"opendal-fs", "opendal-s3", "opendal-gcs", "opendal-oss", "opendal-azdls"] }
 reqsign-core = "3"
 
 [profile.release]
diff --git a/native/core/src/execution/operators/iceberg_scan.rs 
b/native/core/src/execution/operators/iceberg_scan.rs
index d77b208964..e727294fd9 100644
--- a/native/core/src/execution/operators/iceberg_scan.rs
+++ b/native/core/src/execution/operators/iceberg_scan.rs
@@ -660,6 +660,7 @@ mod tests {
             partition: None,
             partition_spec: None,
             name_mapping: None,
+            unified_partition_type: None,
             case_sensitive: false,
             key_metadata: None,
         }
diff --git a/native/core/src/execution/planner.rs 
b/native/core/src/execution/planner.rs
index 39e7ccc2e5..5accd31609 100644
--- a/native/core/src/execution/planner.rs
+++ b/native/core/src/execution/planner.rs
@@ -3835,6 +3835,77 @@ fn parse_file_scan_tasks_from_common(
         })
         .collect::<Result<Vec<_>, _>>()?;
 
+    // Compute unified partition type from the partition_type_pool.
+    // Each entry is a StructType JSON with the resolved field types for one 
partition spec.
+    // Merge all specs into a single unified type, matching Iceberg Java's
+    // Partitioning.buildPartitionProjectionType()/iceberg-rust's own
+    // compute_unified_partition_type(): process specs by spec_id descending 
(so the newest
+    // spec's field name wins when two specs share a field id), then sort the 
merged fields
+    // ascending by field id. The ascending sort matters beyond cosmetics: it 
fixes the
+    // physical Arrow struct field order for `_partition`, which must match 
the field order
+    // Spark's analyzer bound `_partition.<field>` accesses to (also computed 
via
+    // Partitioning.partitionType(), spec-descending + ascending-sort) -- a 
mismatch would
+    // read the wrong value under the right field name instead of erroring.
+    //
+    // partition_type_pool is index-aligned with partition_spec_pool (see the 
.proto comment),
+    // so partition_spec_cache[i].spec_id() gives the spec_id for 
partition_type_pool[i].
+    //
+    // NOTE: The resolved field types here come from Iceberg Java's 
PartitionSpec.partitionType()
+    // (via Scala reflection), which already applied Transform::result_type() 
per field. Because
+    // we only have resolved types (not the original Transform), we cannot 
replicate one further
+    // nuance of the reference algorithms: preferring a non-void transform's 
type over an older
+    // void transform's for the same field id. In practice this only affects a 
field whose
+    // partition source column was later dropped from the schema, which Comet 
already filters out
+    // upstream (see the "unknown type" filtering in serializePartitionData).
+    let unified_partition_type = {
+        let mut indexed_types = proto_common
+            .partition_type_pool
+            .iter()
+            .enumerate()
+            .map(|(idx, type_json)| {
+                let struct_type = 
serde_json::from_str::<iceberg::spec::StructType>(type_json)
+                    .map_err(|e| {
+                        ExecutionError::GeneralError(format!(
+                            "Failed to deserialize partition type JSON from 
pool: {e}"
+                        ))
+                    })?;
+                // Recover spec_id from the index-aligned spec JSON's 
"spec-id" field directly,
+                // rather than from partition_spec_cache: a spec that uses a 
transform iceberg-rust
+                // doesn't recognize (e.g. forward-compatibility tests) fails 
to deserialize into a
+                // PartitionSpec, but its "spec-id" is still present in the 
JSON and its partition
+                // type entry has no usable fields anyway. Falling back to idx 
keeps ordering
+                // deterministic if the field is somehow absent.
+                let spec_id = proto_common
+                    .partition_spec_pool
+                    .get(idx)
+                    .and_then(|json| 
serde_json::from_str::<serde_json::Value>(json).ok())
+                    .and_then(|v| 
v.get("spec-id").and_then(serde_json::Value::as_i64))
+                    .map(|id| id as i32)
+                    .unwrap_or(idx as i32);
+                Ok((spec_id, struct_type))
+            })
+            .collect::<Result<Vec<_>, ExecutionError>>()?;
+
+        indexed_types.sort_by_key(|(spec_id, _)| std::cmp::Reverse(*spec_id));
+
+        let mut seen_field_ids = std::collections::HashSet::new();
+        let mut struct_fields: Vec<iceberg::spec::NestedFieldRef> = Vec::new();
+
+        for (_, struct_type) in &indexed_types {
+            for field in struct_type.fields() {
+                if seen_field_ids.insert(field.id) {
+                    struct_fields.push(Arc::clone(field));
+                }
+            }
+        }
+
+        struct_fields.sort_by_key(|f| f.id);
+
+        iceberg::spec::StructType::new(struct_fields)
+    };
+
+    let unified_partition_type_arc = Arc::new(unified_partition_type);
+
     let results: Result<Vec<_>, _> = proto_tasks
         .iter()
         .map(|proto_task| {
@@ -3939,6 +4010,14 @@ fn parse_file_scan_tasks_from_common(
                 .field_ids
                 .clone();
 
+            let unified_partition_type_for_task = if project_field_ids
+                
.contains(&iceberg::metadata_columns::RESERVED_FIELD_ID_PARTITION)
+            {
+                Some(Arc::clone(&unified_partition_type_arc))
+            } else {
+                None
+            };
+
             Ok(iceberg::scan::FileScanTask {
                 file_size_in_bytes: proto_task.file_size_in_bytes,
                 data_file_path: proto_task.data_file_path.clone(),
@@ -3953,6 +4032,7 @@ fn parse_file_scan_tasks_from_common(
                 partition,
                 partition_spec,
                 name_mapping,
+                unified_partition_type: unified_partition_type_for_task,
                 case_sensitive: false,
                 // Plaintext StandardKeyMetadata forwarded verbatim from the 
JVM; decoded by
                 // iceberg-rust with no KMS unwrap. None for unencrypted data 
files.
@@ -4383,6 +4463,7 @@ mod tests {
 
     use crate::execution::operators::ExecutionError;
     use crate::execution::planner::literal_to_array_ref;
+    use crate::execution::planner::parse_file_scan_tasks_from_common;
     use crate::parquet::parquet_support::SparkParquetOptions;
     use crate::parquet::schema_adapter::SparkPhysicalExprAdapterFactory;
     use datafusion_comet_proto::spark_expression::expr::ExprStruct;
@@ -5455,4 +5536,185 @@ mod tests {
             }
         });
     }
+
+    #[test]
+    fn test_metadata_field_id_constants_match_iceberg_rust() {
+        // These constants are duplicated in Scala 
(CometIcebergNativeScan.MetadataFieldIds)
+        // as Int.MaxValue - 1 and Int.MaxValue - 5. This test ensures the 
Rust constants
+        // haven't drifted, which would cause a silent mismatch with the Scala 
side.
+        assert_eq!(
+            iceberg::metadata_columns::RESERVED_FIELD_ID_FILE,
+            i32::MAX - 1,
+            "RESERVED_FIELD_ID_FILE must be i32::MAX - 1 to match Scala 
MetadataFieldIds"
+        );
+        assert_eq!(
+            iceberg::metadata_columns::RESERVED_FIELD_ID_PARTITION,
+            i32::MAX - 5,
+            "RESERVED_FIELD_ID_PARTITION must be i32::MAX - 5 to match Scala 
MetadataFieldIds"
+        );
+    }
+
+    #[test]
+    fn test_unified_partition_type_merges_specs_by_descending_spec_id() {
+        use iceberg::spec::{NestedField, PartitionSpec, PrimitiveType, Type};
+
+        let iceberg_schema = iceberg::spec::Schema::builder()
+            .with_schema_id(0)
+            .with_fields(vec![
+                NestedField::required(1, "id", 
Type::Primitive(PrimitiveType::Int)).into(),
+                NestedField::required(2, "region", 
Type::Primitive(PrimitiveType::String)).into(),
+                NestedField::required(3, "category", 
Type::Primitive(PrimitiveType::String)).into(),
+            ])
+            .build()
+            .expect("schema");
+        let schema_arc = Arc::new(iceberg_schema);
+
+        // Spec 0 (older): field 1000 named "region_old".
+        let spec0 = PartitionSpec::builder(Arc::clone(&schema_arc))
+            .with_spec_id(0)
+            .add_unbound_field(iceberg::spec::UnboundPartitionField {
+                source_id: 2,
+                field_id: Some(1000),
+                name: "region_old".to_string(),
+                transform: iceberg::spec::Transform::Identity,
+            })
+            .expect("add field")
+            .build()
+            .expect("build spec0");
+
+        // Spec 1 (newer): same field id 1000 renamed to "region_new", plus a 
new field 2000.
+        let spec1 = PartitionSpec::builder(Arc::clone(&schema_arc))
+            .with_spec_id(1)
+            .add_unbound_field(iceberg::spec::UnboundPartitionField {
+                source_id: 2,
+                field_id: Some(1000),
+                name: "region_new".to_string(),
+                transform: iceberg::spec::Transform::Identity,
+            })
+            .expect("add field")
+            .add_unbound_field(iceberg::spec::UnboundPartitionField {
+                source_id: 3,
+                field_id: Some(2000),
+                name: "category".to_string(),
+                transform: iceberg::spec::Transform::Identity,
+            })
+            .expect("add field")
+            .build()
+            .expect("build spec1");
+
+        let spec0_type_json =
+            
serde_json::to_string(&spec0.partition_type(&schema_arc).expect("partition_type"))
+                .expect("serialize");
+        let spec1_type_json =
+            
serde_json::to_string(&spec1.partition_type(&schema_arc).expect("partition_type"))
+                .expect("serialize");
+        let spec0_json = serde_json::to_string(&spec0).expect("serialize 
spec0");
+        let spec1_json = serde_json::to_string(&spec1).expect("serialize 
spec1");
+
+        let schema_json = 
serde_json::to_string(schema_arc.as_ref()).expect("serialize schema");
+
+        // Pool insertion order deliberately does NOT match spec_id order: the 
newer spec
+        // (spec_id 1) is inserted first, at index 0, and the older spec 
(spec_id 0) second, at
+        // index 1. If the merge relied on pool-insertion order instead of 
spec_id, this would
+        // produce the wrong result (region_old kept instead of region_new).
+        let proto_common = spark_operator::IcebergScanCommon {
+            schema_pool: vec![schema_json],
+            partition_type_pool: vec![spec1_type_json, spec0_type_json],
+            partition_spec_pool: vec![spec1_json, spec0_json],
+            project_field_ids_pool: vec![spark_operator::ProjectFieldIdList {
+                field_ids: 
vec![iceberg::metadata_columns::RESERVED_FIELD_ID_PARTITION],
+            }],
+            ..Default::default()
+        };
+
+        let proto_task = spark_operator::IcebergFileScanTask {
+            data_file_path: "file:///tmp/data.parquet".to_string(),
+            file_size_in_bytes: 100,
+            schema_idx: 0,
+            partition_spec_idx: Some(0),
+            project_field_ids_idx: 0,
+            ..Default::default()
+        };
+
+        let tasks =
+            parse_file_scan_tasks_from_common(&proto_common, 
&[proto_task]).expect("parse tasks");
+        assert_eq!(tasks.len(), 1);
+
+        let unified = tasks[0]
+            .unified_partition_type
+            .as_ref()
+            .expect("unified_partition_type must be set when _partition is 
projected");
+
+        let fields = unified.fields();
+        assert_eq!(
+            fields.len(),
+            2,
+            "expected fields 1000 and 2000, got {fields:?}"
+        );
+        // Ascending by field id: 1000 before 2000.
+        assert_eq!(fields[0].id, 1000);
+        assert_eq!(fields[1].id, 2000);
+        // Newest spec (spec_id 1) wins the name for field 1000, despite being 
inserted into the
+        // pool before spec_id 0's entry.
+        assert_eq!(fields[0].name, "region_new");
+        assert_eq!(fields[1].name, "category");
+    }
+
+    #[test]
+    fn test_unified_partition_type_tolerates_unparseable_spec() {
+        // Regression for 
TestForwardCompatibility.testSparkCanReadUnknownTransform: a spec that
+        // uses a transform iceberg-rust doesn't recognize fails to 
deserialize into a
+        // PartitionSpec, but its partition field is filtered out on the Scala 
side (unknown type),
+        // so its partition_type_pool entry is an empty struct. Recovering 
spec_id must not require
+        // the full spec to parse -- the merge must succeed (with no fields) 
rather than erroring.
+        let schema_json = serde_json::to_string(
+            &iceberg::spec::Schema::builder()
+                .with_schema_id(0)
+                .with_fields(vec![iceberg::spec::NestedField::required(
+                    1,
+                    "id",
+                    
iceberg::spec::Type::Primitive(iceberg::spec::PrimitiveType::Int),
+                )
+                .into()])
+                .build()
+                .expect("schema"),
+        )
+        .expect("serialize schema");
+
+        // A spec JSON whose transform iceberg-rust cannot deserialize, paired 
with an empty type
+        // entry (as Scala produces once the unknown-type field is filtered).
+        let unparseable_spec_json = 
r#"{"spec-id":7,"fields":[{"source-id":1,"field-id":1000,"name":"x","transform":"totally_unknown[9]"}]}"#;
+        let empty_type_json = r#"{"type":"struct","fields":[]}"#;
+
+        let proto_common = spark_operator::IcebergScanCommon {
+            schema_pool: vec![schema_json],
+            partition_type_pool: vec![empty_type_json.to_string()],
+            partition_spec_pool: vec![unparseable_spec_json.to_string()],
+            project_field_ids_pool: vec![spark_operator::ProjectFieldIdList {
+                field_ids: 
vec![iceberg::metadata_columns::RESERVED_FIELD_ID_PARTITION],
+            }],
+            ..Default::default()
+        };
+
+        let proto_task = spark_operator::IcebergFileScanTask {
+            data_file_path: "file:///tmp/data.parquet".to_string(),
+            file_size_in_bytes: 100,
+            schema_idx: 0,
+            partition_spec_idx: Some(0),
+            project_field_ids_idx: 0,
+            ..Default::default()
+        };
+
+        let tasks = parse_file_scan_tasks_from_common(&proto_common, 
&[proto_task])
+            .expect("parse tasks must not error on an unparseable spec");
+        assert_eq!(tasks.len(), 1);
+        assert!(
+            tasks[0]
+                .unified_partition_type
+                .as_ref()
+                .map(|t| t.fields().is_empty())
+                .unwrap_or(true),
+            "unified partition type should have no fields for an 
all-unknown-transform spec"
+        );
+    }
 }
diff --git a/native/proto/src/proto/operator.proto 
b/native/proto/src/proto/operator.proto
index dae7eed9fb..d9515edc99 100644
--- a/native/proto/src/proto/operator.proto
+++ b/native/proto/src/proto/operator.proto
@@ -261,6 +261,11 @@ message IcebergScanCommon {
 
   // Deduplication pools (must contain all entries for cross-partition 
deduplication)
   repeated string schema_pool = 4;
+  // Index-aligned with partition_spec_pool: entry i is the StructType JSON 
for the spec at
+  // partition_spec_pool[i]. Native uses this pairing to recover each entry's 
spec_id when
+  // merging partition types across historical specs (see 
parse_file_scan_tasks_from_common
+  // in planner.rs), which partition_type_pool cannot express on its own since 
it is only
+  // resolved field types, not specs.
   repeated string partition_type_pool = 5;
   repeated string partition_spec_pool = 6;
   repeated string name_mapping_pool = 7;
@@ -332,7 +337,6 @@ message IcebergFileScanTask {
 
   // Indices into IcebergScan deduplication pools
   uint32 schema_idx = 15;
-  optional uint32 partition_type_idx = 16;
   optional uint32 partition_spec_idx = 17;
   optional uint32 name_mapping_idx = 18;
   uint32 project_field_ids_idx = 19;
diff --git 
a/spark/src/main/scala/org/apache/comet/iceberg/IcebergReflection.scala 
b/spark/src/main/scala/org/apache/comet/iceberg/IcebergReflection.scala
index 72e885e834..f867d4bc9b 100644
--- a/spark/src/main/scala/org/apache/comet/iceberg/IcebergReflection.scala
+++ b/spark/src/main/scala/org/apache/comet/iceberg/IcebergReflection.scala
@@ -52,6 +52,8 @@ object IcebergReflection extends Logging {
     val SPARK_BATCH_QUERY_SCAN = 
"org.apache.iceberg.spark.source.SparkBatchQueryScan"
     val SPARK_STAGED_SCAN = "org.apache.iceberg.spark.source.SparkStagedScan"
     val SPARK_SCHEMA_UTIL = "org.apache.iceberg.spark.SparkSchemaUtil"
+    val TABLE = "org.apache.iceberg.Table"
+    val PARTITIONING = "org.apache.iceberg.Partitioning"
   }
 
   /**
@@ -66,6 +68,37 @@ object IcebergReflection extends Logging {
 
   def isIcebergScanClass(name: String): Boolean = 
ICEBERG_SCAN_CLASSES.contains(name)
 
+  // Iceberg FileIO implementations whose backing storage Comet's native 
reader can reach.
+  // Custom/test FileIO classes (e.g. CustomFileIO in TestSparkExecutorCache) 
are not compatible
+  // because Comet's native reader bypasses Java FileIO entirely.
+  val COMPATIBLE_FILE_IO_CLASSES: Set[String] = Set(
+    "org.apache.iceberg.hadoop.HadoopFileIO",
+    "org.apache.iceberg.aws.s3.S3FileIO",
+    "org.apache.iceberg.gcp.gcs.GCSFileIO",
+    "org.apache.iceberg.io.ResolvingFileIO",
+    "org.apache.iceberg.spark.SparkFileIO",
+    "org.apache.iceberg.azure.adlsv2.ADLSFileIO",
+    "org.apache.iceberg.CachingFileIO")
+
+  // Prefix of the EncryptingFileIO family. An encrypted table's io() is not 
the bare
+  // EncryptingFileIO but a nested variant chosen from the wrapped delegate's 
capabilities
+  // (e.g. EncryptingFileIO$WithSupportsPrefixOperations when the delegate is 
HadoopFileIO), so
+  // an exact class-name match misses it. Comet forwards each file's 
key_metadata to iceberg-rust
+  // and reads the ciphertext through iceberg-rust's own storage layer, so any 
EncryptingFileIO
+  // variant is compatible.
+  private val ENCRYPTING_FILE_IO_PREFIX = 
"org.apache.iceberg.encryption.EncryptingFileIO"
+
+  /**
+   * True if `fileIO` is a FileIO whose backing storage Comet's native reader 
can reach. Matches
+   * on `fileIO`'s own class hierarchy (via [[classNameInHierarchy]]) rather 
than its exact leaf
+   * class, so a subclass that only adds metrics/retry/credential-routing on 
top of a known-
+   * compatible FileIO (e.g. a custom S3FileIO subclass) still matches, 
instead of silently
+   * falling back to Spark.
+   */
+  def isCompatibleFileIO(fileIO: Any): Boolean =
+    classNameInHierarchy(fileIO.getClass, COMPATIBLE_FILE_IO_CLASSES) ||
+      fileIO.getClass.getName.startsWith(ENCRYPTING_FILE_IO_PREFIX)
+
   /**
    * Iceberg content types.
    */
@@ -125,6 +158,23 @@ object IcebergReflection extends Logging {
     None
   }
 
+  /**
+   * True if `clazz` or any of its superclasses has a name in `names`. Walks 
the already-loaded
+   * class object's own hierarchy, so unlike [[loadClass]] it never risks a
+   * `ClassNotFoundException` for a candidate name that isn't on this JVM's 
classpath (e.g.
+   * checking for a GCS/Azure FileIO class when only iceberg-aws is bundled).
+   */
+  def classNameInHierarchy(clazz: Class[_], names: Set[String]): Boolean = {
+    var current: Class[_] = clazz
+    while (current != null) {
+      if (names.contains(current.getName)) {
+        return true
+      }
+      current = current.getSuperclass
+    }
+    false
+  }
+
   /**
    * Extracts file location from Iceberg ContentFile, handling both location() 
and path().
    *
@@ -461,6 +511,40 @@ object IcebergReflection extends Logging {
     }
   }
 
+  /**
+   * Validates that the table's unified partition type can be computed -- the 
merge of every
+   * historical partition spec, which is what the `_partition` metadata column 
projects.
+   *
+   * Iceberg Java's `Partitioning.partitionType(table)` runs the same 
cross-spec compatibility
+   * check that iceberg-rust does natively: a V1 table does not guarantee 
partition field ids are
+   * unique across specs, so two specs can bind the same id to incompatible 
source/transform
+   * pairs, which cannot be merged into one struct field. iceberg-rust returns 
a DataInvalid error
+   * in that case, but only at scan time -- too late for Comet to fall back. 
Calling the Java
+   * check here, at plan time, lets `CometScanRule` fall back to Spark instead 
of failing inside
+   * the native reader.
+   *
+   * Returns None when the unified type is computable, or Some(reason) when it 
is not -- either
+   * the specs conflict or the reflection call itself failed. Both mean Comet 
cannot safely serve
+   * `_partition`, so both map to a fallback.
+   */
+  def validateUnifiedPartitionType(table: Any): Option[String] = {
+    try {
+      val tableClass = loadClass(ClassNames.TABLE)
+      val partitioningClass = loadClass(ClassNames.PARTITIONING)
+      partitioningClass
+        .getMethod("partitionType", tableClass)
+        .invoke(null, table.asInstanceOf[AnyRef])
+      None
+    } catch {
+      // A conflict surfaces as the ValidationException thrown by 
partitionType(), wrapped by
+      // reflection in InvocationTargetException; unwrap it for a meaningful 
reason.
+      case e: java.lang.reflect.InvocationTargetException =>
+        Some(Option(e.getCause).getOrElse(e).getMessage)
+      case e: Exception =>
+        Some(e.getMessage)
+    }
+  }
+
   /**
    * Gets the table metadata from an Iceberg table.
    *
diff --git a/spark/src/main/scala/org/apache/comet/rules/CometScanRule.scala 
b/spark/src/main/scala/org/apache/comet/rules/CometScanRule.scala
index 5e451b382a..ca917335da 100644
--- a/spark/src/main/scala/org/apache/comet/rules/CometScanRule.scala
+++ b/spark/src/main/scala/org/apache/comet/rules/CometScanRule.scala
@@ -88,12 +88,6 @@ case class CometScanRule(session: SparkSession)
       case _ => false
     }
 
-    def metadataCols(plan: SparkPlan): Seq[String] = {
-      plan.expressions.collect {
-        case a: Attribute if a.isMetadataCol => a.name
-      }
-    }
-
     def isIcebergMetadataTable(scanExec: BatchScanExec): Boolean = {
       // List of Iceberg metadata tables:
       // 
https://iceberg.apache.org/docs/latest/spark-queries/#inspecting-tables
@@ -130,11 +124,6 @@ case class CometScanRule(session: SparkSession)
       case scan if !CometConf.COMET_NATIVE_SCAN_ENABLED.get(conf) =>
         withFallbackReason(scan, "Comet Scan is not enabled")
 
-      case scan if metadataCols(scan).nonEmpty =>
-        withFallbackReason(
-          scan,
-          s"Metadata column(s) ${metadataCols(scan).mkString(", ")} is not 
supported")
-
       // data source V1
       case scanExec: FileSourceScanExec =>
         transformV1Scan(fullPlan, scanExec)
@@ -154,6 +143,12 @@ case class CometScanRule(session: SparkSession)
   }
 
   private def transformV1Scan(plan: SparkPlan, scanExec: FileSourceScanExec): 
SparkPlan = {
+    val metadataColNames = metadataCols(scanExec)
+    if (metadataColNames.nonEmpty) {
+      return withFallbackReason(
+        scanExec,
+        s"Metadata column(s) ${metadataColNames.mkString(", ")} is not 
supported")
+    }
 
     // On Spark 3.4, injectQueryStageOptimizerRule is unavailable, so
     // CometPlanAdaptiveDynamicPruningFilters cannot run. Fall back this scan 
to Spark so that
@@ -302,6 +297,11 @@ case class CometScanRule(session: SparkSession)
 
     scanExec.scan match {
       case scan: CSVScan if COMET_CSV_V2_NATIVE_ENABLED.get() =>
+        if (scanExec.output.exists(_.isMetadataCol)) {
+          return withFallbackReason(
+            scanExec,
+            "Metadata columns are not supported for CSV V2 scans")
+        }
         val fallbackReasons = new ListBuffer[String]()
         val schemaSupported =
           CometBatchScanExec.isSchemaSupported(scan.readDataSchema, 
fallbackReasons)
@@ -361,9 +361,30 @@ case class CometScanRule(session: SparkSession)
           return withFallbackReasons(scanExec, fallbackReasons.toSet)
         }
 
+        // Check for unsupported metadata columns in Iceberg scans
+        val unsupportedMetadataCols = 
scanExec.output.filter(_.isMetadataCol).filterNot { attr =>
+          CometIcebergNativeScan.MetadataFieldIds.keySet.contains(attr.name)
+        }
+        if (unsupportedMetadataCols.nonEmpty) {
+          fallbackReasons += "Unsupported Iceberg metadata columns: " +
+            unsupportedMetadataCols.map(_.name).mkString(", ")
+          return withFallbackReasons(scanExec, fallbackReasons.toSet)
+        }
+
         val typeChecker = CometScanTypeChecker()
+        // Filter out metadata columns from schema check -- their types are 
handled
+        // by iceberg-rust directly (e.g., _partition can be an empty struct 
for
+        // unpartitioned tables which the general type checker rejects).
+        val metadataColNames = 
scanExec.output.filter(_.isMetadataCol).map(_.name).toSet
+        val dataSchema = if (metadataColNames.nonEmpty) {
+          val filtered =
+            scanExec.scan.readSchema().filter(f => 
!metadataColNames.contains(f.name))
+          new org.apache.spark.sql.types.StructType(filtered.toArray)
+        } else {
+          scanExec.scan.readSchema()
+        }
         val schemaSupported =
-          typeChecker.isSchemaSupported(scanExec.scan.readSchema(), 
fallbackReasons)
+          typeChecker.isSchemaSupported(dataSchema, fallbackReasons)
 
         if (!schemaSupported) {
           fallbackReasons += "Comet extension is not enabled for " +
@@ -451,15 +472,17 @@ case class CometScanRule(session: SparkSession)
         }
 
         // Now perform all validation using the pre-extracted metadata
-        // Check if table uses a FileIO implementation compatible with 
iceberg-rust
-
+        // Check if table uses a FileIO implementation compatible with 
iceberg-rust.
+        // Comet's native reader bypasses Iceberg Java's FileIO entirely and 
reads through
+        // iceberg-rust's own storage layer instead. Only allow 
known-compatible implementations
+        // whose backing storage that layer can reach via standard URL schemes.
         val fileIOCompatible = IcebergReflection.getFileIO(metadata.table) 
match {
-          case Some(fileIO)
-              if fileIO.getClass.getName == 
"org.apache.iceberg.inmemory.InMemoryFileIO" =>
-            fallbackReasons += "InMemoryFileIO is not supported by Comet's 
native reader"
-            false
-          case Some(_) =>
+          case Some(fileIO) if IcebergReflection.isCompatibleFileIO(fileIO) =>
             true
+          case Some(fileIO) =>
+            fallbackReasons += s"FileIO ${fileIO.getClass.getName} is not 
supported by " +
+              "Comet's native reader (bypasses Iceberg Java FileIO)"
+            false
           case None =>
             fallbackReasons += "Could not check FileIO compatibility"
             false
@@ -603,6 +626,25 @@ case class CometScanRule(session: SparkSession)
           false
         }
 
+        // The `_partition` metadata column projects the table's unified 
partition type -- the
+        // merge of all historical specs. iceberg-rust computes that merge at 
scan time and errors
+        // (DataInvalid) when specs bind one field id to incompatible 
source/transform pairs (only
+        // possible in V1 tables, which do not keep partition field ids unique 
across specs). That
+        // error would fail the native scan with no chance to fall back, so 
when `_partition` is
+        // projected we run Iceberg Java's equivalent check here and fall back 
if it cannot merge.
+        val unifiedPartitionTypeSupported =
+          if (scanExec.output.exists(a => a.isMetadataCol && a.name == 
"_partition")) {
+            IcebergReflection.validateUnifiedPartitionType(metadata.table) 
match {
+              case Some(reason) =>
+                fallbackReasons += "Iceberg table has partition specs whose 
unified partition " +
+                  s"type cannot be computed for the _partition metadata 
column: $reason"
+                false
+              case None => true
+            }
+          } else {
+            true
+          }
+
         // Get filter expressions for complex predicates check
         val filterExpressionsOpt = 
IcebergReflection.getFilterExpressions(scanExec.scan)
 
@@ -788,6 +830,7 @@ case class CometScanRule(session: SparkSession)
         if (schemaSupported && fileIOCompatible && formatVersionSupported &&
           defaultValuesSupported && schemaTypesSupported && 
encryptionKeyLengthSupported &&
           taskValidation.allParquet && allSupportedFilesystems && 
partitionTypesSupported &&
+          unifiedPartitionTypeSupported &&
           complexTypePredicatesSupported && transformFunctionsSupported &&
           deleteFileTypesSupported && dppSubqueriesSupported) {
           CometBatchScanExec(
@@ -824,6 +867,12 @@ case class CometScanRule(session: SparkSession)
       case _ => false
     }
 
+  private def metadataCols(plan: SparkPlan): Seq[String] = {
+    plan.expressions.collect {
+      case a: Attribute if a.isMetadataCol => a.name
+    }
+  }
+
   private def isSchemaSupported(scanExec: FileSourceScanExec, r: 
HadoopFsRelation): Boolean = {
     val fallbackReasons = new ListBuffer[String]()
     val typeChecker = CometScanTypeChecker()
diff --git 
a/spark/src/main/scala/org/apache/comet/serde/operator/CometIcebergNativeScan.scala
 
b/spark/src/main/scala/org/apache/comet/serde/operator/CometIcebergNativeScan.scala
index f61f1368b2..bb81fd69ca 100644
--- 
a/spark/src/main/scala/org/apache/comet/serde/operator/CometIcebergNativeScan.scala
+++ 
b/spark/src/main/scala/org/apache/comet/serde/operator/CometIcebergNativeScan.scala
@@ -85,6 +85,20 @@ object CometIcebergNativeScan extends 
CometOperatorSerde[CometBatchScanExec] wit
     Constants.Operations.GT -> 
OperatorOuterClass.IcebergPredicateOperator.GreaterThan,
     Constants.Operations.GT_EQ -> 
OperatorOuterClass.IcebergPredicateOperator.GreaterThanOrEq)
 
+  // Iceberg reserved field IDs for metadata columns.
+  // These must match iceberg-rust's constants in 
crates/iceberg/src/metadata_columns.rs:
+  //   RESERVED_FIELD_ID_FILE      = i32::MAX - 1  (2147483646)
+  //   RESERVED_FIELD_ID_POS       = i32::MAX - 2  (2147483645)
+  //   RESERVED_FIELD_ID_SPEC_ID   = i32::MAX - 4  (2147483643)
+  //   RESERVED_FIELD_ID_PARTITION = i32::MAX - 5  (2147483642)
+  // Scala's Int.MaxValue == 2^31 - 1 == Rust's i32::MAX.
+  val MetadataFieldIds: Map[String, Int] =
+    Map(
+      "_file" -> (Int.MaxValue - 1),
+      "_pos" -> (Int.MaxValue - 2),
+      "_spec_id" -> (Int.MaxValue - 4),
+      "_partition" -> (Int.MaxValue - 5))
+
   /**
    * Wraps an Iceberg partition value (a typed primitive) in a PartitionValue. 
The value encoding
    * is shared with predicate literals via [[icebergLiteralToProto]].
@@ -343,7 +357,6 @@ object CometIcebergNativeScan extends 
CometOperatorSerde[CometBatchScanExec] wit
       fileScanTaskClass: Class[_],
       taskBuilder: OperatorOuterClass.IcebergFileScanTask.Builder,
       commonBuilder: OperatorOuterClass.IcebergScanCommon.Builder,
-      partitionTypeToPoolIndex: mutable.HashMap[String, Int],
       partitionSpecToPoolIndex: mutable.HashMap[String, Int],
       partitionDataToPoolIndex: mutable.HashMap[String, Int]): Unit = {
     try {
@@ -351,27 +364,96 @@ object CometIcebergNativeScan extends 
CometOperatorSerde[CometBatchScanExec] wit
       val spec = specMethod.invoke(task)
 
       if (spec != null) {
-        // Deduplicate partition spec
-        try {
-          val partitionSpecParserClass =
-            
IcebergReflection.loadClass(IcebergReflection.ClassNames.PARTITION_SPEC_PARSER)
-          val toJsonMethod = partitionSpecParserClass.getMethod(
-            "toJson",
-            
IcebergReflection.loadClass(IcebergReflection.ClassNames.PARTITION_SPEC))
-          val partitionSpecJson = toJsonMethod
-            .invoke(null, spec)
-            .asInstanceOf[String]
-
-          val specIdx = partitionSpecToPoolIndex.getOrElseUpdate(
-            partitionSpecJson, {
-              val idx = partitionSpecToPoolIndex.size
-              commonBuilder.addPartitionSpecPool(partitionSpecJson)
-              idx
-            })
-          taskBuilder.setPartitionSpecIdx(specIdx)
-        } catch {
-          case e: Exception =>
-            logWarning(s"Failed to serialize partition spec to JSON: 
${e.getMessage}")
+        // Get the partition type/schema from the spec. Needed regardless of 
whether this task
+        // ends up value-less below.
+        val partitionTypeMethod = spec.getClass.getMethod("partitionType")
+        val partitionType = partitionTypeMethod.invoke(spec)
+        val fieldsMethod = partitionType.getClass.getMethod("fields")
+        val fields = fieldsMethod
+          .invoke(partitionType)
+          .asInstanceOf[java.util.List[_]]
+
+        // Helper to get field type string (shared by both type and data 
serialization)
+        def getFieldType(field: Any): String = {
+          val typeMethod = field.getClass.getMethod("type")
+          typeMethod.invoke(field).toString
+        }
+
+        // Filter out fields with unknown types (dropped partition fields).
+        // Unknown type fields represent partition columns that have been 
dropped
+        // from the schema. Per the Iceberg spec, unknown type fields are not
+        // stored in data files and iceberg-rust doesn't support deserializing
+        // them. Since these columns are dropped, we don't need to expose their
+        // partition values when reading.
+        val fieldsJson = fields.asScala.flatMap { field =>
+          val fieldTypeStr = getFieldType(field)
+
+          // Skip fields with unknown type (dropped partition columns)
+          if (fieldTypeStr == IcebergReflection.TypeNames.UNKNOWN) {
+            None
+          } else {
+            val fieldIdMethod = field.getClass.getMethod("fieldId")
+            val fieldId = fieldIdMethod.invoke(field).asInstanceOf[Int]
+
+            val nameMethod = field.getClass.getMethod("name")
+            val fieldName = nameMethod.invoke(field).asInstanceOf[String]
+
+            val isOptionalMethod = field.getClass.getMethod("isOptional")
+            val isOptional =
+              isOptionalMethod.invoke(field).asInstanceOf[Boolean]
+            val required = !isOptional
+
+            Some(
+              ("id" -> fieldId) ~
+                ("name" -> fieldName) ~
+                ("required" -> required) ~
+                ("type" -> fieldTypeStr))
+          }
+        }.toList
+
+        // Serializes the file's real partition spec plus its paired 
partition-type-pool entry,
+        // then points the task at that pool index. Only invoked for tasks 
that actually carry
+        // partition values: a value-less task (partition evolution) takes the 
empty-spec path
+        // below instead, so this avoids the toJson reflection call and pool 
intern that would
+        // otherwise be computed and then immediately overwritten.
+        //
+        // The spec and type pools are index-aligned: native correlates the 
two by index to
+        // recover, per spec, the spec_id needed to merge partition types 
across historical specs
+        // in the same order Iceberg Java's 
Partitioning.partitionType()/iceberg-rust's
+        // compute_unified_partition_type do (newest spec_id first); see
+        // parse_file_scan_tasks_from_common in planner.rs. Every path that 
adds a spec-pool entry
+        // adds its type-pool entry in the same block to keep them aligned.
+        def serializeRealSpec(): Unit = {
+          try {
+            val partitionSpecParserClass =
+              
IcebergReflection.loadClass(IcebergReflection.ClassNames.PARTITION_SPEC_PARSER)
+            val toJsonMethod = partitionSpecParserClass.getMethod(
+              "toJson",
+              
IcebergReflection.loadClass(IcebergReflection.ClassNames.PARTITION_SPEC))
+            val partitionSpecJson = toJsonMethod
+              .invoke(null, spec)
+              .asInstanceOf[String]
+
+            val specIdx = partitionSpecToPoolIndex.getOrElseUpdate(
+              partitionSpecJson, {
+                val idx = partitionSpecToPoolIndex.size
+                commonBuilder.addPartitionSpecPool(partitionSpecJson)
+                // Manually build StructType JSON to match iceberg-rust 
expectations.
+                // Using Iceberg's SchemaParser.toJson() would include 
schema-level
+                // metadata (e.g., "schema-id") that iceberg-rust's StructType
+                // deserializer rejects. We need pure StructType format:
+                // {"type":"struct","fields":[...]}
+                val partitionTypeJson = compact(
+                  render(("type" -> "struct") ~
+                    ("fields" -> fieldsJson)))
+                commonBuilder.addPartitionTypePool(partitionTypeJson)
+                idx
+              })
+            taskBuilder.setPartitionSpecIdx(specIdx)
+          } catch {
+            case e: Exception =>
+              logWarning(s"Failed to serialize partition spec to JSON: 
${e.getMessage}")
+          }
         }
 
         // Get partition data from the task (via file().partition())
@@ -379,84 +461,6 @@ object CometIcebergNativeScan extends 
CometOperatorSerde[CometBatchScanExec] wit
         val partitionData = partitionMethod.invoke(task)
 
         if (partitionData != null) {
-          // Get the partition type/schema from the spec
-          val partitionTypeMethod = spec.getClass.getMethod("partitionType")
-          val partitionType = partitionTypeMethod.invoke(spec)
-
-          // Check if partition type has any fields before serializing
-          val fieldsMethod = partitionType.getClass.getMethod("fields")
-          val fields = fieldsMethod
-            .invoke(partitionType)
-            .asInstanceOf[java.util.List[_]]
-
-          // Helper to get field type string (shared by both type and data 
serialization)
-          def getFieldType(field: Any): String = {
-            val typeMethod = field.getClass.getMethod("type")
-            typeMethod.invoke(field).toString
-          }
-
-          // Only serialize partition type if there are actual partition fields
-          if (!fields.isEmpty) {
-            try {
-              // Manually build StructType JSON to match iceberg-rust 
expectations.
-              // Using Iceberg's SchemaParser.toJson() would include 
schema-level
-              // metadata (e.g., "schema-id") that iceberg-rust's StructType
-              // deserializer rejects. We need pure StructType format:
-              // {"type":"struct","fields":[...]}
-
-              // Filter out fields with unknown types (dropped partition 
fields).
-              // Unknown type fields represent partition columns that have 
been dropped
-              // from the schema. Per the Iceberg spec, unknown type fields 
are not
-              // stored in data files and iceberg-rust doesn't support 
deserializing
-              // them. Since these columns are dropped, we don't need to 
expose their
-              // partition values when reading.
-              val fieldsJson = fields.asScala.flatMap { field =>
-                val fieldTypeStr = getFieldType(field)
-
-                // Skip fields with unknown type (dropped partition columns)
-                if (fieldTypeStr == IcebergReflection.TypeNames.UNKNOWN) {
-                  None
-                } else {
-                  val fieldIdMethod = field.getClass.getMethod("fieldId")
-                  val fieldId = fieldIdMethod.invoke(field).asInstanceOf[Int]
-
-                  val nameMethod = field.getClass.getMethod("name")
-                  val fieldName = nameMethod.invoke(field).asInstanceOf[String]
-
-                  val isOptionalMethod = field.getClass.getMethod("isOptional")
-                  val isOptional =
-                    isOptionalMethod.invoke(field).asInstanceOf[Boolean]
-                  val required = !isOptional
-
-                  Some(
-                    ("id" -> fieldId) ~
-                      ("name" -> fieldName) ~
-                      ("required" -> required) ~
-                      ("type" -> fieldTypeStr))
-                }
-              }.toList
-
-              // Only serialize if we have non-unknown fields
-              if (fieldsJson.nonEmpty) {
-                val partitionTypeJson = compact(
-                  render(
-                    ("type" -> "struct") ~
-                      ("fields" -> fieldsJson)))
-
-                val typeIdx = partitionTypeToPoolIndex.getOrElseUpdate(
-                  partitionTypeJson, {
-                    val idx = partitionTypeToPoolIndex.size
-                    commonBuilder.addPartitionTypePool(partitionTypeJson)
-                    idx
-                  })
-                taskBuilder.setPartitionTypeIdx(typeIdx)
-              }
-            } catch {
-              case e: Exception =>
-                logWarning(s"Failed to serialize partition type to JSON: 
${e.getMessage}")
-            }
-          }
-
           // Serialize partition data to protobuf for native execution.
           // The native execution engine uses partition_data protobuf messages 
to
           // build a constants_map, which provides partition values to 
identity-
@@ -487,25 +491,65 @@ object CometIcebergNativeScan extends 
CometOperatorSerde[CometBatchScanExec] wit
               }
             }.toSeq
 
-          // Only serialize partition data if we have non-unknown fields
-          if (partitionValues.nonEmpty) {
-            val partitionDataProto = OperatorOuterClass.PartitionData
-              .newBuilder()
-              .addAllValues(partitionValues.asJava)
-              .build()
-
-            // Deduplicate by protobuf bytes (use Base64 string as key)
-            val partitionDataBytes = partitionDataProto.toByteArray
-            val partitionDataKey = 
Base64.getEncoder.encodeToString(partitionDataBytes)
-
-            val partitionDataIdx = partitionDataToPoolIndex.getOrElseUpdate(
-              partitionDataKey, {
-                val idx = partitionDataToPoolIndex.size
-                commonBuilder.addPartitionDataPool(partitionDataProto)
+          // Native requires a task to carry both a partition spec and 
partition data, or neither:
+          // iceberg-rust errors when the unified partition type has fields 
but a task is missing
+          // its spec/data. A file written while a partition field was dropped 
(partition
+          // evolution) has no partition values, so partitionValues is empty 
here.
+          //
+          // For that value-less case we must NOT send the file's real spec: 
it may retain a void
+          // field whose id collides with a unified field, which would make 
iceberg-rust index the
+          // empty partition data out of range. Instead send an empty-fields 
spec that keeps the
+          // real spec id (so _spec_id stays correct) plus empty data, so 
native fills every
+          // unified _partition field with null -- matching Spark and the 
pre-tightening behaviour.
+          // Pair it with an empty partition-type-pool entry too, keeping the 
two pools aligned.
+          //
+          // Only the value-carrying path serializes the real spec 
(serializeRealSpec), so the
+          // value-less path never does the reflection/intern work just to 
overwrite it.
+          if (partitionValues.isEmpty) {
+            val specId = 
spec.getClass.getMethod("specId").invoke(spec).asInstanceOf[Int]
+            val emptySpecJson =
+              compact(
+                render(("spec-id" -> specId) ~ ("fields" -> 
List.empty[org.json4s.JObject])))
+            val emptySpecIdx = partitionSpecToPoolIndex.getOrElseUpdate(
+              emptySpecJson, {
+                val idx = partitionSpecToPoolIndex.size
+                commonBuilder.addPartitionSpecPool(emptySpecJson)
+                val emptyTypeJson = compact(
+                  render(("type" -> "struct") ~
+                    ("fields" -> List.empty[org.json4s.JObject])))
+                commonBuilder.addPartitionTypePool(emptyTypeJson)
                 idx
               })
-            taskBuilder.setPartitionDataIdx(partitionDataIdx)
+            taskBuilder.setPartitionSpecIdx(emptySpecIdx)
+          } else {
+            serializeRealSpec()
           }
+
+          // Always send partition data (empty when there are no values) so 
native never sees a
+          // spec without data. Native uses it to build the identity-transform 
constants_map and,
+          // together with the spec above, the _partition column.
+          val partitionDataProto = OperatorOuterClass.PartitionData
+            .newBuilder()
+            .addAllValues(partitionValues.asJava)
+            .build()
+
+          // Deduplicate by protobuf bytes (use Base64 string as key)
+          val partitionDataBytes = partitionDataProto.toByteArray
+          val partitionDataKey = 
Base64.getEncoder.encodeToString(partitionDataBytes)
+
+          val partitionDataIdx = partitionDataToPoolIndex.getOrElseUpdate(
+            partitionDataKey, {
+              val idx = partitionDataToPoolIndex.size
+              commonBuilder.addPartitionDataPool(partitionDataProto)
+              idx
+            })
+          taskBuilder.setPartitionDataIdx(partitionDataIdx)
+        } else {
+          // Defensive: ContentScanTask.partition() returns an empty struct 
(never null) for
+          // unpartitioned tables in practice. If it is ever null we cannot 
compute values, so
+          // just register the file's real spec (preserving the pre-refactor 
behaviour) to keep
+          // _spec_id correct.
+          serializeRealSpec()
         }
       }
     } catch {
@@ -850,7 +894,6 @@ object CometIcebergNativeScan extends 
CometOperatorSerde[CometBatchScanExec] wit
 
     // Deduplication structures - map unique values to pool indices
     val schemaToPoolIndex = mutable.HashMap[AnyRef, Int]()
-    val partitionTypeToPoolIndex = mutable.HashMap[String, Int]()
     val partitionSpecToPoolIndex = mutable.HashMap[String, Int]()
     val nameMappingToPoolIndex = mutable.HashMap[String, Int]()
     val projectFieldIdsToPoolIndex = mutable.HashMap[Seq[Int], Int]()
@@ -1028,14 +1071,16 @@ object CometIcebergNativeScan extends 
CometOperatorSerde[CometBatchScanExec] wit
 
                 val nameToFieldId = 
IcebergReflection.buildFieldIdMapping(schema)
 
-                val projectFieldIds = output.flatMap { attr =>
+                val projectFieldIds = output.map { attr =>
                   nameToFieldId
                     .get(attr.name)
                     .orElse(metadata.globalFieldIdMapping.get(attr.name))
-                    .orElse {
-                      logWarning(s"Column '${attr.name}' not found in task or 
scan schema, " +
-                        "skipping projection")
-                      None
+                    
.orElse(CometIcebergNativeScan.MetadataFieldIds.get(attr.name))
+                    .getOrElse {
+                      throw new IllegalStateException(
+                        s"Column '${attr.name}' not found in task schema, 
global schema, " +
+                          "or metadata field IDs. This indicates a bug in 
CometScanRule " +
+                          "validation -- all output columns should be 
resolvable.")
                     }
                 }
 
@@ -1103,7 +1148,6 @@ object CometIcebergNativeScan extends 
CometOperatorSerde[CometBatchScanExec] wit
                   fileScanTaskClass,
                   taskBuilder,
                   commonBuilder,
-                  partitionTypeToPoolIndex,
                   partitionSpecToPoolIndex,
                   partitionDataToPoolIndex)
 
@@ -1145,7 +1189,6 @@ object CometIcebergNativeScan extends 
CometOperatorSerde[CometBatchScanExec] wit
     // Log deduplication summary
     val allPoolSizes = Seq(
       schemaToPoolIndex.size,
-      partitionTypeToPoolIndex.size,
       partitionSpecToPoolIndex.size,
       nameMappingToPoolIndex.size,
       projectFieldIdsToPoolIndex.size,
diff --git 
a/spark/src/test/resources/sql-tests/iceberg/metadata_column_partition.sql 
b/spark/src/test/resources/sql-tests/iceberg/metadata_column_partition.sql
new file mode 100644
index 0000000000..9ceda6bb8e
--- /dev/null
+++ b/spark/src/test/resources/sql-tests/iceberg/metadata_column_partition.sql
@@ -0,0 +1,854 @@
+-- Licensed to the Apache Software Foundation (ASF) under one
+-- or more contributor license agreements.  See the NOTICE file
+-- distributed with this work for additional information
+-- regarding copyright ownership.  The ASF licenses this file
+-- to you under the Apache License, Version 2.0 (the
+-- "License"); you may not use this file except in compliance
+-- with the License.  You may obtain a copy of the License at
+--
+--   http://www.apache.org/licenses/LICENSE-2.0
+--
+-- Unless required by applicable law or agreed to in writing,
+-- software distributed under the License is distributed on an
+-- "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+-- KIND, either express or implied.  See the License for the
+-- specific language governing permissions and limitations
+-- under the License.
+
+-- Native Iceberg scan is unsupported on Spark 4.2: no Iceberg spark-runtime 
is published for
+-- 4.2 yet, so the build reuses the 4.0 runtime, which is binary-incompatible 
with 4.2 (e.g.
+-- connector.catalog.View became a class). Mirrors the assume(!isSpark42Plus) 
guard in
+-- CometIcebergNativeSuite. See 
https://github.com/apache/datafusion-comet/issues/4969.
+-- MaxSparkVersion: 4.1
+
+-- Config: spark.sql.catalog.test_cat=org.apache.iceberg.spark.SparkCatalog
+-- Config: spark.sql.catalog.test_cat.type=hadoop
+-- Config: spark.sql.catalog.test_cat.warehouse=/tmp/comet-iceberg-sql-test
+-- Config: spark.comet.enabled=true
+-- Config: spark.comet.exec.enabled=true
+-- Config: spark.comet.iceberg.native.enabled=true
+
+-- All `query` assertions below implicitly validate that Comet's partition type
+-- computation (from partition_type_pool JSON) matches Spark's (from Iceberg 
Java's
+-- PartitionSpec.partitionType()). If the two diverged, 
checkSparkAnswerAndOperator
+-- would detect differing struct values. This covers the "dual computation 
path"
+-- cross-validation concern.
+
+-- =============================================================
+-- Setup: Create table with bucket partitioning (format v2 for MoR)
+-- =============================================================
+
+statement
+DROP TABLE IF EXISTS test_cat.db.meta_test
+
+statement
+CREATE TABLE test_cat.db.meta_test (id INT, name STRING, value DOUBLE) USING 
iceberg PARTITIONED BY (bucket(4, id)) TBLPROPERTIES ('format-version' = '2')
+
+statement
+INSERT INTO test_cat.db.meta_test VALUES (1, 'alice', 10.0), (2, 'bob', 20.0), 
(3, 'charlie', 30.0)
+
+statement
+INSERT INTO test_cat.db.meta_test VALUES (4, 'dave', 40.0), (5, 'eve', 50.0)
+
+-- =============================================================
+-- _file: basic projection (native)
+-- =============================================================
+
+query
+SELECT id, _file FROM test_cat.db.meta_test ORDER BY id
+
+-- Multiple inserts produce multiple files
+query
+SELECT COUNT(DISTINCT _file) > 1 FROM test_cat.db.meta_test
+
+-- _file in a WHERE clause exercises filter pushdown interaction
+query
+SELECT id, name FROM test_cat.db.meta_test WHERE _file LIKE '%.parquet' ORDER 
BY id
+
+-- =============================================================
+-- _partition: basic struct projection (native)
+-- =============================================================
+
+query
+SELECT id, _partition FROM test_cat.db.meta_test ORDER BY id
+
+-- Access individual partition fields within the struct
+query
+SELECT id, _partition.id_bucket FROM test_cat.db.meta_test ORDER BY id
+
+-- =============================================================
+-- _spec_id: partition spec ID (scalar per file)
+-- =============================================================
+
+query
+SELECT id, _spec_id FROM test_cat.db.meta_test ORDER BY id
+
+-- All rows should have spec_id = 0 (initial spec)
+query
+SELECT DISTINCT _spec_id FROM test_cat.db.meta_test
+
+-- =============================================================
+-- _pos: row position within each file (0-based)
+-- =============================================================
+
+-- Basic: positions should be 0-based within each file
+query
+SELECT id, _file, _pos FROM test_cat.db.meta_test ORDER BY _file, _pos
+
+-- Positions should be contiguous 0..N-1 per file
+query
+SELECT _file, MIN(_pos) as min_pos, MAX(_pos) as max_pos, COUNT(*) as cnt FROM 
test_cat.db.meta_test GROUP BY _file
+
+-- =============================================================
+-- Partition evolution requires IcebergSparkSessionExtensions
+-- (ALTER TABLE ADD PARTITION FIELD / CALL system.add_partition_field).
+-- The SQL file test framework cannot register session extensions via
+-- Config directives. Partition evolution is covered in
+-- CometIcebergNativeSuite which has full session control.
+-- =============================================================
+
+-- =============================================================
+-- Combined: all metadata columns together (native -- all supported)
+-- =============================================================
+
+query
+SELECT id, _file, _pos, _spec_id, _partition FROM test_cat.db.meta_test ORDER 
BY _file, _pos
+
+-- Row-level operation simulation: what CoW/MoR would project
+query
+SELECT _file, _pos, _spec_id, _partition, id, name FROM test_cat.db.meta_test 
WHERE id > 3 ORDER BY _file, _pos
+
+-- =============================================================
+-- _file + _partition combined (native -- both supported)
+-- =============================================================
+
+query
+SELECT id, _file, _partition FROM test_cat.db.meta_test ORDER BY id
+
+statement
+DROP TABLE test_cat.db.meta_test
+
+-- =============================================================
+-- Identity partition transform
+-- =============================================================
+
+statement
+DROP TABLE IF EXISTS test_cat.db.identity_part
+
+statement
+CREATE TABLE test_cat.db.identity_part (id INT, name STRING, dept STRING) 
USING iceberg PARTITIONED BY (dept)
+
+statement
+INSERT INTO test_cat.db.identity_part VALUES (1, 'Alice', 'eng'), (2, 'Bob', 
'sales'), (3, 'Charlie', 'eng')
+
+query
+SELECT id, _partition FROM test_cat.db.identity_part ORDER BY id
+
+query
+SELECT id, _partition.dept FROM test_cat.db.identity_part ORDER BY id
+
+query
+SELECT id, _file, _partition FROM test_cat.db.identity_part ORDER BY id
+
+statement
+DROP TABLE test_cat.db.identity_part
+
+-- =============================================================
+-- Multiple partition fields
+-- =============================================================
+
+statement
+DROP TABLE IF EXISTS test_cat.db.multi_part
+
+statement
+CREATE TABLE test_cat.db.multi_part (id INT, year INT, month INT, data STRING) 
USING iceberg PARTITIONED BY (year, month)
+
+statement
+INSERT INTO test_cat.db.multi_part VALUES (1, 2023, 6, 'a'), (2, 2023, 7, 
'b'), (3, 2024, 1, 'c')
+
+query
+SELECT id, _partition FROM test_cat.db.multi_part ORDER BY id
+
+statement
+DROP TABLE test_cat.db.multi_part
+
+-- =============================================================
+-- Partition with filter
+-- =============================================================
+
+statement
+DROP TABLE IF EXISTS test_cat.db.filter_part
+
+statement
+CREATE TABLE test_cat.db.filter_part (id INT, dept STRING, value DOUBLE) USING 
iceberg PARTITIONED BY (dept)
+
+statement
+INSERT INTO test_cat.db.filter_part VALUES (1, 'eng', 10.5), (2, 'sales', 
20.3), (3, 'eng', 30.7)
+
+query
+SELECT id, _partition FROM test_cat.db.filter_part WHERE dept = 'eng' ORDER BY 
id
+
+statement
+DROP TABLE test_cat.db.filter_part
+
+-- =============================================================
+-- Edge case: Unpartitioned table
+-- =============================================================
+
+statement
+DROP TABLE IF EXISTS test_cat.db.unpart
+
+statement
+CREATE TABLE test_cat.db.unpart (id INT, val STRING) USING iceberg
+
+statement
+INSERT INTO test_cat.db.unpart VALUES (1, 'a'), (2, 'b')
+
+-- _partition on unpartitioned table is an empty struct (null), which Comet's
+-- shuffle doesn't support. Verify correctness only, not native operator 
coverage.
+query spark_answer_only
+SELECT id, _partition FROM test_cat.db.unpart ORDER BY id
+
+-- Without ORDER BY (no shuffle), the native scan executes end-to-end
+query
+SELECT id, _partition FROM test_cat.db.unpart
+
+query
+SELECT id, _file FROM test_cat.db.unpart ORDER BY id
+
+-- _spec_id on unpartitioned: should be 0
+-- (spark_answer_only because _partition is an empty struct that Comet shuffle 
rejects)
+query spark_answer_only
+SELECT id, _spec_id, _partition FROM test_cat.db.unpart ORDER BY id
+
+statement
+DROP TABLE test_cat.db.unpart
+
+-- =============================================================
+-- Edge case: Large batch (tests multi-batch _pos correctness)
+-- =============================================================
+
+statement
+DROP TABLE IF EXISTS test_cat.db.large_pos
+
+statement
+CREATE TABLE test_cat.db.large_pos (id INT) USING iceberg
+
+statement
+INSERT INTO test_cat.db.large_pos SELECT id FROM range(10000)
+
+query
+SELECT id, _pos FROM test_cat.db.large_pos ORDER BY id LIMIT 5
+
+statement
+DROP TABLE test_cat.db.large_pos
+
+-- =============================================================
+-- Edge case: Multiple row groups (forces _pos offset tracking)
+-- =============================================================
+
+statement
+DROP TABLE IF EXISTS test_cat.db.multi_rg
+
+statement
+CREATE TABLE test_cat.db.multi_rg (id INT, data STRING) USING iceberg 
TBLPROPERTIES ('write.parquet.row-group-size-bytes' = '1024')
+
+statement
+INSERT INTO test_cat.db.multi_rg SELECT id, REPEAT('x', 100) FROM range(1000)
+
+query
+SELECT _pos, id FROM test_cat.db.multi_rg WHERE _pos IN (0, 1, 998, 999) ORDER 
BY _pos
+
+statement
+DROP TABLE test_cat.db.multi_rg
+
+-- =============================================================
+-- DML operations (DELETE, UPDATE, MERGE) internally project metadata columns
+-- (_file, _pos, _spec_id, _partition) to identify rows. All four are now
+-- supported natively. The subsequent SELECT queries verify that reads after
+-- DML return correct results through the native scan path (applying position
+-- deletes, seeing updated data).
+-- =============================================================
+
+-- Copy-on-write DELETE
+statement
+DROP TABLE IF EXISTS test_cat.db.cow_delete
+
+statement
+CREATE TABLE test_cat.db.cow_delete (id INT, name STRING, dept STRING) USING 
iceberg PARTITIONED BY (dept) TBLPROPERTIES 
('write.delete.mode'='copy-on-write')
+
+statement
+INSERT INTO test_cat.db.cow_delete VALUES (1, 'Alice', 'eng'), (2, 'Bob', 
'sales'), (3, 'Charlie', 'eng')
+
+statement
+DELETE FROM test_cat.db.cow_delete WHERE id = 2
+
+query
+SELECT id, _partition FROM test_cat.db.cow_delete ORDER BY id
+
+statement
+DROP TABLE test_cat.db.cow_delete
+
+-- Merge-on-read DELETE (native read applies position deletes)
+statement
+DROP TABLE IF EXISTS test_cat.db.mor_delete
+
+statement
+CREATE TABLE test_cat.db.mor_delete (id INT, name STRING, dept STRING) USING 
iceberg PARTITIONED BY (dept) TBLPROPERTIES 
('write.delete.mode'='merge-on-read')
+
+statement
+INSERT INTO test_cat.db.mor_delete VALUES (1, 'Alice', 'eng'), (2, 'Bob', 
'sales'), (3, 'Charlie', 'eng')
+
+statement
+DELETE FROM test_cat.db.mor_delete WHERE id = 2
+
+query
+SELECT id, _partition FROM test_cat.db.mor_delete ORDER BY id
+
+-- After MoR delete, _pos still references original file positions
+query
+SELECT id, _pos FROM test_cat.db.mor_delete ORDER BY id
+
+statement
+DROP TABLE test_cat.db.mor_delete
+
+-- NOTE: Merge-on-read UPDATE is not tested here because UPDATE TABLE is not 
supported in Spark
+-- 4.0, and is not covered anywhere else either: it requires Iceberg's SQL 
extensions
+-- (IcebergSparkSessionExtensions), which are registered at SparkSession 
construction time and
+-- are not wired into CometTestBase's shared test session (same blocker as 
ALTER TABLE ADD
+-- PARTITION FIELD elsewhere in this test suite). Adding coverage needs that 
session-level
+-- infra, not just a new test case.
+
+-- =============================================================
+-- Temporal transforms: years() on DATE column
+-- =============================================================
+
+statement
+DROP TABLE IF EXISTS test_cat.db.part_years_date
+
+statement
+CREATE TABLE test_cat.db.part_years_date (id INT, event_date DATE, data 
STRING) USING iceberg PARTITIONED BY (years(event_date))
+
+statement
+INSERT INTO test_cat.db.part_years_date VALUES (1, DATE '2022-03-15', 'a'), 
(2, DATE '2023-07-20', 'b'), (3, DATE '2022-11-01', 'c')
+
+query
+SELECT id, _partition FROM test_cat.db.part_years_date ORDER BY id
+
+query
+SELECT id, _partition.event_date_year FROM test_cat.db.part_years_date ORDER 
BY id
+
+statement
+DROP TABLE test_cat.db.part_years_date
+
+-- =============================================================
+-- Temporal transforms: months() on DATE column
+-- =============================================================
+
+statement
+DROP TABLE IF EXISTS test_cat.db.part_months_date
+
+statement
+CREATE TABLE test_cat.db.part_months_date (id INT, event_date DATE, data 
STRING) USING iceberg PARTITIONED BY (months(event_date))
+
+statement
+INSERT INTO test_cat.db.part_months_date VALUES (1, DATE '2023-01-10', 'jan'), 
(2, DATE '2023-02-15', 'feb'), (3, DATE '2023-01-25', 'jan2')
+
+query
+SELECT id, _partition FROM test_cat.db.part_months_date ORDER BY id
+
+query
+SELECT id, _partition.event_date_month FROM test_cat.db.part_months_date ORDER 
BY id
+
+statement
+DROP TABLE test_cat.db.part_months_date
+
+-- =============================================================
+-- Temporal transforms: days() on DATE column
+-- =============================================================
+
+statement
+DROP TABLE IF EXISTS test_cat.db.part_days_date
+
+statement
+CREATE TABLE test_cat.db.part_days_date (id INT, event_date DATE, data STRING) 
USING iceberg PARTITIONED BY (days(event_date))
+
+statement
+INSERT INTO test_cat.db.part_days_date VALUES (1, DATE '2023-06-01', 'x'), (2, 
DATE '2023-06-02', 'y'), (3, DATE '2023-06-01', 'z')
+
+query
+SELECT id, _partition FROM test_cat.db.part_days_date ORDER BY id
+
+query
+SELECT id, _partition.event_date_day FROM test_cat.db.part_days_date ORDER BY 
id
+
+statement
+DROP TABLE test_cat.db.part_days_date
+
+-- =============================================================
+-- Temporal transforms: hours() on TIMESTAMP column
+-- =============================================================
+
+statement
+DROP TABLE IF EXISTS test_cat.db.part_hours_ts
+
+statement
+CREATE TABLE test_cat.db.part_hours_ts (id INT, ts TIMESTAMP, data STRING) 
USING iceberg PARTITIONED BY (hours(ts))
+
+statement
+INSERT INTO test_cat.db.part_hours_ts VALUES (1, TIMESTAMP '2023-06-15 
10:30:00', 'morning'), (2, TIMESTAMP '2023-06-15 14:45:00', 'afternoon'), (3, 
TIMESTAMP '2023-06-15 10:55:00', 'morning2')
+
+query
+SELECT id, _partition FROM test_cat.db.part_hours_ts ORDER BY id
+
+query
+SELECT id, _partition.ts_hour FROM test_cat.db.part_hours_ts ORDER BY id
+
+statement
+DROP TABLE test_cat.db.part_hours_ts
+
+-- =============================================================
+-- Truncate transform: truncate(N, int_col)
+-- =============================================================
+
+statement
+DROP TABLE IF EXISTS test_cat.db.part_trunc_int
+
+statement
+CREATE TABLE test_cat.db.part_trunc_int (id INT, value INT, data STRING) USING 
iceberg PARTITIONED BY (truncate(10, value))
+
+statement
+INSERT INTO test_cat.db.part_trunc_int VALUES (1, 5, 'a'), (2, 15, 'b'), (3, 
7, 'c'), (4, 23, 'd')
+
+query
+SELECT id, _partition FROM test_cat.db.part_trunc_int ORDER BY id
+
+query
+SELECT id, _partition.value_trunc FROM test_cat.db.part_trunc_int ORDER BY id
+
+statement
+DROP TABLE test_cat.db.part_trunc_int
+
+-- =============================================================
+-- Truncate transform: truncate(N, string_col)
+-- =============================================================
+
+statement
+DROP TABLE IF EXISTS test_cat.db.part_trunc_str
+
+statement
+CREATE TABLE test_cat.db.part_trunc_str (id INT, city STRING, data STRING) 
USING iceberg PARTITIONED BY (truncate(3, city))
+
+statement
+INSERT INTO test_cat.db.part_trunc_str VALUES (1, 'San Francisco', 'x'), (2, 
'Santa Cruz', 'y'), (3, 'Boston', 'z')
+
+query
+SELECT id, _partition FROM test_cat.db.part_trunc_str ORDER BY id
+
+query
+SELECT id, _partition.city_trunc FROM test_cat.db.part_trunc_str ORDER BY id
+
+statement
+DROP TABLE test_cat.db.part_trunc_str
+
+-- =============================================================
+-- Multiple non-identity transforms on same table
+-- =============================================================
+
+statement
+DROP TABLE IF EXISTS test_cat.db.part_multi_transform
+
+statement
+CREATE TABLE test_cat.db.part_multi_transform (id INT, name STRING, ts 
TIMESTAMP) USING iceberg PARTITIONED BY (bucket(4, id), truncate(2, name), 
hours(ts))
+
+statement
+INSERT INTO test_cat.db.part_multi_transform VALUES (1, 'Alice', TIMESTAMP 
'2023-06-15 10:00:00'), (2, 'Bob', TIMESTAMP '2023-06-15 11:00:00'), (3, 
'Alice', TIMESTAMP '2023-06-15 10:30:00')
+
+query
+SELECT id, _partition FROM test_cat.db.part_multi_transform ORDER BY id
+
+query
+SELECT id, _partition.id_bucket, _partition.name_trunc, _partition.ts_hour 
FROM test_cat.db.part_multi_transform ORDER BY id
+
+statement
+DROP TABLE test_cat.db.part_multi_transform
+
+-- =============================================================
+-- NULL partition values
+-- =============================================================
+
+statement
+DROP TABLE IF EXISTS test_cat.db.part_nulls
+
+statement
+CREATE TABLE test_cat.db.part_nulls (id INT, dept STRING, value DOUBLE) USING 
iceberg PARTITIONED BY (dept)
+
+statement
+INSERT INTO test_cat.db.part_nulls VALUES (1, 'eng', 10.0), (2, NULL, 20.0), 
(3, 'sales', 30.0), (4, NULL, 40.0)
+
+query
+SELECT id, _partition FROM test_cat.db.part_nulls ORDER BY id
+
+query
+SELECT id, _partition.dept FROM test_cat.db.part_nulls ORDER BY id
+
+query
+SELECT id, _partition FROM test_cat.db.part_nulls WHERE _partition.dept IS 
NULL ORDER BY id
+
+query
+SELECT id, _partition FROM test_cat.db.part_nulls WHERE _partition.dept IS NOT 
NULL ORDER BY id
+
+statement
+DROP TABLE test_cat.db.part_nulls
+
+-- =============================================================
+-- _partition in WHERE clause (filter on metadata column itself)
+-- =============================================================
+
+statement
+DROP TABLE IF EXISTS test_cat.db.part_where
+
+statement
+CREATE TABLE test_cat.db.part_where (id INT, dept STRING, value DOUBLE) USING 
iceberg PARTITIONED BY (dept)
+
+statement
+INSERT INTO test_cat.db.part_where VALUES (1, 'eng', 10.0), (2, 'sales', 
20.0), (3, 'eng', 30.0), (4, 'hr', 40.0)
+
+query
+SELECT id, value FROM test_cat.db.part_where WHERE _partition.dept = 'eng' 
ORDER BY id
+
+query
+SELECT id, _partition FROM test_cat.db.part_where WHERE _partition.dept IN 
('eng', 'hr') ORDER BY id
+
+statement
+DROP TABLE test_cat.db.part_where
+
+-- =============================================================
+-- _partition in GROUP BY
+-- =============================================================
+
+statement
+DROP TABLE IF EXISTS test_cat.db.part_groupby
+
+statement
+CREATE TABLE test_cat.db.part_groupby (id INT, dept STRING, salary DOUBLE) 
USING iceberg PARTITIONED BY (dept)
+
+statement
+INSERT INTO test_cat.db.part_groupby VALUES (1, 'eng', 100.0), (2, 'eng', 
120.0), (3, 'sales', 80.0), (4, 'sales', 90.0), (5, 'hr', 70.0)
+
+query
+SELECT _partition.dept, COUNT(*) as cnt, SUM(salary) as total FROM 
test_cat.db.part_groupby GROUP BY _partition.dept ORDER BY dept
+
+-- ORDER BY on _partition (StructType) falls back because Comet does not 
support Sort on structs
+query spark_answer_only
+SELECT _partition, COUNT(*) as cnt FROM test_cat.db.part_groupby GROUP BY 
_partition ORDER BY _partition
+
+statement
+DROP TABLE test_cat.db.part_groupby
+
+-- =============================================================
+-- _partition in JOIN condition
+-- =============================================================
+
+statement
+DROP TABLE IF EXISTS test_cat.db.part_join_left
+
+statement
+DROP TABLE IF EXISTS test_cat.db.part_join_right
+
+statement
+CREATE TABLE test_cat.db.part_join_left (id INT, dept STRING, value INT) USING 
iceberg PARTITIONED BY (dept)
+
+statement
+CREATE TABLE test_cat.db.part_join_right (id INT, dept STRING, budget INT) 
USING iceberg PARTITIONED BY (dept)
+
+statement
+INSERT INTO test_cat.db.part_join_left VALUES (1, 'eng', 10), (2, 'sales', 
20), (3, 'eng', 30)
+
+statement
+INSERT INTO test_cat.db.part_join_right VALUES (100, 'eng', 500), (200, 
'sales', 300)
+
+query
+SELECT l.id, l.value, r.budget FROM test_cat.db.part_join_left l JOIN 
test_cat.db.part_join_right r ON l._partition.dept = r._partition.dept ORDER BY 
l.id
+
+statement
+DROP TABLE test_cat.db.part_join_left
+
+statement
+DROP TABLE test_cat.db.part_join_right
+
+-- =============================================================
+-- _partition in subquery
+-- =============================================================
+
+statement
+DROP TABLE IF EXISTS test_cat.db.part_subq
+
+statement
+CREATE TABLE test_cat.db.part_subq (id INT, dept STRING, value INT) USING 
iceberg PARTITIONED BY (dept)
+
+statement
+INSERT INTO test_cat.db.part_subq VALUES (1, 'eng', 10), (2, 'sales', 20), (3, 
'hr', 30), (4, 'eng', 40)
+
+query
+SELECT id, value FROM test_cat.db.part_subq WHERE _partition.dept IN (SELECT 
_partition.dept FROM test_cat.db.part_subq WHERE value > 25) ORDER BY id
+
+statement
+DROP TABLE test_cat.db.part_subq
+
+-- =============================================================
+-- _partition with HAVING clause
+-- =============================================================
+
+statement
+DROP TABLE IF EXISTS test_cat.db.part_having
+
+statement
+CREATE TABLE test_cat.db.part_having (id INT, dept STRING, salary DOUBLE) 
USING iceberg PARTITIONED BY (dept)
+
+statement
+INSERT INTO test_cat.db.part_having VALUES (1, 'eng', 100.0), (2, 'eng', 
120.0), (3, 'eng', 110.0), (4, 'sales', 80.0), (5, 'hr', 70.0)
+
+query
+SELECT _partition.dept, COUNT(*) as cnt FROM test_cat.db.part_having GROUP BY 
_partition.dept HAVING COUNT(*) > 1 ORDER BY dept
+
+statement
+DROP TABLE test_cat.db.part_having
+
+-- =============================================================
+-- _partition with ORDER BY on partition field
+-- =============================================================
+
+statement
+DROP TABLE IF EXISTS test_cat.db.part_orderby
+
+statement
+CREATE TABLE test_cat.db.part_orderby (id INT, dept STRING, priority INT) 
USING iceberg PARTITIONED BY (dept)
+
+statement
+INSERT INTO test_cat.db.part_orderby VALUES (1, 'eng', 3), (2, 'sales', 1), 
(3, 'hr', 2), (4, 'eng', 4)
+
+query
+SELECT id, _partition.dept FROM test_cat.db.part_orderby ORDER BY 
_partition.dept, id
+
+statement
+DROP TABLE test_cat.db.part_orderby
+
+-- =============================================================
+-- _partition with DISTINCT
+-- =============================================================
+
+statement
+DROP TABLE IF EXISTS test_cat.db.part_distinct
+
+statement
+CREATE TABLE test_cat.db.part_distinct (id INT, region STRING, data STRING) 
USING iceberg PARTITIONED BY (region)
+
+statement
+INSERT INTO test_cat.db.part_distinct VALUES (1, 'US', 'a'), (2, 'EU', 'b'), 
(3, 'US', 'c'), (4, 'EU', 'd'), (5, 'APAC', 'e')
+
+query spark_answer_only
+SELECT DISTINCT _partition.region FROM test_cat.db.part_distinct ORDER BY 
region
+
+query spark_answer_only
+SELECT DISTINCT _partition FROM test_cat.db.part_distinct ORDER BY 
_partition.region
+
+statement
+DROP TABLE test_cat.db.part_distinct
+
+-- =============================================================
+-- Wide partition struct (many partition fields)
+-- =============================================================
+
+statement
+DROP TABLE IF EXISTS test_cat.db.part_wide
+
+statement
+CREATE TABLE test_cat.db.part_wide (id INT, a INT, b INT, c INT, d STRING, e 
STRING) USING iceberg PARTITIONED BY (a, b, c, d, e)
+
+statement
+INSERT INTO test_cat.db.part_wide VALUES (1, 10, 20, 30, 'x', 'y'), (2, 11, 
21, 31, 'p', 'q'), (3, 10, 20, 30, 'x', 'y')
+
+query
+SELECT id, _partition FROM test_cat.db.part_wide ORDER BY id
+
+query
+SELECT id, _partition.a, _partition.b, _partition.c, _partition.d, 
_partition.e FROM test_cat.db.part_wide ORDER BY id
+
+statement
+DROP TABLE test_cat.db.part_wide
+
+-- =============================================================
+-- DATE identity partition
+-- =============================================================
+
+statement
+DROP TABLE IF EXISTS test_cat.db.part_date_identity
+
+statement
+CREATE TABLE test_cat.db.part_date_identity (id INT, event_date DATE, data 
STRING) USING iceberg PARTITIONED BY (event_date)
+
+statement
+INSERT INTO test_cat.db.part_date_identity VALUES (1, DATE '2023-01-15', 'a'), 
(2, DATE '2023-02-20', 'b'), (3, DATE '2023-01-15', 'c')
+
+query
+SELECT id, _partition FROM test_cat.db.part_date_identity ORDER BY id
+
+query
+SELECT id, _partition.event_date FROM test_cat.db.part_date_identity ORDER BY 
id
+
+query
+SELECT id, data FROM test_cat.db.part_date_identity WHERE 
_partition.event_date = DATE '2023-01-15' ORDER BY id
+
+statement
+DROP TABLE test_cat.db.part_date_identity
+
+-- =============================================================
+-- String identity with special characters
+-- =============================================================
+
+statement
+DROP TABLE IF EXISTS test_cat.db.part_special_chars
+
+statement
+CREATE TABLE test_cat.db.part_special_chars (id INT, tag STRING, data STRING) 
USING iceberg PARTITIONED BY (tag)
+
+statement
+INSERT INTO test_cat.db.part_special_chars VALUES (1, 'hello world', 'a'), (2, 
'foo/bar', 'b'), (3, '', 'c'), (4, 'a=b&c=d', 'd')
+
+query
+SELECT id, _partition FROM test_cat.db.part_special_chars ORDER BY id
+
+query
+SELECT id, _partition.tag FROM test_cat.db.part_special_chars ORDER BY id
+
+query
+SELECT id FROM test_cat.db.part_special_chars WHERE _partition.tag = 'hello 
world' ORDER BY id
+
+query
+SELECT id FROM test_cat.db.part_special_chars WHERE _partition.tag = '' ORDER 
BY id
+
+statement
+DROP TABLE test_cat.db.part_special_chars
+
+-- =============================================================
+-- Bucket on STRING column
+-- =============================================================
+
+statement
+DROP TABLE IF EXISTS test_cat.db.part_bucket_str
+
+statement
+CREATE TABLE test_cat.db.part_bucket_str (id INT, name STRING, value INT) 
USING iceberg PARTITIONED BY (bucket(8, name))
+
+statement
+INSERT INTO test_cat.db.part_bucket_str VALUES (1, 'Alice', 10), (2, 'Bob', 
20), (3, 'Charlie', 30), (4, 'Alice', 40)
+
+query
+SELECT id, _partition FROM test_cat.db.part_bucket_str ORDER BY id
+
+query
+SELECT id, _partition.name_bucket FROM test_cat.db.part_bucket_str ORDER BY id
+
+statement
+DROP TABLE test_cat.db.part_bucket_str
+
+-- =============================================================
+-- years() on TIMESTAMP column
+-- =============================================================
+
+statement
+DROP TABLE IF EXISTS test_cat.db.part_years_ts
+
+statement
+CREATE TABLE test_cat.db.part_years_ts (id INT, ts TIMESTAMP, data STRING) 
USING iceberg PARTITIONED BY (years(ts))
+
+statement
+INSERT INTO test_cat.db.part_years_ts VALUES (1, TIMESTAMP '2021-03-15 
08:00:00', 'a'), (2, TIMESTAMP '2022-07-20 12:00:00', 'b'), (3, TIMESTAMP 
'2021-11-01 16:00:00', 'c')
+
+query
+SELECT id, _partition FROM test_cat.db.part_years_ts ORDER BY id
+
+query
+SELECT id, _partition.ts_year FROM test_cat.db.part_years_ts ORDER BY id
+
+statement
+DROP TABLE test_cat.db.part_years_ts
+
+-- =============================================================
+-- months() on TIMESTAMP column
+-- =============================================================
+
+statement
+DROP TABLE IF EXISTS test_cat.db.part_months_ts
+
+statement
+CREATE TABLE test_cat.db.part_months_ts (id INT, ts TIMESTAMP, data STRING) 
USING iceberg PARTITIONED BY (months(ts))
+
+statement
+INSERT INTO test_cat.db.part_months_ts VALUES (1, TIMESTAMP '2023-01-10 
09:00:00', 'jan'), (2, TIMESTAMP '2023-02-15 10:00:00', 'feb'), (3, TIMESTAMP 
'2023-01-25 11:00:00', 'jan2')
+
+query
+SELECT id, _partition FROM test_cat.db.part_months_ts ORDER BY id
+
+query
+SELECT id, _partition.ts_month FROM test_cat.db.part_months_ts ORDER BY id
+
+statement
+DROP TABLE test_cat.db.part_months_ts
+
+-- =============================================================
+-- Combined: temporal transform + identity on same table
+-- =============================================================
+
+statement
+DROP TABLE IF EXISTS test_cat.db.part_mixed
+
+statement
+CREATE TABLE test_cat.db.part_mixed (id INT, event_date DATE, region STRING, 
data STRING) USING iceberg PARTITIONED BY (years(event_date), region)
+
+statement
+INSERT INTO test_cat.db.part_mixed VALUES (1, DATE '2022-03-01', 'US', 'a'), 
(2, DATE '2023-07-15', 'EU', 'b'), (3, DATE '2022-09-20', 'US', 'c'), (4, DATE 
'2023-01-10', 'US', 'd')
+
+query
+SELECT id, _partition FROM test_cat.db.part_mixed ORDER BY id
+
+query
+SELECT id, _partition.event_date_year, _partition.region FROM 
test_cat.db.part_mixed ORDER BY id
+
+query
+SELECT _partition.event_date_year, COUNT(*) as cnt FROM test_cat.db.part_mixed 
GROUP BY _partition.event_date_year ORDER BY event_date_year
+
+query
+SELECT id, data FROM test_cat.db.part_mixed WHERE _partition.region = 'US' 
ORDER BY id
+
+statement
+DROP TABLE test_cat.db.part_mixed
+
+-- =============================================================
+-- _partition after multiple inserts (different files, same partition)
+-- =============================================================
+
+statement
+DROP TABLE IF EXISTS test_cat.db.part_multi_insert
+
+statement
+CREATE TABLE test_cat.db.part_multi_insert (id INT, dept STRING, value INT) 
USING iceberg PARTITIONED BY (dept)
+
+statement
+INSERT INTO test_cat.db.part_multi_insert VALUES (1, 'eng', 10), (2, 'sales', 
20)
+
+statement
+INSERT INTO test_cat.db.part_multi_insert VALUES (3, 'eng', 30), (4, 'hr', 40)
+
+statement
+INSERT INTO test_cat.db.part_multi_insert VALUES (5, 'sales', 50)
+
+query
+SELECT id, _partition FROM test_cat.db.part_multi_insert ORDER BY id
+
+query
+SELECT _partition.dept, COUNT(DISTINCT _file) as file_cnt, COUNT(*) as row_cnt 
FROM test_cat.db.part_multi_insert GROUP BY _partition.dept ORDER BY dept
+
+statement
+DROP TABLE test_cat.db.part_multi_insert
diff --git 
a/spark/src/test/scala/org/apache/comet/CometIcebergNativeSuite.scala 
b/spark/src/test/scala/org/apache/comet/CometIcebergNativeSuite.scala
index bdad3163d4..c816255924 100644
--- a/spark/src/test/scala/org/apache/comet/CometIcebergNativeSuite.scala
+++ b/spark/src/test/scala/org/apache/comet/CometIcebergNativeSuite.scala
@@ -39,7 +39,7 @@ import org.apache.spark.sql.internal.SQLConf
 import org.apache.spark.sql.types.{StringType, TimestampType}
 
 import org.apache.comet.CometSparkSessionExtensions.{isSpark35Plus, 
isSpark40Plus, isSpark42Plus}
-import org.apache.comet.iceberg.RESTCatalogHelper
+import org.apache.comet.iceberg.{IcebergReflection, RESTCatalogHelper}
 import org.apache.comet.serde.OperatorOuterClass
 import org.apache.comet.testing.{FuzzDataGenerator, SchemaGenOptions}
 
@@ -4806,8 +4806,9 @@ class CometIcebergNativeSuite
     }
   }
 
-  test("CometScanRule should report unsupported metadata columns") {
+  test("metadata columns - still-unsupported column falls back to Spark") {
     assume(icebergAvailable, "Iceberg not available in classpath")
+
     withTempIcebergDir { warehouseDir =>
       withSQLConf(
         "spark.sql.catalog.test_cat" -> 
"org.apache.iceberg.spark.SparkCatalog",
@@ -4815,24 +4816,286 @@ class CometIcebergNativeSuite
         "spark.sql.catalog.test_cat.warehouse" -> warehouseDir.getAbsolutePath,
         CometConf.COMET_ENABLED.key -> "true",
         CometConf.COMET_EXEC_ENABLED.key -> "true",
-        CometConf.COMET_ICEBERG_NATIVE_ENABLED.key -> "true",
-        CometConf.COMET_SCALA_UDF_CODEGEN_ENABLED.key -> "true") {
+        CometConf.COMET_ICEBERG_NATIVE_ENABLED.key -> "true") {
 
-        val table = "test_cat.db.test_meta_cols"
+        val table = "test_cat.db.unsupported_meta_col"
         try {
           spark.sql(s"""
             CREATE TABLE $table (id INT, value DOUBLE) USING iceberg
             TBLPROPERTIES ('format-version' = '2')
           """)
 
+          spark.sql(s"INSERT INTO $table VALUES (1, 10.5), (2, 20.3)")
+
+          // _deleted is a real, selectable Iceberg metadata column 
(SparkTable.metadataColumns())
+          // but is not in CometIcebergNativeScan.MetadataFieldIds, so this 
must fall back rather
+          // than silently drop the column or crash. Guards against a future 
change to
+          // MetadataFieldIds or the unsupportedMetadataCols filter regressing 
this path.
+          checkIcebergNativeScanFallback(
+            s"SELECT id, _deleted FROM $table ORDER BY id",
+            "_deleted is not in CometIcebergNativeScan.MetadataFieldIds")
+        } finally {
+          spark.sql(s"DROP TABLE IF EXISTS $table PURGE")
+        }
+      }
+    }
+  }
+
+  test("metadata columns - _pos returns row position within each file") {
+    assume(icebergAvailable, "Iceberg not available in classpath")
+
+    withTempIcebergDir { warehouseDir =>
+      withSQLConf(
+        "spark.sql.catalog.test_cat" -> 
"org.apache.iceberg.spark.SparkCatalog",
+        "spark.sql.catalog.test_cat.type" -> "hadoop",
+        "spark.sql.catalog.test_cat.warehouse" -> warehouseDir.getAbsolutePath,
+        CometConf.COMET_ENABLED.key -> "true",
+        CometConf.COMET_EXEC_ENABLED.key -> "true",
+        CometConf.COMET_ICEBERG_NATIVE_ENABLED.key -> "true") {
+
+        val table = "test_cat.db.pos_test"
+        try {
           spark.sql(s"""
-          INSERT INTO $table
-          VALUES (1, 10.5), (2, 20.3), (3, 30.7)
-        """)
+            CREATE TABLE $table (id INT, name STRING) USING iceberg
+          """)
+
+          // coalesce(1) guarantees a single file so _pos is 0,1,2
+          spark
+            .sql("SELECT 1 as id, 'Alice' as name UNION ALL SELECT 2, 'Bob' 
UNION ALL SELECT 3, 'Charlie'")
+            .coalesce(1)
+            .write
+            .format("iceberg")
+            .mode("append")
+            .saveAsTable(table)
+
+          checkIcebergNativeScan(s"SELECT id, _pos FROM $table ORDER BY id")
+
+          // _pos should be 0-indexed within the single file
+          val positions = spark
+            .sql(s"SELECT _pos FROM $table ORDER BY _pos")
+            .collect()
+            .map(_.getLong(0))
+          assert(positions.toSeq == Seq(0L, 1L, 2L), s"Expected [0,1,2], got 
${positions.toSeq}")
+        } finally {
+          spark.sql(s"DROP TABLE IF EXISTS $table PURGE")
+        }
+      }
+    }
+  }
+
+  test("metadata columns - _pos resets per file with multiple data files") {
+    assume(icebergAvailable, "Iceberg not available in classpath")
+
+    withTempIcebergDir { warehouseDir =>
+      withSQLConf(
+        "spark.sql.catalog.test_cat" -> 
"org.apache.iceberg.spark.SparkCatalog",
+        "spark.sql.catalog.test_cat.type" -> "hadoop",
+        "spark.sql.catalog.test_cat.warehouse" -> warehouseDir.getAbsolutePath,
+        CometConf.COMET_ENABLED.key -> "true",
+        CometConf.COMET_EXEC_ENABLED.key -> "true",
+        CometConf.COMET_ICEBERG_NATIVE_ENABLED.key -> "true") {
+
+        val table = "test_cat.db.pos_multi_file_test"
+        try {
+          spark.sql(s"""
+            CREATE TABLE $table (id INT, value DOUBLE) USING iceberg
+          """)
+
+          // Two coalesced inserts guarantee exactly two data files
+          spark
+            .range(1, 4)
+            .selectExpr("CAST(id AS INT)", "CAST(id * 10.0 AS DOUBLE) as 
value")
+            .coalesce(1)
+            .write
+            .format("iceberg")
+            .mode("append")
+            .saveAsTable(table)
+          spark
+            .range(4, 6)
+            .selectExpr("CAST(id AS INT)", "CAST(id * 10.0 AS DOUBLE) as 
value")
+            .coalesce(1)
+            .write
+            .format("iceberg")
+            .mode("append")
+            .saveAsTable(table)
+
+          // _pos resets to 0 at the start of each file
+          checkIcebergNativeScan(s"SELECT id, _pos, _file FROM $table ORDER BY 
_file, _pos")
+
+          // Verify each file starts _pos at 0
+          val rows = spark
+            .sql(s"SELECT _file, _pos FROM $table ORDER BY _file, _pos")
+            .collect()
+          val byFile = rows.groupBy(_.getString(0))
+          assert(byFile.size == 2, s"Expected 2 files, got ${byFile.size}")
+          byFile.foreach { case (file, fileRows) =>
+            val positions = fileRows.map(_.getLong(1))
+            assert(
+              positions.head == 0L,
+              s"File $file should start _pos at 0, got ${positions.head}")
+          }
+        } finally {
+          spark.sql(s"DROP TABLE IF EXISTS $table PURGE")
+        }
+      }
+    }
+  }
+
+  test("metadata columns - _spec_id returns partition spec ID") {
+    assume(icebergAvailable, "Iceberg not available in classpath")
+
+    withTempIcebergDir { warehouseDir =>
+      withSQLConf(
+        "spark.sql.catalog.test_cat" -> 
"org.apache.iceberg.spark.SparkCatalog",
+        "spark.sql.catalog.test_cat.type" -> "hadoop",
+        "spark.sql.catalog.test_cat.warehouse" -> warehouseDir.getAbsolutePath,
+        CometConf.COMET_ENABLED.key -> "true",
+        CometConf.COMET_EXEC_ENABLED.key -> "true",
+        CometConf.COMET_ICEBERG_NATIVE_ENABLED.key -> "true") {
+
+        val table = "test_cat.db.spec_id_test"
+        try {
+          spark.sql(s"""
+            CREATE TABLE $table (id INT, category STRING, value DOUBLE)
+            USING iceberg PARTITIONED BY (category)
+          """)
+
+          spark.sql(s"""
+            INSERT INTO $table VALUES
+              (1, 'A', 10.0), (2, 'B', 20.0), (3, 'A', 30.0)
+          """)
+
+          checkIcebergNativeScan(s"SELECT id, _spec_id FROM $table ORDER BY 
id")
+
+          // All rows written under the initial spec should have _spec_id = 0
+          val result = spark.sql(s"SELECT DISTINCT _spec_id FROM 
$table").collect()
+          assert(result.length == 1)
+          assert(result(0).getInt(0) == 0)
+        } finally {
+          spark.sql(s"DROP TABLE IF EXISTS $table PURGE")
+        }
+      }
+    }
+  }
+
+  test("metadata columns - _spec_id changes after partition evolution") {
+    assume(icebergAvailable, "Iceberg not available in classpath")
+
+    withTempIcebergDir { warehouseDir =>
+      withSQLConf(
+        "spark.sql.catalog.test_cat" -> 
"org.apache.iceberg.spark.SparkCatalog",
+        "spark.sql.catalog.test_cat.type" -> "hadoop",
+        "spark.sql.catalog.test_cat.warehouse" -> warehouseDir.getAbsolutePath,
+        CometConf.COMET_ENABLED.key -> "true",
+        CometConf.COMET_EXEC_ENABLED.key -> "true",
+        CometConf.COMET_ICEBERG_NATIVE_ENABLED.key -> "true") {
+
+        import org.apache.iceberg.catalog.TableIdentifier
+        import org.apache.iceberg.spark.SparkCatalog
+
+        val table = "test_cat.db.spec_id_evolution"
+        try {
+          spark.sql(s"""
+            CREATE TABLE $table (id INT, region STRING, category STRING)
+            USING iceberg PARTITIONED BY (region)
+          """)
+
+          // Data under spec 0
+          spark.sql(s"INSERT INTO $table VALUES (1, 'US', 'A'), (2, 'EU', 
'B')")
+
+          // Evolve partition spec: add category field -> spec 1
+          val sparkCatalog = spark.sessionState.catalogManager
+            .catalog("test_cat")
+            .asInstanceOf[SparkCatalog]
+          val iceTable = sparkCatalog
+            .icebergCatalog()
+            .loadTable(TableIdentifier.of("db", "spec_id_evolution"))
+          iceTable.updateSpec().addField("category").commit()
+
+          // Data under spec 1
+          spark.sql(s"INSERT INTO $table VALUES (3, 'APAC', 'C'), (4, 'US', 
'D')")
+
+          checkIcebergNativeScan(s"SELECT id, _spec_id FROM $table ORDER BY 
id")
+
+          // Rows 1,2 should have spec_id=0; rows 3,4 should have spec_id=1
+          val result = spark.sql(s"SELECT id, _spec_id FROM $table ORDER BY 
id").collect()
+          assert(result(0).getInt(1) == 0, "row 1 should have spec_id=0")
+          assert(result(1).getInt(1) == 0, "row 2 should have spec_id=0")
+          assert(result(2).getInt(1) == 1, "row 3 should have spec_id=1")
+          assert(result(3).getInt(1) == 1, "row 4 should have spec_id=1")
+        } finally {
+          spark.sql(s"DROP TABLE IF EXISTS $table PURGE")
+        }
+      }
+    }
+  }
+
+  test("metadata columns - all metadata columns together") {
+    assume(icebergAvailable, "Iceberg not available in classpath")
+
+    withTempIcebergDir { warehouseDir =>
+      withSQLConf(
+        "spark.sql.catalog.test_cat" -> 
"org.apache.iceberg.spark.SparkCatalog",
+        "spark.sql.catalog.test_cat.type" -> "hadoop",
+        "spark.sql.catalog.test_cat.warehouse" -> warehouseDir.getAbsolutePath,
+        CometConf.COMET_ENABLED.key -> "true",
+        CometConf.COMET_EXEC_ENABLED.key -> "true",
+        CometConf.COMET_ICEBERG_NATIVE_ENABLED.key -> "true") {
+
+        val table = "test_cat.db.all_meta_cols"
+        try {
+          spark.sql(s"""
+            CREATE TABLE $table (id INT, value DOUBLE)
+            USING iceberg PARTITIONED BY (bucket(4, id))
+            TBLPROPERTIES ('format-version' = '2')
+          """)
+
+          spark.sql(s"""
+            INSERT INTO $table VALUES (1, 10.5), (2, 20.3), (3, 30.7)
+          """)
 
-          checkSparkAnswerAndFallbackReason(
-            s"SELECT id, value, _spec_id, _pos, _file, _partition FROM $table 
WHERE id >= 2 ORDER BY id",
-            "Metadata column(s) _spec_id, _partition, _file, _pos is not 
supported")
+          // Query all supported metadata columns together
+          checkIcebergNativeScan(
+            s"SELECT id, value, _file, _pos, _spec_id, _partition FROM $table 
ORDER BY id")
+        } finally {
+          spark.sql(s"DROP TABLE IF EXISTS $table PURGE")
+        }
+      }
+    }
+  }
+
+  test("metadata columns - _pos with MOR deletes") {
+    assume(icebergAvailable, "Iceberg not available in classpath")
+
+    withTempIcebergDir { warehouseDir =>
+      withSQLConf(
+        "spark.sql.catalog.test_cat" -> 
"org.apache.iceberg.spark.SparkCatalog",
+        "spark.sql.catalog.test_cat.type" -> "hadoop",
+        "spark.sql.catalog.test_cat.warehouse" -> warehouseDir.getAbsolutePath,
+        CometConf.COMET_ENABLED.key -> "true",
+        CometConf.COMET_EXEC_ENABLED.key -> "true",
+        CometConf.COMET_ICEBERG_NATIVE_ENABLED.key -> "true") {
+
+        val table = "test_cat.db.pos_delete_test"
+        try {
+          spark.sql(s"""
+            CREATE TABLE $table (id INT, name STRING) USING iceberg
+            TBLPROPERTIES (
+              'write.delete.mode' = 'merge-on-read',
+              'write.merge.mode' = 'merge-on-read'
+            )
+          """)
+
+          spark.sql(s"""
+            INSERT INTO $table VALUES
+              (1, 'Alice'), (2, 'Bob'), (3, 'Charlie'), (4, 'Diana'), (5, 
'Eve')
+          """)
+
+          // Delete some rows (creates position delete files)
+          spark.sql(s"DELETE FROM $table WHERE id IN (2, 4)")
+
+          // _pos should still reflect original file positions for surviving 
rows
+          checkIcebergNativeScan(s"SELECT id, _pos FROM $table ORDER BY id")
         } finally {
           spark.sql(s"DROP TABLE IF EXISTS $table PURGE")
         }
@@ -4870,7 +5133,6 @@ class CometIcebergNativeSuite
   test("variant column filter falls back to Spark") {
     assume(isSpark40Plus, "VARIANT type requires Spark 4.0+")
     assume(icebergAvailable, "Iceberg not available in classpath")
-    assume(icebergVersionAtLeast(1, 11), "VARIANT column type requires Iceberg 
1.11+")
     withTempIcebergDir { warehouseDir =>
       withSQLConf(
         "spark.sql.catalog.test_cat" -> 
"org.apache.iceberg.spark.SparkCatalog",
@@ -4896,4 +5158,388 @@ class CometIcebergNativeSuite
       }
     }
   }
+
+  test("partition evolution - _partition contains fields from all historical 
specs") {
+    assume(icebergAvailable, "Iceberg not available in classpath")
+
+    withTempIcebergDir { warehouseDir =>
+      withSQLConf(
+        "spark.sql.catalog.test_cat" -> 
"org.apache.iceberg.spark.SparkCatalog",
+        "spark.sql.catalog.test_cat.type" -> "hadoop",
+        "spark.sql.catalog.test_cat.warehouse" -> warehouseDir.getAbsolutePath,
+        CometConf.COMET_ENABLED.key -> "true",
+        CometConf.COMET_EXEC_ENABLED.key -> "true",
+        CometConf.COMET_ICEBERG_NATIVE_ENABLED.key -> "true") {
+
+        import org.apache.iceberg.catalog.TableIdentifier
+        import org.apache.iceberg.spark.SparkCatalog
+
+        spark.sql("""
+          CREATE TABLE test_cat.db.part_evolution (
+            id INT, region STRING, category STRING, value DOUBLE
+          ) USING iceberg PARTITIONED BY (region)
+        """)
+
+        spark.sql("""
+          INSERT INTO test_cat.db.part_evolution VALUES
+            (1, 'US', 'A', 10.0), (2, 'EU', 'B', 20.0)
+        """)
+
+        // Add a second partition field via Iceberg Java API (partition 
evolution -> spec_id 1)
+        val sparkCatalog = spark.sessionState.catalogManager
+          .catalog("test_cat")
+          .asInstanceOf[SparkCatalog]
+        val table = sparkCatalog
+          .icebergCatalog()
+          .loadTable(TableIdentifier.of("db", "part_evolution"))
+        table.updateSpec().addField("category").commit()
+
+        spark.sql("""
+          INSERT INTO test_cat.db.part_evolution VALUES
+            (3, 'US', 'C', 30.0), (4, 'APAC', 'A', 40.0)
+        """)
+
+        // _partition should be a struct with BOTH region and category fields,
+        // covering all historical specs. Files written under spec 0 will have
+        // category=null in _partition; files under spec 1 have both populated.
+        checkIcebergNativeScan(
+          "SELECT id, _partition FROM test_cat.db.part_evolution ORDER BY id")
+
+        // Verify the struct fields are accessible
+        checkIcebergNativeScan(
+          "SELECT id, _partition.region, _partition.category " +
+            "FROM test_cat.db.part_evolution ORDER BY id")
+
+        // Verify correctness: old rows have null category in _partition
+        val result = spark
+          .sql("SELECT id, _partition.category " +
+            "FROM test_cat.db.part_evolution ORDER BY id")
+          .collect()
+        assert(result(0).isNullAt(1), "row 1 (spec 0) should have null 
_partition.category")
+        assert(result(1).isNullAt(1), "row 2 (spec 0) should have null 
_partition.category")
+        assert(result(2).getString(1) == "C", "row 3 (spec 1) should have 
category=C")
+        assert(result(3).getString(1) == "A", "row 4 (spec 1) should have 
category=A")
+
+        spark.sql("DROP TABLE test_cat.db.part_evolution")
+      }
+    }
+  }
+
+  test("partition evolution - _partition struct fields sorted by field_id 
ascending") {
+    assume(icebergAvailable, "Iceberg not available in classpath")
+
+    withTempIcebergDir { warehouseDir =>
+      withSQLConf(
+        "spark.sql.catalog.test_cat" -> 
"org.apache.iceberg.spark.SparkCatalog",
+        "spark.sql.catalog.test_cat.type" -> "hadoop",
+        "spark.sql.catalog.test_cat.warehouse" -> warehouseDir.getAbsolutePath,
+        CometConf.COMET_ENABLED.key -> "true",
+        CometConf.COMET_EXEC_ENABLED.key -> "true",
+        CometConf.COMET_ICEBERG_NATIVE_ENABLED.key -> "true") {
+
+        import org.apache.iceberg.catalog.TableIdentifier
+        import org.apache.iceberg.spark.SparkCatalog
+
+        // Create table partitioned by region (gets lower field_id in 
partition spec)
+        spark.sql("""
+          CREATE TABLE test_cat.db.field_order (
+            id INT, region STRING, category STRING
+          ) USING iceberg PARTITIONED BY (region)
+        """)
+
+        spark.sql("""
+          INSERT INTO test_cat.db.field_order VALUES (1, 'US', 'A'), (2, 'EU', 
'B')
+        """)
+
+        // Evolve: add category as second partition field (gets higher 
field_id).
+        // Without sort-by-id, spec-descending traversal would emit category 
before region.
+        val sparkCatalog = spark.sessionState.catalogManager
+          .catalog("test_cat")
+          .asInstanceOf[SparkCatalog]
+        val table = sparkCatalog
+          .icebergCatalog()
+          .loadTable(TableIdentifier.of("db", "field_order"))
+        table.updateSpec().addField("category").commit()
+
+        spark.sql("""
+          INSERT INTO test_cat.db.field_order VALUES (3, 'APAC', 'C'), (4, 
'US', 'D')
+        """)
+
+        // Verify _partition struct field order matches Spark (sorted by 
field_id ascending).
+        // Spark's Java reader uses buildPartitionProjectionType which sorts 
by natural key order.
+        checkIcebergNativeScan("SELECT id, _partition FROM 
test_cat.db.field_order ORDER BY id")
+
+        // Access fields by name to confirm both are present and correctly 
ordered
+        checkIcebergNativeScan(
+          "SELECT id, _partition.region, _partition.category " +
+            "FROM test_cat.db.field_order ORDER BY id")
+
+        spark.sql("DROP TABLE test_cat.db.field_order")
+      }
+    }
+  }
+
+  test("partition evolution - dropped partition field preserved in _partition 
struct") {
+    // Exercises the case CTTY raised in iceberg-rust PR #2668:
+    // Java's Partitioning.buildPartitionProjectionType preserves the type of a
+    // partition field that was dropped (Void in newer spec, real transform in 
older spec).
+    // This test verifies iceberg-rust matches that behavior through Comet.
+    assume(icebergAvailable, "Iceberg not available in classpath")
+
+    withTempIcebergDir { warehouseDir =>
+      withSQLConf(
+        "spark.sql.catalog.test_cat" -> 
"org.apache.iceberg.spark.SparkCatalog",
+        "spark.sql.catalog.test_cat.type" -> "hadoop",
+        "spark.sql.catalog.test_cat.warehouse" -> warehouseDir.getAbsolutePath,
+        CometConf.COMET_ENABLED.key -> "true",
+        CometConf.COMET_EXEC_ENABLED.key -> "true",
+        CometConf.COMET_ICEBERG_NATIVE_ENABLED.key -> "true") {
+
+        import org.apache.iceberg.catalog.TableIdentifier
+        import org.apache.iceberg.spark.SparkCatalog
+
+        val table = "test_cat.db.drop_part_field"
+        try {
+          // Spec 0: partitioned by (region, category)
+          spark.sql(s"""
+            CREATE TABLE $table (id INT, region STRING, category STRING, value 
DOUBLE)
+            USING iceberg PARTITIONED BY (region, category)
+          """)
+
+          spark.sql(s"INSERT INTO $table VALUES (1, 'US', 'A', 10.0), (2, 
'EU', 'B', 20.0)")
+
+          // Evolve: drop 'category' from partition spec -> spec 1 marks 
category as Void
+          val sparkCatalog = spark.sessionState.catalogManager
+            .catalog("test_cat")
+            .asInstanceOf[SparkCatalog]
+          val iceTable = sparkCatalog
+            .icebergCatalog()
+            .loadTable(TableIdentifier.of("db", "drop_part_field"))
+          iceTable.updateSpec().removeField("category").commit()
+
+          // Insert data under spec 1 (only region partitioning)
+          spark.sql(s"INSERT INTO $table VALUES (3, 'APAC', 'C', 30.0), (4, 
'US', 'D', 40.0)")
+
+          // _partition struct should still contain BOTH region and category 
fields.
+          // Java preserves dropped partition fields in the unified type;
+          // rows written under spec 1 have category=null in _partition.
+          checkIcebergNativeScan(s"SELECT id, _partition FROM $table ORDER BY 
id")
+
+          checkIcebergNativeScan(
+            s"SELECT id, _partition.region, _partition.category FROM $table 
ORDER BY id")
+
+          // Verify: spec-0 rows have category populated, spec-1 rows have 
category=null
+          val result = spark
+            .sql(s"SELECT id, _partition.category FROM $table ORDER BY id")
+            .collect()
+          assert(
+            result(0).getString(1) == "A",
+            "row 1 (spec 0) should have _partition.category=A")
+          assert(
+            result(1).getString(1) == "B",
+            "row 2 (spec 0) should have _partition.category=B")
+          assert(result(2).isNullAt(1), "row 3 (spec 1) should have null 
_partition.category")
+          assert(result(3).isNullAt(1), "row 4 (spec 1) should have null 
_partition.category")
+        } finally {
+          spark.sql(s"DROP TABLE IF EXISTS $table PURGE")
+        }
+      }
+    }
+  }
+
+  test("partition evolution - re-add dropped partition field") {
+    // Further exercises the void-transform handling: drop a field then re-add 
it.
+    // Java's unified type should contain the field once (not duplicated).
+    assume(icebergAvailable, "Iceberg not available in classpath")
+
+    withTempIcebergDir { warehouseDir =>
+      withSQLConf(
+        "spark.sql.catalog.test_cat" -> 
"org.apache.iceberg.spark.SparkCatalog",
+        "spark.sql.catalog.test_cat.type" -> "hadoop",
+        "spark.sql.catalog.test_cat.warehouse" -> warehouseDir.getAbsolutePath,
+        CometConf.COMET_ENABLED.key -> "true",
+        CometConf.COMET_EXEC_ENABLED.key -> "true",
+        CometConf.COMET_ICEBERG_NATIVE_ENABLED.key -> "true") {
+
+        import org.apache.iceberg.catalog.TableIdentifier
+        import org.apache.iceberg.spark.SparkCatalog
+
+        val table = "test_cat.db.readd_part_field"
+        try {
+          // Spec 0: partitioned by (region)
+          spark.sql(s"""
+            CREATE TABLE $table (id INT, region STRING, value DOUBLE)
+            USING iceberg PARTITIONED BY (region)
+          """)
+
+          spark.sql(s"INSERT INTO $table VALUES (1, 'US', 10.0)")
+
+          val sparkCatalog = spark.sessionState.catalogManager
+            .catalog("test_cat")
+            .asInstanceOf[SparkCatalog]
+          val iceTable = sparkCatalog
+            .icebergCatalog()
+            .loadTable(TableIdentifier.of("db", "readd_part_field"))
+
+          // Spec 1: drop region
+          iceTable.updateSpec().removeField("region").commit()
+          spark.sql(s"INSERT INTO $table VALUES (2, 'EU', 20.0)")
+
+          // Spec 2: re-add region (gets a new partition field_id but same 
source column)
+          iceTable.refresh()
+          iceTable.updateSpec().addField("region").commit()
+          spark.sql(s"INSERT INTO $table VALUES (3, 'APAC', 30.0)")
+
+          // All rows should be queryable with _partition
+          checkIcebergNativeScan(s"SELECT id, _partition FROM $table ORDER BY 
id")
+
+          checkIcebergNativeScan(s"SELECT id, _partition.region FROM $table 
ORDER BY id")
+        } finally {
+          spark.sql(s"DROP TABLE IF EXISTS $table PURGE")
+        }
+      }
+    }
+  }
+
+  test("partition evolution - conflicting V1 spec field detected before native 
scan") {
+    // Iceberg Java's own write path (e.g. updateSpec()) can never produce two 
specs that bind
+    // the same partition field id to different source columns: 
TableMetadata.Builder always
+    // reassigns fresh, non-conflicting field ids when a spec is added. Only 
V1 tables written by
+    // a non-Java writer (or, as here, a hand-edited metadata.json) can have 
this defect --
+    // exactly the case 
IcebergReflection.validateUnifiedPartitionType/CometScanRule's
+    // unifiedPartitionTypeSupported guards against. Table *loading* does not 
validate this
+    // (TableMetadataParser binds all non-default specs via 
UnboundPartitionSpec#bindUnchecked),
+    // so the conflict only surfaces when something asks for the unified 
partition type.
+    //
+    // That "something" turns out to always be Spark itself, not just Comet: 
resolving
+    // _partition's output type for any query that touches metadata columns on 
this table goes
+    // through SparkTable.metadataColumns(), which eagerly calls the same
+    // Partitioning.partitionType(table) and throws during analysis, before 
CometScanRule (which
+    // runs during physical planning) ever sees the query. So there's no SQL 
query that
+    // reaches CometScanRule's fallback for this case -- plain Spark can't 
analyze it either. This
+    // test instead exercises IcebergReflection.validateUnifiedPartitionType 
directly, which is
+    // the reflection call unifiedPartitionTypeSupported relies on to fall 
back safely in the
+    // (currently unreached, but not guaranteed to stay that way across 
Iceberg versions) case
+    // where analysis succeeds but the native scan's own merge would otherwise 
fail.
+    assume(icebergAvailable, "Iceberg not available in classpath")
+
+    withTempIcebergDir { warehouseDir =>
+      withSQLConf(
+        "spark.sql.catalog.test_cat" -> 
"org.apache.iceberg.spark.SparkCatalog",
+        "spark.sql.catalog.test_cat.type" -> "hadoop",
+        "spark.sql.catalog.test_cat.warehouse" -> warehouseDir.getAbsolutePath,
+        CometConf.COMET_ENABLED.key -> "true",
+        CometConf.COMET_EXEC_ENABLED.key -> "true",
+        CometConf.COMET_ICEBERG_NATIVE_ENABLED.key -> "true") {
+
+        val table = "test_cat.db.conflicting_spec"
+        try {
+          spark.sql(s"""
+            CREATE TABLE $table (id INT, region STRING, ts TIMESTAMP)
+            USING iceberg PARTITIONED BY (region)
+            TBLPROPERTIES ('format-version' = '1')
+          """)
+          spark.sql(s"INSERT INTO $table VALUES (1, 'US', TIMESTAMP 
'2024-01-01 00:00:00')")
+
+          import org.apache.iceberg.catalog.TableIdentifier
+          import org.apache.iceberg.spark.SparkCatalog
+
+          val sparkCatalog = spark.sessionState.catalogManager
+            .catalog("test_cat")
+            .asInstanceOf[SparkCatalog]
+          val iceTable = sparkCatalog
+            .icebergCatalog()
+            .loadTable(TableIdentifier.of("db", "conflicting_spec"))
+
+          // Derive the metadata dir from the table's own reported location 
rather than
+          // reconstructing the warehouse layout by hand, since location() may 
or may not carry a
+          // "file:" URI scheme depending on the FileIO in use.
+          val tableLocationUri = iceTable.location()
+          val tableDir =
+            if (tableLocationUri.contains(":")) new File(new 
java.net.URI(tableLocationUri))
+            else new File(tableLocationUri)
+          val metadataDir = new File(tableDir, "metadata")
+          // Discover the current metadata version by listing files rather 
than trusting
+          // version-hint.text: HadoopCatalog (unlike plain HadoopTables) does 
not always write
+          // it, and Iceberg's own HadoopTableOperations falls back to a 
directory scan for the
+          // same reason.
+          val versionPattern = "^v(\\d+)\\.metadata\\.json$".r
+          val currentVersion = metadataDir
+            .listFiles()
+            .flatMap(f => 
versionPattern.findFirstMatchIn(f.getName).map(_.group(1).toInt))
+            .max
+          val currentMetadataFile = new File(metadataDir, 
s"v$currentVersion.metadata.json")
+          val currentMetadataJson =
+            new 
String(java.nio.file.Files.readAllBytes(currentMetadataFile.toPath), UTF_8)
+
+          val mapper = new com.fasterxml.jackson.databind.ObjectMapper()
+          val root = mapper
+            .readTree(currentMetadataJson)
+            .asInstanceOf[com.fasterxml.jackson.databind.node.ObjectNode]
+          val specs = root
+            .get("partition-specs")
+            .asInstanceOf[com.fasterxml.jackson.databind.node.ArrayNode]
+          val spec0 = specs.get(0)
+          val regionFieldId = 
spec0.get("fields").get(0).get("field-id").asInt()
+          val idFieldId = root
+            .get("schemas")
+            .get(0)
+            .get("fields")
+            .elements()
+            .asScala
+            .find(_.get("name").asText() == "id")
+            .get
+            .get("id")
+            .asInt()
+
+          // New spec (id 1) reuses region's field id for a field bound to a 
*different* source
+          // column ("id" instead of "region") -- exactly what 
Partitioning.partitionType()/
+          // compute_unified_partition_type's equivalentIgnoringNames rejects.
+          val conflictingSpec = mapper.createObjectNode()
+          conflictingSpec.put("spec-id", 1)
+          val conflictingFields = mapper.createArrayNode()
+          val conflictingField = mapper.createObjectNode()
+          conflictingField.put("source-id", idFieldId)
+          conflictingField.put("field-id", regionFieldId)
+          conflictingField.put("name", "id_as_region")
+          conflictingField.put("transform", "identity")
+          conflictingFields.add(conflictingField)
+          conflictingSpec.set("fields", conflictingFields)
+          specs.add(conflictingSpec)
+
+          // Round-trip through Iceberg's own parser before writing, so a 
malformed hand-edit
+          // fails this test with a clear parse error rather than producing an 
unloadable table.
+          val newMetadataJson = mapper.writeValueAsString(root)
+          org.apache.iceberg.TableMetadataParser.fromJson(newMetadataJson)
+
+          val newVersion = currentVersion + 1
+          val newMetadataFile = new File(metadataDir, 
s"v$newVersion.metadata.json")
+          java.nio.file.Files.write(newMetadataFile.toPath, 
newMetadataJson.getBytes(UTF_8))
+          // Best-effort: some catalog configurations track the current 
version via this file;
+          // others (see the directory-listing fallback above) don't require 
it.
+          java.nio.file.Files.write(
+            new File(metadataDir, "version-hint.text").toPath,
+            newVersion.toString.getBytes(UTF_8))
+
+          // Not testable end-to-end via a SQL query: resolving _partition's 
output type for
+          // *any* query touching metadata columns on this table goes through 
Spark's own
+          // SparkTable.metadataColumns() -> SparkMetadataColumns.partition() 
->
+          // Partitioning.partitionType(table), which throws this exact 
ValidationException
+          // during analysis -- before CometScanRule (which runs during 
physical planning) ever
+          // sees the query. So this exercises the same reflection call 
CometScanRule's
+          // unifiedPartitionTypeSupported guards with, directly, against the 
conflicting table.
+          iceTable.refresh()
+          val reason = IcebergReflection.validateUnifiedPartitionType(iceTable)
+          assert(
+            reason.isDefined,
+            "Expected a validation failure for conflicting partition specs")
+          assert(
+            reason.get.contains("Conflicting partition fields"),
+            s"Expected a conflicting-partition-fields reason, got: 
${reason.get}")
+        } finally {
+          spark.sql(s"DROP TABLE IF EXISTS $table PURGE")
+        }
+      }
+    }
+  }
 }


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to