Skip to main content

smriti/db/
trash_repo.rs

1//! Trash database operations.
2
3use rusqlite::{Connection, Result as SqliteResult};
4
5/// Trashed photo record.
6#[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
15/// Trash repository.
16pub 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}