use std::num::NonZeroI32;
use chrono::{Local, NaiveDate, NaiveDateTime, TimeZone, Timelike};
use lexical_write_float::{format::STANDARD, Options, ToLexicalWithOptions};
use paimon::spec::{BinaryRow, DataField, DataType, Datum};
use paimon::{Error, Result};
const MILLIS_PER_DAY: i64 = 86_400_000;
const JAVA_FLOAT_OPTIONS: Options = Options::builder()
.positive_exponent_break(NonZeroI32::new(6))
.negative_exponent_break(NonZeroI32::new(-3))
.exponent(b'E')
.inf_string(Some(b"Infinity"))
.build_strict();
pub(super) fn format_row_as_java_cast_string(
row: &BinaryRow,
fields: &[DataField],
) -> Result<String> {
validate_row(row, fields)?;
let mut out = String::from("{");
for (pos, field) in fields.iter().enumerate() {
if pos > 0 {
out.push_str(", ");
}
out.push_str(&format_field(row, pos, field.data_type())?);
}
out.push('}');
Ok(out)
}
pub(super) fn format_row_as_java_array_string(
row: &BinaryRow,
fields: &[DataField],
) -> Result<String> {
validate_row(row, fields)?;
let mut out = String::from("[");
for (pos, field) in fields.iter().enumerate() {
if pos > 0 {
out.push_str(", ");
}
out.push_str(&format_field(row, pos, field.data_type())?);
}
out.push(']');
Ok(out)
}
pub(super) fn format_row_field_as_java_cast_string(
row: &BinaryRow,
pos: usize,
data_type: &DataType,
) -> Result<Option<String>> {
validate_row_has_field(row, pos)?;
if row.is_null_at(pos) {
return Ok(None);
}
format_field(row, pos, data_type).map(Some)
}
fn validate_row(row: &BinaryRow, fields: &[DataField]) -> Result<()> {
if row.arity() < 0 {
return Err(data_invalid(format!(
"Row string cast row has negative arity {}",
row.arity()
)));
}
let arity = row.arity() as usize;
if arity != fields.len() {
return Err(data_invalid(format!(
"Row string cast row arity {arity} does not match field count {}",
fields.len()
)));
}
let min_size = BinaryRow::cal_fix_part_size_in_bytes(row.arity()) as usize;
if row.data().len() < min_size {
return Err(data_invalid(format!(
"Row string cast row data too short: need at least {min_size} bytes, got {}",
row.data().len()
)));
}
Ok(())
}
fn validate_row_has_field(row: &BinaryRow, pos: usize) -> Result<()> {
if row.arity() < 0 {
return Err(data_invalid(format!(
"Row string cast row has negative arity {}",
row.arity()
)));
}
let arity = row.arity() as usize;
if pos >= arity {
return Err(data_invalid(format!(
"Row string cast field index {pos} is outside row arity {arity}"
)));
}
let min_size = BinaryRow::cal_fix_part_size_in_bytes(row.arity()) as usize;
if row.data().len() < min_size {
return Err(data_invalid(format!(
"Row string cast row data too short: need at least {min_size} bytes, got {}",
row.data().len()
)));
}
Ok(())
}
fn format_field(row: &BinaryRow, pos: usize, data_type: &DataType) -> Result<String> {
let Some(datum) = row.get_datum(pos, data_type)? else {
return Ok("null".to_string());
};
match (datum, data_type) {
(Datum::Bool(v), DataType::Boolean(_)) => Ok(v.to_string()),
(Datum::TinyInt(v), DataType::TinyInt(_)) => Ok(v.to_string()),
(Datum::SmallInt(v), DataType::SmallInt(_)) => Ok(v.to_string()),
(Datum::Int(v), DataType::Int(_)) => Ok(v.to_string()),
(Datum::Long(v), DataType::BigInt(_)) => Ok(v.to_string()),
(Datum::Float(v), DataType::Float(_)) => Ok(format_float(v)),
(Datum::Double(v), DataType::Double(_)) => Ok(format_double(v)),
(Datum::String(v), DataType::Char(_) | DataType::VarChar(_)) => Ok(v),
(Datum::Bytes(v), DataType::Binary(_) | DataType::VarBinary(_)) => {
Ok(String::from_utf8_lossy(&v).into_owned())
}
(Datum::Date(v), DataType::Date(_)) => format_date(v),
(Datum::Time(v), DataType::Time(t)) => format_time(v, t.precision()),
(
Datum::Decimal {
unscaled, scale, ..
},
DataType::Decimal(_),
) => Ok(format_decimal_plain(unscaled, scale)),
(Datum::Timestamp { millis, nanos }, DataType::Timestamp(t)) => {
format_timestamp(millis, nanos, t.precision())
}
(Datum::LocalZonedTimestamp { millis, nanos }, DataType::LocalZonedTimestamp(t)) => {
format_local_zoned_timestamp(millis, nanos, t.precision())
}
(datum, _) => Err(data_invalid(format!(
"Decoded row string cast datum {datum:?} does not match type {data_type:?}"
))),
}
}
fn format_float(value: f32) -> String {
const BUFFER_SIZE: usize = JAVA_FLOAT_OPTIONS.buffer_size_const::<f32, STANDARD>();
let mut buffer = [0u8; BUFFER_SIZE];
let bytes = value.to_lexical_with_options::<STANDARD>(&mut buffer, &JAVA_FLOAT_OPTIONS);
std::str::from_utf8(bytes)
.expect("lexical float output is valid UTF-8")
.to_string()
}
fn format_double(value: f64) -> String {
const BUFFER_SIZE: usize = JAVA_FLOAT_OPTIONS.buffer_size_const::<f64, STANDARD>();
let mut buffer = [0u8; BUFFER_SIZE];
let bytes = value.to_lexical_with_options::<STANDARD>(&mut buffer, &JAVA_FLOAT_OPTIONS);
std::str::from_utf8(bytes)
.expect("lexical double output is valid UTF-8")
.to_string()
}
fn format_date(epoch_days: i32) -> Result<String> {
let ce_days = epoch_days.checked_add(719_163).ok_or_else(|| {
data_invalid(format!(
"Date row string cast value {epoch_days} is outside supported range"
))
})?;
let date = NaiveDate::from_num_days_from_ce_opt(ce_days).ok_or_else(|| {
data_invalid(format!(
"Date row string cast value {epoch_days} is outside supported range"
))
})?;
Ok(date.format("%Y-%m-%d").to_string())
}
fn format_time(millis_of_day: i32, precision: u32) -> Result<String> {
let mut millis = millis_of_day as i64;
while millis < 0 {
millis += MILLIS_PER_DAY;
}
let h = millis / 3_600_000;
let m = (millis % 3_600_000) / 60_000;
let s = (millis % 60_000) / 1_000;
let mut ms = millis % 1_000;
let mut out = format!("{h:02}:{m:02}:{s:02}");
if precision > 0 {
out.push('.');
let mut remaining = precision;
while remaining > 0 {
out.push((b'0' + (ms / 100) as u8) as char);
ms = (ms % 100) * 10;
if ms == 0 {
break;
}
remaining -= 1;
}
}
Ok(out)
}
fn format_decimal_plain(unscaled: i128, scale: u32) -> String {
if scale == 0 {
return unscaled.to_string();
}
let negative = unscaled < 0;
let abs = if unscaled == i128::MIN {
(i128::MAX as u128) + 1
} else {
unscaled.unsigned_abs()
};
let digits = abs.to_string();
let scale = scale as usize;
let result = if digits.len() <= scale {
let mut s = String::with_capacity(scale + 2);
s.push_str("0.");
for _ in 0..(scale - digits.len()) {
s.push('0');
}
s.push_str(&digits);
s
} else {
let int_len = digits.len() - scale;
let mut s = String::with_capacity(digits.len() + 1);
s.push_str(&digits[..int_len]);
s.push('.');
s.push_str(&digits[int_len..]);
s
};
if negative {
format!("-{result}")
} else {
result
}
}
fn format_timestamp(millis: i64, nano_of_milli: i32, precision: u32) -> Result<String> {
format_timestamp_naive(millis_to_naive_datetime(millis, nano_of_milli)?, precision)
}
fn format_local_zoned_timestamp(millis: i64, nano_of_milli: i32, precision: u32) -> Result<String> {
let nanos = timestamp_nanos(millis, nano_of_milli)?;
let secs = millis.div_euclid(1000);
let local = Local
.timestamp_opt(secs, nanos)
.single()
.ok_or_else(|| data_invalid(format!("Invalid local zoned timestamp millis {millis}")))?;
format_timestamp_naive(local.naive_local(), precision)
}
fn format_timestamp_naive(dt: NaiveDateTime, precision: u32) -> Result<String> {
let precision = usize::try_from(precision).map_err(|e| Error::DataInvalid {
message: format!("Timestamp row string cast precision {precision} is invalid"),
source: Some(Box::new(e)),
})?;
if precision > 9 {
return Err(data_invalid(format!(
"Timestamp row string cast precision {precision} is outside 0..=9"
)));
}
let mut out = dt.format("%Y-%m-%d %H:%M:%S").to_string();
if precision > 0 {
let fraction = format!("{:09}", dt.nanosecond());
out.push('.');
out.push_str(&fraction[..precision]);
}
Ok(out)
}
fn millis_to_naive_datetime(millis: i64, nano_of_milli: i32) -> Result<NaiveDateTime> {
let nanos = timestamp_nanos(millis, nano_of_milli)?;
let days = millis.div_euclid(MILLIS_PER_DAY);
let millis_of_day = millis.rem_euclid(MILLIS_PER_DAY) as u64;
let nano_of_day = millis_of_day * 1_000_000 + u64::from(nanos % 1_000_000);
let ce_days = days.checked_add(719_163).ok_or_else(|| {
data_invalid(format!(
"Timestamp row string cast millis {millis} is outside supported range"
))
})?;
let ce_days = i32::try_from(ce_days).map_err(|e| Error::DataInvalid {
message: format!("Timestamp row string cast millis {millis} is outside supported range"),
source: Some(Box::new(e)),
})?;
let date = NaiveDate::from_num_days_from_ce_opt(ce_days).ok_or_else(|| {
data_invalid(format!(
"Timestamp row string cast millis {millis} is outside supported range"
))
})?;
let time = chrono::NaiveTime::from_num_seconds_from_midnight_opt(
(nano_of_day / 1_000_000_000) as u32,
(nano_of_day % 1_000_000_000) as u32,
)
.ok_or_else(|| data_invalid(format!("Invalid timestamp millis {millis}")))?;
Ok(NaiveDateTime::new(date, time))
}
fn timestamp_nanos(millis: i64, nano_of_milli: i32) -> Result<u32> {
if !(0..=999_999).contains(&nano_of_milli) {
return Err(data_invalid(format!(
"Timestamp nano-of-millisecond {nano_of_milli} is outside 0..=999999"
)));
}
Ok(millis.rem_euclid(1000) as u32 * 1_000_000 + nano_of_milli as u32)
}
fn data_invalid(message: String) -> Error {
Error::DataInvalid {
message,
source: None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use paimon::spec::{
BigIntType, BinaryType, BlobType, BooleanType, CharType, DateType, DecimalType, DoubleType,
FloatType, IntType, LocalZonedTimestampType, SmallIntType, TimeType, TimestampType,
TinyIntType, VarBinaryType, VarCharType,
};
fn field(id: i32, data_type: DataType) -> DataField {
DataField::new(id, format!("f{id}"), data_type)
}
fn row(values: &[(Option<Datum>, DataType)]) -> BinaryRow {
let refs: Vec<_> = values
.iter()
.map(|(datum, data_type)| (datum.as_ref(), data_type))
.collect();
BinaryRow::from_datums(&refs)
}
fn format_value(values: &[(Option<Datum>, DataType)]) -> Result<String> {
let fields: Vec<_> = values
.iter()
.enumerate()
.map(|(i, (_, data_type))| field(i as i32, data_type.clone()))
.collect();
format_row_as_java_cast_string(&row(values), &fields)
}
#[test]
fn test_format_supported_scalar_types() {
let values = vec![
(
Some(Datum::Bool(true)),
DataType::Boolean(BooleanType::new()),
),
(
Some(Datum::TinyInt(-1)),
DataType::TinyInt(TinyIntType::new()),
),
(
Some(Datum::SmallInt(2)),
DataType::SmallInt(SmallIntType::new()),
),
(Some(Datum::Int(3)), DataType::Int(IntType::new())),
(Some(Datum::Long(4)), DataType::BigInt(BigIntType::new())),
(Some(Datum::Float(1.0)), DataType::Float(FloatType::new())),
(
Some(Datum::Double(10_000_000.0)),
DataType::Double(DoubleType::new()),
),
(
Some(Datum::String("c".to_string())),
DataType::Char(CharType::new(1).unwrap()),
),
(
Some(Datum::Bytes(b"xy".to_vec())),
DataType::Binary(BinaryType::new(2).unwrap()),
),
(
Some(Datum::Bytes(b"abc".to_vec())),
DataType::VarBinary(VarBinaryType::new(3).unwrap()),
),
(Some(Datum::Date(19_723)), DataType::Date(DateType::new())),
(
Some(Datum::Time(45_296_000)),
DataType::Time(TimeType::new(3).unwrap()),
),
(
Some(Datum::Decimal {
unscaled: -100,
precision: 10,
scale: 3,
}),
DataType::Decimal(DecimalType::new(10, 3).unwrap()),
),
(
Some(Datum::Timestamp {
millis: 1_704_110_400_123,
nanos: 456_000,
}),
DataType::Timestamp(TimestampType::new(6).unwrap()),
),
];
assert_eq!(
format_value(&values).unwrap(),
"{true, -1, 2, 3, 4, 1.0, 1.0E7, c, xy, abc, 2024-01-01, 12:34:56.0, -0.100, 2024-01-01 12:00:00.123456}"
);
}
#[test]
fn test_format_float_double_uses_java_display_thresholds() {
let values = vec![
(
Some(Datum::Double(9_999_999.0)),
DataType::Double(DoubleType::new()),
),
(
Some(Datum::Double(10_000_000.0)),
DataType::Double(DoubleType::new()),
),
(
Some(Datum::Double(0.001)),
DataType::Double(DoubleType::new()),
),
(
Some(Datum::Double(0.000_999_999)),
DataType::Double(DoubleType::new()),
),
(Some(Datum::Float(-0.0)), DataType::Float(FloatType::new())),
(
Some(Datum::Double(f64::INFINITY)),
DataType::Double(DoubleType::new()),
),
(
Some(Datum::Double(f64::NEG_INFINITY)),
DataType::Double(DoubleType::new()),
),
(
Some(Datum::Double(f64::NAN)),
DataType::Double(DoubleType::new()),
),
];
assert_eq!(
format_value(&values).unwrap(),
"{9999999.0, 1.0E7, 0.001, 9.99999E-4, -0.0, Infinity, -Infinity, NaN}"
);
}
#[test]
fn test_format_binary_invalid_utf8_uses_lossy_string() {
let bytes = vec![0xff];
let data_type = DataType::VarBinary(VarBinaryType::new(1).unwrap());
let expected = String::from_utf8_lossy(&bytes).into_owned();
assert_eq!(
format_value(&[(Some(Datum::Bytes(bytes)), data_type)]).unwrap(),
format!("{{{expected}}}")
);
}
#[test]
fn test_format_null_values() {
let values = vec![
(None, DataType::Int(IntType::new())),
(None, DataType::VarChar(VarCharType::string_type())),
];
assert_eq!(format_value(&values).unwrap(), "{null, null}");
}
#[test]
fn test_format_timestamp_precision_matches_java_cast() {
let values = vec![
(
Some(Datum::Timestamp {
millis: 1_704_110_400_123,
nanos: 456_000,
}),
DataType::Timestamp(TimestampType::new(3).unwrap()),
),
(
Some(Datum::Timestamp {
millis: 1_704_110_400_123,
nanos: 456_000,
}),
DataType::Timestamp(TimestampType::new(6).unwrap()),
),
];
assert_eq!(
format_value(&values).unwrap(),
"{2024-01-01 12:00:00.123, 2024-01-01 12:00:00.123456}"
);
}
#[test]
fn test_format_local_zoned_timestamp() {
let data_type = DataType::LocalZonedTimestamp(LocalZonedTimestampType::new(3).unwrap());
let millis = 1_704_067_200_000;
let expected = Local
.timestamp_opt(millis / 1000, 0)
.single()
.map(|dt| format!("{}.000", dt.format("%Y-%m-%d %H:%M:%S")))
.unwrap();
assert_eq!(
format_value(&[(
Some(Datum::LocalZonedTimestamp { millis, nanos: 0 }),
data_type,
)])
.unwrap(),
format!("{{{expected}}}")
);
}
#[test]
fn test_format_unsupported_type_returns_error() {
let data_type = DataType::Blob(BlobType::new());
let err = format_value(&[(Some(Datum::Bytes(b"x".to_vec())), data_type)])
.expect_err("blob row string cast should be unsupported");
assert!(matches!(err, Error::Unsupported { .. }));
}
#[test]
fn test_format_arity_mismatch_returns_error() {
let int_type = DataType::Int(IntType::new());
let row = row(&[(Some(Datum::Int(1)), int_type.clone())]);
let fields = vec![
field(0, int_type.clone()),
field(1, DataType::Int(IntType::new())),
];
let err =
format_row_as_java_cast_string(&row, &fields).expect_err("arity mismatch should fail");
assert!(err.to_string().contains("arity"));
}
#[test]
fn test_format_truncated_row_returns_error() {
let row = BinaryRow::from_bytes(1, vec![0, 0, 0, 0]);
let fields = vec![field(0, DataType::Int(IntType::new()))];
let err =
format_row_as_java_cast_string(&row, &fields).expect_err("truncated row should fail");
assert!(err.to_string().contains("too short"));
}
}