Skip to content
Open
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
68 changes: 64 additions & 4 deletions sqlx-sqlite/src/types/chrono.rs
Original file line number Diff line number Diff line change
Expand Up @@ -176,10 +176,16 @@ fn decode_datetime_from_float(value: f64) -> Option<DateTime<FixedOffset>> {
// We checked above if the value is infinite or NaN which could otherwise cause problems
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
{
let seconds = timestamp.trunc() as i64;
let nanos = (timestamp.fract() * 1E9).abs() as u32;

Utc.fix().timestamp_opt(seconds, nanos).single()
// Split into whole seconds and a non-negative sub-second remainder.
// `timestamp_opt` always adds `nanos` *forward* in time, so we must round
// `seconds` toward negative infinity (not toward zero) and take the
// fraction relative to that. Using `trunc()`/`fract().abs()` here would
// push pre-epoch timestamps up to ~2 seconds off (and onto the wrong side
// of the epoch), since the fractional part is negative below zero.
let seconds = timestamp.floor();
let nanos = ((timestamp - seconds) * 1E9) as u32;

Utc.fix().timestamp_opt(seconds as i64, nanos).single()
}
}

Expand Down Expand Up @@ -218,3 +224,57 @@ impl<'r> Decode<'r, Sqlite> for NaiveTime {
Err(format!("invalid time: {value}").into())
}
}

#[cfg(test)]
mod tests {
use super::decode_datetime_from_float;
use chrono::{Offset, TimeZone, Utc};

// SQLite may store a datetime as a REAL holding a Julian day number, e.g. the
// result of `julianday(...)`. This mirrors that conversion so a test can feed
// `decode_datetime_from_float` the value for a known instant.
fn julian_day(unix_seconds: f64) -> f64 {
2_440_587.5 + unix_seconds / 86_400.0
}

// Assert that the Julian day for `unix_seconds + subsec_nanos` decodes back to
// that same instant. The tolerance only absorbs f64 round-off in the
// round-trip (tens of microseconds), well below the errors under test.
#[track_caller]
fn assert_decodes_near(unix_seconds: i64, subsec_nanos: u32) {
let input = julian_day(unix_seconds as f64 + f64::from(subsec_nanos) / 1e9);
let decoded = decode_datetime_from_float(input).expect("valid Julian day should decode");
let expected = Utc
.fix()
.timestamp_opt(unix_seconds, subsec_nanos)
.single()
.unwrap();

let diff_us = (decoded - expected)
.num_microseconds()
.expect("difference fits in microseconds")
.abs();
assert!(
diff_us < 1_000,
"decoded {decoded} differs from expected {expected} by {diff_us} us",
);
}

// A Julian day landing before the UNIX epoch must keep its sub-second part in
// the correct direction. Before the fix these decoded up to ~2 seconds off,
// and could even cross to the wrong side of the epoch.
#[test]
fn decodes_pre_epoch_float_datetime() {
// 1969-12-31 23:59:59.500 UTC
assert_decodes_near(-1, 500_000_000);
// 1969-12-31 23:59:58.750 UTC
assert_decodes_near(-2, 750_000_000);
}

// Post-epoch values were already correct; guard against a regression.
#[test]
fn decodes_post_epoch_float_datetime() {
// 1970-01-01 00:00:01.500 UTC
assert_decodes_near(1, 500_000_000);
}
}
Loading