Skip to main content

omry_db/
model.rs

1//! Data structures for flora's SQLite database.
2use chrono::{DateTime, FixedOffset};
3use omry_archiving::record::LanguageRecord;
4use omry_archiving::{
5    ClientDateTimeRecord, Document, Marshal, RawRecord, Record, RecordMeta, RecordParams, ToRecord,
6};
7use thiserror::Error;
8
9/// Public wrapper for database API errors.
10#[derive(Debug, Error)]
11#[error(transparent)]
12pub struct DbRecordError(#[from] DbRecordErrorRepr);
13
14/// Private representation of database API errors.
15#[derive(Debug, Error)]
16enum DbRecordErrorRepr {
17    /// Errors encountered when deserializing the web page.
18    #[error("Error (de)serializing the web page: {0}")]
19    WebArchive(String),
20
21    #[error("This record is missing an ID.")]
22    MissingId,
23}
24
25/// SQLite representation of a flora `Record`.
26#[derive(Debug)]
27pub struct DbRecord {
28    /// The SQLite row ID of this record.
29    pub(crate) id: Option<i64>,
30
31    /// The original URL that corresponds to this record.
32    pub(crate) url: String,
33
34    /// The client date and time, when the client saved the content to be archived.
35    pub(crate) client_datetime: DateTime<FixedOffset>,
36
37    /// Raw date time, in case we couldn't parse it.
38    pub(crate) client_datetime_raw: Option<String>,
39
40    /// The Omry server timestamp, when Omry first imported this record.
41    pub(crate) timestamp_flora: i64,
42
43    /// The page title that the client provided for this record.
44    pub(crate) title: String,
45
46    /// The language we extracted from this record's metadata, if any.
47    pub(crate) language: Option<String>,
48
49    /// The binary serialized content of this record's document.
50    pub(crate) document: Vec<u8>,
51
52    /// Server timestamp, if the record was updated.
53    pub(crate) updated_at: Option<i64>,
54}
55
56impl DbRecord {
57    /// The SQLite row ID of this record.
58    ///
59    /// This should only return `None` if the record
60    /// hasn't yet been inserted into the database.
61    #[must_use]
62    pub fn id(&self) -> Option<i64> {
63        self.id
64    }
65}
66
67impl TryFrom<Record> for DbRecord {
68    type Error = DbRecordError;
69
70    fn try_from(value: Record) -> Result<Self, Self::Error> {
71        let id = value.id();
72        let url = value.url().to_string();
73        let title = value.title().to_string();
74
75        let (client_datetime, client_datetime_raw) = match value.client_datetime() {
76            ClientDateTimeRecord::Parsed(datetime) => (*datetime, None),
77            ClientDateTimeRecord::Interpolated { value, raw } => (*value, Some(raw.clone())),
78        };
79
80        let timestamp_flora = value.timestamp_archived();
81
82        let language = value.language().map(|lang| {
83            lang.primary()
84                .unwrap_or_else(|error| error.raw_data().to_string())
85        });
86
87        let web_archive_serialized = value.document().to_byte_vec().map_err(|error| {
88            DbRecordErrorRepr::WebArchive(format!("Couldn't serialize the archive: {error}"))
89        })?;
90
91        Ok(Self {
92            id,
93            url,
94            client_datetime,
95            client_datetime_raw,
96            timestamp_flora,
97            title,
98            language,
99            document: web_archive_serialized,
100            updated_at: value.updated_at(),
101        })
102    }
103}
104
105impl ToRecord for DbRecord {
106    type Error = DbRecordError;
107
108    fn to_record(&self) -> Result<Record, Self::Error> {
109        let document = Document::from_bytes(&self.document)
110            .map_err(|error| DbRecordErrorRepr::WebArchive(error.to_string()))?;
111
112        let params = RecordParams {
113            id: self.id,
114            url: self.url.clone(),
115            title: self.title.clone(),
116            client_datetime: self.client_datetime.to_rfc3339(),
117            timestamp_flora: Some(self.timestamp_flora),
118            language: self.language.clone(),
119            document,
120            updated_at: self.updated_at,
121        };
122
123        Ok(Record::new(params))
124    }
125}
126
127/// Two instances of `DbRecord` are partially equal if all their fields except `id` are equal,
128/// **and** either:
129/// - Both of their `id` fields are equal (including if both are `None`), or
130/// - One of the records' `id` fields is `None`.
131///
132/// We consider records where one `DbRecord` is `Some` and the other is `None` to be
133/// partially equal to account for a situation where a new record was just inserted into
134/// the database, and we are comparing it to the same record that we got back from the database.
135impl PartialEq for DbRecord {
136    fn eq(&self, other: &Self) -> bool {
137        // Short-circuit if the `id` fields are actually different.
138        let ids_considered_equal = if let (Some(this_id), Some(other_id)) = (self.id, other.id) {
139            this_id == other_id
140        } else {
141            // One or both `id`s are `None`
142            true
143        };
144
145        self.url == other.url
146            && self.client_datetime == other.client_datetime
147            && self.client_datetime_raw == other.client_datetime_raw
148            && self.timestamp_flora == other.timestamp_flora
149            && self.title == other.title
150            && self.language == other.language
151            && self.document == other.document
152            && ids_considered_equal
153    }
154}
155
156impl TryFrom<DbRecord> for RawRecord {
157    type Error = DbRecordError;
158
159    fn try_from(rec: DbRecord) -> Result<Self, Self::Error> {
160        let DbRecord {
161            id,
162            url,
163            client_datetime,
164            client_datetime_raw,
165            timestamp_flora,
166            title,
167            language,
168            document,
169            updated_at,
170        } = rec;
171
172        let id = id.ok_or(DbRecordErrorRepr::MissingId)?;
173        Ok(Self {
174            id,
175            url,
176            client_datetime,
177            client_datetime_raw,
178            timestamp_flora,
179            title,
180            language,
181            document,
182            updated_at,
183        })
184    }
185}
186
187/// Counterpart to [`RecordMeta`] for data as it is stored in SQLite.
188#[derive(Debug, PartialEq)]
189pub struct DbRecordMeta {
190    /// The SQLite row ID of this record.
191    pub(crate) id: i64,
192
193    /// The original URL that corresponds to this record.
194    pub(crate) url: String,
195
196    /// The client date and time, when the client saved the content to be archived.
197    pub(crate) client_datetime: DateTime<FixedOffset>,
198
199    /// The Omry server timestamp, when Omry first imported this record.
200    pub(crate) timestamp_flora: i64,
201
202    /// The page title that the client provided for this record.
203    pub(crate) title: String,
204
205    /// The language we extracted from this record's metadata, if any.
206    pub(crate) language: Option<String>,
207
208    /// Server timestamp, if the record was updated.
209    pub(crate) updated_at: Option<i64>,
210}
211
212impl From<DbRecordMeta> for RecordMeta {
213    fn from(record: DbRecordMeta) -> Self {
214        let DbRecordMeta {
215            id,
216            url,
217            client_datetime,
218            timestamp_flora,
219            title,
220            language,
221            updated_at,
222        } = record;
223
224        let client_datetime = ClientDateTimeRecord::new(&client_datetime.to_rfc3339());
225
226        RecordMeta {
227            id,
228            url,
229            title,
230            client_datetime,
231            timestamp_flora,
232            language: language.map(LanguageRecord::new),
233            updated_at,
234        }
235    }
236}
237
238#[cfg(test)]
239pub(crate) mod tests {
240    use anyhow::{Context, Result};
241    use omry_archiving::{Document, Marshal, record::RecordParams};
242    use pretty_assertions::assert_eq;
243    use std::env;
244    use std::fs::{self, File};
245    use std::io::prelude::*;
246    use std::path::PathBuf;
247
248    use super::*;
249
250    const TEST_ARCHIVE_PATH: &str = "test_data/example.postcard";
251    const WORKSPACE_DIR_KEY: &str = "CARGO_WORKSPACE_DIR";
252
253    /// Gets the path of the test page ("example.html")
254    fn get_test_path(path: &str) -> Result<PathBuf> {
255        let workspace_dir_path = env::var(WORKSPACE_DIR_KEY)
256            .context("WORKSPACE_DIR_KEY is not set")
257            .map(fs::canonicalize)??;
258        Ok(workspace_dir_path.join(path))
259    }
260
261    fn load_test_archive_holder() -> Result<Document> {
262        let path = get_test_path(TEST_ARCHIVE_PATH)?;
263        let mut file = File::open(&path)?;
264        let mut buf = vec![];
265        file.read_to_end(&mut buf)?;
266
267        let archive_holder = Document::from_bytes(&buf)?;
268        Ok(archive_holder)
269    }
270
271    pub(crate) fn test_record_params() -> Result<RecordParams> {
272        let document = load_test_archive_holder()?;
273
274        let url = "https://example.org/".to_string();
275        let title = "Example Domain";
276        let client_datetime = "2024-10-11T13:49:46-05:00";
277
278        #[allow(clippy::unreadable_literal)]
279        let timestamp_flora = Some(1728695243i64);
280        let language = Some("en".to_string());
281
282        Ok(RecordParams {
283            id: None,
284            url,
285            title: title.to_string(),
286            client_datetime: client_datetime.to_string(),
287            timestamp_flora,
288            language,
289            document,
290            updated_at: None,
291        })
292    }
293
294    #[allow(clippy::expect_used)]
295    #[test]
296    fn can_convert_record_to_db_record() -> Result<()> {
297        let record = Record::new(test_record_params()?);
298        let same_record = Record::new(test_record_params()?);
299        let primary_language_tag = same_record.language().map(|lang| {
300            lang.primary()
301                .expect("Couldn't compute the primary language tag")
302        });
303
304        let db_record: DbRecord = record.try_into()?;
305        assert_eq!(db_record.title, same_record.title());
306        assert_eq!(db_record.language, primary_language_tag);
307        Ok(())
308    }
309
310    #[test]
311    fn same_record_is_equal() -> anyhow::Result<()> {
312        let record: DbRecord = Record::new(test_record_params()?).try_into()?;
313        assert_eq!(record, record);
314        Ok(())
315    }
316
317    #[test]
318    fn different_records_are_not_equal() -> Result<()> {
319        let record = Record::new(test_record_params()?);
320        let mut other_record_params = test_record_params()?;
321        other_record_params.url = "https://example.com".into();
322        let other_record = Record::new(other_record_params);
323
324        let record: DbRecord = record.try_into()?;
325        let other_record = other_record.try_into()?;
326        assert_ne!(record, other_record);
327        Ok(())
328    }
329
330    #[test]
331    fn record_can_roundtrip_through_db_record() -> Result<()> {
332        let record = Record::new(test_record_params()?);
333        let db_record: DbRecord = record.try_into()?;
334        let roundtripped_record = db_record.to_record()?;
335
336        let same_record = Record::new(test_record_params()?);
337        assert_eq!(same_record, roundtripped_record);
338        Ok(())
339    }
340}