diff --git a/xee-interpreter/src/atomic/cast_datetime.rs b/xee-interpreter/src/atomic/cast_datetime.rs index 362b378a6..ea220d5e9 100644 --- a/xee-interpreter/src/atomic/cast_datetime.rs +++ b/xee-interpreter/src/atomic/cast_datetime.rs @@ -328,10 +328,13 @@ impl atomic::Atomic { } atomic::Atomic::DateTime(date_time) => { if let Some(offset) = date_time.offset { - Ok(atomic::Atomic::DateTimeStamp( - chrono::DateTime::from_naive_utc_and_offset(date_time.date_time, offset) - .into(), - )) + // the naive datetime is local to its offset; normalizing + // it to UTC can overflow chrono's range at the extremes + let date_time_stamp = offset + .from_local_datetime(&date_time.date_time) + .single() + .ok_or(error::Error::FODT0001)?; + Ok(atomic::Atomic::DateTimeStamp(date_time_stamp.into())) } else { Err(error::Error::XPTY0004) } @@ -366,7 +369,8 @@ impl atomic::Atomic { )), atomic::Atomic::DateTimeStamp(date_time) => Ok(atomic::Atomic::Date( NaiveDateWithOffset::new( - date_time.naive_utc().date(), + // the date in the timezone of the value, not the UTC date + date_time.naive_local().date(), Some(date_time.offset().fix()), ) .into(), @@ -987,7 +991,13 @@ fn date_time_stamp_parser<'a>( let tz = tz_parser().boxed(); date_time .then(tz) - .map(|(date_time, tz)| tz.from_utc_datetime(&date_time)) + // the lexical datetime is local to its timezone; normalizing it to + // UTC can overflow chrono's datetime range at the extremes + .try_map(|(date_time, tz), _| { + tz.from_local_datetime(&date_time) + .single() + .ok_or_else(|| error::Error::FODT0001.into()) + }) } fn offset_time_parser<'a>() -> impl Parser<'a, &'a str, i32, MyExtra> { diff --git a/xee-interpreter/src/atomic/datetime.rs b/xee-interpreter/src/atomic/datetime.rs index f1a48d1f1..e7edcc3ca 100644 --- a/xee-interpreter/src/atomic/datetime.rs +++ b/xee-interpreter/src/atomic/datetime.rs @@ -136,10 +136,8 @@ pub struct NaiveDateTimeWithOffset { impl From for chrono::DateTime { fn from(naive_date_time_with_offset: NaiveDateTimeWithOffset) -> Self { - let offset = naive_date_time_with_offset - .offset - .unwrap_or_else(|| chrono::offset::Utc.fix()); - chrono::DateTime::from_naive_utc_and_offset(naive_date_time_with_offset.date_time, offset) + // the naive datetime is local to its offset + naive_date_time_with_offset.to_date_time_stamp(chrono::offset::Utc.fix()) } } diff --git a/xee-interpreter/src/atomic/map_key.rs b/xee-interpreter/src/atomic/map_key.rs index 867ba5d3a..7022294ea 100644 --- a/xee-interpreter/src/atomic/map_key.rs +++ b/xee-interpreter/src/atomic/map_key.rs @@ -113,7 +113,8 @@ impl MapKey { Ok(MapKey::NaiveDateTime(d.date_time)) } } - Atomic::DateTimeStamp(d) => Ok(MapKey::DateTime(d.naive_local())), + // normalized to UTC, like the DateTime case above + Atomic::DateTimeStamp(d) => Ok(MapKey::DateTime(d.naive_utc())), // times and dates with a timezone are stored as a chrono // datetime (but separately), or they are stored as a naive // time or date diff --git a/xee-xpath/tests/datetime_context.rs b/xee-xpath/tests/datetime_context.rs new file mode 100644 index 000000000..e2ca727fa --- /dev/null +++ b/xee-xpath/tests/datetime_context.rs @@ -0,0 +1,142 @@ +// Tests for the interaction between the current date/time in the dynamic +// context, timezone offsets, and the date/time types. +// +// https://github.com/Paligo/xee/issues/117 + +use xee_xpath::{error, Documents, Queries, Query}; + +// At 2025-04-01T23:00:00-01:00 the local date is April 1st, but the UTC +// date is already April 2nd. +const NOW: &str = "2025-04-01T23:00:00-01:00"; + +fn eval(xpath: &str, now: &str) -> error::Result { + let mut documents = Documents::new(); + let queries = Queries::default(); + let q = queries.one(xpath, |_, item| Ok(item.try_into_value::()?))?; + let now = chrono::DateTime::parse_from_rfc3339(now).unwrap(); + q.execute_build_context(&mut documents, |builder| { + builder.current_datetime(now); + }) +} + +#[test] +fn test_current_date_uses_local_date() { + assert_eq!( + eval("string(current-date())", NOW).unwrap(), + "2025-04-01-01:00" + ); +} + +#[test] +fn test_date_of_current_date_time_matches_current_date() { + assert_eq!( + eval("string(xs:date(current-dateTime()))", NOW).unwrap(), + "2025-04-01-01:00" + ); +} + +#[test] +fn test_current_date_time_string() { + assert_eq!( + eval("string(current-dateTime())", NOW).unwrap(), + "2025-04-01T23:00:00-01:00" + ); +} + +#[test] +fn test_date_time_stamp_from_string_round_trips() { + assert_eq!( + eval( + r#"string(xs:dateTimeStamp("2025-04-01T23:00:00-01:00"))"#, + NOW + ) + .unwrap(), + "2025-04-01T23:00:00-01:00" + ); + assert_eq!( + eval(r#"string(xs:dateTimeStamp("2025-04-02T00:00:00Z"))"#, NOW).unwrap(), + "2025-04-02T00:00:00Z" + ); +} + +#[test] +fn test_date_time_to_date_time_stamp_round_trips() { + assert_eq!( + eval( + r#"string(xs:dateTimeStamp(xs:dateTime("2025-04-01T23:00:00-01:00")))"#, + NOW + ) + .unwrap(), + "2025-04-01T23:00:00-01:00" + ); +} + +#[test] +fn test_date_time_stamp_to_date_uses_local_date() { + assert_eq!( + eval( + r#"string(xs:date(xs:dateTimeStamp("2025-04-01T23:00:00-01:00")))"#, + NOW + ) + .unwrap(), + "2025-04-01-01:00" + ); +} + +#[test] +fn test_local_date_ahead_of_utc() { + // here the local date is April 2nd while the UTC date is April 1st + let now = "2025-04-02T01:00:00+03:00"; + assert_eq!( + eval("string(current-date())", now).unwrap(), + "2025-04-02+03:00" + ); + assert_eq!( + eval("string(xs:date(current-dateTime()))", now).unwrap(), + "2025-04-02+03:00" + ); +} + +#[test] +fn test_date_time_and_date_time_stamp_are_equal_map_keys() { + // the same instant must be the same map key, whether it is stored as + // xs:dateTime or as xs:dateTimeStamp + assert_eq!( + eval( + r#"map{xs:dateTimeStamp("2025-01-01T13:00:00+01:00"): "hit"}(xs:dateTime("2025-01-01T12:00:00Z"))"#, + NOW + ) + .unwrap(), + "hit" + ); + assert_eq!( + eval( + r#"map{xs:dateTime("2025-01-01T13:00:00+01:00"): "hit"}(xs:dateTimeStamp("2025-01-01T12:00:00Z"))"#, + NOW + ) + .unwrap(), + "hit" + ); +} + +#[test] +fn test_date_time_stamp_out_of_range_is_an_error_not_a_panic() { + // normalizing this value to UTC overflows chrono's datetime range; + // that must surface as a dynamic error, never a panic + let parsed = eval( + r#"string("262142-12-31T23:30:00-01:00" cast as xs:dateTimeStamp)"#, + NOW, + ); + assert!( + format!("{:?}", parsed.unwrap_err()).contains("FODT0001"), + "string cast must raise FODT0001" + ); + let cast = eval( + r#"string(xs:dateTimeStamp(xs:dateTime("262142-12-31T23:30:00-01:00")))"#, + NOW, + ); + assert!( + format!("{:?}", cast.unwrap_err()).contains("FODT0001"), + "dateTime cast must raise FODT0001" + ); +} diff --git a/xee-xpath/tests/snapshots/xpath__cast_date_time_stamp.snap b/xee-xpath/tests/snapshots/xpath__cast_date_time_stamp.snap index 8b645c697..510ac8cea 100644 --- a/xee-xpath/tests/snapshots/xpath__cast_date_time_stamp.snap +++ b/xee-xpath/tests/snapshots/xpath__cast_date_time_stamp.snap @@ -1,5 +1,6 @@ --- -source: xee-xpath-compiler/tests/xpath.rs +source: xee-xpath/tests/xpath.rs +assertion_line: 1011 expression: "run(\"'2019-01-01T00:00:00.123+01:00' cast as xs:dateTimeStamp\")" --- Ok( @@ -7,7 +8,7 @@ Ok( One { item: Atomic( DateTimeStamp( - 2019-01-01T01:00:00.123+01:00, + 2019-01-01T00:00:00.123+01:00, ), ), }, diff --git a/xee-xpath/tests/snapshots/xpath__cast_date_time_stamp_millis_back_to_string.snap b/xee-xpath/tests/snapshots/xpath__cast_date_time_stamp_millis_back_to_string.snap index 05bf8c632..4397ba1ea 100644 --- a/xee-xpath/tests/snapshots/xpath__cast_date_time_stamp_millis_back_to_string.snap +++ b/xee-xpath/tests/snapshots/xpath__cast_date_time_stamp_millis_back_to_string.snap @@ -1,5 +1,6 @@ --- -source: xee-xpath-compiler/tests/xpath.rs +source: xee-xpath/tests/xpath.rs +assertion_line: 1025 expression: "run(\"('2019-01-03T15:14:30.123+01:00' cast as xs:dateTimeStamp) cast as xs:string\")" --- Ok( @@ -8,7 +9,7 @@ Ok( item: Atomic( String( String, - "2019-01-03T16:14:30.123+01:00", + "2019-01-03T15:14:30.123+01:00", ), ), }, diff --git a/xee-xpath/tests/snapshots/xpath__cast_date_time_stamp_no_millis_back_to_string.snap b/xee-xpath/tests/snapshots/xpath__cast_date_time_stamp_no_millis_back_to_string.snap index 4dfedbc4f..91d5cdc1a 100644 --- a/xee-xpath/tests/snapshots/xpath__cast_date_time_stamp_no_millis_back_to_string.snap +++ b/xee-xpath/tests/snapshots/xpath__cast_date_time_stamp_no_millis_back_to_string.snap @@ -1,5 +1,6 @@ --- -source: xee-xpath-compiler/tests/xpath.rs +source: xee-xpath/tests/xpath.rs +assertion_line: 1018 expression: "run(\"('2019-01-03T15:14:30+01:00' cast as xs:dateTimeStamp) cast as xs:string\")" --- Ok( @@ -8,7 +9,7 @@ Ok( item: Atomic( String( String, - "2019-01-03T16:14:30+01:00", + "2019-01-03T15:14:30+01:00", ), ), },