1use rusqlite::{params, Connection, OptionalExtension};
2
3#[derive(Debug, Clone)]
4pub struct BookRow {
5 pub stable_id: Option<String>,
6 pub file_hash: Option<String>,
7 pub title_norm: String,
8 pub author_norm: Option<String>,
9 pub path: String,
10 pub title: String,
11 pub author: Option<String>,
12 pub language: Option<String>,
13 pub publisher: Option<String>,
14 pub published_at: Option<String>,
15 pub word_count: Option<u64>,
16 pub page_count: Option<u64>,
17 pub parse_error: Option<String>,
18}
19
20impl BookRow {
21 pub fn new_fixture(name: &str) -> Self {
23 Self {
24 stable_id: Some(format!("urn:fixture:{name}")),
25 file_hash: Some(format!("{name}-hash")),
26 title_norm: format!("fixture {name}"),
27 author_norm: Some("fixture author".into()),
28 path: format!("/tmp/{name}.epub"),
29 title: format!("Fixture {name}"),
30 author: Some("Fixture Author".into()),
31 language: Some("en".into()),
32 publisher: None,
33 published_at: None,
34 word_count: Some(1000),
35 page_count: Some(4),
36 parse_error: None,
37 }
38 }
39}
40
41#[derive(Debug, PartialEq, Eq)]
42pub enum IdentityMatch {
43 ById(i64),
44 ByHash(i64),
45 ByNorm(i64),
46}
47
48pub fn resolve_identity(c: &Connection, row: &BookRow) -> anyhow::Result<Option<IdentityMatch>> {
49 if let Some(sid) = &row.stable_id {
50 if let Some(id) = c
51 .query_row(
52 "SELECT id FROM books WHERE stable_id = ? AND deleted_at IS NULL",
53 params![sid],
54 |r| r.get::<_, i64>(0),
55 )
56 .optional()?
57 {
58 return Ok(Some(IdentityMatch::ById(id)));
59 }
60 }
61 if let Some(fh) = &row.file_hash {
62 if let Some(id) = c
63 .query_row(
64 "SELECT id FROM books WHERE file_hash = ? AND deleted_at IS NULL",
65 params![fh],
66 |r| r.get::<_, i64>(0),
67 )
68 .optional()?
69 {
70 return Ok(Some(IdentityMatch::ByHash(id)));
71 }
72 }
73 if let Some(a) = &row.author_norm {
74 if let Some(id) = c.query_row(
75 "SELECT id FROM books WHERE title_norm = ? AND author_norm = ? AND deleted_at IS NULL",
76 params![row.title_norm, a], |r| r.get::<_, i64>(0),
77 ).optional()? { return Ok(Some(IdentityMatch::ByNorm(id))); }
78 }
79 Ok(None)
80}
81
82pub fn upsert(c: &mut Connection, row: &BookRow) -> anyhow::Result<i64> {
84 let tx = c.transaction()?;
85 let existing = resolve_identity(&tx, row)?;
86 let id = match existing {
87 Some(IdentityMatch::ById(id) | IdentityMatch::ByHash(id) | IdentityMatch::ByNorm(id)) => {
88 tx.execute(
89 "UPDATE books SET stable_id = COALESCE(?, stable_id),
90 file_hash = COALESCE(?, file_hash),
91 title_norm = ?, author_norm = ?,
92 path = ?, title = ?, author = ?, language = ?,
93 publisher = ?, published_at = ?,
94 word_count = ?, page_count = ?, parse_error = ?,
95 deleted_at = NULL
96 WHERE id = ?",
97 params![
98 row.stable_id,
99 row.file_hash,
100 row.title_norm,
101 row.author_norm,
102 row.path,
103 row.title,
104 row.author,
105 row.language,
106 row.publisher,
107 row.published_at,
108 row.word_count,
109 row.page_count,
110 row.parse_error,
111 id
112 ],
113 )?;
114 id
115 }
116 None => {
117 tx.execute(
118 "INSERT INTO books (stable_id, file_hash, title_norm, author_norm,
119 path, title, author, language, publisher, published_at,
120 word_count, page_count, parse_error)
121 VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
122 params![
123 row.stable_id,
124 row.file_hash,
125 row.title_norm,
126 row.author_norm,
127 row.path,
128 row.title,
129 row.author,
130 row.language,
131 row.publisher,
132 row.published_at,
133 row.word_count,
134 row.page_count,
135 row.parse_error
136 ],
137 )?;
138 tx.last_insert_rowid()
139 }
140 };
141 tx.commit()?;
142 Ok(id)
143}