This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-rust.git
The following commit(s) were added to refs/heads/main by this push:
new 820f0dc4 feat(predicate): read Java's string temporal and decimal
literals in REST predicate JSON (#1018)
820f0dc4 is described below
commit 820f0dc49bc0ab1618440ee6dc9baa6dfb62fb8b
Author: Jiajia Li <[email protected]>
AuthorDate: Wed Oct 7 19:39:43 2026 +0800
feat(predicate): read Java's string temporal and decimal literals in REST
predicate JSON (#1018)
---
crates/paimon/src/spec/predicate.rs | 241 +++++++++++++++++++++++++++++++++++-
1 file changed, 237 insertions(+), 4 deletions(-)
diff --git a/crates/paimon/src/spec/predicate.rs
b/crates/paimon/src/spec/predicate.rs
index 73a078c2..3cfa72c2 100644
--- a/crates/paimon/src/spec/predicate.rs
+++ b/crates/paimon/src/spec/predicate.rs
@@ -1037,14 +1037,146 @@ fn json_to_datum(value: &serde_json::Value, data_type:
&DataType) -> Result<Datu
.as_str()
.map(|s| Datum::String(s.to_string()))
.ok_or_else(type_err),
- // Temporal/decimal literals fail closed: Java's own JSON deserializer
- // cannot round-trip them.
+ // Java `LeafPredicate.toJsonFriendly`: ISO strings, and a plain
decimal
+ // string rounded HALF_UP to the column scale
(`Decimal.fromBigDecimal`).
+ DataType::Date(_) => value
+ .as_str()
+ .and_then(|s| chrono::NaiveDate::parse_from_str(s,
"%Y-%m-%d").ok())
+ .map(|date| {
+ let epoch = chrono::DateTime::UNIX_EPOCH.date_naive();
+ Datum::Date((date - epoch).num_days() as i32)
+ })
+ .ok_or_else(type_err),
+ DataType::Time(_) => value
+ .as_str()
+ .and_then(|s| {
+ chrono::NaiveTime::parse_from_str(s, "%H:%M:%S%.f")
+ .or_else(|_| chrono::NaiveTime::parse_from_str(s, "%H:%M"))
+ .ok()
+ })
+ .filter(|time| !is_leap_second(time))
+ .map(|time| {
+ use chrono::Timelike;
+ let millis =
+ time.num_seconds_from_midnight() * 1000 +
time.nanosecond() / 1_000_000;
+ Datum::Time(millis as i32)
+ })
+ .ok_or_else(type_err),
+ DataType::Timestamp(_) => value
+ .as_str()
+ .and_then(|s| {
+ chrono::NaiveDateTime::parse_from_str(s,
"%Y-%m-%dT%H:%M:%S%.f")
+ .or_else(|_| chrono::NaiveDateTime::parse_from_str(s,
"%Y-%m-%dT%H:%M"))
+ .ok()
+ })
+ .filter(|datetime| !is_leap_second(datetime))
+ .map(|datetime| {
+ let (millis, nanos) = millis_and_nanos(datetime.and_utc());
+ Datum::Timestamp { millis, nanos }
+ })
+ .ok_or_else(type_err),
+ DataType::LocalZonedTimestamp(_) => value
+ .as_str()
+ .and_then(parse_instant)
+ .map(|instant| {
+ let (millis, nanos) = millis_and_nanos(instant);
+ Datum::LocalZonedTimestamp { millis, nanos }
+ })
+ .ok_or_else(type_err),
+ DataType::Decimal(decimal) => value
+ .as_str()
+ .and_then(|s| decimal_literal(s, decimal.precision(),
decimal.scale()))
+ .ok_or_else(type_err),
other => Err(rest_json_err(format!(
"literal conversion for type {other:?} is not supported"
))),
}
}
+/// Java `Instant.toString()` is UTC with a signed year outside 0000..=9999,
+/// which RFC 3339 cannot carry.
+fn parse_instant(text: &str) -> Option<chrono::DateTime<chrono::Utc>> {
+ match text.strip_suffix('Z') {
+ Some(local) => chrono::NaiveDateTime::parse_from_str(local,
"%Y-%m-%dT%H:%M:%S%.f")
+ .ok()
+ .and_then(fold_leap_second)
+ .map(|datetime| datetime.and_utc()),
+ None => chrono::DateTime::parse_from_rfc3339(text)
+ .ok()
+ .and_then(fold_leap_second)
+ .map(|instant| instant.to_utc()),
+ }
+}
+
+/// chrono reads second 60 as a leap second; Java's `LocalTime` and
`LocalDateTime` refuse it.
+fn is_leap_second(time: &impl chrono::Timelike) -> bool {
+ time.nanosecond() >= 1_000_000_000
+}
+
+/// Java's `Instant.parse` folds `23:59:60` into `23:59:59` and refuses any
other leap second.
+fn fold_leap_second<T: chrono::Timelike>(time: T) -> Option<T> {
+ if !is_leap_second(&time) {
+ Some(time)
+ } else if time.hour() == 23 && time.minute() == 59 {
+ time.with_nanosecond(time.nanosecond() - 1_000_000_000)
+ } else {
+ None
+ }
+}
+
+/// Epoch millis and the nanos within that milli, as Paimon `Timestamp` keeps
them.
+fn millis_and_nanos(instant: chrono::DateTime<chrono::Utc>) -> (i64, i32) {
+ (
+ instant.timestamp_millis(),
+ (instant.timestamp_subsec_nanos() % 1_000_000) as i32,
+ )
+}
+
+/// `None` when the rounded value no longer fits `precision`, where Java gives
a
+/// null literal.
+fn decimal_literal(text: &str, precision: u32, scale: u32) -> Option<Datum> {
+ let (negative, digits) = match text.strip_prefix('-') {
+ Some(rest) => (true, rest),
+ None => (false, text.strip_prefix('+').unwrap_or(text)),
+ };
+ let (whole, fraction) = digits.split_once('.').unwrap_or((digits, ""));
+ if whole.is_empty() && fraction.is_empty()
+ || !whole
+ .chars()
+ .chain(fraction.chars())
+ .all(|c| c.is_ascii_digit())
+ {
+ return None;
+ }
+ let mut unscaled: i128 = 0;
+ for c in whole.chars().chain(fraction.chars()) {
+ unscaled = unscaled
+ .checked_mul(10)?
+ .checked_add(i128::from(c as u8 - b'0'))?;
+ }
+ let literal_scale = fraction.len() as u32;
+ if literal_scale > scale {
+ let divisor = 10i128.checked_pow(literal_scale - scale)?;
+ let (quotient, remainder) = (unscaled / divisor, unscaled % divisor);
+ // HALF_UP on the magnitude, before the sign is applied; `remainder *
2`
+ // can overflow at 38 digits.
+ unscaled = quotient + i128::from(remainder >= divisor - remainder);
+ } else {
+ unscaled = unscaled.checked_mul(10i128.checked_pow(scale -
literal_scale)?)?;
+ }
+ let digit_count = unscaled.checked_ilog10().map_or(1, |log| log + 1);
+ // Java counts the digits of the rounded value, dropping leading zeros, so
+ // `0.05` at scale 2 has precision 1.
+ if digit_count > precision {
+ return None;
+ }
+ Some(Datum::Decimal {
+ unscaled: if negative { -unscaled } else { unscaled },
+ precision,
+ scale,
+ })
+}
+
fn int_datum(value: &serde_json::Value, build: impl Fn(i64) -> Option<Datum>)
-> Result<Datum> {
value
.as_i64()
@@ -3666,8 +3798,11 @@ mod tests {
"must parse {function}: {json}"
);
}
- // DATE has no Java-round-trippable wire form; every shape fails
closed.
- for literals in ["[19000]", "[\"2022-01-15\"]", "[[2022,1,15]]"] {
+ // DATE travels as an ISO string since Java #9640; the older Jackson
+ // shapes still fail closed.
+ let json = rest_leaf_json("EQUAL", "dt", "[\"2022-01-15\"]");
+ assert!(Predicate::from_rest_json(&json, &fields).is_ok(), "{json}");
+ for literals in ["[19000]", "[[2022,1,15]]"] {
let json = rest_leaf_json("EQUAL", "dt", literals);
assert!(
Predicate::from_rest_json(&json, &fields).is_err(),
@@ -3766,6 +3901,104 @@ mod tests {
}
}
+ #[test]
+ fn test_from_rest_json_reads_java_temporal_and_decimal_literals() {
+ use crate::spec::{DecimalType, LocalZonedTimestampType, TimeType,
TimestampType};
+ let literal = |data_type: DataType, json: &str| {
+ let fields = vec![DataField::new(0, "c".to_string(), data_type)];
+ let leaf = rest_leaf_json_typed("EQUAL", "c", "ANY",
&format!("[{json}]"));
+ Predicate::from_rest_json(&leaf, &fields).map(|p| match p {
+ Predicate::Leaf { literals, .. } => literals[0].clone(),
+ other => panic!("expected leaf, got {other:?}"),
+ })
+ };
+ let date = || DataType::Date(DateType::new());
+ assert_eq!(
+ literal(date(), r#""2024-01-15""#).unwrap(),
+ Datum::Date(19737)
+ );
+ assert_eq!(literal(date(), r#""1969-12-31""#).unwrap(),
Datum::Date(-1));
+ // Java signs a year outside 0000..=9999.
+ assert_eq!(
+ literal(date(), r#""+10000-01-01""#).unwrap(),
+ Datum::Date(2_932_897)
+ );
+ assert_eq!(
+ literal(date(), r#""-0001-01-01""#).unwrap(),
+ Datum::Date(-719_893)
+ );
+
+ let time = || DataType::Time(TimeType::new(3).unwrap());
+ assert_eq!(
+ literal(time(), r#""10:15""#).unwrap(),
+ Datum::Time(36_900_000)
+ );
+ assert_eq!(
+ literal(time(), r#""10:15:30.123456""#).unwrap(),
+ Datum::Time(36_930_123)
+ );
+
+ let timestamp = || DataType::Timestamp(TimestampType::new(9).unwrap());
+ let at = |millis, nanos| Datum::Timestamp { millis, nanos };
+ // `LocalDateTime.toString()` drops zero seconds.
+ assert_eq!(
+ literal(timestamp(), r#""2024-01-15T10:15""#).unwrap(),
+ at(1_705_313_700_000, 0)
+ );
+ assert_eq!(
+ literal(timestamp(),
r#""2024-01-15T10:15:30.123456789""#).unwrap(),
+ at(1_705_313_730_123, 456_789)
+ );
+ assert_eq!(
+ literal(timestamp(), r#""+10000-01-01T00:00""#).unwrap(),
+ at(253_402_300_800_000, 0)
+ );
+ let instant = ||
DataType::LocalZonedTimestamp(LocalZonedTimestampType::new(6).unwrap());
+ let utc = |millis| Datum::LocalZonedTimestamp { millis, nanos: 0 };
+ for (json, millis) in [
+ (r#""2024-01-15T10:15:30.5Z""#, 1_705_313_730_500),
+ (r#""+10000-01-01T00:00:00Z""#, 253_402_300_800_000),
+ (r#""-0001-01-01T00:00:00Z""#, -62_198_755_200_000),
+ (r#""2024-01-15T18:15:30.5+08:00""#, 1_705_313_730_500),
+ (r#""2016-12-31T23:59:60.5Z""#, 1_483_228_799_500),
+ ] {
+ assert_eq!(literal(instant(), json).unwrap(), utc(millis),
"{json}");
+ }
+
+ // Rounded HALF_UP to the column scale, then checked against its
precision.
+ let decimal = || DataType::Decimal(DecimalType::new(5, 2).unwrap());
+ let unscaled = |datum: Datum| match datum {
+ Datum::Decimal { unscaled, .. } => unscaled,
+ other => panic!("expected decimal, got {other:?}"),
+ };
+ assert_eq!(unscaled(literal(decimal(), r#""123.45""#).unwrap()),
12345);
+ assert_eq!(unscaled(literal(decimal(), r#""1.005""#).unwrap()), 101);
+ assert_eq!(unscaled(literal(decimal(), r#""-1.005""#).unwrap()), -101);
+ assert_eq!(unscaled(literal(decimal(), r#""7""#).unwrap()), 700);
+ let one_digit = || DataType::Decimal(DecimalType::new(1, 0).unwrap());
+ let nines = format!(r#""0.{}""#, "9".repeat(38));
+ assert_eq!(unscaled(literal(one_digit(), &nines).unwrap()), 1);
+ assert!(
+ literal(decimal(), r#""999.995""#).is_err(),
+ "rounds past DECIMAL(5, 2)"
+ );
+
+ for (data_type, json) in [
+ (date(), r#""2024-13-01""#),
+ (date(), "19737"),
+ (timestamp(), r#""2024-01-15 10:15:30""#),
+ (instant(), r#""2024-01-15T10:15:30""#),
+ (instant(), r#""2016-12-31T23:30:60Z""#),
+ (instant(), r#""2016-12-31T12:59:60+08:00""#),
+ (time(), r#""23:59:60""#),
+ (timestamp(), r#""2016-12-31T23:59:60""#),
+ (decimal(), r#""1e3""#),
+ (decimal(), "1.5"),
+ ] {
+ assert!(literal(data_type, json).is_err(), "{json}");
+ }
+ }
+
#[test]
fn test_from_rest_json_validates_every_literal() {
let fields = test_fields();