diff --git a/docs/source/user-guide/latest/compatibility/scans.md b/docs/source/user-guide/latest/compatibility/scans.md index 36d245992eb..d479e75ffd8 100644 --- a/docs/source/user-guide/latest/compatibility/scans.md +++ b/docs/source/user-guide/latest/compatibility/scans.md @@ -62,6 +62,20 @@ The following limitation may produce incorrect results without falling back to S The following limitations raise an error at scan time rather than falling back to Spark: +- Selecting a field by name when multiple physical siblings match, including inside structs, + arrays, and maps. Comet raises a duplicate-field error instead of resolving the collision. + Checks cover referenced columns, including predicates; unselected roots do not prevent + reading a unique field by name or field ID. Exact-name projections of unique children in + structs and arrays of structs remain supported. Casts that cannot use this pruning reject + byte-identical duplicate siblings anywhere in the decoded physical subtree, including maps. + Field-ID resolution retains precedence, but selecting a byte-identically duplicated physical + root name still raises a duplicate-field error, even when the requested field is renamed. + Names in separate groups do not collide. Spark may read a duplicate-bearing file with an + explicit schema in case-sensitive mode, but its choice of sibling depends on the field shape + and can produce unexpected values. Spark rejects schema inference from a single file with + duplicate names; inference across files can depend on merge order. + Resolution is tracked in [#5884](https://github.com/apache/datafusion-comet/issues/5884), + with mixed-type behavior in [#5964](https://github.com/apache/datafusion-comet/issues/5964). - Invalid UTF-8 bytes in `STRING` columns. Spark permits arbitrary byte sequences in a `STRING` column (for example from `CAST(X'C1' AS STRING)`), but Comet's native execution path is built on Arrow, whose string type is strictly UTF-8. Reading a Parquet file whose `STRING` column contains diff --git a/native/core/src/execution/operators/iceberg_scan.rs b/native/core/src/execution/operators/iceberg_scan.rs index fdcd6c8ba5a..9f465329ca0 100644 --- a/native/core/src/execution/operators/iceberg_scan.rs +++ b/native/core/src/execution/operators/iceberg_scan.rs @@ -619,6 +619,61 @@ mod tests { FileIOBuilder::new(Arc::new(OpenDalStorageFactory::Fs)).build() } + #[test] + fn issue_5783_projection_rejects_selected_duplicate_root() { + use arrow::array::Int64Array; + use arrow::datatypes::{DataType, Field, Schema as ArrowSchema}; + use datafusion_physical_expr_adapter::PhysicalExprAdapterFactory; + + let physical = Arc::new(ArrowSchema::new(vec![ + Field::new("a", DataType::Int64, false), + Field::new("a", DataType::Int64, false), + Field::new("b", DataType::Int64, false), + ])); + let mut options = super::SparkParquetOptions::new(super::EvalMode::Legacy, "UTC", false); + options.case_sensitive = true; + let factory = super::SparkPhysicalExprAdapterFactory::new(options, None); + for name in ["a", "b"] { + let target = Arc::new(ArrowSchema::new(vec![Field::new( + name, + DataType::Int64, + false, + )])); + let adapter = factory + .create(Arc::clone(&target), Arc::clone(&physical)) + .unwrap(); + let result = super::build_projection_expressions(&target, &adapter); + if name == "a" { + let error = result + .expect_err("selected root must be ambiguous") + .to_string(); + assert!(error.contains("duplicate"), "{error}"); + } else { + let batch = super::RecordBatch::try_new( + Arc::clone(&physical), + vec![ + Arc::new(Int64Array::from(vec![1])), + Arc::new(Int64Array::from(vec![2])), + Arc::new(Int64Array::from(vec![3])), + ], + ) + .unwrap(); + let output = + super::adapt_batch_with_expressions(batch, &target, &result.unwrap()).unwrap(); + assert_eq!(output.num_rows(), 1); + assert_eq!( + output + .column(0) + .as_any() + .downcast_ref::() + .unwrap() + .value(0), + 3 + ); + } + } + } + fn task_with_deletes(deletes: Vec) -> FileScanTask { FileScanTask::builder() .with_file_size_in_bytes(0) diff --git a/native/core/src/parquet/parquet_exec.rs b/native/core/src/parquet/parquet_exec.rs index 5d70f2aaa76..93ac29e824b 100644 --- a/native/core/src/parquet/parquet_exec.rs +++ b/native/core/src/parquet/parquet_exec.rs @@ -177,7 +177,7 @@ pub(crate) fn init_datasource_exec( // `store_sales`), the page index is re-fetched, uncached, on every open (comet#3978). // `EagerPageIndexReaderFactory` forces the page index to load on the first fetch and be // cached with the footer, at the cost of losing the skip's benefit when it would have - // applied. Filed upstream as apache/datafusion#23978; revert this once that's fixed. + // applied. Filed upstream as apache/datafusion#23978. // // Preserve bytes_scanned's existing requested data/Bloom-filter range accounting. Footer // and page-index reads through get_metadata bypass it, and coalescing may fetch extra bytes. diff --git a/native/core/src/parquet/parquet_support.rs b/native/core/src/parquet/parquet_support.rs index d6ac9bed636..521f95d4590 100644 --- a/native/core/src/parquet/parquet_support.rs +++ b/native/core/src/parquet/parquet_support.rs @@ -61,6 +61,10 @@ use super::objectstore::s3_blob_fs_support::{ normalize_object_store_url, NormalizedObjectStoreUrl, }; +pub(crate) fn duplicate_parquet_field_error(name: &str) -> DataFusionError { + DataFusionError::Execution(format!("Found duplicate Parquet field name '{name}'")) +} + // This file originates from cast.rs. While developing native scan support and implementing // SparkSchemaAdapter we observed that Spark's type conversion logic on Parquet reads does not // always align to the CAST expression's logic, so it was duplicated here to adapt its behavior. @@ -420,8 +424,8 @@ fn field_id(field: &arrow::datatypes::Field) -> Option { /// ID-bearing requested fields match ONLY by ID (a missing ID is a missing column, never a name /// fallback); other fields match by name, folded with the same `toLowerCase(Locale.ROOT)` fold /// the top-level schema adapter uses when `case_sensitive` is false. A requested field whose -/// folded name matches more than one file field in case-insensitive mode raises Spark's -/// `foundDuplicateFieldInCaseInsensitiveModeError`. +/// folded name matches more than one file field is rejected. Case-insensitive matching retains +/// Spark's `foundDuplicateFieldInCaseInsensitiveModeError`. /// /// Shared by the runtime convert (`parquet_convert_struct_to_struct`) and the plan-time /// conversion check in `schema_adapter`, so both resolve nested fields identically. @@ -473,14 +477,11 @@ pub(crate) fn match_struct_fields( // falling back to name match. (true, Some(id)) => Ok(from_id_to_index.get(&id).copied()), _ => match folded_to_indices.get(to_folded[to_pos].as_str()) { - // Mirror Spark's `foundDuplicateFieldInCaseInsensitiveModeError`: a - // requested field matching more than one file field is ambiguous. Gated on - // case-insensitive mode to match the top-level check (which only runs when - // `!case_sensitive`): when case-sensitive the fold is identity, so a - // collision means byte-identical sibling names, and raising an error whose - // message says "in case-insensitive mode" would be wrong. Fall through to - // the first match in that case. - Some(indices) if indices.len() > 1 && !parquet_options.case_sensitive => { + // Reject selected ambiguity before a decoder can multiply rows. + Some(indices) if indices.len() > 1 => { + if parquet_options.case_sensitive { + return Err(duplicate_parquet_field_error(to_field.name())); + } let matched: Vec<&str> = indices .iter() .map(|&i| from_fields[i].name().as_str()) diff --git a/native/core/src/parquet/schema_adapter.rs b/native/core/src/parquet/schema_adapter.rs index e06408b44e1..dc6a7799588 100644 --- a/native/core/src/parquet/schema_adapter.rs +++ b/native/core/src/parquet/schema_adapter.rs @@ -18,7 +18,7 @@ use crate::parquet::cast_column::CometCastColumnExpr; use crate::parquet::name_fold::{fold_name, fold_names, fold_schema_names}; use crate::parquet::parquet_support::{ - match_struct_fields, spark_parquet_convert, SparkParquetOptions, + duplicate_parquet_field_error, match_struct_fields, spark_parquet_convert, SparkParquetOptions, }; use arrow::array::new_empty_array; use arrow::compute::can_cast_types; @@ -97,6 +97,8 @@ fn schema_has_field_ids(schema: &SchemaRef) -> bool { /// (`build_projection_read_plan`'s cast-clipping, apache/datafusion#24090) can see the cast /// and read only the requested Parquet leaves, instead of falling back to a full-column read /// because it can't recognize `CometCastColumnExpr`. +/// This is also a decoder-safety obligation: returning true bypasses full-subtree duplicate +/// validation, so every omitted sibling must actually be clipped from the read. /// /// This is deliberately an allow list, not a deny list: it only recurses through the two /// container shapes `nested_struct::cast_column` actually implements (Struct, List / @@ -810,6 +812,42 @@ fn check_conversion( } } +/// A Comet cast is opaque to Parquet leaf clipping and decodes its entire input subtree. +/// Check physical byte-identical names, not requested-name resolution, before that read. +fn check_decoded_field_names(data_type: &DataType) -> DataFusionResult<()> { + match data_type { + DataType::Struct(fields) => { + let mut names = HashSet::with_capacity(fields.len()); + for field in fields { + if !names.insert(field.name()) { + return Err(duplicate_parquet_field_error(field.name())); + } + check_decoded_field_names(field.data_type())?; + } + } + DataType::List(field) + | DataType::LargeList(field) + | DataType::FixedSizeList(field, _) + | DataType::ListView(field) + | DataType::LargeListView(field) + | DataType::Map(field, _) => { + check_decoded_field_names(field.data_type())?; + } + DataType::Dictionary(_, value) => check_decoded_field_names(value)?, + _ => {} + } + Ok(()) +} + +/// Check an expression before it can decode its entire physical subtree. +fn checked_decoded_expr( + physical_type: &DataType, + expr: Arc, +) -> DataFusionResult> { + check_decoded_field_names(physical_type)?; + Ok(expr) +} + /// Whether `col_name` (with folded form `col_folded`) is case-insensitively ambiguous in the /// file. `folded_to_indices` maps each folded physical name to the indices of the original /// physical fields that fold to it (built once in `create`), so more than one index under the @@ -856,54 +894,62 @@ impl PhysicalExprAdapterFactory for SparkPhysicalExprAdapterFactory { let should_match_by_id = self.parquet_options.use_field_id && schema_has_field_ids(&logical_file_schema); let needs_remap = !case_sensitive || should_match_by_id; - let (adapted_physical_schema, logical_to_physical_names, original_physical_dup_check) = - if needs_remap { - let (remapped, logical_to_physical) = remap_physical_schema( - &logical_file_schema, - &physical_file_schema, - case_sensitive, - self.parquet_options.use_field_id, - self.parquet_options.ignore_missing_field_id, - )?; - // Build the folded-name -> original-physical-field-indices map once for per-column - // duplicate detection, paired with the original schema so the rare error path can - // resolve the colliding names. Only meaningful in case-insensitive mode; it mirrors - // the `folded_to_indices` map the nested convert builds in `parquet_support`, so - // both paths detect ambiguity the same way instead of drifting. - let original_physical_dup_check = if !case_sensitive { - let folded = fold_schema_names(&physical_file_schema, false)?; - let mut map: HashMap> = HashMap::new(); - for (i, folded_name) in folded.into_iter().enumerate() { - map.entry(folded_name).or_default().push(i); - } - Some((Arc::clone(&physical_file_schema), map)) - } else { + let (adapted_physical_schema, logical_to_physical_names) = if needs_remap { + let (remapped, logical_to_physical) = remap_physical_schema( + &logical_file_schema, + &physical_file_schema, + case_sensitive, + self.parquet_options.use_field_id, + self.parquet_options.ignore_missing_field_id, + )?; + ( + remapped, + if logical_to_physical.is_empty() { None - }; - ( - remapped, - if logical_to_physical.is_empty() { - None - } else { - Some(logical_to_physical) - }, - original_physical_dup_check, - ) - } else { - (Arc::clone(&physical_file_schema), None, None) - }; + } else { + Some(logical_to_physical) + }, + ) + } else { + (Arc::clone(&physical_file_schema), None) + }; // Fold both schemas once here so the per-column rewrite paths reuse them instead of // re-folding on every `rewrite` call. Case-sensitive mode folds to identity. let logical_folded = fold_schema_names(&logical_file_schema, case_sensitive)?; let physical_folded = fold_schema_names(&adapted_physical_schema, case_sensitive)?; + let original_folded = if Arc::ptr_eq(&adapted_physical_schema, &physical_file_schema) { + None + } else { + Some(fold_schema_names(&physical_file_schema, case_sensitive)?) + }; + let original_folded = original_folded.as_ref().unwrap_or(&physical_folded); + + // Only allocate per-column index vectors when a folded name actually collides. + let mut seen = HashSet::new(); + let mut collisions = HashSet::new(); + for name in original_folded { + if !seen.insert(name.as_str()) { + collisions.insert(name.as_str()); + } + } + let original_physical_dup_check = if collisions.is_empty() { + None + } else { + let mut duplicates: HashMap> = HashMap::new(); + for (i, name) in original_folded.iter().enumerate() { + if collisions.contains(name.as_str()) { + duplicates.entry(name.clone()).or_default().push(i); + } + } + Some((Arc::clone(&physical_file_schema), duplicates)) + }; // Folded names of logical fields that resolve by Parquet field id. Spark's `matchIdField` - // selects these by id before comparing names, so the case-insensitive duplicate check must + // selects these by id before comparing names, so the duplicate check must // skip them: an explicit `ω` (id 2) can select the file's `ω` (id 2) even when the file - // also holds `Ω` (id 1). Derived from `logical_folded`, which is the case-insensitive fold - // here since this only runs when `!case_sensitive`. - let id_resolved_logical_folded = if should_match_by_id && !case_sensitive { + // also holds `Ω` (id 1). Use the configured fold in both case modes. + let id_resolved_logical_folded = if should_match_by_id { Some( logical_file_schema .fields() @@ -917,6 +963,34 @@ impl PhysicalExprAdapterFactory for SparkPhysicalExprAdapterFactory { None }; + let id_duplicate_roots = if should_match_by_id { + let mut exact_names = HashSet::new(); + let mut duplicate_names = HashSet::new(); + for field in physical_file_schema.fields() { + if !exact_names.insert(field.name()) { + duplicate_names.insert(field.name()); + } + } + let duplicated_ids: HashMap = physical_file_schema + .fields() + .iter() + .filter(|f| duplicate_names.contains(f.name())) + .filter_map(|f| parse_field_id(f).map(|id| (id, f.name().clone()))) + .collect(); + logical_file_schema + .fields() + .iter() + .zip(&logical_folded) + .filter_map(|(field, folded)| { + parse_field_id(field) + .and_then(|id| duplicated_ids.get(&id)) + .map(|name| (folded.clone(), name.clone())) + }) + .collect() + } else { + HashMap::new() + }; + let default_factory = DefaultPhysicalExprAdapterFactory; let default_adapter = default_factory.create( Arc::clone(&logical_file_schema), @@ -932,6 +1006,7 @@ impl PhysicalExprAdapterFactory for SparkPhysicalExprAdapterFactory { logical_to_physical_names, original_physical_dup_check, id_resolved_logical_folded, + id_duplicate_roots, logical_folded, physical_folded, })) @@ -963,16 +1038,19 @@ struct SparkPhysicalExprAdapter { /// physical names so that downstream reassign_expr_columns can find /// columns in the actual stream schema. logical_to_physical_names: Option>, - /// Case-insensitive duplicate detection, built once in `create`: the original (un-remapped) + /// Duplicate detection, built once in `create`: the original (un-remapped) /// physical schema paired with a `folded physical name -> field indices` map. A referenced - /// column whose folded name maps to more than one index is the `_LEGACY_ERROR_TEMP_2093` - /// ambiguity Spark raises; the schema resolves the colliding names on that error path. `None` - /// in case-sensitive mode (no folding, so nothing to detect). + /// column whose folded name maps to more than one index is ambiguous. The schema resolves + /// colliding names on the error path. `None` when no names collide. original_physical_dup_check: Option<(SchemaRef, HashMap>)>, /// Folded names of logical fields resolved by Parquet field id (see `create`). Spark selects /// these by id before comparing names, so the duplicate check above must not fire for them. /// `None` when not matching by id. id_resolved_logical_folded: Option>, + /// Folded logical name -> byte-identical duplicate physical name. Populated only when + /// matching by field ID, then checked before the ID-resolved name skip so decoded duplicate + /// roots still fail. + id_duplicate_roots: HashMap, /// `logical_file_schema` field names pre-folded once (see `fold_names`), parallel to /// `logical_file_schema.fields()`. Lets the per-column rewrite fallbacks match by folded name /// without re-folding the schema on every `rewrite` call. @@ -984,8 +1062,7 @@ struct SparkPhysicalExprAdapter { impl PhysicalExprAdapter for SparkPhysicalExprAdapter { fn rewrite(&self, expr: Arc) -> DataFusionResult> { - // In case-insensitive mode, check if any Column in this expression references - // a field with multiple case-insensitive matches in the physical schema. + // Check if any Column references multiple physical fields under the configured fold. // Only the columns actually referenced trigger the error (not the whole schema). if let Some((orig_physical, folded_to_indices)) = &self.original_physical_dup_check { // Collect referenced column names, then fold them in one JVM crossing rather than one @@ -998,8 +1075,11 @@ impl PhysicalExprAdapter for SparkPhysicalExprAdapter { Ok(Transformed::no(e)) }); let col_refs: Vec<&str> = col_names.iter().map(|s| s.as_str()).collect(); - let col_folded = fold_names(&col_refs, false)?; + let col_folded = fold_names(&col_refs, self.parquet_options.case_sensitive)?; for (name, folded) in col_names.iter().zip(&col_folded) { + if let Some(physical_name) = self.id_duplicate_roots.get(folded) { + return Err(duplicate_parquet_field_error(physical_name)); + } // Fields resolved by Parquet field id are selected by id before names are // compared, so an id-resolved column must not trip the name-ambiguity check // (mirrors Spark's `matchIdField`, which never raises the duplicate-field error). @@ -1010,6 +1090,9 @@ impl PhysicalExprAdapter for SparkPhysicalExprAdapter { { continue; } + if self.parquet_options.case_sensitive && folded_to_indices.contains_key(folded) { + return Err(duplicate_parquet_field_error(name)); + } if let Some(err) = check_column_duplicate(name, folded, folded_to_indices, orig_physical) { @@ -1091,7 +1174,7 @@ impl SparkPhysicalExprAdapter { return Ok(expr); }; - Ok(Arc::new( + let cast: Arc = Arc::new( CometCastColumnExpr::try_new( expr, Arc::clone(physical_field), @@ -1099,7 +1182,8 @@ impl SparkPhysicalExprAdapter { None, )? .with_parquet_options(self.parquet_options.clone()), - )) + ); + checked_decoded_expr(physical_field.data_type(), cast) } /// Wrap ALL Column expressions that have type mismatches with CometCastColumnExpr. @@ -1168,13 +1252,17 @@ impl SparkPhysicalExprAdapter { physical_type: leaf_physical_type, target_type: leaf_target_type, } => { - return Ok(Transformed::yes(reject_on_non_empty_expr( + let rejected = reject_on_non_empty_expr( remapped, logical_field, &column, &leaf_physical_type, &leaf_target_type, - ))); + ); + return Ok(Transformed::yes(checked_decoded_expr( + physical_field.data_type(), + rejected, + )?)); } } @@ -1187,7 +1275,10 @@ impl SparkPhysicalExprAdapter { )? .with_parquet_options(self.parquet_options.clone()), ); - return Ok(Transformed::yes(cast_expr)); + return Ok(Transformed::yes(checked_decoded_expr( + physical_field.data_type(), + cast_expr, + )?)); } else if column.index() != phys_idx { return Ok(Transformed::yes(remapped)); } @@ -1228,12 +1319,13 @@ impl SparkPhysicalExprAdapter { let comet_cast: Arc = Arc::new( CometCastColumnExpr::try_new( child, - input_field, + Arc::clone(&input_field), Arc::clone(cast.target_field()), None, )? .with_parquet_options(self.parquet_options.clone()), ); + let comet_cast = checked_decoded_expr(physical_type, comet_cast)?; return Ok(Transformed::yes(comet_cast)); } @@ -1269,13 +1361,17 @@ impl SparkPhysicalExprAdapter { physical_type: leaf_physical_type, target_type: leaf_target_type, } => { - return Ok(Transformed::yes(reject_on_non_empty_expr( + let rejected = reject_on_non_empty_expr( child, cast.target_field(), &column, &leaf_physical_type, &leaf_target_type, - ))); + ); + return Ok(Transformed::yes(checked_decoded_expr( + physical_type, + rejected, + )?)); } } @@ -1340,12 +1436,13 @@ impl SparkPhysicalExprAdapter { let comet_cast: Arc = Arc::new( CometCastColumnExpr::try_new( child, - input_field, + Arc::clone(&input_field), Arc::clone(cast.target_field()), None, )? .with_parquet_options(self.parquet_options.clone()), ); + let comet_cast = checked_decoded_expr(physical_type, comet_cast)?; return Ok(Transformed::yes(comet_cast)); } @@ -1366,7 +1463,10 @@ impl SparkPhysicalExprAdapter { None, )); - return Ok(Transformed::yes(spark_cast as Arc)); + return Ok(Transformed::yes(checked_decoded_expr( + physical_type, + spark_cast as Arc, + )?)); } Ok(Transformed::no(expr)) @@ -2993,6 +3093,274 @@ mod test { Ok(()) } + #[test] + fn issue_5783_referenced_root_duplicate() { + for nullable in [false, true] { + let physical = Arc::new(Schema::new(vec![ + Field::new("a", DataType::Int64, false), + Field::new("a", DataType::Int64, false), + Field::new("b", DataType::Int64, false), + ])); + let logical = Arc::new(Schema::new(vec![ + Field::new("a", DataType::Int64, nullable), + Field::new("b", DataType::Int64, false), + ])); + let mut options = SparkParquetOptions::new(EvalMode::Legacy, "UTC", false); + options.case_sensitive = true; + let adapter = SparkPhysicalExprAdapterFactory::new(options, None) + .create(logical, physical) + .unwrap(); + let selected = adapter.rewrite(Arc::new(Column::new("a", 0))); + let error = selected + .expect_err("selected duplicate root must fail") + .to_string(); + assert!(error.contains("duplicate"), "{error}"); + assert!(!error.contains("case-insensitive"), "{error}"); + let predicate = datafusion::physical_expr::expressions::BinaryExpr::new( + Arc::new(Column::new("a", 0)), + datafusion::logical_expr::Operator::Gt, + Arc::new(datafusion::physical_expr::expressions::Literal::new( + datafusion::common::ScalarValue::Int64(Some(0)), + )), + ); + assert!(adapter + .rewrite(Arc::new(predicate)) + .unwrap_err() + .to_string() + .contains("duplicate")); + let safe = adapter.rewrite(Arc::new(Column::new("b", 1))).unwrap(); + assert_eq!(safe.downcast_ref::().unwrap().index(), 2); + } + } + + #[test] + fn issue_5783_nested_name_duplicate() { + let fields = vec![ + Arc::new(Field::new("dup", DataType::Int64, true)), + Arc::new(Field::new("dup", DataType::Int64, true)), + ]; + let mut options = SparkParquetOptions::new(EvalMode::Legacy, "UTC", false); + options.case_sensitive = true; + let error = super::match_struct_fields(&fields, &fields[..1], &options) + .expect_err("selected duplicate child must fail") + .to_string(); + assert!(error.contains("duplicate"), "{error}"); + assert!(!error.contains("case-insensitive"), "{error}"); + } + + #[test] + fn issue_5783_fallback_deferred_rejection_checks_decoded_subtree() { + let logical = struct_schema(vec![ + Field::new("other", DataType::Int32, true), + Field::new("force_fallback", DataType::Int32, true), + ]); + for duplicate in [false, true] { + let mut fields = vec![ + Field::new("other", DataType::Int64, true), + Field::new( + "force_fallback", + DataType::List(Arc::new(Field::new("element", DataType::Int32, true))), + true, + ), + Field::new("dup", DataType::Int64, true), + ]; + if duplicate { + fields.push(Field::new("dup", DataType::Int64, true)); + } + let physical = struct_schema(fields); + let column: Arc = Arc::new(Column::new("s", 0)); + let default = super::DefaultPhysicalExprAdapterFactory + .create(Arc::clone(&logical), Arc::clone(&physical)) + .unwrap(); + assert!( + default.rewrite(Arc::clone(&column)).is_err(), + "fixture must reach the default-adapter fallback" + ); + let mut options = SparkParquetOptions::new(EvalMode::Legacy, "UTC", false); + options.case_sensitive = true; + let adapter = SparkPhysicalExprAdapterFactory::new(options, None) + .create(Arc::clone(&logical), Arc::clone(&physical)) + .unwrap(); + let result = adapter.rewrite(column); + if duplicate { + let error = result + .expect_err("decoded duplicates must fail") + .to_string(); + assert!( + error.contains("duplicate Parquet field name 'dup'"), + "{error}" + ); + } else { + let expr = result.unwrap(); + assert!(expr.downcast_ref::().is_some()); + let empty = RecordBatch::new_empty(physical); + assert_eq!( + expr.evaluate(&empty).unwrap().into_array(0).unwrap().len(), + 0 + ); + } + } + } + + #[tokio::test] + async fn issue_5783_non_pruning_scan_rejects_before_output() { + for mode in ["deferred", "missing", "dictionary"] { + let values: ArrayRef = Arc::new(Int64Array::from(vec![900, 901, 902])); + let other = if mode == "dictionary" { + arrow::compute::cast( + &values, + &DataType::Dictionary(Box::new(DataType::Int32), Box::new(DataType::Int64)), + ) + .unwrap() + } else { + Arc::clone(&values) + }; + let fields = Fields::from(vec![ + Field::new("dup", DataType::Int64, true), + Field::new("dup", DataType::Int64, true), + Field::new("other", other.data_type().clone(), true), + ]); + let array = StructArray::new( + fields.clone(), + vec![Arc::clone(&values), values, other], + None, + ); + let batch = RecordBatch::try_new( + struct_schema(fields.iter().map(|f| f.as_ref().clone()).collect()), + vec![Arc::new(array)], + ) + .unwrap(); + let mut requested = vec![Field::new( + "other", + if mode == "deferred" { + DataType::Int32 + } else { + DataType::Int64 + }, + true, + )]; + if mode == "missing" { + requested.push(Field::new("missing", DataType::Int64, true)); + } + let mut options = SparkParquetOptions::new(EvalMode::Legacy, "UTC", false); + options.case_sensitive = true; + let mut stream = scan_parquet(&batch, struct_schema(requested), options).unwrap(); + let error = stream + .next() + .await + .unwrap() + .expect_err("must fail before first batch"); + assert!( + error + .to_string() + .contains("duplicate Parquet field name 'dup'"), + "{mode}: {error}" + ); + } + } + + #[tokio::test] + async fn issue_5783_physical_duplicates_survive_arrow_and_fail_before_output() { + use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder; + + for shape in 0..5 { + let mut children = vec![ + Field::new("dup", DataType::Int64, true), + Field::new("dup", DataType::Int64, true), + ]; + if shape == 1 { + children.push(Field::new("dup", DataType::Int64, true)); + } + if shape == 2 { + children.push(Field::new("other", DataType::Int64, true)); + } + let fields = Fields::from(children); + let values: ArrayRef = Arc::new(Int64Array::from(vec![0, 1, 2])); + let mut array: ArrayRef = Arc::new(StructArray::new( + fields.clone(), + (0..fields.len()).map(|_| Arc::clone(&values)).collect(), + None, + )); + let mut requested = + DataType::Struct(Fields::from(vec![Field::new("dup", DataType::Int64, true)])); + if shape == 3 { + array = Arc::new(ListArray::new( + Arc::new(Field::new("element", array.data_type().clone(), true)), + OffsetBuffer::new(vec![0, 1, 2, 3].into()), + array, + None, + )); + requested = DataType::List(Arc::new(Field::new("element", requested, true))); + } + if shape == 4 { + let entries = StructArray::new( + Fields::from(vec![ + Field::new("key", DataType::Utf8, false), + Field::new("value", array.data_type().clone(), true), + ]), + vec![ + Arc::new(arrow::array::StringArray::from(vec!["k", "k", "k"])), + array, + ], + None, + ); + array = Arc::new(arrow::array::MapArray::new( + Arc::new(Field::new("entries", entries.data_type().clone(), false)), + OffsetBuffer::new(vec![0, 1, 2, 3].into()), + entries, + None, + false, + )); + requested = DataType::Map( + Arc::new(Field::new( + "entries", + DataType::Struct(Fields::from(vec![ + Field::new("key", DataType::Utf8, false), + Field::new("value", requested, true), + ])), + false, + )), + false, + ); + } + let physical = Arc::new(Schema::new(vec![Field::new( + "s", + array.data_type().clone(), + true, + )])); + let batch = RecordBatch::try_new(physical, vec![array]).unwrap(); + let required = Arc::new(Schema::new(vec![Field::new("s", requested, true)])); + let mut bytes = Vec::new(); + let mut writer = ArrowWriter::try_new(&mut bytes, batch.schema(), None).unwrap(); + writer.write(&batch).unwrap(); + writer.close().unwrap(); + let reader = + ParquetRecordBatchReaderBuilder::try_new(bytes::Bytes::from(bytes)).unwrap(); + assert_eq!( + reader.schema().field(0).data_type(), + batch.schema().field(0).data_type() + ); + assert_ne!( + reader.schema().field(0).data_type(), + required.field(0).data_type() + ); + let mut options = SparkParquetOptions::new(EvalMode::Legacy, "UTC", false); + options.case_sensitive = true; + let mut stream = scan_parquet(&batch, required, options).unwrap(); + let error = stream + .next() + .await + .unwrap() + .expect_err("duplicate must fail before first batch"); + assert!( + error + .to_string() + .contains("duplicate Parquet field name 'dup'"), + "{error}" + ); + } + } + #[tokio::test] async fn parquet_duplicate_fields_case_insensitive() { // Parquet file has columns "A", "B", "b" - reading "b" in case-insensitive mode @@ -3229,6 +3597,57 @@ mod test { .has_valid_extension_type::()); } + #[test] + fn variant_with_duplicate_physical_children_is_rejected() { + let physical_type = DataType::Struct(Fields::from(vec![ + Field::new("value", DataType::Binary, false), + Field::new("value", DataType::Binary, false), + Field::new("metadata", DataType::Binary, false), + ])); + let canonical_type = DataType::Struct(Fields::from(vec![ + Field::new("value", DataType::Binary, false), + Field::new("metadata", DataType::Binary, false), + ])); + for logical_type in [physical_type.clone(), canonical_type] { + let identical = logical_type == physical_type; + let logical = Arc::new(Schema::new(vec![ + Field::new("v", logical_type, true).with_extension_type(VariantType) + ])); + let physical = Arc::new(Schema::new(vec![Field::new( + "v", + physical_type.clone(), + true, + ) + .with_extension_type(VariantType)])); + let default = super::DefaultPhysicalExprAdapterFactory + .create(Arc::clone(&logical), Arc::clone(&physical)) + .unwrap() + .rewrite(Arc::new(Column::new("v", 0))) + .unwrap(); + if identical { + assert!(default.downcast_ref::().is_some()); + } else { + assert!(default + .downcast_ref::() + .is_some()); + } + let adapter = SparkPhysicalExprAdapterFactory::new( + SparkParquetOptions::new(EvalMode::Legacy, "UTC", false), + None, + ) + .create(logical, physical) + .unwrap(); + let error = adapter + .rewrite(Arc::new(Column::new("v", 0))) + .expect_err("variant decoding must reject duplicate physical children") + .to_string(); + assert!( + error.contains("duplicate Parquet field name 'value'"), + "{error}" + ); + } + } + #[test] fn variant_field_id_wins_over_a_shadowing_name() { let storage = DataType::Struct(Fields::from(vec![ diff --git a/spark/src/test/scala/org/apache/comet/exec/CometNativeReaderSuite.scala b/spark/src/test/scala/org/apache/comet/exec/CometNativeReaderSuite.scala index 93567756d75..61fdd35bca5 100644 --- a/spark/src/test/scala/org/apache/comet/exec/CometNativeReaderSuite.scala +++ b/spark/src/test/scala/org/apache/comet/exec/CometNativeReaderSuite.scala @@ -54,6 +54,235 @@ class CometNativeReaderSuite extends CometTestBase with AdaptiveSparkPlanHelper } } + private def causeMessages(error: Throwable): String = + causeChain(error).flatMap(e => Option(e.getMessage)).mkString("\n") + + test("duplicate Parquet field names - Spark logical schema legality") { + withTempPath { path => + withSQLConf(CometConf.COMET_ENABLED.key -> "false") { + spark.range(3).coalesce(1).write.parquet(path.toString) + Seq( + "s struct", + "s array>", + "s map>", + "s struct>").foreach { schema => + val error = intercept[org.apache.spark.sql.AnalysisException] { + spark.read.schema(schema).parquet(path.toString).collect() + } + assert(error.getMessage.contains("COLUMN_ALREADY_EXISTS"), error.getMessage) + } + } + } + } + + Seq( + ("two children", "named_struct('dup', id, 'dup', id + 100)", "struct"), + ( + "three children", + "named_struct('dup', id, 'dup', id + 100, 'dup', id + 200)", + "struct"), + ( + "distinct sibling", + "named_struct('dup', id, 'dup', id + 100, 'other', id + 900)", + "struct"), + ( + "array element", + "array(named_struct('dup', id, 'dup', id + 100))", + "array>"), + ( + "map value", + "map('key', named_struct('dup', id, 'dup', id + 100))", + "map>")).foreach { case (shape, expression, readType) => + Seq(1, 4096).foreach { batchSize => + test(s"duplicate Parquet field names fail clearly - $shape batch $batchSize") { + withSQLConf( + SQLConf.CASE_SENSITIVE.key -> "true", + CometConf.COMET_BATCH_SIZE.key -> batchSize.toString) { + withTempPath { path => + withSQLConf(CometConf.COMET_ENABLED.key -> "false") { + spark + .range(3) + .coalesce(1) + .selectExpr(s"$expression as s") + .write + .parquet(path.toString) + // The file is readable by Spark with an explicit schema. + assert( + spark.read.schema(s"s $readType").parquet(path.toString).collect().length == 3) + } + val df = spark.read.schema(s"s $readType").parquet(path.toString) + assert( + find(df.queryExecution.executedPlan)(_.isInstanceOf[CometNativeScanExec]).isDefined) + val error = intercept[Exception](df.collect()) + val messages = causeMessages(error) + assert(messages.contains("duplicate Parquet field name 'dup'"), messages) + assert(!messages.toLowerCase.contains("case-insensitive"), messages) + } + } + } + } + } + + test("duplicate Parquet field names - multiple files and schema merge") { + withTempPath { cleanPath => + withTempPath { duplicatePath => + withSQLConf(CometConf.COMET_ENABLED.key -> "false") { + spark + .range(3) + .coalesce(1) + .selectExpr("named_struct('dup', id, 'other', id + 900) as s") + .write + .parquet(cleanPath.toString) + spark + .range(3) + .coalesce(1) + .selectExpr("named_struct('dup', id, 'dup', id + 100, 'other', id + 900) as s") + .write + .parquet(duplicatePath.toString) + } + val paths = Seq(cleanPath.toString, duplicatePath.toString) + Seq("true", "false").foreach { mergeSchema => + Seq(true, false).foreach { caseSensitive => + withSQLConf(SQLConf.CASE_SENSITIVE.key -> caseSensitive.toString) { + // mergeSchema only affects inference; an explicit schema reads the unique sibling. + val unique = spark.read + .option("mergeSchema", mergeSchema) + .schema("s struct") + .parquet(paths: _*) + assert(find(unique.queryExecution.executedPlan)( + _.isInstanceOf[CometNativeScanExec]).isDefined) + checkSparkAnswerAndOperator(unique) + checkAnswer(unique, Seq(900L, 901L, 902L, 900L, 901L, 902L).map(n => Row(Row(n)))) + checkAnswer( + unique.where("s.other >= 901"), + Seq(901L, 902L, 901L, 902L).map(n => Row(Row(n)))) + val duplicate = spark.read + .option("mergeSchema", mergeSchema) + .schema("s struct") + .parquet(paths: _*) + assert(find(duplicate.queryExecution.executedPlan)( + _.isInstanceOf[CometNativeScanExec]).isDefined) + val error = intercept[Exception](duplicate.collect()) + assert(causeMessages(error).toLowerCase.contains("duplicate"), causeMessages(error)) + } + } + } + withSQLConf(CometConf.COMET_ENABLED.key -> "false") { + val inferred = intercept[org.apache.spark.sql.AnalysisException] { + spark.read.parquet(duplicatePath.toString).schema + } + assert(inferred.getMessage.contains("COLUMN_ALREADY_EXISTS"), inferred.getMessage) + } + } + } + } + + test("duplicate Parquet field names - unprojected fields and repeated reads") { + withTempPath { path => + withSQLConf(CometConf.COMET_ENABLED.key -> "false", SQLConf.CASE_SENSITIVE.key -> "true") { + spark + .range(3) + .selectExpr("id", "named_struct('dup', id, 'dup', id + 100) as s") + .write + .parquet(path.toString) + } + Seq(true, false).foreach { caseSensitive => + withSQLConf(SQLConf.CASE_SENSITIVE.key -> caseSensitive.toString) { + val name = if (caseSensitive) "id" else "ID" + val df = spark.read + .schema(s"$name bigint, s struct") + .parquet(path.toString) + .select(name) + assert( + find(df.queryExecution.executedPlan)(_.isInstanceOf[CometNativeScanExec]).isDefined) + (1 to 2).foreach { _ => + checkAnswer(df, Seq(Row(0L), Row(1L), Row(2L))) + checkAnswer(df.where("id > 1000"), Seq.empty) + checkAnswer(df.selectExpr("count(*)"), Seq(Row(3L))) + } + } + } + } + } + + test("duplicate Parquet field names - root group and unprojected root duplicates") { + withSQLConf(SQLConf.CASE_SENSITIVE.key -> "true") { + withTempPath { path => + writeDirect( + path.toString, + "message spark_schema { optional int64 a = 1; optional int64 a = 2; optional int64 b = 3; }", + { rc => + rc.startMessage() + Seq(("a", 0, 1L), ("a", 1, 2L), ("b", 2, 3L)).foreach { case (name, index, value) => + rc.startField(name, index) + rc.addLong(value) + rc.endField(name, index) + } + rc.endMessage() + }) + withSQLConf(CometConf.COMET_ENABLED.key -> "false") { + checkAnswer(spark.read.schema("a bigint").parquet(path.toString), Seq(Row(1L))) + } + val selected = spark.read.schema("a bigint").parquet(path.toString) + assert( + find(selected.queryExecution.executedPlan)( + _.isInstanceOf[CometNativeScanExec]).isDefined) + val error = intercept[Exception](selected.collect()) + val messages = causeMessages(error) + assert(messages.contains("duplicate Parquet field name 'a'"), messages) + val valid = spark.read.schema("b bigint").parquet(path.toString) + assert( + find(valid.queryExecution.executedPlan)(_.isInstanceOf[CometNativeScanExec]).isDefined) + (1 to 2).foreach { _ => checkAnswer(valid, Seq(Row(3L))) } + withSQLConf(SQLConf.PARQUET_FIELD_ID_READ_ENABLED.key -> "true") { + val schema = new StructType().add( + "renamed_b", + LongType, + nullable = true, + new MetadataBuilder().putLong("parquet.field.id", 3L).build()) + val byId = spark.read.schema(schema).parquet(path.toString) + assert( + find(byId.queryExecution.executedPlan)(_.isInstanceOf[CometNativeScanExec]).isDefined) + (1 to 2).foreach { _ => checkAnswer(byId, Seq(Row(3L))) } + for (id <- Seq(1L, 2L); name <- Seq("a", "renamed_a")) { + val duplicateSchema = new StructType().add( + name, + LongType, + nullable = true, + new MetadataBuilder().putLong("parquet.field.id", id).build()) + val duplicate = spark.read.schema(duplicateSchema).parquet(path.toString) + val error = intercept[Exception](duplicate.collect()) + val messages = causeMessages(error) + assert(messages.contains("duplicate Parquet field name 'a'"), messages) + } + } + } + } + } + + test( + "duplicate Parquet field names - distinct siblings and repeated names in separate groups") { + withSQLConf(SQLConf.CASE_SENSITIVE.key -> "true") { + withTempPath { path => + withSQLConf(CometConf.COMET_ENABLED.key -> "false") { + spark + .range(3) + .selectExpr( + "named_struct('dup', id, 'Dup', id + 100) as s", + "named_struct('dup', id + 200) as t") + .write + .parquet(path.toString) + } + def read = spark.read + .schema("s struct, t struct") + .parquet(path.toString) + assert( + find(read.queryExecution.executedPlan)(_.isInstanceOf[CometNativeScanExec]).isDefined) + checkSparkAnswer(read) + } + } + } + test("native reader case sensitivity") { withTempPath { path => spark.range(10).toDF("a").write.parquet(path.toString) @@ -249,6 +478,105 @@ class CometNativeReaderSuite extends CometTestBase with AdaptiveSparkPlanHelper } } + test("duplicate Parquet field names - exact-name projection works in both resolver modes") { + withTempPath { path => + withSQLConf(CometConf.COMET_ENABLED.key -> "false") { + spark + .range(3) + .coalesce(1) + .selectExpr("named_struct('dup', id, 'dup', id + 100, 'other', id + 900) as s") + .write + .parquet(path.toString) + } + Seq(true, false).foreach { caseSensitive => + withSQLConf(SQLConf.CASE_SENSITIVE.key -> caseSensitive.toString) { + val df = spark.read.schema("s struct").parquet(path.toString) + assert( + find(df.queryExecution.executedPlan)(_.isInstanceOf[CometNativeScanExec]).isDefined) + checkSparkAnswerAndOperator(df) + checkAnswer(df, Seq(Row(Row(900L)), Row(Row(901L)), Row(Row(902L)))) + } + } + } + } + + Seq( + ( + "map", + "map('k', named_struct('dup', id, 'dup', id + 100, 'other', id + 900))", + "s map>"), + ( + "case", + "named_struct('dup', id, 'dup', id + 100, 'other', id + 900)", + "S struct")).foreach { case (shape, expression, schema) => + test(s"duplicate Parquet field names - non-pruning $shape fails clearly") { + withTempPath { path => + withSQLConf(CometConf.COMET_ENABLED.key -> "false") { + spark.range(3).coalesce(1).selectExpr(s"$expression as s").write.parquet(path.toString) + assert(spark.read.schema(schema).parquet(path.toString).collect().length == 3) + } + withSQLConf(SQLConf.CASE_SENSITIVE.key -> "false") { + val df = spark.read.schema(schema).parquet(path.toString) + assert( + find(df.queryExecution.executedPlan)(_.isInstanceOf[CometNativeScanExec]).isDefined) + val error = intercept[Exception](df.collect()) + val messages = causeMessages(error) + assert(messages.contains("duplicate Parquet field name 'dup'"), messages) + } + } + } + } + + Seq( + ( + "struct", + "named_struct('dup', id, 'dup', id + 100, 'other', id + 900)", + "struct", + "s.other", + (n: Long) => Row(Row(n))), + ( + "deeper", + "named_struct('t', named_struct('dup', id, 'dup', id + 100, 'other', id + 900))", + "struct>", + "s.t.other", + (n: Long) => Row(Row(Row(n)))), + ( + "array", + "array(named_struct('dup', id, 'dup', id + 100, 'other', id + 900))", + "array>", + "s[0].other", + (n: Long) => Row(Seq(Row(n))))) + .foreach { case (shape, expression, readType, predicate, expected) => + test(s"duplicate Parquet field names - $shape projection and positive filter") { + withTempPath { path => + withSQLConf(CometConf.COMET_ENABLED.key -> "false") { + spark + .range(3) + .coalesce(1) + .selectExpr(s"$expression as s") + .write + .parquet(path.toString) + } + Seq(1, 4096).foreach { batchSize => + withSQLConf( + SQLConf.CASE_SENSITIVE.key -> "true", + CometConf.COMET_BATCH_SIZE.key -> batchSize.toString, + CometConf.COMET_PARQUET_ROW_FILTER_PUSHDOWN_ENABLED.key -> "true") { + val df = spark.read.schema(s"s $readType").parquet(path.toString) + assert( + find(df.queryExecution.executedPlan)( + _.isInstanceOf[CometNativeScanExec]).isDefined) + checkAnswer(df, Seq(900L, 901L, 902L).map(expected)) + checkSparkAnswer(df) + val filtered = df.where(s"$predicate >= 901") + checkAnswer(filtered, Seq(901L, 902L).map(expected)) + checkSparkAnswer(filtered) + } + } + } + } + } + test("native reader - read simple STRUCT fields") { testSingleLineQuery( """