Skip to main content

oflow/
database.rs

1// Copyright (c) 2026
2//
3// This Source Code Form is subject to the terms of the Mozilla Public
4// License, v. 2.0. If a copy of the MPL was not distributed with this
5// file, You can obtain one at http://mozilla.org/MPL/2.0/.
6
7//! Database persistence layer for [`TodoList`].
8//!
9//! This module provides SQLite-backed storage methods that are attached to
10//! [`TodoList`] via `impl` blocks. These methods handle syncing in-memory
11//! state to the database, loading from the database, and querying by content.
12
13use crate::models::{Todo, TodoList};
14use sqlx::SqlitePool;
15
16impl TodoList {
17    /// Synchronize all in-memory todos to the database.
18    ///
19    /// Deletes database rows whose IDs are not present in the in-memory map,
20    /// then performs an upsert (INSERT ... ON CONFLICT DO UPDATE) for each
21    /// remaining todo in a single transaction. This ensures that the database
22    /// exactly reflects the current state of the in-memory [`TodoList`].
23    ///
24    /// # Arguments
25    ///
26    /// * `pool` - SQLite connection pool
27    ///
28    /// # Errors
29    ///
30    /// Returns an error if the transaction fails.
31    pub async fn sync_to_db(&self, pool: &SqlitePool) -> color_eyre::Result<()> {
32        let mut tx = pool.begin().await?;
33
34        // Delete todos that exist in the database but not in memory
35        let in_memory_ids: Vec<String> = self.todos.keys().cloned().collect();
36        if in_memory_ids.is_empty() {
37            // No todos in memory — delete everything from the database
38            sqlx::query("DELETE FROM todos")
39                .execute(&mut *tx)
40                .await?;
41        } else {
42            // Build placeholders for the NOT IN clause
43            let placeholders = in_memory_ids.iter().map(|_| "?").collect::<Vec<_>>().join(",");
44            let delete_sql = format!(
45                "DELETE FROM todos WHERE id NOT IN ({})",
46                placeholders
47            );
48            let mut query = sqlx::query(&delete_sql);
49            for id in &in_memory_ids {
50                query = query.bind(id);
51            }
52            query.execute(&mut *tx).await?;
53        }
54
55        // Upsert remaining todos
56        for todo in self.todos.values() {
57            sqlx::query(
58                "INSERT INTO todos (id, content, created_at, finished_at, finished)
59                VALUES (?, ?, ?, ?, ?)
60                ON CONFLICT(id) DO UPDATE SET
61                    content = excluded.content,
62                    finished_at = excluded.finished_at,
63                    finished = excluded.finished",
64            )
65            .bind(todo.id)
66            .bind(&todo.content)
67            .bind(todo.created_at)
68            .bind(todo.finished_at)
69            .bind(todo.finished)
70            .execute(&mut *tx)
71            .await?;
72        }
73
74        tx.commit().await?;
75
76        Ok(())
77    }
78
79    /// Load all todos from the database into memory.
80    ///
81    /// Replaces the current in-memory todo map with all rows from the
82    /// `todos` table.
83    ///
84    /// # Arguments
85    ///
86    /// * `pool` - SQLite connection pool
87    ///
88    /// # Errors
89    ///
90    /// Returns an error if the query fails.
91    pub async fn load_from_db(&mut self, pool: &SqlitePool) -> color_eyre::Result<()> {
92        let rows = sqlx::query_as::<_, Todo>("SELECT * FROM todos")
93            .fetch_all(pool)
94            .await?;
95
96        self.todos = rows.into_iter().map(|t| (t.id.to_string(), t)).collect();
97
98        Ok(())
99    }
100
101    /// Find a todo by its content.
102    ///
103    /// # Arguments
104    ///
105    /// * `pool` - Database connection pool
106    /// * `content` - Content to search for
107    ///
108    /// # Returns
109    ///
110    /// Returns the Todo if found, None otherwise.
111    pub async fn find_by_content(
112        pool: &SqlitePool,
113        content: &str,
114    ) -> color_eyre::Result<Option<Todo>> {
115        let res = sqlx::query_as::<_, Todo>("SELECT * FROM todos WHERE content = ? LIMIT 1")
116            .bind(content)
117            .fetch_optional(pool)
118            .await?;
119
120        Ok(res)
121    }
122}