omry-db 0.19.0

Database abstractions used by the Omry backend.
Documentation
//! Data structures for flora's SQLite database.
use chrono::{DateTime, FixedOffset};
use omry_archiving::record::LanguageRecord;
use omry_archiving::{
    ClientDateTimeRecord, Document, Marshal, RawRecord, Record, RecordMeta, RecordParams, ToRecord,
};
use std::fmt;
use thiserror::Error;

/// Data types for the database columns.
#[allow(dead_code)]
#[allow(clippy::missing_docs_in_private_items)]
#[derive(Debug)]
enum ColumnType {
    Boolean,
    Integer,
    Real,
    Text,
    Blob,
}

impl fmt::Display for ColumnType {
    fn fmt(&self, fmt: &mut fmt::Formatter) -> fmt::Result {
        let s = match self {
            Self::Boolean => "BOOLEAN",
            Self::Integer => "INTEGER",
            Self::Real => "REAL",
            Self::Text => "TEXT",
            Self::Blob => "BLOB",
        };
        write!(fmt, "{s}")
    }
}

/// Public wrappere for database API errors.
#[derive(Debug, Error)]
#[error(transparent)]
pub struct DbRecordError(#[from] DbRecordErrorRepr);

/// Private representation of database API errors.
#[derive(Debug, Error)]
enum DbRecordErrorRepr {
    /// Errors encountered when deserializing the web page.
    #[error("Error (de)serializing the web page: {0}")]
    WebArchive(String),

    #[error("This record is missing an ID.")]
    MissingId,
}

/// SQLite representation of a flora `Record`.
#[derive(Debug)]
pub struct DbRecord {
    /// The SQLite row ID of this record.
    pub(crate) id: Option<i64>,

    /// The original URL that corresponds to this record.
    pub(crate) url: String,

    /// The client date and time, when the client saved the content to be archived.
    pub(crate) client_datetime: DateTime<FixedOffset>,

    /// Raw date time, in case we couldn't parse it.
    pub(crate) client_datetime_raw: Option<String>,

    /// The Omry server timestamp, when Omry first imported this record.
    pub(crate) timestamp_flora: i64,

    /// The page title that the client provided for this record.
    pub(crate) title: String,

    /// The language we extracted from this record's metadata, if any.
    pub(crate) language: Option<String>,

    /// The binary serialized content of this record's document.
    pub(crate) document: Vec<u8>,

    /// Server timestamp, if the record was updated.
    pub(crate) updated_at: Option<i64>,
}

impl DbRecord {
    /// The SQLite row ID of this record.
    ///
    /// This should only return `None` if the record
    /// hasn't yet been inserted into the database.
    #[must_use]
    pub fn id(&self) -> Option<i64> {
        self.id
    }
}

impl TryFrom<Record> for DbRecord {
    type Error = DbRecordError;

    fn try_from(value: Record) -> Result<Self, Self::Error> {
        let id = value.id();
        let url = value.url().to_string();
        let title = value.title().to_string();

        let (client_datetime, client_datetime_raw) = match value.client_datetime() {
            ClientDateTimeRecord::Parsed(datetime) => (*datetime, None),
            ClientDateTimeRecord::Interpolated { value, raw } => (*value, Some(raw.clone())),
        };

        let timestamp_flora = value.timestamp_archived();

        let language = value.language().map(|lang| {
            lang.primary()
                .unwrap_or_else(|error| error.raw_data().to_string())
        });

        let web_archive_serialized = value.document().to_byte_vec().map_err(|error| {
            DbRecordErrorRepr::WebArchive(format!("Couldn't serialize the archive: {error}"))
        })?;

        Ok(Self {
            id,
            url,
            client_datetime,
            client_datetime_raw,
            timestamp_flora,
            title,
            language,
            document: web_archive_serialized,
            updated_at: value.updated_at(),
        })
    }
}

impl ToRecord for DbRecord {
    type Error = DbRecordError;

    fn to_record(&self) -> Result<Record, Self::Error> {
        let document = Document::from_bytes(&self.document)
            .map_err(|error| DbRecordErrorRepr::WebArchive(error.to_string()))?;

        let params = RecordParams {
            id: self.id,
            url: self.url.clone(),
            title: self.title.clone(),
            client_datetime: self.client_datetime.to_rfc3339(),
            timestamp_flora: Some(self.timestamp_flora),
            language: self.language.clone(),
            document,
            updated_at: self.updated_at,
        };

        Ok(Record::new(params))
    }
}

/// Two instances of `DbRecord` are partially equal if all their fields except `id` are equal,
/// **and** either:
/// - Both of their `id` fields are equal (including if both are `None`), or
/// - One of the records' `id` fields is `None`.
///
/// We consider records where one `DbRecord` is `Some` and the other is `None` to be
/// partially equal to account for a situation where a new record was just inserted into
/// the database, and we are comparing it to the same record that we got back from the database.
impl PartialEq for DbRecord {
    fn eq(&self, other: &Self) -> bool {
        // Short-circuit if the `id` fields are actually different.
        let ids_considered_equal = if let (Some(this_id), Some(other_id)) = (self.id, other.id) {
            this_id == other_id
        } else {
            // One or both `id`s are `None`
            true
        };

        self.url == other.url
            && self.client_datetime == other.client_datetime
            && self.client_datetime_raw == other.client_datetime_raw
            && self.timestamp_flora == other.timestamp_flora
            && self.title == other.title
            && self.language == other.language
            && self.document == other.document
            && ids_considered_equal
    }
}

impl TryFrom<DbRecord> for RawRecord {
    type Error = DbRecordError;

    fn try_from(rec: DbRecord) -> Result<Self, Self::Error> {
        let DbRecord {
            id,
            url,
            client_datetime,
            client_datetime_raw,
            timestamp_flora,
            title,
            language,
            document,
            updated_at,
        } = rec;

        let id = id.ok_or(DbRecordErrorRepr::MissingId)?;
        Ok(Self {
            id,
            url,
            client_datetime,
            client_datetime_raw,
            timestamp_flora,
            title,
            language,
            document,
            updated_at,
        })
    }
}

/// Counterpart to [`RecordMeta`] for data as it is stored in SQLite.
#[derive(Debug, PartialEq)]
pub struct DbRecordMeta {
    /// The SQLite row ID of this record.
    pub(crate) id: i64,

    /// The original URL that corresponds to this record.
    pub(crate) url: String,

    /// The client date and time, when the client saved the content to be archived.
    pub(crate) client_datetime: DateTime<FixedOffset>,

    /// The Omry server timestamp, when Omry first imported this record.
    pub(crate) timestamp_flora: i64,

    /// The page title that the client provided for this record.
    pub(crate) title: String,

    /// The language we extracted from this record's metadata, if any.
    pub(crate) language: Option<String>,

    /// Server timestamp, if the record was updated.
    pub(crate) updated_at: Option<i64>,
}

impl From<DbRecordMeta> for RecordMeta {
    fn from(record: DbRecordMeta) -> Self {
        let DbRecordMeta {
            id,
            url,
            client_datetime,
            timestamp_flora,
            title,
            language,
            updated_at,
        } = record;

        let client_datetime = ClientDateTimeRecord::new(&client_datetime.to_rfc3339());

        RecordMeta {
            id,
            url,
            title,
            client_datetime,
            timestamp_flora,
            language: language.map(LanguageRecord::new),
            updated_at,
        }
    }
}

#[cfg(test)]
pub(crate) mod tests {
    use anyhow::Result;
    use omry_archiving::{Document, Marshal, record::RecordParams};
    use pretty_assertions::assert_eq;
    use std::env;
    use std::fs::{self, File};
    use std::io::prelude::*;
    use std::path::PathBuf;

    use super::*;

    const TEST_ARCHIVE_PATH: &str = "test_data/example.postcard";
    const WORKSPACE_DIR_KEY: &str = "CARGO_WORKSPACE_DIR";

    /// Gets the path of the test page ("example.html")
    fn get_test_path(path: &str) -> Result<PathBuf> {
        let (_, workspace_dir_path) = env::vars()
            .find(|(k, _)| k == WORKSPACE_DIR_KEY)
            .ok_or_else(|| anyhow::anyhow!("{WORKSPACE_DIR_KEY} is not set"))?;

        let workspace_dir_path = fs::canonicalize(workspace_dir_path)?;
        Ok(workspace_dir_path.join(path))
    }

    fn load_test_archive_holder() -> Result<Document> {
        let path = get_test_path(TEST_ARCHIVE_PATH)?;
        let mut file = File::open(&path)?;
        let mut buf = vec![];
        file.read_to_end(&mut buf)?;

        let archive_holder = Document::from_bytes(&buf)?;
        Ok(archive_holder)
    }

    pub(crate) fn test_record_params() -> Result<RecordParams> {
        let document = load_test_archive_holder()?;

        let url = "https://example.org/".to_string();
        let title = "Example Domain";
        let client_datetime = "2024-10-11T13:49:46-05:00";

        #[allow(clippy::unreadable_literal)]
        let timestamp_flora = Some(1728695243i64);
        let language = Some("en".to_string());

        Ok(RecordParams {
            id: None,
            url,
            title: title.to_string(),
            client_datetime: client_datetime.to_string(),
            timestamp_flora,
            language,
            document,
            updated_at: None,
        })
    }

    #[allow(clippy::expect_used)]
    #[test]
    fn can_convert_record_to_db_record() -> Result<()> {
        let record = Record::new(test_record_params()?);
        let same_record = Record::new(test_record_params()?);
        let primary_language_tag = same_record.language().map(|lang| {
            lang.primary()
                .expect("Couldn't compute the primary language tag")
        });

        let db_record: DbRecord = record.try_into()?;
        assert_eq!(db_record.title, same_record.title());
        assert_eq!(db_record.language, primary_language_tag);
        Ok(())
    }

    #[test]
    fn same_record_is_equal() -> anyhow::Result<()> {
        let record: DbRecord = Record::new(test_record_params()?).try_into()?;
        assert_eq!(record, record);
        Ok(())
    }

    #[test]
    fn different_records_are_not_equal() -> Result<()> {
        let record = Record::new(test_record_params()?);
        let mut other_record_params = test_record_params()?;
        other_record_params.url = "https://example.com".into();
        let other_record = Record::new(other_record_params);

        let record: DbRecord = record.try_into()?;
        let other_record = other_record.try_into()?;
        assert_ne!(record, other_record);
        Ok(())
    }

    #[test]
    fn record_can_roundtrip_through_db_record() -> Result<()> {
        let record = Record::new(test_record_params()?);
        let db_record: DbRecord = record.try_into()?;
        let roundtripped_record = db_record.to_record()?;

        let same_record = Record::new(test_record_params()?);
        assert_eq!(same_record, roundtripped_record);
        Ok(())
    }
}