viirya commented on code in PR #6170:
URL: https://github.com/apache/datafusion-comet/pull/6170#discussion_r4209880058


##########
native/spark-expr/src/array_funcs/array_extrema.rs:
##########
@@ -53,8 +105,33 @@ impl SparkArrayExtrema {
             } else {
                 array_max_udf()
             },
+            string_collations: Vec::new(),
+            unicode_version: 0,
         }
     }
+
+    /// Scala validates the Unicode version and lists string-leaf collations 
depth-first.
+    pub fn with_collations(

Review Comment:
   The doc comment says Scala validates the Unicode version, but this 
constructor accepts any value. With `UTF8_LCASE` and a version other than 16 or 
17, it quietly uses the Unicode 16 table. Could it return an error in that 
case, so a mismatch between the serde and the native side fails loudly instead 
of returning a different winner than Spark?



##########
spark/src/main/scala/org/apache/comet/serde/arrays.scala:
##########
@@ -256,16 +256,57 @@ object CometArrayIntersect
 
 private object ArrayExtremaSupport extends CometTypeShim {
   val incompatReason: String =
-    "Array extrema use binary string ordering for non-UTF8_BINARY collations " 
+
+    "Array extrema support UTF8_BINARY, UTF8_LCASE, and UTF8_LCASE_RTRIM 
natively; " +
+      "UTF8_LCASE requires Unicode 16 or 17. Other collations use binary 
ordering when opted in " +
       "(https://github.com/apache/datafusion-comet/issues/4496)."
 
-  def getSupportLevel(elementType: DataType): SupportLevel = {
-    if (hasNonDefaultStringCollation(elementType)) {
-      // The dispatcher runs Spark's own comparison with the original 
collation IDs, including
-      // strings nested in arrays or structs. Keep the native bytewise 
comparison opt-in only.
-      Incompatible(Some(incompatReason))
-    } else {
+  val binaryRtrimReason: String =
+    "UTF8_BINARY_RTRIM array extrema use Spark's comparator."
+
+  private def stringCollations(dt: DataType): Seq[String] = dt match {
+    case stringType: StringType => Seq(stringCollationName(stringType))
+    case ArrayType(elementType, _) => stringCollations(elementType)
+    case StructType(fields) => fields.toSeq.flatMap(f => 
stringCollations(f.dataType))
+    case _ => Seq.empty
+  }
+
+  private def supportsCollations(collations: Seq[String]): Boolean = 
collations.forall {
+    case "UTF8_BINARY" | "UTF8_BINARY_RTRIM" => true
+    case "UTF8_LCASE" | "UTF8_LCASE_RTRIM" =>
+      collationUnicodeVersion == 16 || collationUnicodeVersion == 17
+    case _ => false
+  }
+
+  def getSupportLevel(elementType: DataType): SupportLevel =
+    // Keep this path on the dispatcher until its native performance 
regression is resolved.
+    // Use Unsupported so allowIncompatible cannot silently change its 
comparison semantics.
+    if (stringCollations(elementType).contains("UTF8_BINARY_RTRIM")) {
+      Unsupported(Some(binaryRtrimReason))
+    } else if (supportsCollations(stringCollations(elementType))) {
       Compatible()
+    } else {
+      Incompatible(Some(incompatReason))
+    }
+
+  def convert(
+      expr: Expression,
+      inputs: Seq[Attribute],
+      binding: Boolean,
+      isMin: Boolean): Option[ExprOuterClass.Expr] = {
+    val collations = stringCollations(expr.dataType)
+    exprToProtoInternal(expr.children.head, inputs, binding).map { child =>
+      val builder = ExprOuterClass.ArrayExtrema
+        .newBuilder()
+        .setChild(child)
+        .setIsMin(isMin)
+      // Preserve the existing binary comparison when unsupported collations 
are explicitly
+      // opted in. By default those expressions use the JVM dispatcher.
+      if (!collations.forall(_ == "UTF8_BINARY") && 
supportsCollations(collations)) {

Review Comment:
   When the element type mixes a supported and an unsupported collation, for 
example a struct with a `UTF8_LCASE` field and a `UNICODE_CI` field, and the 
user opts in, `supportsCollations` is false for the whole list. No collations 
are sent, so the `UTF8_LCASE` field is also compared bytewise. The incompatible 
reason says only the other collations use binary ordering. Would it work to 
send the list with each unsupported entry mapped to `UTF8_BINARY`? That keeps 
the reason accurate and keeps the supported leaves correct under opt-in.



##########
native/spark-expr/src/array_funcs/array_extrema.rs:
##########
@@ -293,5 +389,100 @@ fn take_extrema_values(values: &ArrayRef, indices: 
&UInt32Array) -> Result<Array
     }
 }
 
+/// Build one comparator per child array, adding string collation to Spark's 
ordering.
+/// Noncollated values use the shared Spark comparator, including its 
floating-point rules.
+fn collated_comparator(
+    array: &ArrayRef,
+    collations: &mut std::slice::Iter<Utf8Collation>,
+    unicode_version: u32,
+) -> Result<DynComparator> {
+    if collations.as_slice().is_empty() {
+        return spark_comparator(array.as_ref(), array.as_ref());
+    }
+    match array.data_type() {
+        DataType::List(_) => {
+            let array = array.as_list::<i32>();
+            let compare = collated_comparator(array.values(), collations, 
unicode_version)?;
+            let offsets = array.offsets().clone();
+            Ok(nulls_first(array.nulls().cloned(), move |left, right| {
+                let left_start = offsets[left] as usize;
+                let right_start = offsets[right] as usize;
+                let left_len = offsets[left + 1] as usize - left_start;
+                let right_len = offsets[right + 1] as usize - right_start;
+                for offset in 0..left_len.min(right_len) {
+                    let ordering = compare(left_start + offset, right_start + 
offset);
+                    if ordering != Ordering::Equal {
+                        return ordering;
+                    }
+                }
+                left_len.cmp(&right_len)
+            }))
+        }
+        DataType::Struct(_) => {
+            let array = array.as_struct();
+            let fields = array
+                .columns()
+                .iter()
+                .map(|field| collated_comparator(field, collations, 
unicode_version))
+                .collect::<Result<Vec<_>>>()?;
+            Ok(nulls_first(array.nulls().cloned(), move |left, right| {
+                fields
+                    .iter()
+                    .map(|compare| compare(left, right))
+                    .find(|&ordering| ordering != Ordering::Equal)
+                    .unwrap_or(Ordering::Equal)
+            }))
+        }
+        DataType::Utf8 | DataType::LargeUtf8 | DataType::Utf8View
+            if !collations.as_slice().is_empty() =>
+        {
+            let collation = collations.next().unwrap();
+            Ok(match array.data_type() {
+                DataType::Utf8 => string_comparator(
+                    array.as_string::<i32>().clone(),
+                    *collation,
+                    unicode_version,
+                ),
+                DataType::LargeUtf8 => string_comparator(
+                    array.as_string::<i64>().clone(),
+                    *collation,
+                    unicode_version,
+                ),
+                _ => string_comparator(array.as_string_view().clone(), 
*collation, unicode_version),
+            })
+        }
+        _ => spark_comparator(array.as_ref(), array.as_ref()),

Review Comment:
   The Scala side and this function each walk the element type depth-first, and 
a string leaf picks its collation by position. Nothing checks that the two 
walks agree. This arm does not consume an entry. So a string-bearing subtree it 
does not recognize, such as a `LargeList`, a `FixedSizeList` or a 
dictionary-encoded string (all of which `spark_comparator` accepts), would 
compare bytewise and shift every later leaf onto the wrong collation without an 
error. Comet does not produce those types here today, but a later change could 
introduce that drift with no failing test. Could this arm return an error when 
the type contains a string while collations remain?



##########
spark/src/test/resources/sql-tests/expressions/array/array_extrema_collation.sql:
##########
@@ -41,17 +48,39 @@ SELECT id, array_min(array(a, b)), array_max(array(a, b)),
 FROM test_array_extrema_collation
 
 -- Case-insensitive ordering differs from binary ordering for 'a' and 'B'.
-query expect_dispatch(array_min,array_max)
+-- Non-ASCII ties run in both input orders and must retain the first original 
value.
+query expect_native(array_min,array_max)
 SELECT id, array_min(array(CAST(a AS STRING COLLATE UTF8_LCASE), CAST(b AS 
STRING COLLATE UTF8_LCASE))),
        array_max(array(CAST(a AS STRING COLLATE UTF8_LCASE), CAST(b AS STRING 
COLLATE UTF8_LCASE)))
 FROM test_array_extrema_collation
 
--- Collation detection and Spark comparison also recurse through struct fields 
and arrays.
-query expect_dispatch(array_min,array_max)
+-- Collation comparison also recurses through struct fields and arrays. The 
second
+-- string field uses binary ordering, independently of the first field's 
collation.
+query expect_native(array_min,array_max)
 SELECT id, array_min(array(
-         named_struct('s', array(CAST(a AS STRING COLLATE UTF8_LCASE)), 'f', 
x),
-         named_struct('s', array(CAST(b AS STRING COLLATE UTF8_LCASE)), 'f', 
y))),
+         named_struct('s', array(CAST(a AS STRING COLLATE UTF8_LCASE)), 
'binary', a, 'f', x),

Review Comment:
   This query puts the collated field ahead of the binary one, so it would not 
catch a `UTF8_BINARY` leaf that fails to consume its entry. Could you add a 
variant with the binary field first, for example `named_struct('binary', a, 
's', array(CAST(a AS STRING COLLATE UTF8_LCASE)), 'f', x)`? Then the positional 
mapping is guarded from both directions.



##########
native/spark-expr/src/array_funcs/array_extrema.rs:
##########
@@ -156,9 +243,14 @@ where
     result.finish()
 }
 
-fn nested_extrema(array: &ListArray, is_min: bool) -> Result<ArrayRef> {
+fn nested_extrema(
+    array: &ListArray,
+    is_min: bool,
+    mut collations: std::slice::Iter<Utf8Collation>,
+    unicode_version: u32,
+) -> Result<ArrayRef> {
     let values = array.values();
-    let compare = spark_comparator(values.as_ref(), values.as_ref())?;
+    let compare = collated_comparator(values, &mut collations, 
unicode_version)?;

Review Comment:
   Related to the fallback arm below, could this check that `collations` is 
empty once the comparator is built and return an error otherwise? Leftover 
entries mean the native walk and `stringCollations` on the Scala side 
disagreed, and today that would go unnoticed.



##########
spark/src/main/scala/org/apache/comet/serde/arrays.scala:
##########
@@ -256,16 +256,57 @@ object CometArrayIntersect
 
 private object ArrayExtremaSupport extends CometTypeShim {
   val incompatReason: String =
-    "Array extrema use binary string ordering for non-UTF8_BINARY collations " 
+
+    "Array extrema support UTF8_BINARY, UTF8_LCASE, and UTF8_LCASE_RTRIM 
natively; " +
+      "UTF8_LCASE requires Unicode 16 or 17. Other collations use binary 
ordering when opted in " +
       "(https://github.com/apache/datafusion-comet/issues/4496)."
 
-  def getSupportLevel(elementType: DataType): SupportLevel = {
-    if (hasNonDefaultStringCollation(elementType)) {
-      // The dispatcher runs Spark's own comparison with the original 
collation IDs, including
-      // strings nested in arrays or structs. Keep the native bytewise 
comparison opt-in only.
-      Incompatible(Some(incompatReason))
-    } else {
+  val binaryRtrimReason: String =
+    "UTF8_BINARY_RTRIM array extrema use Spark's comparator."
+
+  private def stringCollations(dt: DataType): Seq[String] = dt match {
+    case stringType: StringType => Seq(stringCollationName(stringType))
+    case ArrayType(elementType, _) => stringCollations(elementType)
+    case StructType(fields) => fields.toSeq.flatMap(f => 
stringCollations(f.dataType))
+    case _ => Seq.empty
+  }
+
+  private def supportsCollations(collations: Seq[String]): Boolean = 
collations.forall {
+    case "UTF8_BINARY" | "UTF8_BINARY_RTRIM" => true
+    case "UTF8_LCASE" | "UTF8_LCASE_RTRIM" =>
+      collationUnicodeVersion == 16 || collationUnicodeVersion == 17
+    case _ => false
+  }
+
+  def getSupportLevel(elementType: DataType): SupportLevel =
+    // Keep this path on the dispatcher until its native performance 
regression is resolved.
+    // Use Unsupported so allowIncompatible cannot silently change its 
comparison semantics.
+    if (stringCollations(elementType).contains("UTF8_BINARY_RTRIM")) {
+      Unsupported(Some(binaryRtrimReason))

Review Comment:
   The comment above says `Unsupported` keeps `allowIncompatible` from silently 
changing the comparison semantics. With `Incompatible` and opt-in, though, 
`convert` would send `UTF8_BINARY_RTRIM`, and the native comparator handles it 
correctly, so the semantics would not change. The real reason is the 
performance regression, and the comment should say that. This branch also makes 
the native RTRIM support unreachable. That covers the `UTF8_BINARY_RTRIM` case 
in `supportsCollations`, `Utf8Collation::BinaryRtrim` and its mapping in 
`with_collations`, and the unit tests that exercise them. Could you either 
remove those pieces, or keep them and open a tracking issue for the regression? 
Then this branch, the comment and `binaryRtrimReason` could link to it. 
`binaryRtrimReason` also ends up in the generated compatibility guide as an 
unsupported reason, and "use Spark's comparator" alone does not tell a reader 
why.



-- 
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