blob: 8e901e2a60e6343de66e5af11962185664f75836 [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::sync::Arc;
use arrow::array::{Array, AsArray, GenericBinaryArray, 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}")
}
}
}
fn encode_array<O: OffsetSizeTrait>(array: &GenericBinaryArray<O>, chunk: bool) -> StringArray {
array
.iter()
.map(|value| value.map(|bytes| encode(bytes, chunk)))
.collect()
}
fn encode(bytes: &[u8], chunk: bool) -> String {
let encoded = BASE64_STANDARD.encode(bytes);
if chunk {
chunk_into_lines(encoded)
} else {
encoded
}
}
/// Wrap a base64 string into lines of at most 76 characters joined by CRLF, with no trailing
/// separator. Matches `java.util.Base64.getMimeEncoder()`. base64 output is pure ASCII, so byte
/// offsets and character offsets coincide. Takes the string by value so the common short-input
/// case (no wrapping needed) returns it without a second allocation.
fn chunk_into_lines(encoded: String) -> String {
const LINE_LEN: usize = 76;
if encoded.len() <= LINE_LEN {
return encoded;
}
let separators = (encoded.len() - 1) / LINE_LEN;
let mut out = String::with_capacity(encoded.len() + separators * 2);
let mut offset = 0;
while offset < encoded.len() {
if offset > 0 {
out.push_str("\r\n");
}
let end = (offset + LINE_LEN).min(encoded.len());
out.push_str(&encoded[offset..end]);
offset = end;
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[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]
);
}
}