blob: b565af46c3a6fadd0fb57e280f9bef609bd6705e [file]
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
use crate::decode_utf8_spark_lossy;
use arrow::array::{
downcast_dictionary_array, Array, ArrayRef, FixedSizeListArray, GenericListArray,
GenericStringArray, GenericStringBuilder, MapArray, OffsetSizeTrait, StructArray,
};
use arrow::datatypes::DataType;
use arrow::error::ArrowError;
use std::sync::Arc;
/// Ensure every `Utf8`/`LargeUtf8` array reachable from `array` holds valid UTF-8, decoding invalid
/// bytes the way Spark renders `StringType`. Returns the same `Arc` (zero-copy) when nothing needed
/// decoding. Used at the JVM->native FFI import boundary, where arrow's `from_ffi` builds string
/// arrays via `new_unchecked` and does not validate UTF-8. Unsupported string-bearing Arrow types
/// fail closed rather than preserving unchecked string data.
pub fn decode_string_arrays(array: &ArrayRef) -> Result<ArrayRef, ArrowError> {
match array.data_type() {
DataType::Utf8 => decode_generic_string::<i32>(array),
DataType::LargeUtf8 => decode_generic_string::<i64>(array),
DataType::Dictionary(_, value_type) => {
if !data_type_contains_string(value_type) {
return Ok(Arc::clone(array));
}
// Capture the original Arc before `downcast_dictionary_array!` shadows `array`, so the
// unchanged branch returns it verbatim, preserving the zero-copy contract that the
// Struct/List/Map arms rely on via `Arc::ptr_eq`.
let original = Arc::clone(array);
downcast_dictionary_array!(
array => {
let values = array.values();
let decoded = decode_string_arrays(values)?;
if Arc::ptr_eq(&decoded, values) {
Ok(original)
} else {
Ok(Arc::new(array.with_values(decoded)))
}
}
t => unreachable!("dictionary key type validated by Arrow: {t}"),
)
}
DataType::Struct(fields) => {
let s = array
.as_any()
.downcast_ref::<StructArray>()
.expect("data type checked by caller");
let mut changed = false;
let mut columns = Vec::with_capacity(s.num_columns());
for col in s.columns() {
let decoded = decode_string_arrays(col)?;
changed |= !Arc::ptr_eq(&decoded, col);
columns.push(decoded);
}
if !changed {
return Ok(Arc::clone(array));
}
Ok(Arc::new(StructArray::new(
fields.clone(),
columns,
s.nulls().cloned(),
)))
}
DataType::List(_) => decode_list::<i32>(array),
DataType::LargeList(_) => decode_list::<i64>(array),
DataType::FixedSizeList(field, size) => {
let list = array
.as_any()
.downcast_ref::<FixedSizeListArray>()
.expect("data type checked by caller");
let values = list.values();
let decoded = decode_string_arrays(values)?;
if Arc::ptr_eq(&decoded, values) {
return Ok(Arc::clone(array));
}
Ok(Arc::new(FixedSizeListArray::try_new(
Arc::clone(field),
*size,
decoded,
list.nulls().cloned(),
)?))
}
DataType::Map(field, ordered) => {
let map = array
.as_any()
.downcast_ref::<MapArray>()
.expect("data type checked by caller");
let entries = map.entries();
let mut changed = false;
let mut columns = Vec::with_capacity(entries.num_columns());
for col in entries.columns() {
let decoded = decode_string_arrays(col)?;
changed |= !Arc::ptr_eq(&decoded, col);
columns.push(decoded);
}
if !changed {
return Ok(Arc::clone(array));
}
let decoded_entries =
StructArray::new(entries.fields().clone(), columns, entries.nulls().cloned());
Ok(Arc::new(MapArray::try_new(
Arc::clone(field),
map.offsets().clone(),
decoded_entries,
map.nulls().cloned(),
*ordered,
)?))
}
// Spark's current JVM producers emit StringType as Utf8/LargeUtf8 and the supported
// containers above. Fail closed if a different string-bearing representation reaches this
// boundary: passing it through would preserve the invalid UTF-8 this function exists to
// remove. Keep `data_type_contains_string` exhaustive so an Arrow upgrade adding another
// data type forces this safety audit to be revisited.
data_type => {
if data_type_contains_string(data_type) {
Err(unsupported_string_type(data_type))
} else {
Ok(Arc::clone(array))
}
}
}
}
fn unsupported_string_type(data_type: &DataType) -> ArrowError {
ArrowError::NotYetImplemented(format!(
"decoding FFI-imported string data in {data_type} is not supported"
))
}
/// Whether `data_type` is, or can contain, a logical string representation.
///
/// This match is deliberately exhaustive: when Arrow adds a data type, the compiler should force
/// us to decide whether it can carry strings before `decode_string_arrays` passes it through.
fn data_type_contains_string(data_type: &DataType) -> bool {
match data_type {
DataType::Utf8 | DataType::LargeUtf8 | DataType::Utf8View => true,
DataType::List(field)
| DataType::ListView(field)
| DataType::FixedSizeList(field, _)
| DataType::LargeList(field)
| DataType::LargeListView(field) => data_type_contains_string(field.data_type()),
DataType::Struct(fields) => fields
.iter()
.any(|field| data_type_contains_string(field.data_type())),
DataType::Union(fields, _) => fields
.iter()
.any(|(_, field)| data_type_contains_string(field.data_type())),
DataType::Dictionary(_, value_type) => data_type_contains_string(value_type),
DataType::Map(field, _) => data_type_contains_string(field.data_type()),
DataType::RunEndEncoded(_, values) => data_type_contains_string(values.data_type()),
DataType::Null
| DataType::Boolean
| DataType::Int8
| DataType::Int16
| DataType::Int32
| DataType::Int64
| DataType::UInt8
| DataType::UInt16
| DataType::UInt32
| DataType::UInt64
| DataType::Float16
| DataType::Float32
| DataType::Float64
| DataType::Timestamp(_, _)
| DataType::Date32
| DataType::Date64
| DataType::Time32(_)
| DataType::Time64(_)
| DataType::Duration(_)
| DataType::Interval(_)
| DataType::Binary
| DataType::FixedSizeBinary(_)
| DataType::LargeBinary
| DataType::BinaryView
| DataType::Decimal32(_, _)
| DataType::Decimal64(_, _)
| DataType::Decimal128(_, _)
| DataType::Decimal256(_, _) => false,
}
}
fn decode_generic_string<O: OffsetSizeTrait>(array: &ArrayRef) -> Result<ArrayRef, ArrowError> {
let arr = array
.as_any()
.downcast_ref::<GenericStringArray<O>>()
.expect("data type checked by caller");
let len = arr.len();
if len == 0 {
return Ok(Arc::clone(array));
}
let values: &[u8] = arr.value_data();
let offsets = arr.value_offsets(); // &[O], length == len + 1
let start = offsets[0].as_usize();
let end = offsets[len].as_usize();
// Fast path: the used byte range parses as UTF-8 AND no element boundary splits a codepoint.
// Both are required: a whole-buffer-valid "é" (C3 A9) with per-element offsets [0,1,2] yields
// element slices `C3` and `A9`, each invalid, and `value()` would decode them unchecked (UB).
//
// Every imported string column pays this pass, so it uses simdutf8 rather than
// `std::str::from_utf8`: std is fast on ASCII but falls back to a byte-at-a-time loop on
// non-ASCII text, which made multibyte columns several times slower to import. The `basic`
// validator is enough because an invalid buffer takes the slow path below, which finds the
// bad bytes itself.
if simdutf8::basic::from_utf8(&values[start..end]).is_ok() {
let mut boundaries_ok = true;
for off in &offsets[1..len] {
let o = off.as_usize();
// A boundary landing on a UTF-8 continuation byte (0b10xx_xxxx) splits a codepoint.
if o < values.len() && (values[o] & 0xC0) == 0x80 {
boundaries_ok = false;
break;
}
}
if boundaries_ok {
return Ok(Arc::clone(array));
}
}
// Slow path: rebuild element-by-element via the Spark-lossy decoder. We slice the raw values
// buffer directly rather than calling `arr.value(i)`, which uses `from_utf8_unchecked`.
let mut builder = GenericStringBuilder::<O>::with_capacity(len, end - start);
for i in 0..len {
if arr.is_null(i) {
builder.append_null();
} else {
let s = offsets[i].as_usize();
let e = offsets[i + 1].as_usize();
builder.append_value(decode_utf8_spark_lossy(&values[s..e]));
}
}
Ok(Arc::new(builder.finish()))
}
fn decode_list<O: OffsetSizeTrait>(array: &ArrayRef) -> Result<ArrayRef, ArrowError> {
let list = array
.as_any()
.downcast_ref::<GenericListArray<O>>()
.expect("data type checked by caller");
let values = list.values();
let decoded = decode_string_arrays(values)?;
if Arc::ptr_eq(&decoded, values) {
return Ok(Arc::clone(array));
}
let field = match array.data_type() {
DataType::List(f) | DataType::LargeList(f) => Arc::clone(f),
_ => unreachable!("decode_list called on non-list"),
};
Ok(Arc::new(GenericListArray::<O>::try_new(
field,
list.offsets().clone(),
decoded,
list.nulls().cloned(),
)?))
}
#[cfg(test)]
mod walker_tests {
use super::{data_type_contains_string, decode_string_arrays};
use arrow::array::{
make_array, Array, ArrayData, ArrayRef, LargeStringArray, StringArray, StringViewArray,
};
use arrow::buffer::Buffer;
use arrow::datatypes::DataType;
use arrow::error::ArrowError;
use std::sync::Arc;
/// Build a (possibly invalid) Utf8 array from raw offsets + value bytes, the way an FFI import
/// would deliver it (no validation). `build_unchecked` mirrors arrow's `from_ffi`.
fn utf8_unchecked(offsets: &[i32], values: &[u8], len: usize) -> ArrayRef {
let data = unsafe {
ArrayData::builder(DataType::Utf8)
.len(len)
.add_buffer(Buffer::from_slice_ref(offsets))
.add_buffer(Buffer::from(values.to_vec()))
.build_unchecked()
};
make_array(data)
}
#[test]
fn valid_utf8_is_zero_copy() {
let input: ArrayRef = Arc::new(StringArray::from(vec!["a", "é", "🦀"]));
let out = decode_string_arrays(&input).unwrap();
assert!(
Arc::ptr_eq(&input, &out),
"valid input must be returned unchanged"
);
}
#[test]
fn unsupported_string_representation_fails_closed() {
let input: ArrayRef = Arc::new(StringViewArray::from(vec!["valid"]));
let err = decode_string_arrays(&input).unwrap_err();
assert!(matches!(
err,
ArrowError::NotYetImplemented(message) if message.contains("Utf8View")
));
}
#[test]
fn invalid_bytes_decode_to_replacement() {
// element 0 = [0xFF, 0x41] -> "\u{FFFD}A", element 1 = [0x42] -> "B"
let input = utf8_unchecked(&[0, 2, 3], &[0xFF, 0x41, 0x42], 2);
let out = decode_string_arrays(&input).unwrap();
let s = out.as_any().downcast_ref::<StringArray>().unwrap();
assert_eq!(s.value(0), "\u{FFFD}A");
assert_eq!(s.value(1), "B");
}
#[test]
fn split_codepoint_boundary_is_rebuilt() {
// whole buffer "é" (C3 A9) is valid UTF-8, but offsets split it into two invalid slices.
let input = utf8_unchecked(&[0, 1, 2], &[0xC3, 0xA9], 2);
let out = decode_string_arrays(&input).unwrap();
let s = out.as_any().downcast_ref::<StringArray>().unwrap();
assert_eq!(s.value(0), "\u{FFFD}");
assert_eq!(s.value(1), "\u{FFFD}");
}
#[test]
fn nulls_are_preserved() {
let data = unsafe {
ArrayData::builder(DataType::Utf8)
.len(3)
.null_count(1)
.null_bit_buffer(Some(Buffer::from(vec![0b0000_0101_u8])))
.add_buffer(Buffer::from_slice_ref([0_i32, 1, 2, 3]))
.add_buffer(Buffer::from(vec![0xff, 0xfe, b'b']))
.build_unchecked()
};
let input = make_array(data);
let out = decode_string_arrays(&input).unwrap();
let s = out.as_any().downcast_ref::<StringArray>().unwrap();
assert_eq!(s.value(0), "\u{FFFD}");
assert!(s.is_null(1));
assert_eq!(s.value(2), "b");
}
#[test]
fn large_utf8_invalid_decodes() {
let data = unsafe {
ArrayData::builder(DataType::LargeUtf8)
.len(1)
.add_buffer(Buffer::from_slice_ref([0i64, 1]))
.add_buffer(Buffer::from(vec![0xFFu8]))
.build_unchecked()
};
let out = decode_string_arrays(&make_array(data)).unwrap();
let s = out.as_any().downcast_ref::<LargeStringArray>().unwrap();
assert_eq!(s.value(0), "\u{FFFD}");
}
use arrow::array::{
DictionaryArray, FixedSizeListArray, Int32Array, ListArray, MapArray, StructArray,
};
use arrow::datatypes::{Field, Fields, Int32Type};
#[test]
fn detects_unhandled_nested_string_representations() {
let string_field = Arc::new(Field::new("item", DataType::Utf8, true));
let list_view = DataType::ListView(Arc::clone(&string_field));
let large_list_view = DataType::LargeListView(Arc::clone(&string_field));
assert!(data_type_contains_string(&list_view));
assert!(data_type_contains_string(&large_list_view));
let run_ends = Arc::new(Field::new("run_ends", DataType::Int32, false));
let run_end_encoded = DataType::RunEndEncoded(run_ends, string_field);
assert!(data_type_contains_string(&run_end_encoded));
let int_field = Arc::new(Field::new("item", DataType::Int32, true));
assert!(!data_type_contains_string(&DataType::ListView(int_field)));
}
/// An invalid Utf8 leaf ["\u{FFFD}"] built from raw bytes.
fn invalid_leaf() -> ArrayRef {
utf8_unchecked(&[0, 1], &[0xFF], 1)
}
#[test]
fn dictionary_values_are_decoded() {
let values = invalid_leaf();
let keys = Int32Array::from(vec![0, 0]);
let dict: ArrayRef = Arc::new(DictionaryArray::<Int32Type>::new(keys, values));
let out = decode_string_arrays(&dict).unwrap();
let d = out
.as_any()
.downcast_ref::<DictionaryArray<Int32Type>>()
.unwrap();
let vals = d.values().as_any().downcast_ref::<StringArray>().unwrap();
assert_eq!(vals.value(0), "\u{FFFD}");
}
#[test]
fn struct_field_is_decoded() {
let field = Arc::new(Field::new("s", DataType::Utf8, true));
let input: ArrayRef = Arc::new(StructArray::new(
Fields::from(vec![field]),
vec![invalid_leaf()],
None,
));
let out = decode_string_arrays(&input).unwrap();
let s = out.as_any().downcast_ref::<StructArray>().unwrap();
let col = s.column(0).as_any().downcast_ref::<StringArray>().unwrap();
assert_eq!(col.value(0), "\u{FFFD}");
}
#[test]
fn list_values_are_decoded() {
let field = Arc::new(Field::new("item", DataType::Utf8, true));
let offsets = arrow::buffer::OffsetBuffer::new(vec![0i32, 1].into());
let input: ArrayRef =
Arc::new(ListArray::try_new(field, offsets, invalid_leaf(), None).unwrap());
let out = decode_string_arrays(&input).unwrap();
let l = out.as_any().downcast_ref::<ListArray>().unwrap();
let vals = l.values().as_any().downcast_ref::<StringArray>().unwrap();
assert_eq!(vals.value(0), "\u{FFFD}");
}
#[test]
fn valid_struct_is_zero_copy() {
let field = Arc::new(Field::new("s", DataType::Utf8, true));
let leaf: ArrayRef = Arc::new(StringArray::from(vec!["ok"]));
let input: ArrayRef = Arc::new(StructArray::new(
Fields::from(vec![field]),
vec![leaf],
None,
));
let out = decode_string_arrays(&input).unwrap();
assert!(
Arc::ptr_eq(&input, &out),
"all-valid nested input must be unchanged"
);
}
#[test]
fn dictionary_valid_values_are_zero_copy() {
let values: ArrayRef = Arc::new(StringArray::from(vec!["a", "b"]));
let keys = Int32Array::from(vec![0, 1, 0]);
let input: ArrayRef = Arc::new(DictionaryArray::<Int32Type>::new(keys, values));
let out = decode_string_arrays(&input).unwrap();
assert!(
Arc::ptr_eq(&input, &out),
"dictionary with all-valid values must be returned as the original Arc"
);
}
#[test]
fn fixed_size_list_values_are_decoded() {
let field = Arc::new(Field::new("item", DataType::Utf8, true));
let input: ArrayRef =
Arc::new(FixedSizeListArray::try_new(field, 1, invalid_leaf(), None).unwrap());
let out = decode_string_arrays(&input).unwrap();
let l = out.as_any().downcast_ref::<FixedSizeListArray>().unwrap();
let vals = l.values().as_any().downcast_ref::<StringArray>().unwrap();
assert_eq!(vals.value(0), "\u{FFFD}");
}
#[test]
fn fixed_size_list_valid_is_zero_copy() {
let field = Arc::new(Field::new("item", DataType::Utf8, true));
let leaf: ArrayRef = Arc::new(StringArray::from(vec!["ok"]));
let input: ArrayRef = Arc::new(FixedSizeListArray::try_new(field, 1, leaf, None).unwrap());
let out = decode_string_arrays(&input).unwrap();
assert!(
Arc::ptr_eq(&input, &out),
"fixed-size list with all-valid values must be unchanged"
);
}
/// Build a Map array whose single entry maps `key` -> `values` (a Utf8 leaf array of length 1).
fn build_map(key: &str, values: ArrayRef) -> ArrayRef {
let entries_fields = Fields::from(vec![
Arc::new(Field::new("keys", DataType::Utf8, false)),
Arc::new(Field::new("values", DataType::Utf8, true)),
]);
let keys: ArrayRef = Arc::new(StringArray::from(vec![key]));
let entries = StructArray::new(entries_fields.clone(), vec![keys, values], None);
let map_field = Arc::new(Field::new(
"entries",
DataType::Struct(entries_fields),
false,
));
let offsets = arrow::buffer::OffsetBuffer::new(vec![0i32, 1].into());
Arc::new(MapArray::try_new(map_field, offsets, entries, None, false).unwrap())
}
#[test]
fn map_values_are_decoded() {
let input = build_map("k", invalid_leaf());
let out = decode_string_arrays(&input).unwrap();
let m = out.as_any().downcast_ref::<MapArray>().unwrap();
let values = m
.entries()
.column(1)
.as_any()
.downcast_ref::<StringArray>()
.unwrap();
assert_eq!(values.value(0), "\u{FFFD}");
}
#[test]
fn map_valid_is_zero_copy() {
let values: ArrayRef = Arc::new(StringArray::from(vec!["ok"]));
let input = build_map("k", values);
let out = decode_string_arrays(&input).unwrap();
assert!(
Arc::ptr_eq(&input, &out),
"map with all-valid values must be unchanged"
);
}
#[test]
fn trailing_empty_string_offset_is_handled() {
// element 0 = "ab", element 1 = "" (its offset lands exactly at values.len()).
let input = utf8_unchecked(&[0, 2, 2], b"ab", 2);
let out = decode_string_arrays(&input).unwrap();
assert!(
Arc::ptr_eq(&input, &out),
"valid input with a trailing empty element must be returned unchanged"
);
let s = out.as_any().downcast_ref::<StringArray>().unwrap();
assert_eq!(s.value(0), "ab");
assert_eq!(s.value(1), "");
}
#[test]
fn sliced_array_is_decoded() {
// Slicing shifts offsets[0] away from 0; the fast path must still handle this correctly.
let base: ArrayRef = Arc::new(StringArray::from(vec!["aa", "bb", "cc"]));
let sliced: ArrayRef = base.slice(1, 2);
let out = decode_string_arrays(&sliced).unwrap();
// Slicing already produces a new Arc distinct from `base`, so ptr_eq against `base` isn't
// meaningful here; what matters is that the fast path recognizes the slice as valid and
// returns it unchanged (same Arc as `sliced`) rather than rebuilding it.
assert!(
Arc::ptr_eq(&sliced, &out),
"valid sliced input must be returned unchanged"
);
let s = out.as_any().downcast_ref::<StringArray>().unwrap();
assert_eq!(s.value(0), "bb");
assert_eq!(s.value(1), "cc");
}
}