Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 16 additions & 6 deletions xee-interpreter/src/atomic/cast_datetime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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> {
Expand Down
6 changes: 2 additions & 4 deletions xee-interpreter/src/atomic/datetime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -136,10 +136,8 @@ pub struct NaiveDateTimeWithOffset {

impl From<NaiveDateTimeWithOffset> for chrono::DateTime<chrono::FixedOffset> {
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())
}
}

Expand Down
3 changes: 2 additions & 1 deletion xee-interpreter/src/atomic/map_key.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
142 changes: 142 additions & 0 deletions xee-xpath/tests/datetime_context.rs
Original file line number Diff line number Diff line change
@@ -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<String> {
let mut documents = Documents::new();
let queries = Queries::default();
let q = queries.one(xpath, |_, item| Ok(item.try_into_value::<String>()?))?;
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"
);
}
5 changes: 3 additions & 2 deletions xee-xpath/tests/snapshots/xpath__cast_date_time_stamp.snap
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
---
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(
One(
One {
item: Atomic(
DateTimeStamp(
2019-01-01T01:00:00.123+01:00,
2019-01-01T00:00:00.123+01:00,
),
),
},
Expand Down
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -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",
),
),
},
Expand Down
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -8,7 +9,7 @@ Ok(
item: Atomic(
String(
String,
"2019-01-03T16:14:30+01:00",
"2019-01-03T15:14:30+01:00",
),
),
},
Expand Down