use chrono::{DateTime, FixedOffset};
use omry_archiving::{ClientDateTimeRecord, Document, Marshal, Record, RecordParams, ToRecord};
use std::fmt;
use thiserror::Error;
#[allow(dead_code)]
#[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}")
}
}
struct ColumnDefinitionBuilder {
name: String,
column_type: ColumnType,
not_null: Option<bool>,
primary_key: Option<bool>,
}
impl ColumnDefinitionBuilder {
fn build(self) -> ColumnDefinition {
ColumnDefinition {
name: self.name,
column_type: self.column_type,
not_null: self.not_null.unwrap_or_default(),
primary_key: self.primary_key.unwrap_or_default(),
}
}
fn not_null(mut self, not_null: bool) -> Self {
self.not_null = Some(not_null);
self
}
fn primary_key(mut self, primary_key: bool) -> Self {
self.primary_key = Some(primary_key);
self
}
}
#[derive(Debug)]
struct ColumnDefinition {
name: String,
column_type: ColumnType,
not_null: bool,
primary_key: bool,
}
impl ColumnDefinition {
fn builder(name: String, column_type: ColumnType) -> ColumnDefinitionBuilder {
ColumnDefinitionBuilder {
name,
column_type,
not_null: None,
primary_key: None,
}
}
}
#[derive(Debug)]
pub(crate) struct Schema {
pub(crate) table_name: String,
columns: Vec<ColumnDefinition>,
}
impl fmt::Display for Schema {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
writeln!(f, "{} (", self.table_name)?;
let mut parts = vec![];
let mut s = String::new();
for column in &self.columns {
s.push_str(format!("\t{} {}", column.name, column.column_type).as_str());
if column.primary_key {
s.push_str(" PRIMARY KEY");
}
if column.primary_key || column.not_null {
s.push_str(" NOT NULL");
}
parts.push(s.clone());
s.clear();
}
let s = parts.join(",\n");
write!(f, "{s}\n)")
}
}
#[derive(Debug, Error)]
#[error(transparent)]
pub struct DbRecordError(#[from] DbRecordErrorRepr);
#[derive(Debug, Error)]
enum DbRecordErrorRepr {
#[error("Error (de)serializing WebArchive: {0}")]
WebArchive(String),
}
#[derive(Debug)]
pub struct DbRecord {
pub(crate) id: Option<i64>,
pub(crate) url: String,
pub(crate) client_datetime: DateTime<FixedOffset>,
pub(crate) client_datetime_raw: Option<String>,
pub(crate) timestamp_flora: i64,
pub(crate) title: String,
pub(crate) language: Option<String>,
pub(crate) web_archive_serialized: Vec<u8>,
}
impl DbRecord {
pub(crate) fn schema() -> Schema {
let columns = vec![
ColumnDefinition::builder("id".to_string(), ColumnType::Integer)
.primary_key(true)
.build(),
ColumnDefinition::builder("url".to_string(), ColumnType::Text)
.not_null(true)
.build(),
ColumnDefinition::builder("client_datetime".to_string(), ColumnType::Text)
.not_null(true)
.build(),
ColumnDefinition::builder("client_datetime_raw".to_string(), ColumnType::Text).build(),
ColumnDefinition::builder("timestamp_flora".to_string(), ColumnType::Integer)
.not_null(true)
.build(),
ColumnDefinition::builder("title".to_string(), ColumnType::Text)
.not_null(true)
.build(),
ColumnDefinition::builder("language".to_string(), ColumnType::Text).build(),
ColumnDefinition::builder("web_archive_serialized".to_string(), ColumnType::Blob)
.not_null(true)
.build(),
];
Schema {
table_name: "records".to_string(),
columns,
}
}
#[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_record().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,
web_archive_serialized,
})
}
}
impl ToRecord for DbRecord {
type Error = DbRecordError;
fn to_record(&self) -> Result<Record, Self::Error> {
let document = Document::from_bytes(&self.web_archive_serialized)
.map_err(|error| DbRecordErrorRepr::WebArchive(error.to_string()))?;
let params = RecordParams {
id: self.id,
url: self.url.to_string(),
title: self.title.to_string(),
client_datetime: self.client_datetime.to_rfc3339(),
timestamp_flora: Some(self.timestamp_flora),
language: self.language.clone(),
document,
};
Ok(Record::new(params))
}
}
impl PartialEq for DbRecord {
fn eq(&self, other: &Self) -> bool {
let ids_considered_equal = if let (Some(this_id), Some(other_id)) = (self.id, other.id) {
this_id == other_id
} else {
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.web_archive_serialized == other.web_archive_serialized
&& ids_considered_equal
}
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
use omry_archiving::{Document, Marshal, record::RecordParams};
use pretty_assertions::assert_eq;
use std::fs::File;
use std::io::prelude::*;
const TEST_ARCHIVE_PATH: &str = "../test/data/example.bincode";
fn load_test_archive_holder() -> anyhow::Result<Document> {
let mut file = File::open(TEST_ARCHIVE_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() -> anyhow::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,
})
}
#[allow(clippy::expect_used)]
#[test]
fn can_convert_record_to_db_record() -> anyhow::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 can_get_schema() {
let schema = DbRecord::schema();
insta::assert_snapshot!(&schema);
}
#[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() -> anyhow::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() -> anyhow::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(())
}
}