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