sunchao commented on code in PR #5932:
URL: https://github.com/apache/datafusion-comet/pull/5932#discussion_r4012091659


##########
native/core/src/parquet/schema_adapter.rs:
##########
@@ -911,6 +1088,29 @@ impl PhysicalExprAdapterFactory for 
SparkPhysicalExprAdapterFactory {
             None
         };
 
+        // Compare the file's VARIANT annotations against the requested types 
before handing the
+        // schemas to the default adapter. `adapted_physical_schema` is used 
so that field-id and
+        // case-insensitive resolution has already aligned the two sides' 
top-level names.
+        if !self.parquet_options.ignore_variant_annotation {
+            let mut physical_by_folded: HashMap<&str, usize> = HashMap::new();
+            for (i, name) in physical_folded.iter().enumerate() {
+                physical_by_folded.entry(name.as_str()).or_insert(i);
+            }
+            let mut path = Vec::new();
+            for (logical_field, folded) in 
logical_file_schema.fields().iter().zip(&logical_folded)

Review Comment:
   ### Correctness
   
   [P2] Could this validation use the actual requested read schema rather than 
every field of `logical_file_schema`? `CometNativeScan` keeps an ordinary 
struct in `nativeDataSchema` even when it is unprojected, and 
`init_datasource_exec` passes that full schema to `ParquetSource` with a 
separate projection. DataFusion 55.1.0 consequently calls this factory with the 
full schema before rewriting the projection. For a file with `id INT, v 
VARIANT(1)`, a read declared as `id INT, v STRUCT<value BINARY, metadata 
BINARY>` followed by `.select("id")` now fails on `v`, although no read of `v` 
was requested. Spark clips its Parquet schema to the requested columns before 
conversion. Please keep the eager check for requested fields, including empty 
files, and add a regression covering an omitted annotated root.



##########
native/core/src/parquet/schema_adapter.rs:
##########
@@ -434,6 +434,183 @@ fn is_string_or_binary(dt: &DataType) -> bool {
     )
 }
 
