1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
use serde::{Deserialize, Serialize};

/// UTC timestamp backed by `jiff::Timestamp`.
///
/// Wraps `jiff::Timestamp` with `sqlx` and `serde` integration so it can be
/// used directly in model structs that derive `FromRow`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Timestamp(pub jiff::Timestamp);

impl std::fmt::Display for Timestamp {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.0.strftime("%Y-%m-%dT%H:%M:%SZ"))
    }
}

impl sqlx::Type<sqlx::Sqlite> for Timestamp {
    fn type_info() -> sqlx::sqlite::SqliteTypeInfo {
        <str as sqlx::Type<sqlx::Sqlite>>::type_info()
    }
}

impl<'r> sqlx::Decode<'r, sqlx::Sqlite> for Timestamp {
    fn decode(value: sqlx::sqlite::SqliteValueRef<'r>) -> Result<Self, sqlx::error::BoxDynError> {
        let s = <&str as sqlx::Decode<sqlx::Sqlite>>::decode(value)?;
        let ts: jiff::Timestamp = s.parse()?;
        Ok(Timestamp(ts))
    }
}

impl sqlx::Encode<'_, sqlx::Sqlite> for Timestamp {
    fn encode_by_ref(
        &self,
        buf: &mut <sqlx::Sqlite as sqlx::Database>::ArgumentBuffer<'_>,
    ) -> Result<sqlx::encode::IsNull, sqlx::error::BoxDynError> {
        let s = self.to_string();
        <String as sqlx::Encode<sqlx::Sqlite>>::encode(s, buf)
    }
}

// Timestamp is used as a field in model structs that derive FromRow,
// which requires both Decode and Encode. Decode is exercised by every
// query that returns a model. Encode is exercised when a Timestamp is
// bound as a query parameter — currently only in tests, since production
// code lets SQLite generate timestamps via strftime.

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn display_formats_as_iso8601() {
        let ts = Timestamp(jiff::Timestamp::from_second(1_700_000_000).unwrap());
        assert_eq!(ts.to_string(), "2023-11-14T22:13:20Z");
    }

    #[test]
    fn serde_roundtrips() {
        let ts = Timestamp(jiff::Timestamp::from_second(1_700_000_000).unwrap());
        let json = serde_json::to_string(&ts).unwrap();
        let deserialized: Timestamp = serde_json::from_str(&json).unwrap();
        assert_eq!(ts, deserialized);
    }

    #[tokio::test]
    async fn sqlx_encode_roundtrips_through_sqlite() {
        let dir = tempfile::tempdir().unwrap();
        let pool = crate::db::connect(&dir.path().join("test.db"))
            .await
            .unwrap();
        let mut conn = pool.acquire().await.unwrap();

        let ts = Timestamp(jiff::Timestamp::from_second(1_700_000_000).unwrap());
        let row: (String,) = sqlx::query_as("SELECT ?")
            .bind(&ts)
            .fetch_one(&mut *conn)
            .await
            .unwrap();
        assert_eq!(row.0, "2023-11-14T22:13:20Z");
    }
}