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            && self.updated_at == other.updated_at
154    }
155}
156
157impl TryFrom<DbRecord> for RawRecord {
158    type Error = DbRecordError;
159
160    fn try_from(rec: DbRecord) -> Result<Self, Self::Error> {
161        let DbRecord {
162            id,
163            url,
164            client_datetime,
165            client_datetime_raw,
166            timestamp_flora,
167            title,
168            language,
169            document,
170            updated_at,
171        } = rec;
172
173        let id = id.ok_or(DbRecordErrorRepr::MissingId)?;
174        Ok(Self {
175            id,
176            url,
177            client_datetime,
178            client_datetime_raw,
179            timestamp_flora,
180            title,
181            language,
182            document,
183            updated_at,
184        })
185    }
186}
187
188/// Counterpart to [`RecordMeta`] for data as it is stored in SQLite.
189#[derive(Debug, PartialEq)]
190pub struct DbRecordMeta {
191    /// The SQLite row ID of this record.
192    pub(crate) id: i64,
193
194    /// The original URL that corresponds to this record.
195    pub(crate) url: String,
196
197    /// The client date and time, when the client saved the content to be archived.
198    pub(crate) client_datetime: DateTime<FixedOffset>,
199
200    /// The Omry server timestamp, when Omry first imported this record.
201    pub(crate) timestamp_flora: i64,
202
203    /// The page title that the client provided for this record.
204    pub(crate) title: String,
205
206    /// The language we extracted from this record's metadata, if any.
207    pub(crate) language: Option<String>,
208
209    /// Server timestamp, if the record was updated.
210    pub(crate) updated_at: Option<i64>,
211}
212
213impl From<DbRecordMeta> for RecordMeta {
214    fn from(record: DbRecordMeta) -> Self {
215        let DbRecordMeta {
216            id,
217            url,
218            client_datetime,
219            timestamp_flora,
220            title,
221            language,
222            updated_at,
223        } = record;
224
225        let client_datetime = ClientDateTimeRecord::new(&client_datetime.to_rfc3339());
226
227        RecordMeta {
228            id,
229            url,
230            title,
231            client_datetime,
232            timestamp_flora,
233            language: language.map(LanguageRecord::new),
234            updated_at,
235        }
236    }
237}
238
239#[cfg(test)]
240pub(crate) mod tests {
241    use anyhow::{Context, Result};
242    use omry_archiving::{Document, Marshal, record::RecordParams};
243    use pretty_assertions::assert_eq;
244    use std::env;
245    use std::fs::{self, File};
246    use std::io::prelude::*;
247    use std::path::PathBuf;
248
249    use super::*;
250
251    const TEST_ARCHIVE_PATH: &str = "test_data/example.postcard";
252    const WORKSPACE_DIR_KEY: &str = "CARGO_WORKSPACE_DIR";
253
254    /// Gets the path of the test page ("example.html")
255    fn get_test_path(path: &str) -> Result<PathBuf> {
256        let workspace_dir_path = env::var(WORKSPACE_DIR_KEY)
257            .context("WORKSPACE_DIR_KEY is not set")
258            .map(fs::canonicalize)??;
259        Ok(workspace_dir_path.join(path))
260    }
261
262    fn load_test_archive_holder() -> Result<Document> {
263        let path = get_test_path(TEST_ARCHIVE_PATH)?;
264        let mut file = File::open(&path)?;
265        let mut buf = vec![];
266        file.read_to_end(&mut buf)?;
267
268        let archive_holder = Document::from_bytes(&buf)?;
269        Ok(archive_holder)
270    }
271
272    pub(crate) fn test_record_params() -> Result<RecordParams> {
273        let document = load_test_archive_holder()?;
274
275        let url = "https://example.org/".to_string();
276        let title = "Example Domain";
277        let client_datetime = "2024-10-11T13:49:46-05:00";
278
279        #[allow(clippy::unreadable_literal)]
280        let timestamp_flora = Some(1728695243i64);
281        let language = Some("en".to_string());
282
283        Ok(RecordParams {
284            id: None,
285            url,
286            title: title.to_string(),
287            client_datetime: client_datetime.to_string(),
288            timestamp_flora,
289            language,
290            document,
291            updated_at: None,
292        })
293    }
294
295    #[allow(clippy::expect_used)]
296    #[test]
297    fn can_convert_record_to_db_record() -> Result<()> {
298        let record = Record::new(test_record_params()?);
299        let same_record = Record::new(test_record_params()?);
300        let primary_language_tag = same_record.language().map(|lang| {
301            lang.primary()
302                .expect("Couldn't compute the primary language tag")
303        });
304
305        let db_record: DbRecord = record.try_into()?;
306        assert_eq!(db_record.title, same_record.title());
307        assert_eq!(db_record.language, primary_language_tag);
308        Ok(())
309    }
310
311    #[test]
312    fn same_record_is_equal() -> anyhow::Result<()> {
313        let record: DbRecord = Record::new(test_record_params()?).try_into()?;
314        assert_eq!(record, record);
315        Ok(())
316    }
317
318    #[test]
319    fn different_records_are_not_equal() -> Result<()> {
320        let record = Record::new(test_record_params()?);
321        let mut other_record_params = test_record_params()?;
322        other_record_params.url = "https://example.com".into();
323        let other_record = Record::new(other_record_params);
324
325        let record: DbRecord = record.try_into()?;
326        let other_record = other_record.try_into()?;
327        assert_ne!(record, other_record);
328        Ok(())
329    }
330
331    #[test]
332    fn record_can_roundtrip_through_db_record() -> Result<()> {
333        let record = Record::new(test_record_params()?);
334        let db_record: DbRecord = record.try_into()?;
335        let roundtripped_record = db_record.to_record()?;
336
337        let same_record = Record::new(test_record_params()?);
338        assert_eq!(same_record, roundtripped_record);
339        Ok(())
340    }
341}