+/// Approximate Spark's `DataType.sql` for the variant rejection message. 
`spark_catalog_name`
+/// bottoms out at "unknown" for nested types, which is exactly the shape a 
hand-written Variant
+/// read schema has, so the nested cases are spelled out here.
+///
+/// This is close to `DataType.sql` but not identical. Spark's 
`StructField.sql` wraps names in
+/// `QuotingUtils.quoteIfNeeded` and appends the nullability and comment DDL, 
none of which is
+/// reproduced here, so a field named `has space` renders bare. The whole 
message already differs
+/// from Spark's by design (see `variant_annotation_err`), and the rendering 
exists to tell a user
+/// which type they asked for, so it is kept simple rather than made 
byte-identical.
+fn spark_read_type_name(dt: &DataType) -> String {
+    match dt {
+        DataType::Struct(fields) => {
+            let rendered = fields
+                .iter()
+                .map(|field| {
+                    format!(
+                        "{}: {}",
+                        field.name(),
+                        spark_read_type_name(field.data_type())
+                    )
+                })
+                .collect::<Vec<_>>()
+                .join(", ");
+            format!("STRUCT<{rendered}>")
+        }
+        DataType::List(item) | DataType::LargeList(item) => {
+            format!("ARRAY<{}>", spark_read_type_name(item.data_type()))
+        }
+        DataType::Map(entries, _) => match entries.data_type() {
+            DataType::Struct(fields) if fields.len() == 2 => format!(
+                "MAP<{}, {}>",
+                spark_read_type_name(fields[0].data_type()),
+                spark_read_type_name(fields[1].data_type())
+            ),
+            other => format!("MAP<{}>", spark_read_type_name(other)),
+        },
+        other => spark_catalog_name(other).to_uppercase(),
+    }
+}
+
+/// Build the carrier for a Parquet field whose VARIANT logical type 
annotation is incompatible
+/// with the requested Spark read type. The JVM shim turns these into Spark's
+/// `_LEGACY_ERROR_TEMP_3071` `AnalysisException`.
+///
+/// `column` is the dotted path to the offending field. Spark's own message 
interpolates the
+/// Parquet `Type` itself, roughly `optional group v (VARIANT(1)) { ... }`, so 
the two messages
+/// deliberately differ. `ParquetVariantShreddingSuite` matches on `Invalid 
Spark read type` and
+/// accepts either, and a path is the more useful of the two once the field is 
nested.
+fn variant_annotation_err(column: &str, target_type: &DataType) -> 
DataFusionError {
+    
DataFusionError::External(Box::new(SparkError::ParquetVariantAnnotationMismatch 
{
+        file_path: String::new(),
+        column: column.to_string(),
+        spark_type: spark_read_type_name(target_type),
+    }))
+}
+
+/// Whether `field` is marked as a Variant. On a physical field the marker is 
the Parquet VARIANT
+/// logical type annotation, which arrow-rs surfaces as the 
`arrow.parquet.variant` Arrow
+/// extension type for struct fields, list elements and map values alike. On a 
requested field it
+/// is the same marker Comet's serde applies for `VariantType`. 
`check_variant_annotation` relies
+/// on both sides using it, so it compares them through this one predicate.
+///
+/// The marker does not carry the annotation's spec version, and Spark keys 
its handling off that
+/// version. `ParquetToSparkSchemaConverter.convertGroupField` matches
+/// `case v: VariantLogicalTypeAnnotation if v.getSpecVersion == 1`, and an 
annotation failing
+/// that guard falls through to `case _ => throw 
unrecognizedParquetTypeError(...)`, which is
+/// `PARQUET_TYPE_NOT_RECOGNIZED`. arrow-rs matches `LogicalType::Variant(_)` 
for any version, so
+/// this predicate cannot tell the two apart. Spark's writer emits version 1. 
An Arrow-based
+/// writer emits no version at all, which parquet-java reads back as 0.
+///
+/// Both engines therefore reject a non-v1 annotation, with different error 
classes: Spark raises
+/// `PARQUET_TYPE_NOT_RECOGNIZED` and Comet raises `_LEGACY_ERROR_TEMP_3071`.
+/// `variant_annotation_without_a_spec_version_is_also_rejected` pins that.
+///
+/// The one behavioral gap is `ignore_variant_annotation` on a non-v1 
annotation. Spark's guard
+/// fails before it reaches its own ignore branch, so Spark still raises
+/// `PARQUET_TYPE_NOT_RECOGNIZED`, while Comet skips the check entirely and 
reads the plain
+/// struct. Comet is the more permissive of the two there. Closing it would 
mean pairing the
+/// requested schema against the Parquet `SchemaDescriptor` rather than the 
Arrow schema, which
+/// duplicates the field-id and case-folding rules `check_variant_annotation` 
reuses. Closing it
+/// means either reading the version from the Parquet schema or arrow-rs 
carrying it on the
+/// extension type.
+fn is_variant_marked(field: &Field) -> bool {
+    field.has_valid_extension_type::<VariantType>()
+}
+
+/// Reject reading a Parquet field that carries the VARIANT logical type 
annotation as anything
+/// other than Spark's `VariantType`, mirroring the 
`checkConversionRequirement` in Spark's
+/// `ParquetToSparkSchemaConverter.convertGroupField`.
+///
+/// Comet's scan never runs Spark's schema converter, so this is the only 
place the file's
+/// annotation is ever compared against the requested type. The two sides are 
compared
+/// symmetrically through `is_variant_marked`: a marked request is a 
legitimate Variant read (see
+/// `parquet_exec::init_datasource_exec`'s `projects_variant`) and must not be 
rejected.
+/// Identifying a Variant by its `value`/`metadata` child names instead would 
misclassify ordinary
+/// structs, which is what apache/datafusion-comet#5741 rules out.
+///
+/// Runs once per file when the reader opens it, before any row group is 
inspected, so a file with
+/// no row groups is rejected too. That matches Spark, which rejects while 
converting the schema,
+/// and is the opposite of the type-promotion checks in this module, which 
`RejectOnNonEmpty`
+/// defers to execution to match Spark's per-row-group behavior.
+fn check_variant_annotation(
+    logical: &FieldRef,
+    physical: &FieldRef,
+    parquet_options: &SparkParquetOptions,
+    path: &mut Vec<String>,
+) -> DataFusionResult<()> {
+    path.push(logical.name().clone());
+    if is_variant_marked(physical) && !is_variant_marked(logical) {
+        return Err(variant_annotation_err(&path.join("."), 
logical.data_type()));
+    }
+    match (logical.data_type(), physical.data_type()) {
+        (DataType::Struct(logical_fields), DataType::Struct(physical_fields)) 
=> {
+            // Pair nested fields the same way `spark_parquet_convert` does, 
so the field this
+            // check inspects is the one the read will actually pull from the 
file: when the
+            // logical struct carries Parquet field IDs anywhere, ID-bearing 
logical fields match
+            // ONLY by ID and the rest fall back to the folded name. A logical 
field with no
+            // counterpart in the file is null-filled and carries no 
annotation to check.
+            let should_match_by_id = parquet_options.use_field_id
+                && logical_fields.iter().any(|f| parse_field_id(f).is_some());
+            let physical_id_to_index: HashMap<i32, usize> = if 
should_match_by_id {
+                let mut map = HashMap::new();
+                for (i, field) in physical_fields.iter().enumerate() {
+                    if let Some(id) = parse_field_id(field) {
+                        map.entry(id).or_insert(i);
+                    }
+                }
+                map
+            } else {
+                HashMap::new()
+            };
+            let physical_names = physical_fields
+                .iter()
+                .map(|field| field.name().as_str())
+                .collect::<Vec<_>>();
+            let physical_folded = fold_names(&physical_names, 
parquet_options.case_sensitive);
+            // First match wins on a folded-name collision, as 
`spark_parquet_convert` does when it
+            // falls through its ambiguity check; `collect` would arbitrarily 
keep the last.
+            let mut folded_to_index: HashMap<&str, usize> = HashMap::new();
+            for (i, folded) in physical_folded.iter().enumerate() {
+                folded_to_index.entry(folded.as_str()).or_insert(i);
+            }
+
+            for logical_child in logical_fields {
+                let physical_index = match (should_match_by_id, 
parse_field_id(logical_child)) {
+                    (true, Some(id)) => physical_id_to_index.get(&id).copied(),
+                    _ => {
+                        let folded =
+                            fold_name(logical_child.name(), 
parquet_options.case_sensitive);
+                        folded_to_index.get(folded.as_str()).copied()
+                    }
+                };
+                if let Some(i) = physical_index {
+                    check_variant_annotation(
+                        logical_child,
+                        &physical_fields[i],
+                        parquet_options,
+                        path,
+                    )?;
+                }
+            }
+        }
+        (DataType::List(logical_item), DataType::List(physical_item))
+        | (DataType::LargeList(logical_item), 
DataType::LargeList(physical_item))
+        | (DataType::List(logical_item), DataType::LargeList(physical_item))
+        | (DataType::LargeList(logical_item), DataType::List(physical_item)) 
=> {
+            check_variant_annotation(logical_item, physical_item, 
parquet_options, path)?;
+        }
+        (DataType::Map(logical_entries, _), DataType::Map(physical_entries, 
_)) => {

Review Comment:
   ### Correctness
   
   [P2] Could the map branch validate the key and value by position instead of 
recursing through the entries struct's name/field-ID matcher? The requested 
Arrow map uses `key`/`value`, but parquet-rs retains the file's child names. If 
the second child is named `payload` and carries `VARIANT(1)`, the struct 
matcher finds no `value` field and skips its annotation. Both 
`check_conversion` and `parquet_convert_map_to_map` still read that second 
child positionally, so the incompatible plain-struct map value gets past this 
new guard. Spark's map reader also selects children 0 and 1 rather than 
requiring those names. Please use the same positional pairing here and cover an 
annotated map value whose Parquet field name differs from `value`.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


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

Reply via email to