1use chrono::{DateTime, FixedOffset};
3use omry_archiving::{ClientDateTimeRecord, Document, Marshal, Record, RecordParams, ToRecord};
4use std::fmt;
5use thiserror::Error;
6
7#[allow(dead_code)]
9#[allow(clippy::missing_docs_in_private_items)]
10#[derive(Debug)]
11enum ColumnType {
12 Boolean,
13 Integer,
14 Real,
15 Text,
16 Blob,
17}
18
19impl fmt::Display for ColumnType {
20 fn fmt(&self, fmt: &mut fmt::Formatter) -> fmt::Result {
21 let s = match self {
22 Self::Boolean => "BOOLEAN",
23 Self::Integer => "INTEGER",
24 Self::Real => "REAL",
25 Self::Text => "TEXT",
26 Self::Blob => "BLOB",
27 };
28 write!(fmt, "{s}")
29 }
30}
31
32struct ColumnDefinitionBuilder {
34 name: String,
36
37 column_type: ColumnType,
39
40 not_null: Option<bool>,
42
43 primary_key: Option<bool>,
45}
46
47impl ColumnDefinitionBuilder {
48 fn build(self) -> ColumnDefinition {
50 ColumnDefinition {
51 name: self.name,
52 column_type: self.column_type,
53 not_null: self.not_null.unwrap_or_default(),
54 primary_key: self.primary_key.unwrap_or_default(),
55 }
56 }
57
58 fn not_null(mut self, not_null: bool) -> Self {
60 self.not_null = Some(not_null);
61 self
62 }
63
64 fn primary_key(mut self, primary_key: bool) -> Self {
66 self.primary_key = Some(primary_key);
67 self
68 }
69}
70
71#[derive(Debug)]
73struct ColumnDefinition {
74 name: String,
76
77 column_type: ColumnType,
79
80 not_null: bool,
82
83 primary_key: bool,
85}
86
87impl ColumnDefinition {
88 fn builder(name: String, column_type: ColumnType) -> ColumnDefinitionBuilder {
90 ColumnDefinitionBuilder {
91 name,
92 column_type,
93 not_null: None,
94 primary_key: None,
95 }
96 }
97}
98
99#[derive(Debug)]
101pub(crate) struct Schema {
102 table_name: String,
104
105 columns: Vec<ColumnDefinition>,
107}
108
109impl Schema {
110 pub(crate) fn table_name(&self) -> &str {
112 &self.table_name
113 }
114}
115
116impl fmt::Display for Schema {
117 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
118 writeln!(f, "{} (", self.table_name)?;
119
120 let mut parts = vec![];
121 let mut s = String::new();
122
123 for column in &self.columns {
124 s.push_str(format!("\t{} {}", column.name, column.column_type).as_str());
125
126 if column.primary_key {
127 s.push_str(" PRIMARY KEY");
128 }
129
130 if column.primary_key || column.not_null {
135 s.push_str(" NOT NULL");
136 }
137
138 parts.push(s.clone());
139 s.clear();
140 }
141
142 let s = parts.join(",\n");
143 write!(f, "{s}\n)")
144 }
145}
146
147#[derive(Debug, Error)]
149#[error(transparent)]
150pub struct DbRecordError(#[from] DbRecordErrorRepr);
151
152#[derive(Debug, Error)]
154enum DbRecordErrorRepr {
155 #[error("Error (de)serializing the web page: {0}")]
157 WebArchive(String),
158}
159
160#[derive(Debug)]
162pub struct DbRecord {
163 pub(crate) id: Option<i64>,
165
166 pub(crate) url: String,
168
169 pub(crate) client_datetime: DateTime<FixedOffset>,
171
172 pub(crate) client_datetime_raw: Option<String>,
174
175 pub(crate) timestamp_flora: i64,
177
178 pub(crate) title: String,
180
181 pub(crate) language: Option<String>,
183
184 pub(crate) web_archive_serialized: Vec<u8>,
186}
187
188impl DbRecord {
189 pub(crate) fn schema() -> Schema {
191 let columns = vec![
192 ColumnDefinition::builder("id".to_string(), ColumnType::Integer)
193 .primary_key(true)
194 .build(),
195 ColumnDefinition::builder("url".to_string(), ColumnType::Text)
196 .not_null(true)
197 .build(),
198 ColumnDefinition::builder("client_datetime".to_string(), ColumnType::Text)
199 .not_null(true)
200 .build(),
201 ColumnDefinition::builder("client_datetime_raw".to_string(), ColumnType::Text).build(),
202 ColumnDefinition::builder("timestamp_flora".to_string(), ColumnType::Integer)
203 .not_null(true)
204 .build(),
205 ColumnDefinition::builder("title".to_string(), ColumnType::Text)
206 .not_null(true)
207 .build(),
208 ColumnDefinition::builder("language".to_string(), ColumnType::Text).build(),
209 ColumnDefinition::builder("web_archive_serialized".to_string(), ColumnType::Blob)
210 .not_null(true)
211 .build(),
212 ];
213 Schema {
214 table_name: "records".to_string(),
215 columns,
216 }
217 }
218
219 #[must_use]
224 pub fn id(&self) -> Option<i64> {
225 self.id
226 }
227}
228
229impl TryFrom<Record> for DbRecord {
230 type Error = DbRecordError;
231
232 fn try_from(value: Record) -> Result<Self, Self::Error> {
233 let id = value.id();
234 let url = value.url().to_string();
235 let title = value.title().to_string();
236
237 let (client_datetime, client_datetime_raw) = match value.client_datetime() {
238 ClientDateTimeRecord::Parsed(datetime) => (*datetime, None),
239 ClientDateTimeRecord::Interpolated { value, raw } => (*value, Some(raw.clone())),
240 };
241 let timestamp_flora = value.timestamp_archived();
242
243 let language = value.language().map(|lang| {
244 lang.primary()
245 .unwrap_or_else(|error| error.raw_record().to_string())
246 });
247 let web_archive_serialized = value.document().to_byte_vec().map_err(|error| {
248 DbRecordErrorRepr::WebArchive(format!("Couldn't serialize the archive: {error}"))
249 })?;
250
251 Ok(Self {
252 id,
253 url,
254 client_datetime,
255 client_datetime_raw,
256 timestamp_flora,
257 title,
258 language,
259 web_archive_serialized,
260 })
261 }
262}
263
264impl ToRecord for DbRecord {
265 type Error = DbRecordError;
266
267 fn to_record(&self) -> Result<Record, Self::Error> {
268 let document = Document::from_bytes(&self.web_archive_serialized)
269 .map_err(|error| DbRecordErrorRepr::WebArchive(error.to_string()))?;
270
271 let params = RecordParams {
272 id: self.id,
273 url: self.url.clone(),
274 title: self.title.clone(),
275 client_datetime: self.client_datetime.to_rfc3339(),
276 timestamp_flora: Some(self.timestamp_flora),
277 language: self.language.clone(),
278 document,
279 };
280
281 Ok(Record::new(params))
282 }
283}
284
285impl PartialEq for DbRecord {
294 fn eq(&self, other: &Self) -> bool {
295 let ids_considered_equal = if let (Some(this_id), Some(other_id)) = (self.id, other.id) {
297 this_id == other_id
298 } else {
299 true
301 };
302
303 self.url == other.url
304 && self.client_datetime == other.client_datetime
305 && self.client_datetime_raw == other.client_datetime_raw
306 && self.timestamp_flora == other.timestamp_flora
307 && self.title == other.title
308 && self.language == other.language
309 && self.web_archive_serialized == other.web_archive_serialized
310 && ids_considered_equal
311 }
312}
313
314#[cfg(test)]
315pub(crate) mod tests {
316 use super::*;
317 use omry_archiving::{Document, Marshal, record::RecordParams};
318 use pretty_assertions::assert_eq;
319 use std::fs::File;
320 use std::io::prelude::*;
321
322 const TEST_ARCHIVE_PATH: &str = "../test/data/example.bincode";
323
324 fn load_test_archive_holder() -> anyhow::Result<Document> {
325 let mut file = File::open(TEST_ARCHIVE_PATH)?;
326 let mut buf = vec![];
327 file.read_to_end(&mut buf)?;
328
329 let archive_holder = Document::from_bytes(&buf)?;
330 Ok(archive_holder)
331 }
332
333 pub(crate) fn test_record_params() -> anyhow::Result<RecordParams> {
334 let document = load_test_archive_holder()?;
335
336 let url = "https://example.org/".to_string();
337 let title = "Example Domain";
338 let client_datetime = "2024-10-11T13:49:46-05:00";
339
340 #[allow(clippy::unreadable_literal)]
341 let timestamp_flora = Some(1728695243i64);
342 let language = Some("en".to_string());
343
344 Ok(RecordParams {
345 id: None,
346 url,
347 title: title.to_string(),
348 client_datetime: client_datetime.to_string(),
349 timestamp_flora,
350 language,
351 document,
352 })
353 }
354
355 #[allow(clippy::expect_used)]
356 #[test]
357 fn can_convert_record_to_db_record() -> anyhow::Result<()> {
358 let record = Record::new(test_record_params()?);
359 let same_record = Record::new(test_record_params()?);
360 let primary_language_tag = same_record.language().map(|lang| {
361 lang.primary()
362 .expect("Couldn't compute the primary language tag")
363 });
364
365 let db_record: DbRecord = record.try_into()?;
366 assert_eq!(db_record.title, same_record.title());
367 assert_eq!(db_record.language, primary_language_tag);
368 Ok(())
369 }
370
371 #[test]
372 fn can_get_schema() {
373 let schema = DbRecord::schema();
374 insta::assert_snapshot!(&schema);
375 }
376
377 #[test]
378 fn same_record_is_equal() -> anyhow::Result<()> {
379 let record: DbRecord = Record::new(test_record_params()?).try_into()?;
380 assert_eq!(record, record);
381 Ok(())
382 }
383
384 #[test]
385 fn different_records_are_not_equal() -> anyhow::Result<()> {
386 let record = Record::new(test_record_params()?);
387 let mut other_record_params = test_record_params()?;
388 other_record_params.url = "https://example.com".into();
389 let other_record = Record::new(other_record_params);
390
391 let record: DbRecord = record.try_into()?;
392 let other_record = other_record.try_into()?;
393 assert_ne!(record, other_record);
394 Ok(())
395 }
396
397 #[test]
398 fn record_can_roundtrip_through_db_record() -> anyhow::Result<()> {
399 let record = Record::new(test_record_params()?);
400 let db_record: DbRecord = record.try_into()?;
401 let roundtripped_record = db_record.to_record()?;
402
403 let same_record = Record::new(test_record_params()?);
404 assert_eq!(same_record, roundtripped_record);
405 Ok(())
406 }
407
408 #[test]
409 fn can_get_table_name_from_schema() {
410 let schema = DbRecord::schema();
411 insta::assert_snapshot!(schema.table_name());
412 }
413}