1use rusqlite::{OptionalExtension, params};
12
13use crate::error::DbError;
14use crate::sqlite::Database;
15
16#[derive(Clone)]
17pub struct SqliteShortlistRepository {
18 db: Database,
19}
20
21impl SqliteShortlistRepository {
22 pub fn new(db: Database) -> Self {
23 Self { db }
24 }
25
26 pub fn contains(
28 &self,
29 question_id: &str,
30 paper_id: &str,
31 reader: &str,
32 ) -> Result<bool, DbError> {
33 let conn = self.db.conn()?;
34 let exists: Option<i64> = conn
35 .query_row(
36 "SELECT 1 FROM shortlist_members
37 WHERE question_id = ?1 AND paper_id = ?2 AND reader = ?3",
38 params![question_id, paper_id, reader],
39 |r| r.get(0),
40 )
41 .optional()?;
42 Ok(exists.is_some())
43 }
44
45 pub fn toggle(&self, question_id: &str, paper_id: &str, reader: &str) -> Result<bool, DbError> {
51 let conn = self.db.conn()?;
52 let exists: Option<i64> = conn
53 .query_row(
54 "SELECT 1 FROM shortlist_members
55 WHERE question_id = ?1 AND paper_id = ?2 AND reader = ?3",
56 params![question_id, paper_id, reader],
57 |r| r.get(0),
58 )
59 .optional()?;
60 if exists.is_some() {
61 conn.execute(
62 "DELETE FROM shortlist_members
63 WHERE question_id = ?1 AND paper_id = ?2 AND reader = ?3",
64 params![question_id, paper_id, reader],
65 )?;
66 Ok(false)
67 } else {
68 conn.execute(
69 "INSERT INTO shortlist_members (question_id, paper_id, reader, added_at)
70 VALUES (?1, ?2, ?3, ?4)",
71 params![
72 question_id,
73 paper_id,
74 reader,
75 chrono::Utc::now().to_rfc3339()
76 ],
77 )?;
78 Ok(true)
79 }
80 }
81
82 pub fn list(&self, question_id: &str, reader: &str) -> Result<Vec<String>, DbError> {
85 let conn = self.db.conn()?;
86 let mut stmt = conn.prepare(
87 "SELECT paper_id FROM shortlist_members
88 WHERE question_id = ?1 AND reader = ?2
89 ORDER BY added_at ASC",
90 )?;
91 let rows = stmt.query_map(params![question_id, reader], |r| r.get::<_, String>(0))?;
92 Ok(rows.filter_map(Result::ok).collect())
93 }
94
95 pub fn members_set(
98 &self,
99 question_id: &str,
100 reader: &str,
101 ) -> Result<std::collections::HashSet<String>, DbError> {
102 Ok(self.list(question_id, reader)?.into_iter().collect())
103 }
104
105 pub fn max_updated_at_for_question(
121 &self,
122 question_id: &str,
123 ) -> Result<Option<String>, DbError> {
124 let conn = self.db.conn()?;
125 let ts: Option<String> = conn
126 .query_row(
127 "SELECT MAX(t) FROM (
128 SELECT MAX(updated_at) AS t FROM papers
129 WHERE id IN (SELECT paper_id FROM shortlist_members
130 WHERE question_id = ?1)
131 UNION ALL
132 SELECT MAX(updated_at) AS t FROM paper_state
133 WHERE paper_id IN (SELECT paper_id FROM shortlist_members
134 WHERE question_id = ?1)
135 UNION ALL
136 SELECT MAX(added_at) AS t FROM shortlist_members
137 WHERE question_id = ?1
138 )",
139 params![question_id],
140 |r| r.get::<_, Option<String>>(0),
141 )
142 .optional()?
143 .flatten();
144 Ok(ts)
145 }
146}
147
148#[cfg(test)]
149mod tests {
150 use super::*;
151
152 fn fresh() -> SqliteShortlistRepository {
153 let db = Database::open_in_memory().unwrap();
154 db.migrate().unwrap();
155 let conn = db.conn().unwrap();
156 conn.execute(
158 "INSERT INTO research_questions (id, text, description, created_at, updated_at)
159 VALUES ('q1', 'Q', '', datetime('now'), datetime('now'))",
160 [],
161 )
162 .unwrap();
163 conn.execute(
164 "INSERT INTO papers (id, title, created_at, updated_at)
165 VALUES ('p1', 'T', datetime('now'), datetime('now'))",
166 [],
167 )
168 .unwrap();
169 SqliteShortlistRepository::new(db)
170 }
171
172 #[test]
173 fn toggle_round_trip() {
174 let repo = fresh();
175 assert!(repo.toggle("q1", "p1", "lars").unwrap(), "adds on first");
176 assert!(repo.contains("q1", "p1", "lars").unwrap());
177 assert!(
178 !repo.toggle("q1", "p1", "lars").unwrap(),
179 "removes on second"
180 );
181 assert!(!repo.contains("q1", "p1", "lars").unwrap());
182 }
183
184 #[test]
185 fn list_is_added_at_ascending() {
186 let repo = fresh();
187 {
191 let conn = repo.db.conn().unwrap();
192 conn.execute(
193 "INSERT INTO papers (id, title, created_at, updated_at)
194 VALUES ('p2', 'T2', datetime('now'), datetime('now'))",
195 [],
196 )
197 .unwrap();
198 }
199 repo.toggle("q1", "p2", "lars").unwrap();
200 std::thread::sleep(std::time::Duration::from_millis(10));
201 repo.toggle("q1", "p1", "lars").unwrap();
202 let ids = repo.list("q1", "lars").unwrap();
203 assert_eq!(ids, vec!["p2", "p1"], "oldest first");
204 }
205
206 #[test]
207 fn readers_have_independent_shortlists() {
208 let repo = fresh();
209 repo.toggle("q1", "p1", "lars").unwrap();
210 assert!(!repo.contains("q1", "p1", "claude").unwrap());
211 assert!(repo.contains("q1", "p1", "lars").unwrap());
212 }
213}