@@ -102,43 +102,6 @@ fn remap_physical_schema(
102102 ) ) ) ;
103103 }
104104
105- // Spark resolves case-insensitive names by Unicode lowercasing (`toLowerCase(Locale.ROOT)`
106- // in `ParquetReadSupport`), while the matcher below folds ASCII only. A physical name such
107- // as `K` (U+212A KELVIN SIGN) resolves to a requested ASCII `k` in Spark but stays
108- // unmatched here, and the scan-planning gate cannot see physical file names, so the column
109- // would be silently null-filled as missing. Until #5495 brings Spark-parity Unicode
110- // matching, fail Variant-bearing scans whose resolution would differ under Unicode folding
111- // rather than return values that disagree with Spark.
112- if !case_sensitive
113- && !should_match_by_id
114- && logical_schema. fields ( ) . iter ( ) . any ( |f| is_variant_field ( f) )
115- {
116- for logical_field in logical_schema. fields ( ) {
117- let name = logical_field. name ( ) ;
118- if physical_schema
119- . fields ( )
120- . iter ( )
121- . any ( |pf| pf. name ( ) . eq_ignore_ascii_case ( name) )
122- {
123- continue ;
124- }
125- if let Some ( pf) = physical_schema
126- . fields ( )
127- . iter ( )
128- . find ( |pf| !pf. name ( ) . is_ascii ( ) && pf. name ( ) . to_lowercase ( ) == name. to_lowercase ( ) )
129- {
130- return Err ( DataFusionError :: Execution ( format ! (
131- "Case-insensitive scan of a Variant-bearing schema requires Unicode column \
132- matching: requested column '{name}' resolves to physical Parquet column \
133- '{phys}' only under Unicode case folding, which Comet's native scan does \
134- not support yet (https://github.com/apache/datafusion-comet/issues/5495). \
135- Retry with spark.comet.enabled=false, or align the column name case.",
136- phys = pf. name( ) ,
137- ) ) ) ;
138- }
139- }
140- }
141-
142105 // Build id -> all matching physical field names. We need the full list so we can mirror
143106 // Spark's `_LEGACY_ERROR_TEMP_2094` "Found duplicate field(s)" error when an ID-bearing
144107 // logical field would resolve to more than one physical field.
@@ -387,6 +350,31 @@ fn check_column_duplicate(col_name: &str, physical_schema: &SchemaRef) -> Option
387350 }
388351}
389352
353+ /// Detect a physical Parquet column that Spark's case-insensitive resolver would match to
354+ /// `col_name` but the ASCII-only matcher cannot. Spark groups physical names by Unicode
355+ /// lowercasing (`toLowerCase(Locale.ROOT)` in `ParquetReadSupport`), so a physical name such
356+ /// as `K` (U+212A KELVIN SIGN) resolves to a requested ASCII `k` there while staying
357+ /// unmatched here, and the scan-planning gate cannot see physical file names. Returns the
358+ /// folded physical name when no ASCII-insensitive match exists but a non-ASCII physical name
359+ /// Unicode-lowercases to the same string as `col_name`.
360+ fn check_column_unicode_fold_mismatch (
361+ col_name : & str ,
362+ physical_schema : & SchemaRef ,
363+ ) -> Option < String > {
364+ if physical_schema
365+ . fields ( )
366+ . iter ( )
367+ . any ( |pf| pf. name ( ) . eq_ignore_ascii_case ( col_name) )
368+ {
369+ return None ;
370+ }
371+ physical_schema
372+ . fields ( )
373+ . iter ( )
374+ . find ( |pf| !pf. name ( ) . is_ascii ( ) && pf. name ( ) . to_lowercase ( ) == col_name. to_lowercase ( ) )
375+ . map ( |pf| pf. name ( ) . clone ( ) )
376+ }
377+
390378impl PhysicalExprAdapterFactory for SparkPhysicalExprAdapterFactory {
391379 fn create (
392380 & self ,
@@ -488,23 +476,63 @@ impl PhysicalExprAdapter for SparkPhysicalExprAdapter {
488476 // a field with multiple case-insensitive matches in the physical schema.
489477 // Only the columns actually referenced trigger the error (not the whole schema).
490478 if let Some ( orig_physical) = & self . original_physical_schema {
479+ // Until #5495 brings Spark-parity Unicode name matching, Variant-bearing scans
480+ // fail closed for referenced columns whose resolution would differ under Unicode
481+ // folding, rather than silently null-filling values Spark reads. Restricting the
482+ // check to referenced columns keeps unused ordinary columns in the native data
483+ // schema from aborting a scan that never reads them, and the per-field ID
484+ // exemption mirrors the name-match eligibility in `remap_physical_schema`:
485+ // ID-bearing logical fields resolve by ID, everything else falls through to the
486+ // ASCII name matcher and needs the guard.
487+ let variant_scan = self
488+ . logical_file_schema
489+ . fields ( )
490+ . iter ( )
491+ . any ( |f| is_variant_field ( f) ) ;
492+ let should_match_by_id = self . parquet_options . use_field_id
493+ && schema_has_field_ids ( & self . logical_file_schema ) ;
491494 // Walk the expression tree to find Column references
492- let mut duplicate_err : Option < DataFusionError > = None ;
495+ let mut column_err : Option < DataFusionError > = None ;
493496 let _ = Arc :: < dyn PhysicalExpr > :: clone ( & expr) . transform ( |e| {
494497 if let Some ( col) = e. downcast_ref :: < Column > ( ) {
495498 if let Some ( ( req, matched) ) = check_column_duplicate ( col. name ( ) , orig_physical)
496499 {
497- duplicate_err = Some ( DataFusionError :: External ( Box :: new (
500+ column_err = Some ( DataFusionError :: External ( Box :: new (
498501 SparkError :: DuplicateFieldCaseInsensitive {
499502 required_field_name : req,
500503 matched_fields : matched,
501504 } ,
502505 ) ) ) ;
503506 }
507+ if column_err. is_none ( ) && variant_scan {
508+ let id_matched = should_match_by_id
509+ && self
510+ . logical_file_schema
511+ . field_with_name ( col. name ( ) )
512+ . ok ( )
513+ . and_then ( parse_field_id)
514+ . is_some ( ) ;
515+ if !id_matched {
516+ if let Some ( phys) =
517+ check_column_unicode_fold_mismatch ( col. name ( ) , orig_physical)
518+ {
519+ column_err = Some ( DataFusionError :: Execution ( format ! (
520+ "Case-insensitive scan of a Variant-bearing schema requires \
521+ Unicode column matching: requested column '{name}' resolves \
522+ to physical Parquet column '{phys}' only under Unicode case \
523+ folding, which Comet's native scan does not support yet \
524+ (https://github.com/apache/datafusion-comet/issues/5495). \
525+ Retry with spark.comet.enabled=false, or align the column \
526+ name case.",
527+ name = col. name( ) ,
528+ ) ) ) ;
529+ }
530+ }
531+ }
504532 }
505533 Ok ( Transformed :: no ( e) )
506534 } ) ;
507- if let Some ( err) = duplicate_err {
535+ if let Some ( err) = column_err {
508536 return Err ( err) ;
509537 }
510538 }
@@ -2033,67 +2061,148 @@ mod test {
20332061 }
20342062
20352063 #[ test]
2036- fn test_case_insensitive_variant_scan_rejects_unicode_folded_physical_name ( ) {
2037- use super :: remap_physical_schema;
2038-
2039- let variant_storage = DataType :: Struct ( Fields :: from ( vec ! [
2040- Field :: new( "value" , DataType :: Binary , true ) ,
2041- Field :: new( "metadata" , DataType :: Binary , true ) ,
2042- ] ) ) ;
2043- let variant_logical: SchemaRef = Arc :: new ( Schema :: new ( vec ! [ Field :: new(
2044- "k" ,
2045- variant_storage. clone( ) ,
2046- true ,
2047- )
2048- . with_extension_type( VariantType ) ] ) ) ;
2064+ fn test_check_column_unicode_fold_mismatch ( ) {
2065+ use super :: check_column_unicode_fold_mismatch;
20492066
20502067 // Physical `K` (U+212A KELVIN SIGN) folds to `k` in Spark's Unicode resolver but not
2051- // in the ASCII matcher: the scan must fail closed instead of null-filling `k` .
2068+ // in the ASCII matcher: the guard must report it .
20522069 let kelvin: SchemaRef = Arc :: new ( Schema :: new ( vec ! [ Field :: new(
20532070 "\u{212A} " ,
2054- variant_storage . clone ( ) ,
2071+ DataType :: Int32 ,
20552072 true ,
20562073 ) ] ) ) ;
2057- let err =
2058- remap_physical_schema ( & variant_logical , & kelvin, false , false , false ) . unwrap_err ( ) ;
2059- assert ! ( err . to_string ( ) . contains ( "Unicode case folding" ) , "{err}" ) ;
2060- assert ! ( err . to_string ( ) . contains ( "5495" ) , "{err}" ) ;
2074+ assert_eq ! (
2075+ check_column_unicode_fold_mismatch ( "k" , & kelvin) ,
2076+ Some ( " \u{212A} " . to_string ( ) )
2077+ ) ;
20612078
2062- // ASCII case differences still match natively.
2063- let ascii: SchemaRef = Arc :: new ( Schema :: new ( vec ! [ Field :: new(
2064- "K" ,
2065- variant_storage. clone( ) ,
2066- true ,
2067- ) ] ) ) ;
2068- let ( remapped, name_map) =
2069- remap_physical_schema ( & variant_logical, & ascii, false , false , false ) . unwrap ( ) ;
2070- assert_eq ! ( remapped. field( 0 ) . name( ) , "k" ) ;
2071- assert_eq ! ( name_map. get( "k" ) . map( String :: as_str) , Some ( "K" ) ) ;
2079+ // ASCII case differences are handled by the existing matcher.
2080+ let ascii: SchemaRef = Arc :: new ( Schema :: new ( vec ! [ Field :: new( "K" , DataType :: Int32 , true ) ] ) ) ;
2081+ assert_eq ! ( check_column_unicode_fold_mismatch( "k" , & ascii) , None ) ;
20722082
20732083 // `ſ` (U+017F) does not lowercase to `s`, so Spark also treats the requested column
2074- // as missing; the existing missing-column path is kept .
2084+ // as missing; both engines agree on the missing-column path.
20752085 let long_s: SchemaRef = Arc :: new ( Schema :: new ( vec ! [ Field :: new(
20762086 "\u{17F} " ,
2077- variant_storage . clone ( ) ,
2087+ DataType :: Int32 ,
20782088 true ,
20792089 ) ] ) ) ;
2080- let variant_s: SchemaRef = Arc :: new ( Schema :: new ( vec ! [ Field :: new(
2081- "s" ,
2082- variant_storage. clone( ) ,
2083- true ,
2084- )
2085- . with_extension_type( VariantType ) ] ) ) ;
2086- assert ! ( remap_physical_schema( & variant_s, & long_s, false , false , false ) . is_ok( ) ) ;
2090+ assert_eq ! ( check_column_unicode_fold_mismatch( "s" , & long_s) , None ) ;
20872091
2088- // Without a marked Variant field in the required schema the pre-#5495 behavior is
2089- // unchanged.
2090- let plain_logical: SchemaRef =
2091- Arc :: new ( Schema :: new ( vec ! [ Field :: new( "k" , DataType :: Int32 , true ) ] ) ) ;
2092- let plain_kelvin: SchemaRef = Arc :: new ( Schema :: new ( vec ! [ Field :: new(
2093- "\u{212A} " ,
2092+ // A genuinely missing column reports nothing.
2093+ let other: SchemaRef = Arc :: new ( Schema :: new ( vec ! [ Field :: new(
2094+ "other" ,
20942095 DataType :: Int32 ,
20952096 true ,
20962097 ) ] ) ) ;
2097- assert ! ( remap_physical_schema( & plain_logical, & plain_kelvin, false , false , false ) . is_ok( ) ) ;
2098+ assert_eq ! ( check_column_unicode_fold_mismatch( "k" , & other) , None ) ;
2099+ }
2100+
2101+ fn variant_field ( name : & str , id : Option < i32 > ) -> Field {
2102+ let field = Field :: new (
2103+ name,
2104+ DataType :: Struct ( Fields :: from ( vec ! [
2105+ Field :: new( "value" , DataType :: Binary , true ) ,
2106+ Field :: new( "metadata" , DataType :: Binary , true ) ,
2107+ ] ) ) ,
2108+ true ,
2109+ )
2110+ . with_extension_type ( VariantType ) ;
2111+ match id {
2112+ Some ( id) => {
2113+ let mut metadata = field. metadata ( ) . clone ( ) ;
2114+ metadata. insert ( "PARQUET:field_id" . to_string ( ) , id. to_string ( ) ) ;
2115+ field. with_metadata ( metadata)
2116+ }
2117+ None => field,
2118+ }
2119+ }
2120+
2121+ fn int_field ( name : & str , id : Option < i32 > ) -> Field {
2122+ let field = Field :: new ( name, DataType :: Int32 , true ) ;
2123+ match id {
2124+ Some ( id) => {
2125+ let mut metadata = std:: collections:: HashMap :: new ( ) ;
2126+ metadata. insert ( "PARQUET:field_id" . to_string ( ) , id. to_string ( ) ) ;
2127+ field. with_metadata ( metadata)
2128+ }
2129+ None => field,
2130+ }
2131+ }
2132+
2133+ fn rewrite_column (
2134+ logical : SchemaRef ,
2135+ physical : SchemaRef ,
2136+ use_field_id : bool ,
2137+ column : & str ,
2138+ ) -> Result < ( ) , DataFusionError > {
2139+ use datafusion:: physical_expr:: expressions:: Column ;
2140+ use datafusion:: physical_expr:: PhysicalExpr ;
2141+
2142+ let mut options = SparkParquetOptions :: new ( EvalMode :: Legacy , "UTC" , false ) ;
2143+ options. case_sensitive = false ;
2144+ options. use_field_id = use_field_id;
2145+ let factory = SparkPhysicalExprAdapterFactory :: new ( options, None ) ;
2146+ let adapter = factory. create ( Arc :: clone ( & logical) , physical) ?;
2147+ let index = logical. index_of ( column) . unwrap ( ) ;
2148+ let expr: Arc < dyn PhysicalExpr > = Arc :: new ( Column :: new ( column, index) ) ;
2149+ adapter. rewrite ( expr) . map ( |_| ( ) )
2150+ }
2151+
2152+ #[ test]
2153+ fn test_case_insensitive_variant_scan_fails_closed_on_unicode_folded_name ( ) {
2154+ // Referencing `k` whose physical name is Kelvin `K` must fail closed rather than
2155+ // null-fill values Spark reads (#5495).
2156+ let logical: SchemaRef = Arc :: new ( Schema :: new ( vec ! [ variant_field( "k" , None ) ] ) ) ;
2157+ let physical: SchemaRef = Arc :: new ( Schema :: new ( vec ! [ variant_field( "\u{212A} " , None ) ] ) ) ;
2158+ let err = rewrite_column ( logical, physical, false , "k" ) . unwrap_err ( ) ;
2159+ assert ! ( err. to_string( ) . contains( "Unicode case folding" ) , "{err}" ) ;
2160+ assert ! ( err. to_string( ) . contains( "5495" ) , "{err}" ) ;
2161+ }
2162+
2163+ #[ test]
2164+ fn test_unicode_guard_skips_unreferenced_columns ( ) {
2165+ // An unused ordinary column with a Unicode-folded physical name stays in the native
2166+ // data schema; only referenced columns may abort the scan.
2167+ let logical: SchemaRef = Arc :: new ( Schema :: new ( vec ! [
2168+ variant_field( "v" , None ) ,
2169+ int_field( "k" , None ) ,
2170+ ] ) ) ;
2171+ let physical: SchemaRef = Arc :: new ( Schema :: new ( vec ! [
2172+ variant_field( "v" , None ) ,
2173+ int_field( "\u{212A} " , None ) ,
2174+ ] ) ) ;
2175+ rewrite_column ( Arc :: clone ( & logical) , Arc :: clone ( & physical) , false , "v" ) . unwrap ( ) ;
2176+ let err = rewrite_column ( logical, physical, false , "k" ) . unwrap_err ( ) ;
2177+ assert ! ( err. to_string( ) . contains( "Unicode case folding" ) , "{err}" ) ;
2178+ }
2179+
2180+ #[ test]
2181+ fn test_unicode_guard_applies_to_id_less_fields_in_field_id_scans ( ) {
2182+ // With field-ID reads enabled, ID-bearing fields resolve by ID and are exempt, but an
2183+ // ID-less Variant field still falls through to ASCII name matching and needs the
2184+ // guard.
2185+ let logical: SchemaRef = Arc :: new ( Schema :: new ( vec ! [
2186+ int_field( "sibling" , Some ( 1 ) ) ,
2187+ variant_field( "k" , None ) ,
2188+ ] ) ) ;
2189+ let physical: SchemaRef = Arc :: new ( Schema :: new ( vec ! [
2190+ int_field( "data" , Some ( 1 ) ) ,
2191+ variant_field( "\u{212A} " , None ) ,
2192+ ] ) ) ;
2193+ rewrite_column ( Arc :: clone ( & logical) , Arc :: clone ( & physical) , true , "sibling" ) . unwrap ( ) ;
2194+ let err = rewrite_column ( logical, physical, true , "k" ) . unwrap_err ( ) ;
2195+ assert ! ( err. to_string( ) . contains( "Unicode case folding" ) , "{err}" ) ;
2196+
2197+ // The same Variant field carrying an ID resolves by ID and stays exempt.
2198+ let logical: SchemaRef = Arc :: new ( Schema :: new ( vec ! [
2199+ int_field( "sibling" , Some ( 1 ) ) ,
2200+ variant_field( "k" , Some ( 2 ) ) ,
2201+ ] ) ) ;
2202+ let physical: SchemaRef = Arc :: new ( Schema :: new ( vec ! [
2203+ int_field( "data" , Some ( 1 ) ) ,
2204+ variant_field( "\u{212A} " , Some ( 2 ) ) ,
2205+ ] ) ) ;
2206+ rewrite_column ( logical, physical, true , "k" ) . unwrap ( ) ;
20982207 }
20992208}
0 commit comments