1use rusqlite::{Connection, Result as SqliteResult};
4
5#[derive(Debug, Clone)]
7pub struct TrashedPhotoRecord {
8 pub photo_id: i64,
9 pub original_path: String,
10 pub trashed_at: String,
11 pub file_size: Option<i64>,
12 pub thumbnail_path: Option<String>,
13}
14
15pub struct TrashRepo<'a> {
17 conn: &'a Connection,
18}
19
20impl<'a> TrashRepo<'a> {
21 pub fn new(conn: &'a Connection) -> Self {
22 Self { conn }
23 }
24
25 pub fn count_all(&self) -> SqliteResult<i64> {
26 self.conn
27 .query_row("SELECT COUNT(*) FROM trash", [], |row| row.get(0))
28 }
29
30 pub fn page_after(
31 &self,
32 after: Option<(String, i64)>,
33 limit: i64,
34 ) -> SqliteResult<Vec<TrashedPhotoRecord>> {
35 let limit = limit.max(0);
36 let sql_without_cursor = r#"
37 SELECT
38 t.id,
39 t.photo_id,
40 t.original_path,
41 t.trashed_at,
42 p.file_size,
43 p.date_taken,
44 p.thumbnail_path
45 FROM trash t
46 JOIN photos p ON t.photo_id = p.id
47 ORDER BY t.trashed_at DESC, t.photo_id DESC
48 LIMIT ?1
49 "#;
50 let sql_with_cursor = r#"
51 SELECT
52 t.id,
53 t.photo_id,
54 t.original_path,
55 t.trashed_at,
56 p.file_size,
57 p.date_taken,
58 p.thumbnail_path
59 FROM trash t
60 JOIN photos p ON t.photo_id = p.id
61 WHERE t.trashed_at < ?1
62 OR (t.trashed_at = ?1 AND t.photo_id < ?2)
63 ORDER BY t.trashed_at DESC, t.photo_id DESC
64 LIMIT ?3
65 "#;
66
67 let mut out = Vec::new();
68 match after {
69 Some((trashed_at, photo_id)) => {
70 let mut stmt = self.conn.prepare(sql_with_cursor)?;
71 let rows = stmt.query_map((trashed_at, photo_id, limit), trash_row)?;
72 for row in rows {
73 out.push(row?);
74 }
75 }
76 None => {
77 let mut stmt = self.conn.prepare(sql_without_cursor)?;
78 let rows = stmt.query_map([limit], trash_row)?;
79 for row in rows {
80 out.push(row?);
81 }
82 }
83 }
84 Ok(out)
85 }
86
87 pub fn get_all(&self) -> SqliteResult<Vec<TrashedPhotoRecord>> {
88 let mut stmt = self.conn.prepare(
89 r#"
90 SELECT
91 t.id,
92 t.photo_id,
93 t.original_path,
94 t.trashed_at,
95 p.file_size,
96 p.date_taken,
97 p.thumbnail_path
98 FROM trash t
99 JOIN photos p ON t.photo_id = p.id
100 ORDER BY t.trashed_at DESC
101 "#,
102 )?;
103
104 let rows = stmt.query_map([], |row| {
105 Ok(TrashedPhotoRecord {
106 photo_id: row.get(1)?,
107 original_path: row.get(2)?,
108 trashed_at: row.get(3)?,
109 file_size: row.get(4)?,
110 thumbnail_path: row.get(6)?,
111 })
112 })?;
113
114 let mut items = Vec::new();
115 for row in rows {
116 items.push(row?);
117 }
118 Ok(items)
119 }
120}
121
122fn trash_row(row: &rusqlite::Row<'_>) -> SqliteResult<TrashedPhotoRecord> {
123 Ok(TrashedPhotoRecord {
124 photo_id: row.get(1)?,
125 original_path: row.get(2)?,
126 trashed_at: row.get(3)?,
127 file_size: row.get(4)?,
128 thumbnail_path: row.get(6)?,
129 })
130}
131
132#[cfg(test)]
133mod tests {
134 use super::TrashRepo;
135 use rusqlite::Connection;
136
137 fn setup() -> Connection {
138 let conn = Connection::open_in_memory().unwrap();
139 conn.execute_batch(
140 r#"
141 CREATE TABLE photos (
142 id INTEGER PRIMARY KEY,
143 file_size INTEGER,
144 date_taken TEXT,
145 thumbnail_path TEXT
146 );
147 CREATE TABLE trash (
148 id INTEGER PRIMARY KEY,
149 photo_id INTEGER NOT NULL UNIQUE,
150 original_path TEXT NOT NULL,
151 trashed_at TEXT NOT NULL
152 );
153 INSERT INTO photos (id, file_size, date_taken, thumbnail_path) VALUES
154 (1, 10, NULL, 'a.jpg'),
155 (2, 20, NULL, 'b.jpg'),
156 (3, 30, NULL, 'c.jpg');
157 INSERT INTO trash (photo_id, original_path, trashed_at) VALUES
158 (1, 'one.jpg', '2026-01-03T00:00:00Z'),
159 (2, 'two.jpg', '2026-01-02T00:00:00Z'),
160 (3, 'three.jpg', '2026-01-01T00:00:00Z');
161 "#,
162 )
163 .unwrap();
164 conn
165 }
166
167 #[test]
168 fn page_after_fetches_only_next_window() {
169 let conn = setup();
170 let repo = TrashRepo::new(&conn);
171
172 let first = repo.page_after(None, 2).unwrap();
173 assert_eq!(
174 first.iter().map(|row| row.photo_id).collect::<Vec<_>>(),
175 vec![1, 2]
176 );
177
178 let second = repo
179 .page_after(Some((first[1].trashed_at.clone(), first[1].photo_id)), 2)
180 .unwrap();
181 assert_eq!(
182 second.iter().map(|row| row.photo_id).collect::<Vec<_>>(),
183 vec![3]
184 );
185 }
186
187 #[test]
188 fn page_after_survives_missing_cursor_row() {
189 let conn = setup();
190 let repo = TrashRepo::new(&conn);
191
192 let first = repo.page_after(None, 2).unwrap();
193 let cursor = (first[1].trashed_at.clone(), first[1].photo_id);
194 conn.execute("DELETE FROM trash WHERE photo_id = 2", [])
195 .unwrap();
196
197 let second = repo.page_after(Some(cursor), 2).unwrap();
198 assert_eq!(
199 second.iter().map(|row| row.photo_id).collect::<Vec<_>>(),
200 vec![3]
201 );
202 }
203}