diff --git a/crates/iceberg/src/spec/values/literal.rs b/crates/iceberg/src/spec/values/literal.rs index 2eef4eba07..7f82d8509c 100644 --- a/crates/iceberg/src/spec/values/literal.rs +++ b/crates/iceberg/src/spec/values/literal.rs @@ -493,6 +493,33 @@ impl Literal { )), )))) } + (PrimitiveType::TimestampNs, JsonValue::String(s)) => { + let ndt = NaiveDateTime::parse_from_str(&s, "%Y-%m-%dT%H:%M:%S%.f")?; + let nanos = timestamp::datetime_to_nanoseconds(&ndt).ok_or_else(|| { + Error::new( + crate::ErrorKind::DataInvalid, + format!( + "Timestamp is outside the representable nanosecond range: {ndt}" + ), + ) + })?; + Ok(Some(Literal::Primitive(PrimitiveLiteral::Long(nanos)))) + } + (PrimitiveType::TimestamptzNs, JsonValue::String(s)) => { + let dt = Utc.from_utc_datetime(&NaiveDateTime::parse_from_str( + &s, + "%Y-%m-%dT%H:%M:%S%.f+00:00", + )?); + let nanos = timestamptz::datetimetz_to_nanoseconds(&dt).ok_or_else(|| { + Error::new( + crate::ErrorKind::DataInvalid, + format!( + "Timestamptz is outside the representable nanosecond range: {dt}" + ), + ) + })?; + Ok(Some(Literal::Primitive(PrimitiveLiteral::Long(nanos)))) + } (PrimitiveType::String, JsonValue::String(s)) => { Ok(Some(Literal::Primitive(PrimitiveLiteral::String(s)))) } diff --git a/crates/iceberg/src/spec/values/temporal.rs b/crates/iceberg/src/spec/values/temporal.rs index ad797e127e..c9efcf4c4a 100644 --- a/crates/iceberg/src/spec/values/temporal.rs +++ b/crates/iceberg/src/spec/values/temporal.rs @@ -82,6 +82,12 @@ pub(crate) mod timestamp { pub(crate) fn nanoseconds_to_datetime(nanos: i64) -> NaiveDateTime { DateTime::from_timestamp_nanos(nanos).naive_utc() } + + /// Nanoseconds since the Unix epoch, or `None` if outside the representable `i64` range + /// (roughly the years 1678–2262). + pub(crate) fn datetime_to_nanoseconds(time: &NaiveDateTime) -> Option { + time.and_utc().timestamp_nanos_opt() + } } pub(crate) mod timestamptz { @@ -102,4 +108,10 @@ pub(crate) mod timestamptz { DateTime::from_timestamp(secs, rem as u32).unwrap() } + + /// Nanoseconds since the Unix epoch, or `None` if outside the representable `i64` range + /// (roughly the years 1678–2262). + pub(crate) fn datetimetz_to_nanoseconds(time: &DateTime) -> Option { + time.timestamp_nanos_opt() + } } diff --git a/crates/iceberg/src/spec/values/tests.rs b/crates/iceberg/src/spec/values/tests.rs index a8a6716f9d..f097a74a59 100644 --- a/crates/iceberg/src/spec/values/tests.rs +++ b/crates/iceberg/src/spec/values/tests.rs @@ -214,6 +214,53 @@ fn json_timestamptz() { ); } +#[test] +fn json_timestamp_ns() { + let record = r#""2017-11-16T22:31:08.123456789""#; + + check_json_serde( + record, + Literal::Primitive(PrimitiveLiteral::Long(1510871468123456789)), + &Type::Primitive(PrimitiveType::TimestampNs), + ); +} + +#[test] +fn json_timestamptz_ns() { + let record = r#""2017-11-16T22:31:08.123456789+00:00""#; + + check_json_serde( + record, + Literal::Primitive(PrimitiveLiteral::Long(1510871468123456789)), + &Type::Primitive(PrimitiveType::TimestamptzNs), + ); +} + +#[test] +fn json_timestamptz_ns_rejects_non_utc_offset() { + // Per the spec, timestamptz_ns single-value serialization must use offset "+00:00"; Java's + // SingleValueParser enforces the same (DateTimeUtil.isUTCTimestamptz). A non-UTC offset is not a + // valid encoding and must be rejected, not silently re-based to UTC. + let record = serde_json::Value::String("2017-11-16T22:31:08.123456789+05:00".to_string()); + let result = Literal::try_from_json(record, &Type::Primitive(PrimitiveType::TimestamptzNs)); + assert!( + result.is_err(), + "non-UTC offset must be rejected for timestamptz_ns, got {result:?}" + ); +} + +#[test] +fn json_timestamptz_rejects_non_utc_offset() { + // Micros-precision counterpart, mirroring Java's TestSingleValueParser.testInvalidTimestamptz: + // the offset must be "+00:00", so a non-UTC offset is rejected. + let record = serde_json::Value::String("2017-11-16T22:31:08.123456+05:00".to_string()); + let result = Literal::try_from_json(record, &Type::Primitive(PrimitiveType::Timestamptz)); + assert!( + result.is_err(), + "non-UTC offset must be rejected for timestamptz, got {result:?}" + ); +} + #[test] fn json_string() { let record = r#""iceberg""#;