-
Notifications
You must be signed in to change notification settings - Fork 495
feat: add support for _spec_id metadata column
#2695
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -37,7 +37,9 @@ use crate::arrow::record_batch_transformer::RecordBatchTransformerBuilder; | |
| use crate::arrow::scan_metrics::{CountingFileRead, ScanMetrics, ScanResult}; | ||
| use crate::error::Result; | ||
| use crate::io::{FileIO, FileMetadata, FileRead}; | ||
| use crate::metadata_columns::{RESERVED_FIELD_ID_FILE, is_metadata_field}; | ||
| use crate::metadata_columns::{ | ||
| RESERVED_FIELD_ID_FILE, RESERVED_FIELD_ID_SPEC_ID, is_metadata_field, | ||
| }; | ||
| use crate::scan::{ArrowRecordBatchStream, FileScanTask, FileScanTaskStream}; | ||
| use crate::spec::Datum; | ||
| use crate::{Error, ErrorKind}; | ||
|
|
@@ -248,6 +250,16 @@ impl FileScanTaskReader { | |
| record_batch_transformer_builder.with_constant(RESERVED_FIELD_ID_FILE, file_datum); | ||
| } | ||
|
|
||
| if task | ||
| .project_field_ids() | ||
| .contains(&RESERVED_FIELD_ID_SPEC_ID) | ||
| && let Some(partition_spec) = &task.partition_spec | ||
| { | ||
| let spec_id_datum = Datum::int(partition_spec.spec_id()); | ||
| record_batch_transformer_builder = record_batch_transformer_builder | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Routing |
||
| .with_constant(RESERVED_FIELD_ID_SPEC_ID, spec_id_datum); | ||
| } | ||
|
|
||
| if let (Some(partition_spec), Some(partition_data)) = | ||
| (task.partition_spec.clone(), task.partition.clone()) | ||
| { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -383,11 +383,7 @@ impl RecordBatchTransformer { | |
| .get(field_id) | ||
| .ok_or(Error::new(ErrorKind::Unexpected, "field not found"))? | ||
| .0; | ||
| let datum = constant_fields.get(field_id).ok_or(Error::new( | ||
| ErrorKind::Unexpected, | ||
| "constant field not found", | ||
| ))?; | ||
| let arrow_type = datum_to_arrow_type_with_ree(datum); | ||
| let arrow_type = field.data_type().clone(); | ||
| // Use the type from constant_fields (REE for constants) | ||
| let constant_field = | ||
| Field::new(field.name(), arrow_type, field.is_nullable()) | ||
|
|
@@ -486,7 +482,20 @@ impl RecordBatchTransformer { | |
| // they exist in the Parquet file. This is per Iceberg spec rule #1: partition metadata | ||
| // is authoritative and should be preferred over file data. | ||
| if let Some(datum) = constant_fields.get(field_id) { | ||
| let arrow_type = datum_to_arrow_type_with_ree(datum); | ||
| let arrow_type = if get_metadata_field(*field_id).is_ok() { | ||
| datum_to_arrow_type_with_ree(datum) | ||
| } else { | ||
| field_id_to_mapped_schema_map | ||
| .get(field_id) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Separating "metadata field becomes REE" from "identity-partition field becomes schema type" looks like a real correctness improvement: identity-partition constants shouldn't be forced to RunEndEncoded. Worth calling out to the #2668/#2746 authors so they rebase on top rather than reintroducing the old derivation. This branch is the scalar arm that #2668 would fold into a |
||
| .ok_or(Error::new( | ||
| ErrorKind::Unexpected, | ||
| "could not find field in schema", | ||
| ))? | ||
| .0 | ||
| .data_type() | ||
| .clone() | ||
| }; | ||
|
|
||
| return Ok(ColumnSource::Add { | ||
| value: Some(datum.literal().clone()), | ||
| target_type: arrow_type, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -834,6 +834,9 @@ pub(crate) fn create_primitive_array_repeated( | |
| let vals: Vec<Option<i32>> = vec![None; num_rows]; | ||
| Arc::new(Date32Array::from(vals)) | ||
| } | ||
| (DataType::Int64, Some(PrimitiveLiteral::Int(value))) => { | ||
| Arc::new(Int64Array::from(vec![i64::from(*value); num_rows])) | ||
| } | ||
| (DataType::Int64, Some(PrimitiveLiteral::Long(value))) => { | ||
| Arc::new(Int64Array::from(vec![*value; num_rows])) | ||
| } | ||
|
|
@@ -969,7 +972,7 @@ pub(crate) fn create_primitive_array_repeated( | |
| (dt, _) => { | ||
| return Err(Error::new( | ||
| ErrorKind::Unexpected, | ||
| format!("unexpected target column type {dt}"), | ||
| format!("unexpected target column type {dt}, prim_lit {:?}", prim_lit), | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Mixed styles in one string; |
||
| )); | ||
| } | ||
| }) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -366,12 +366,12 @@ mod tests { | |
| -- child 2: "lower_bound" (Utf8) | ||
| StringArray | ||
| [ | ||
| "100", | ||
| "1", | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This expected-output edit only exists because the fixture changed; if the fixture stays, this revert isn't needed. Flagging so reviewers know it's coupled to the above, not an independent behavior change. |
||
| ] | ||
| -- child 3: "upper_bound" (Utf8) | ||
| StringArray | ||
| [ | ||
| "300", | ||
| "1", | ||
| ] | ||
| ], | ||
| ]"#]], | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -625,8 +625,9 @@ pub mod tests { | |
| use std::sync::Arc; | ||
|
|
||
| use arrow_array::cast::AsArray; | ||
| use arrow_array::types::Int32Type; | ||
| use arrow_array::{ | ||
| Array, ArrayRef, BooleanArray, Float64Array, Int32Array, Int64Array, RecordBatch, | ||
| Array, ArrayRef, BooleanArray, Float64Array, Int32Array, Int64Array, RecordBatch, RunArray, | ||
| StringArray, | ||
| }; | ||
| use futures::{TryStreamExt, stream}; | ||
|
|
@@ -641,7 +642,7 @@ pub mod tests { | |
| use crate::arrow::ArrowReaderBuilder; | ||
| use crate::expr::{BoundPredicate, Reference}; | ||
| use crate::io::{FileIO, OutputFile}; | ||
| use crate::metadata_columns::RESERVED_COL_NAME_FILE; | ||
| use crate::metadata_columns::{RESERVED_COL_NAME_FILE, RESERVED_COL_NAME_SPEC_ID}; | ||
| use crate::scan::FileScanTask; | ||
| use crate::spec::{ | ||
| DEFAULT_SCHEMA_NAME_MAPPING, DataContentType, DataFileBuilder, DataFileFormat, Datum, | ||
|
|
@@ -862,7 +863,7 @@ pub mod tests { | |
| .file_format(DataFileFormat::Parquet) | ||
| .file_size_in_bytes(parquet_file_size) | ||
| .record_count(1) | ||
| .partition(Struct::from_iter([Some(Literal::long(100))])) | ||
| .partition(Struct::from_iter([Some(Literal::long(1))])) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Collapsing three distinct partition values to |
||
| .key_metadata(None) | ||
| .build() | ||
| .unwrap(), | ||
|
|
@@ -885,7 +886,7 @@ pub mod tests { | |
| .file_format(DataFileFormat::Parquet) | ||
| .file_size_in_bytes(parquet_file_size) | ||
| .record_count(1) | ||
| .partition(Struct::from_iter([Some(Literal::long(200))])) | ||
| .partition(Struct::from_iter([Some(Literal::long(1))])) | ||
| .build() | ||
| .unwrap(), | ||
| ) | ||
|
|
@@ -907,7 +908,7 @@ pub mod tests { | |
| .file_format(DataFileFormat::Parquet) | ||
| .file_size_in_bytes(parquet_file_size) | ||
| .record_count(1) | ||
| .partition(Struct::from_iter([Some(Literal::long(300))])) | ||
| .partition(Struct::from_iter([Some(Literal::long(1))])) | ||
| .build() | ||
| .unwrap(), | ||
| ) | ||
|
|
@@ -2025,8 +2026,6 @@ pub mod tests { | |
|
|
||
| #[tokio::test] | ||
| async fn test_select_with_file_column() { | ||
| use arrow_array::cast::AsArray; | ||
|
|
||
| let mut fixture = TableTestFixture::new(); | ||
| fixture.setup_manifest_files().await; | ||
|
|
||
|
|
@@ -2070,7 +2069,7 @@ pub mod tests { | |
| // Decode the RunArray to verify it contains the file path | ||
| let run_array = file_col | ||
| .as_any() | ||
| .downcast_ref::<arrow_array::RunArray<arrow_array::types::Int32Type>>() | ||
| .downcast_ref::<RunArray<Int32Type>>() | ||
| .expect("_file column should be a RunArray"); | ||
|
|
||
| let values = run_array.values(); | ||
|
|
@@ -2369,4 +2368,114 @@ pub mod tests { | |
| // Assert it finished (didn't timeout) | ||
| assert!(result.is_ok(), "Scan timed out - deadlock detected"); | ||
| } | ||
|
|
||
| #[tokio::test] | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Both new tests assert |
||
| async fn test_select_with_spec_id_column() { | ||
| let mut fixture = TableTestFixture::new(); | ||
| fixture.setup_manifest_files().await; | ||
|
|
||
| // Select regular columns plus the _spec_id column | ||
| let table_scan = fixture | ||
| .table | ||
| .scan() | ||
| .select(["x", RESERVED_COL_NAME_SPEC_ID, "z"]) | ||
| .with_row_selection_enabled(true) | ||
| .build() | ||
| .unwrap(); | ||
|
|
||
| let batch_stream = table_scan.to_arrow().await.unwrap(); | ||
| let batches: Vec<_> = batch_stream.try_collect().await.unwrap(); | ||
|
|
||
| // Verify we have 3 columns: x, _spec_id, and z | ||
| assert_eq!(batches[0].num_columns(), 3); | ||
|
|
||
| // Verify the x column exists and has correct data | ||
| let col1 = batches[0].column_by_name("x").unwrap(); | ||
| let int64_arr = col1.as_any().downcast_ref::<Int64Array>().unwrap(); | ||
| assert_eq!(int64_arr.value(0), 1); | ||
|
|
||
| // Verify the _spec_id column exists | ||
| let spec_id_col = batches[0].column_by_name(RESERVED_COL_NAME_SPEC_ID); | ||
| assert!( | ||
| spec_id_col.is_some(), | ||
| "_spec_id column should be present in the batch" | ||
| ); | ||
|
|
||
| // Verify the _spec_id data type | ||
| let spec_id_col = spec_id_col.unwrap(); | ||
| assert!( | ||
| matches!( | ||
| spec_id_col.data_type(), | ||
| arrow_schema::DataType::RunEndEncoded(_, _) | ||
| ), | ||
| "_spec_id column should use RunEndEncoded type" | ||
| ); | ||
|
|
||
| // Decode the RunArray to verify it contains the spec id | ||
| let run_array = spec_id_col | ||
| .as_any() | ||
| .downcast_ref::<RunArray<Int32Type>>() | ||
| .expect("_spec_id column should be a RunArray"); | ||
|
|
||
| let values = run_array.values(); | ||
| let int_values = values.as_primitive::<Int32Type>(); | ||
| assert_eq!(int_values.len(), 1, "Should have a single _spec_id"); | ||
|
|
||
| let spec_id = int_values.value(0); | ||
| assert_eq!(spec_id, 0, "_spec_id should be 0, got: {spec_id}"); | ||
|
|
||
| // Verify 'z' column exists | ||
| assert!(batches[0].column_by_name("z").is_some()); | ||
| } | ||
|
|
||
| #[tokio::test] | ||
| async fn test_select_with_spec_id_column_from_unpartitioned_table() { | ||
| let mut fixture = TableTestFixture::new_unpartitioned(); | ||
| fixture.setup_unpartitioned_manifest_files().await; | ||
|
|
||
| // Select regular columns plus the _spec_id column | ||
| let table_scan = fixture | ||
| .table | ||
| .scan() | ||
| .select(["x", RESERVED_COL_NAME_SPEC_ID]) | ||
| .with_row_selection_enabled(true) | ||
| .build() | ||
| .unwrap(); | ||
|
|
||
| let batch_stream = table_scan.to_arrow().await.unwrap(); | ||
| let batches: Vec<_> = batch_stream.try_collect().await.unwrap(); | ||
|
|
||
| // Verify we have 2 columns: x and _spec_id | ||
| assert_eq!(batches[0].num_columns(), 2); | ||
|
|
||
| // Verify the _spec_id column exists | ||
| let spec_id_col = batches[0].column_by_name(RESERVED_COL_NAME_SPEC_ID); | ||
| assert!( | ||
| spec_id_col.is_some(), | ||
| "_spec_id column should be present in the batch" | ||
| ); | ||
|
|
||
| // Verify the _spec_id data type | ||
| let spec_id_col = spec_id_col.unwrap(); | ||
| assert!( | ||
| matches!( | ||
| spec_id_col.data_type(), | ||
| arrow_schema::DataType::RunEndEncoded(_, _) | ||
| ), | ||
| "_spec_id column should use RunEndEncoded type" | ||
| ); | ||
|
|
||
| // Decode the RunArray to verify it contains the spec id | ||
| let run_array = spec_id_col | ||
| .as_any() | ||
| .downcast_ref::<RunArray<Int32Type>>() | ||
| .expect("_spec_id column should be a RunArray"); | ||
|
|
||
| let values = run_array.values(); | ||
| let int_values = values.as_primitive::<Int32Type>(); | ||
| assert_eq!(int_values.len(), 1, "Should have a single _spec_id"); | ||
|
|
||
| let spec_id = int_values.value(0); | ||
| assert_eq!(spec_id, 0, "_spec_id should be 0, got: {spec_id}"); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we should always add spec_id into the constant map if it's selected/projected.
The task.partition_spec should be present even for unpartitioned table(spec_id = 0).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@advancedxy already raised this; this is a +1 to second it, to post as a reply on that thread:
"+1. If the invariant is that
task.partition_specis always present (spec_id = 0 even for unpartitioned tables), then gating the column onlet Some(partition_spec)means a projected_spec_idcan silently produce no column when that invariant is ever violated, which would be a confusing 'column vanished' bug rather than a clean error. Adding_spec_idto the constant map whenever it's projected (per @advancedxy) is the safer contract. Worth a test on an unpartitioned table to lock in spec_id = 0."