1use 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 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#[derive(Debug)]
126pub struct DbRecord {
127 pub(crate) id: Option<i64>,
129 pub(crate) url: String,
130 pub(crate) client_datetime: DateTime<FixedOffset>,
131 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 #[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
235impl PartialEq for DbRecord {
244 fn eq(&self, other: &Self) -> bool {
245 let ids_considered_equal = if let (Some(this_id), Some(other_id)) = (self.id, other.id) {
247 this_id == other_id
248 } else {
249 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}