blob: d394cfdccfbde9e3af361638a6e8e4c57fcf0cbc [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 std::fmt::{self, Write};
use std::sync::Arc;
use arrow::array::{
Array, AsArray, GenericBinaryArray, GenericStringBuilder, OffsetSizeTrait, StringArray,
};
use arrow::datatypes::DataType;
use base64::prelude::BASE64_STANDARD;
use base64::Engine;
use datafusion::common::{exec_err, DataFusionError, ScalarValue};
use datafusion::physical_plan::ColumnarValue;
/// Spark `base64(bin)`: encodes a binary value as a padded base64 string.
///
/// The second argument is a boolean `chunk` flag mirroring Spark's
/// `spark.sql.chunkBase64String.enabled`. When `chunk` is true (Spark's default, and the only
/// behavior on Spark 3.4), the output matches `java.util.Base64.getMimeEncoder()`: lines of at most
/// 76 characters joined by a CRLF (`\r\n`), with no trailing separator. When false, the output is a
/// single unwrapped line, matching `java.util.Base64.getMimeEncoder(-1, [])`.
pub fn spark_base64(args: &[ColumnarValue]) -> Result<ColumnarValue, DataFusionError> {
if args.len() != 2 {
return exec_err!("base64 expects exactly two arguments, got {}", args.len());
}
let chunk = match &args[1] {
ColumnarValue::Scalar(ScalarValue::Boolean(Some(chunk))) => *chunk,
other => return exec_err!("base64 expects a boolean chunk flag, got {other:?}"),
};
match &args[0] {
ColumnarValue::Array(array) => match array.data_type() {
DataType::Binary => Ok(ColumnarValue::Array(Arc::new(encode_array(
array.as_binary::<i32>(),
chunk,
)))),
DataType::LargeBinary => Ok(ColumnarValue::Array(Arc::new(encode_array(
array.as_binary::<i64>(),
chunk,
)))),
other => exec_err!("base64 expects a binary argument, got {other}"),
},
ColumnarValue::Scalar(ScalarValue::Binary(value))
| ColumnarValue::Scalar(ScalarValue::LargeBinary(value)) => {
let encoded = value.as_ref().map(|bytes| encode(bytes, chunk));
Ok(ColumnarValue::Scalar(ScalarValue::Utf8(encoded)))
}
ColumnarValue::Scalar(other) => {
exec_err!("base64 expects a binary argument, got {other}")
}
}
}
const LINE_LEN: usize = 76;
/// Panic message for the `fmt::Write` sinks used here; neither can actually fail.
const INFALLIBLE_SINK: &str = "writing base64 to an in-memory buffer cannot fail";
/// Length of the padded base64 encoding of `n` input bytes.
fn base64_encoded_len(n: usize) -> usize {
n.div_ceil(3) * 4
}
/// Length after CRLF wrapping if `encoded_len` bytes are chunked at `LINE_LEN` chars per line.
fn chunked_len(encoded_len: usize) -> usize {
if encoded_len == 0 {
0
} else {
encoded_len + ((encoded_len - 1) / LINE_LEN) * 2
}
}
/// Encodes `bytes` as base64 into `out`, wrapping at `LINE_LEN` when `chunk` is true.
///
/// `scratch` receives the unwrapped encoding from a single bulk `encode_string` call. Both
/// `scratch` and `out` are owned by the caller and reused across rows, so encoding a batch performs
/// no per-row heap allocation. Because `out` is a `fmt::Write` sink, the array path can pass the
/// output builder directly and have the wrapped result land in its value buffer, rather than
/// staging each row in a second buffer and copying it in.
///
/// An alternative is to skip `scratch` entirely and encode line-sized windows straight into `out`:
/// MIME wrapping is aligned to base64's block structure, so 57 input bytes encode to exactly
/// `LINE_LEN` chars with no padding, and only the final window can carry padding. That is correct
/// and allocation-free, but measurably slower — the per-call overhead of many small `encode_string`
/// calls exceeds the cost of one bulk encode plus the copy out. See the benchmark discussion on
/// <https://github.com/apache/datafusion-comet/pull/4885>.
fn encode_into<W: Write>(
bytes: &[u8],
chunk: bool,
scratch: &mut String,
out: &mut W,
) -> fmt::Result {
scratch.clear();
BASE64_STANDARD.encode_string(bytes, scratch);
if !chunk || scratch.len() <= LINE_LEN {
return out.write_str(scratch);
}
let mut offset = 0;
while offset < scratch.len() {
if offset > 0 {
out.write_str("\r\n")?;
}
let end = (offset + LINE_LEN).min(scratch.len());
out.write_str(&scratch[offset..end])?;
offset = end;
}
Ok(())
}
/// O(1) upper bound on the total encoded length of `array`, for sizing the output value buffer.
///
/// Each row pads independently, so the encoded total is `sum ceil(len_i / 3) * 4`, which cannot be
/// derived from the input's total byte count alone: N rows of one byte each encode to `4N`, not to
/// `base64_encoded_len(N)`. Since `sum ceil(x_i) <= ceil(sum x_i) + (N - 1)`, adding `4 * (N - 1)`
/// to the whole-input encoding turns the estimate into a true upper bound while staying O(1) — no
/// pass over the offsets. It over-reserves by at most 4 bytes per row, which matters only for
/// arrays of very short values, where the buffer is small in absolute terms anyway.
fn encoded_capacity<O: OffsetSizeTrait>(array: &GenericBinaryArray<O>, chunk: bool) -> usize {
let encoded_total =
base64_encoded_len(array.value_data().len()) + 4 * array.len().saturating_sub(1);
if chunk {
chunked_len(encoded_total)
} else {
encoded_total
}
}
fn encode_array<O: OffsetSizeTrait>(array: &GenericBinaryArray<O>, chunk: bool) -> StringArray {
let mut builder =
GenericStringBuilder::<i32>::with_capacity(array.len(), encoded_capacity(array, chunk));
// Reused across rows, so a batch pays no per-row allocation.
let mut scratch = String::new();
for i in 0..array.len() {
if array.is_null(i) {
builder.append_null();
continue;
}
encode_into(array.value(i), chunk, &mut scratch, &mut builder).expect(INFALLIBLE_SINK);
// Finalizes the value written through `fmt::Write` above.
builder.append_value("");
}
builder.finish()
}
fn encode(bytes: &[u8], chunk: bool) -> String {
let mut scratch = String::new();
let mut out = String::new();
encode_into(bytes, chunk, &mut scratch, &mut out).expect(INFALLIBLE_SINK);
out
}
#[cfg(test)]
mod tests {
use super::*;
use arrow::array::{BinaryArray, LargeBinaryArray};
#[test]
fn unchunked_is_a_single_line() {
assert_eq!(encode(b"abc", false), "YWJj");
assert_eq!(
encode(&[b'a'; 58], false),
BASE64_STANDARD.encode([b'a'; 58])
);
assert_eq!(encode(b"", false), "");
}
#[test]
fn chunked_matches_java_mime_encoder() {
// Empty and short inputs are returned without a separator.
assert_eq!(encode(b"", true), "");
assert_eq!(encode(b"abc", true), "YWJj");
// 57 input bytes encode to exactly 76 characters: the line limit, so no separator.
let exactly_76 = encode(&[b'a'; 57], true);
assert_eq!(exactly_76.len(), 76);
assert!(!exactly_76.contains("\r\n"));
// 58 input bytes encode to 80 characters, wrapping once after 76.
let wrapped_once = encode(&[b'b'; 58], true);
assert_eq!(wrapped_once.matches("\r\n").count(), 1);
assert_eq!(wrapped_once.split("\r\n").next().unwrap().len(), 76);
// 120 input bytes encode to 160 characters, wrapping twice (76 + 76 + 8).
let wrapped_twice = encode(&[b'c'; 120], true);
assert_eq!(wrapped_twice.matches("\r\n").count(), 2);
let lines: Vec<&str> = wrapped_twice.split("\r\n").collect();
assert_eq!(
lines.iter().map(|l| l.len()).collect::<Vec<_>>(),
vec![76, 76, 8]
);
}
#[test]
fn chunked_matches_encode_then_split_at_every_length() {
// The window encoding must be byte-for-byte what encoding the whole input and splitting
// every LINE_LEN chars produces. Sweep every length across three window boundaries so a
// padding or off-by-one error in the window size cannot hide.
for n in 0..=(57 * 3 + 4) {
let input = vec![b'z'; n];
let unwrapped = BASE64_STANDARD.encode(&input);
let expected = unwrapped
.as_bytes()
.chunks(LINE_LEN)
.map(|line| std::str::from_utf8(line).unwrap())
.collect::<Vec<_>>()
.join("\r\n");
assert_eq!(encode(&input, true), expected, "length {n}");
// Every line is full except the last, and no line exceeds the limit.
let actual = encode(&input, true);
let lines: Vec<&str> = actual.split("\r\n").collect();
for (i, line) in lines.iter().enumerate() {
if i + 1 < lines.len() {
assert_eq!(line.len(), LINE_LEN, "length {n}, line {i}");
} else {
assert!(line.len() <= LINE_LEN, "length {n}, last line");
}
}
}
}
#[test]
fn encoded_capacity_is_an_upper_bound() {
// Regression test for the capacity estimate: rows pad independently, so an estimate
// derived from the summed input bytes alone under-reserves by ~3x for many tiny rows.
let shapes: Vec<Vec<usize>> = vec![
vec![1; 64], // worst case: every row pads to 4 chars
vec![2; 64], //
vec![3; 64], // no padding at all
vec![0; 16], // all empty
vec![57; 8], // exactly one line each
vec![58; 8], // wraps once each
vec![200, 1, 0, 57, 58, 3], // mixed
vec![4096; 4], // few large rows
];
for shape in shapes {
let values: Vec<Option<Vec<u8>>> = shape.iter().map(|&n| Some(vec![b'q'; n])).collect();
let input = BinaryArray::from_iter(values);
for chunk in [false, true] {
let actual: usize = (0..input.len())
.map(|i| encode(input.value(i), chunk).len())
.sum();
let estimated = encoded_capacity(&input, chunk);
assert!(
estimated >= actual,
"shape {shape:?}, chunk={chunk}: estimate {estimated} < actual {actual}"
);
}
}
}
#[test]
fn encode_array_many_tiny_rows() {
// The shape that the old capacity estimate under-reserved for. Verifies output
// correctness independently of the reservation.
let values: Vec<Option<&[u8]>> = (0..100).map(|_| Some(&b"a"[..])).collect();
let input = BinaryArray::from(values);
for chunk in [false, true] {
let out = encode_array(&input, chunk);
assert_eq!(out.len(), 100);
for i in 0..out.len() {
assert_eq!(out.value(i), "YQ==");
}
}
}
#[test]
fn encode_array_binary_chunked() {
// Nulls, empty values, values that wrap, and one on the boundary.
let a57 = [b'a'; 57];
let b58 = [b'b'; 58];
let c120 = [b'c'; 120];
let input = BinaryArray::from(vec![
Some(&b""[..]),
None,
Some(&a57[..]), // exactly 76 encoded chars: no wrap
Some(&b58[..]), // wraps once
None,
Some(&c120[..]), // wraps twice
]);
let out = encode_array(&input, true);
assert_eq!(out.len(), 6);
assert_eq!(out.value(0), "");
assert!(out.is_null(1));
assert_eq!(out.value(2), encode(&a57, true));
assert_eq!(out.value(3), encode(&b58, true));
assert!(out.is_null(4));
assert_eq!(out.value(5), encode(&c120, true));
}
#[test]
fn encode_array_large_binary_unchunked() {
// Exercises the LargeBinaryArray (i64 offsets) instantiation of the generic.
let input = LargeBinaryArray::from(vec![None, Some(&b"abc"[..]), Some(&b"hi!"[..])]);
let out = encode_array(&input, false);
assert_eq!(out.len(), 3);
assert!(out.is_null(0));
assert_eq!(out.value(1), "YWJj");
assert_eq!(out.value(2), BASE64_STANDARD.encode(b"hi!"));
}
}