omry_db/
model.rs

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