comphead commented on code in PR #7106:
URL: https://github.com/apache/datafusion-comet/pull/7106#discussion_r4332014144


##########
native/spark-expr/src/conversion_funcs/cast.rs:
##########
@@ -876,7 +878,78 @@ fn cast_binary_to_string<O: OffsetSizeTrait>(
 
     let num_rows = input.len();
     let offsets = input.value_offsets();
-    let value_bytes = offsets[num_rows].as_usize() - offsets[0].as_usize();
+    let start = offsets[0].as_usize();
+    let end = offsets[num_rows].as_usize();
+    // Bytes of the non-null rows. A null row can still span bytes, for 
example after `nullif`.
+    let live_bytes = match input.nulls().filter(|nulls| nulls.null_count() > 
0) {
+        Some(nulls) => nulls
+            .valid_indices()
+            .map(|i| offsets[i + 1].as_usize() - offsets[i].as_usize())
+            .sum(),
+        None => end - start,
+    };
+
+    // Check the bytes the rows span once. If they are valid UTF-8, reuse the 
input's value buffer,
+    // or copy them when they fill less than half of its allocation; otherwise 
copy the rows in the
+    // valid prefix and decode the rest. Skip this when null rows span more 
than half of those
+    // bytes: decoding the live rows one at a time then reads less and returns 
a compact buffer
+    // instead of keeping the null rows' bytes alive.
+    let mut valid_prefix = "";
+    if matches!(
+        spark_cast_options.binary_output_style,
+        None | Some(BinaryOutputStyle::Utf8)
+    ) && 2 * live_bytes >= end - start
+    {
+        let data = input.value_data();
+        match std::str::from_utf8(&data[start..end]) {
+            Ok(valid) => {
+                // An offset splits a code point only if it points at a 
continuation byte.
+                if offsets.iter().all(|offset| {
+                    let offset = offset.as_usize();
+                    offset == end || !matches!(data[offset], 0x80..=0xBF)
+                }) {
+                    // A slice of a large array shares its whole allocation, 
and memory accounting
+                    // such as a sort's charges all of it to every batch that 
holds it. Copy the
+                    // bytes into a compact buffer then, without decoding them 
again.
+                    let (offsets, values) = if 2 * (end - start) >= 
input.values().capacity() {
+                        // Rebase a slice's offsets onto the narrowed value 
buffer.
+                        let offsets = if start == 0 {
+                            input.offsets().clone()
+                        } else {
+                            
OffsetBuffer::from_lengths(input.offsets().lengths())
+                        };
+                        (
+                            offsets,
+                            input.values().slice_with_length(start, end - 
start),
+                        )
+                    } else {
+                        (
+                            
OffsetBuffer::from_lengths(input.offsets().lengths()),
+                            Buffer::from_slice_ref(&data[start..end]),
+                        )
+                    };
+                    // SAFETY: the value buffer is valid UTF-8 and every 
offset falls on a char
+                    // boundary, which is what `GenericStringArray::try_new` 
checks.
+                    let strings = unsafe {
+                        GenericStringArray::<O>::new_unchecked(

Review Comment:
   The compact-copy branch rebuilds the offsets with 
`OffsetBuffer::from_lengths(input.offsets().lengths())` even when `start == 0`, 
while the reuse branch above already clones `input.offsets()` for that case. 
Only the values buffer differs between the two branches, so the offsets could 
be computed once and shared. That avoids an O(rows) allocation and checked-add 
pass for unsliced arrays and shortens the nested block. Separately, the doc 
comment on `cast_binary_to_string` a few lines up still says that without a 
binary output style "the bytes are reinterpreted as a string as-is". As far as 
I can tell the function now decodes and can return an array that shares the 
input's value buffer, so that sentence seems worth updating while this function 
is being reworked.



##########
native/core/src/parquet/parquet_support.rs:
##########
@@ -330,6 +330,28 @@ fn parquet_convert_array_impl(
 
             Ok(Arc::new(string_array))
         }
+        // Unannotated BINARY read as a string. Spark's cast replaces 
malformed UTF-8 with
+        // U+FFFD, where Arrow's safe cast below would return NULL. Top-level 
columns don't
+        // reach this arm (#7032). The reader keeps a file's ARROW:schema 
types, so an Arrow
+        // writer's `LargeBinary` or `BinaryView` arrives as is; Spark's cast 
takes only
+        // `Binary`, and converting `LargeBinary` reuses its value buffer.
+        (Binary | LargeBinary | BinaryView, Utf8) => {
+            let array = match from_type {
+                Binary => array,
+                _ => cast_with_options(&array, &Binary, &PARQUET_OPTIONS)?,
+            };
+            let mut cast_options =
+                SparkCastOptions::new(parquet_options.eval_mode, 
&parquet_options.timezone);
+            cast_options.allow_cast_unsigned_ints = 
parquet_options.allow_cast_unsigned_ints;
+            cast_options.is_adapting_schema = true;
+
+            match spark_cast(ColumnarValue::Array(array), to_type, 
&cast_options)? {
+                ColumnarValue::Array(array) => Ok(array),
+                ColumnarValue::Scalar(_) => Err(DataFusionError::Internal(
+                    "Spark cast returned a scalar for an array 
input".to_string(),
+                )),
+            }
+        }
         // If Arrow cast supports the cast, delegate the cast to Arrow

Review Comment:
   For `(Binary, Utf8)` the call reaches `cast_binary_to_string`, which reads 
only `binary_output_style`. As far as I can tell `eval_mode`, `timezone`, 
`allow_cast_unsigned_ints` and `is_adapting_schema` have no effect on this 
pair, so copying them from `parquet_options` adds nothing, and 
`SparkCastOptions::new_without_timezone(EvalMode::Legacy)` would say the same 
thing. The `ColumnarValue::Scalar(_) => Err(Internal)` arm also looks 
unreachable for an array input. Could the call use `.into_array(len)`, or call 
the kernel directly? I did not run this.



##########
spark/src/test/scala/org/apache/comet/parquet/ParquetReadSuite.scala:
##########
@@ -146,6 +146,75 @@ abstract class ParquetReadSuite extends CometTestBase {
     }
   }
 
+  test("native scan decodes invalid UTF-8 in nested BINARY read as string") {

Review Comment:
   Neither fixture row has a NULL, so there is no null list element, struct 
field or map value, and no null list, struct or map row. The Rust cast tests 
cover nulls inside the kernel, but nothing here exercises nulls through the 
list, struct and map plumbing in `parquet_convert_array_impl` (offsets and 
parent nulls). A third row with a null element, a null struct field, a null map 
value and a null whole container would pin that. A null-bearing valid row ahead 
of the invalid one would also give a batch that mixes nulls, valid values and 
an invalid 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