Skip to main content

rws_server/model/
timestamp.rs

1//! [`Timestamp`]: a UTC instant with microsecond precision, the type of the
2//! `created_at` / `updated_at` / `deleted_at` columns the repository
3//! maintains (18.30.0).
4//!
5//! How it is stored, per backend:
6//!
7//! | Backend | Column type | What rws writes |
8//! |---|---|---|
9//! | SQLite | `TEXT` | `2026-11-04T12:34:56.123456Z` (RFC 3339, sorts as text) |
10//! | PostgreSQL | `TIMESTAMPTZ` (or `TIMESTAMP`) | the same text, cast to `TIMESTAMPTZ` |
11//! | MySQL | `DATETIME(6)` | `2026-11-04 12:34:56.123456` (UTC, no zone) |
12//!
13//! Reading accepts every form these produce (`T` or space, a fraction of 0
14//! to 9 digits, `Z`, `+00`, `+00:00` or no zone, a date alone) and, for an
15//! integer column, Unix seconds.
16
17use super::backend::Backend;
18use super::{DbError, FromColumn, ToColumn, Value};
19
20/// A UTC instant, microseconds since the Unix epoch.
21///
22/// ```rust
23/// # extern crate rws_server as rust_web_server;
24/// use rust_web_server::model::Timestamp;
25///
26/// let t = Timestamp::parse("2026-11-04 12:34:56.5+00:00").unwrap();
27/// assert_eq!(t.to_string(), "2026-11-04T12:34:56.500000Z");
28/// assert_eq!(t, Timestamp::from_unix_micros(1_793_795_696_500_000));
29/// ```
30#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
31pub struct Timestamp(i64);
32
33impl Timestamp {
34    /// The current time (the system clock, truncated to microseconds).
35    pub fn now() -> Timestamp {
36        use std::time::{SystemTime, UNIX_EPOCH};
37        match SystemTime::now().duration_since(UNIX_EPOCH) {
38            Ok(d) => Timestamp(d.as_micros() as i64),
39            Err(e) => Timestamp(-(e.duration().as_micros() as i64)),
40        }
41    }
42
43    /// The instant `micros` microseconds after the Unix epoch.
44    pub const fn from_unix_micros(micros: i64) -> Timestamp {
45        Timestamp(micros)
46    }
47
48    /// The instant `seconds` seconds after the Unix epoch.
49    pub const fn from_unix_seconds(seconds: i64) -> Timestamp {
50        Timestamp(seconds.saturating_mul(1_000_000))
51    }
52
53    /// Microseconds since the Unix epoch.
54    pub const fn unix_micros(self) -> i64 {
55        self.0
56    }
57
58    /// Whole seconds since the Unix epoch (rounded down).
59    pub const fn unix_seconds(self) -> i64 {
60        self.0.div_euclid(1_000_000)
61    }
62
63    /// Parse `YYYY-MM-DD`, optionally followed by `T` or a space,
64    /// `HH:MM[:SS[.fraction]]` and a zone (`Z`, `±HH`, `±HHMM`, `±HH:MM`; none
65    /// means UTC). Digits past the sixth of the fraction are dropped.
66    pub fn parse(text: &str) -> Result<Timestamp, DbError> {
67        parse(text.trim()).ok_or_else(|| {
68            DbError::new(format!(
69                "cannot read {text:?} as a timestamp: expected e.g. 2026-11-04T12:34:56Z or 2026-11-04 12:34:56.123456"
70            ))
71        })
72    }
73
74    /// RFC 3339 in UTC with six fraction digits:
75    /// `2026-11-04T12:34:56.123456Z` (also what `Display` writes).
76    pub fn to_rfc3339(self) -> String {
77        let (date, time, micros) = self.parts();
78        format!("{date}T{time}.{micros:06}Z")
79    }
80
81    /// The value rws binds for this instant on `backend`: RFC 3339 text,
82    /// or `YYYY-MM-DD HH:MM:SS.ffffff` on MySQL, whose `DATETIME` takes no
83    /// zone designator.
84    pub fn to_value(self, backend: Backend) -> Value {
85        match backend {
86            #[cfg(feature = "model-mysql")]
87            Backend::MySql => {
88                let (date, time, micros) = self.parts();
89                Value::Text(format!("{date} {time}.{micros:06}"))
90            }
91            #[allow(unreachable_patterns)]
92            _ => Value::Text(self.to_rfc3339()),
93        }
94    }
95
96    fn parts(self) -> (String, String, i64) {
97        let secs = self.0.div_euclid(1_000_000);
98        let micros = self.0.rem_euclid(1_000_000);
99        let days = secs.div_euclid(86_400);
100        let rem = secs.rem_euclid(86_400);
101        let (y, m, d) = civil_from_days(days);
102        (
103            format!("{y:04}-{m:02}-{d:02}"),
104            format!("{:02}:{:02}:{:02}", rem / 3600, (rem % 3600) / 60, rem % 60),
105            micros,
106        )
107    }
108}
109
110impl std::fmt::Display for Timestamp {
111    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
112        f.write_str(&self.to_rfc3339())
113    }
114}
115
116impl std::str::FromStr for Timestamp {
117    type Err = DbError;
118    fn from_str(s: &str) -> Result<Self, DbError> {
119        Timestamp::parse(s)
120    }
121}
122
123impl FromColumn for Timestamp {
124    fn from_column(v: Value) -> Result<Self, DbError> {
125        match v {
126            Value::Text(s) => Timestamp::parse(&s),
127            Value::Int(secs) => Ok(Timestamp::from_unix_seconds(secs)),
128            Value::Null => Err(DbError::new("unexpected NULL for Timestamp (use Option<Timestamp>)")),
129            other => Err(DbError::new(format!("cannot convert {other:?} to Timestamp"))),
130        }
131    }
132}
133
134impl ToColumn for Timestamp {
135    fn to_column(&self) -> Value {
136        Value::Text(self.to_rfc3339())
137    }
138}
139
140#[cfg(feature = "serde")]
141impl serde::Serialize for Timestamp {
142    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
143        serializer.serialize_str(&self.to_rfc3339())
144    }
145}
146
147#[cfg(feature = "serde")]
148impl<'de> serde::Deserialize<'de> for Timestamp {
149    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
150        let text = String::deserialize(deserializer)?;
151        Timestamp::parse(&text).map_err(|e| serde::de::Error::custom(e.0))
152    }
153}
154
155/// Days since 1970-01-01 → (year, month, day), proleptic Gregorian
156/// (Howard Hinnant's `civil_from_days`).
157#[doc(hidden)]
158pub fn civil_from_days(days: i64) -> (i64, u32, u32) {
159    let z = days + 719_468;
160    let era = z.div_euclid(146_097);
161    let doe = z.rem_euclid(146_097);
162    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
163    let y = yoe + era * 400;
164    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
165    let mp = (5 * doy + 2) / 153;
166    let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
167    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
168    (if m <= 2 { y + 1 } else { y }, m, d)
169}
170
171/// (year, month, day) → days since 1970-01-01 (`days_from_civil`).
172#[doc(hidden)]
173pub fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
174    let y = if m <= 2 { y - 1 } else { y };
175    let era = y.div_euclid(400);
176    let yoe = y.rem_euclid(400);
177    let m = m as i64;
178    let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d as i64 - 1;
179    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
180    era * 146_097 + doe - 719_468
181}
182
183/// A cursor over ASCII digits and separators.
184struct Cursor<'a> {
185    bytes: &'a [u8],
186    at: usize,
187}
188
189impl Cursor<'_> {
190    fn digits(&mut self, n: usize) -> Option<i64> {
191        let slice = self.bytes.get(self.at..self.at + n)?;
192        let mut value = 0i64;
193        for &b in slice {
194            if !b.is_ascii_digit() {
195                return None;
196            }
197            value = value * 10 + (b - b'0') as i64;
198        }
199        self.at += n;
200        Some(value)
201    }
202
203    fn eat(&mut self, byte: u8) -> bool {
204        if self.bytes.get(self.at) == Some(&byte) {
205            self.at += 1;
206            true
207        } else {
208            false
209        }
210    }
211
212    fn peek(&self) -> Option<u8> {
213        self.bytes.get(self.at).copied()
214    }
215
216    fn done(&self) -> bool {
217        self.at == self.bytes.len()
218    }
219}
220
221fn parse(text: &str) -> Option<Timestamp> {
222    let mut c = Cursor { bytes: text.as_bytes(), at: 0 };
223    let year = c.digits(4)?;
224    if !c.eat(b'-') {
225        return None;
226    }
227    let month = c.digits(2)? as u32;
228    if !c.eat(b'-') {
229        return None;
230    }
231    let day = c.digits(2)? as u32;
232    if !(1..=12).contains(&month) || day == 0 || day > days_in_month(year, month) {
233        return None;
234    }
235    let (mut hour, mut minute, mut second, mut micros) = (0, 0, 0, 0i64);
236    let mut offset_secs = 0i64;
237    if !c.done() {
238        if !(c.eat(b'T') || c.eat(b't') || c.eat(b' ')) {
239            return None;
240        }
241        hour = c.digits(2)?;
242        if !c.eat(b':') {
243            return None;
244        }
245        minute = c.digits(2)?;
246        if c.eat(b':') {
247            second = c.digits(2)?;
248            if c.eat(b'.') || c.eat(b',') {
249                let start = c.at;
250                let mut scale = 100_000i64;
251                while let Some(b) = c.peek().filter(u8::is_ascii_digit) {
252                    micros += (b - b'0') as i64 * scale;
253                    scale /= 10;
254                    c.at += 1;
255                }
256                if c.at == start {
257                    return None;
258                }
259            }
260        }
261        if hour > 23 || minute > 59 || second > 60 {
262            return None;
263        }
264        if c.eat(b'Z') || c.eat(b'z') {
265        } else if let Some(sign @ (b'+' | b'-')) = c.peek() {
266            c.at += 1;
267            let oh = c.digits(2)?;
268            c.eat(b':');
269            let om = if c.done() { 0 } else { c.digits(2)? };
270            if oh > 23 || om > 59 {
271                return None;
272            }
273            offset_secs = (oh * 3600 + om * 60) * if sign == b'+' { 1 } else { -1 };
274        }
275        if !c.done() {
276            return None;
277        }
278    }
279    let days = days_from_civil(year, month, day);
280    let secs = days * 86_400 + hour * 3600 + minute * 60 + second.min(59) - offset_secs;
281    Some(Timestamp(secs.checked_mul(1_000_000)?.checked_add(micros)?))
282}
283
284fn days_in_month(year: i64, month: u32) -> u32 {
285    match month {
286        2 if (year % 4 == 0 && year % 100 != 0) || year % 400 == 0 => 29,
287        2 => 28,
288        4 | 6 | 9 | 11 => 30,
289        _ => 31,
290    }
291}