pipedash_core/infrastructure/database/
device_repository.rs1use chrono::{
2 DateTime,
3 Utc,
4};
5
6use super::DatabasePool;
7use crate::domain::{
8 DomainError,
9 DomainResult,
10 PairedDevice,
11};
12
13type Row = (
14 String,
15 String,
16 String,
17 String,
18 DateTime<Utc>,
19 Option<DateTime<Utc>>,
20);
21
22const COLUMNS: &str = "id, token_hash, name, platform, created_at, last_seen_at";
23
24fn db_error(error: sqlx::Error) -> DomainError {
25 DomainError::DatabaseError(error.to_string())
26}
27
28fn device(row: Row) -> PairedDevice {
29 let (id, token_hash, name, platform, created_at, last_seen_at) = row;
30
31 PairedDevice {
32 id,
33 token_hash,
34 name,
35 platform,
36 created_at,
37 last_seen_at,
38 }
39}
40
41pub struct DeviceRepository {
42 pool: DatabasePool,
43}
44
45impl DeviceRepository {
46 pub fn new(pool: DatabasePool) -> Self {
47 Self { pool }
48 }
49
50 fn placeholder(&self, index: usize) -> String {
51 match &self.pool {
52 DatabasePool::Sqlite(_) => "?".to_string(),
53 DatabasePool::Postgres(_) => format!("${index}"),
54 }
55 }
56
57 pub async fn insert(&self, device: &PairedDevice) -> DomainResult<()> {
58 let sql = format!(
59 "INSERT INTO paired_devices ({COLUMNS}) VALUES ({}, {}, {}, {}, {}, {})",
60 self.placeholder(1),
61 self.placeholder(2),
62 self.placeholder(3),
63 self.placeholder(4),
64 self.placeholder(5),
65 self.placeholder(6),
66 );
67 self.execute_insert(sql, device).await
68 }
69
70 pub async fn list(&self) -> DomainResult<Vec<PairedDevice>> {
71 let sql = format!("SELECT {COLUMNS} FROM paired_devices ORDER BY created_at, id");
72 let rows = match &self.pool {
73 DatabasePool::Sqlite(p) => {
74 sqlx::query_as::<_, Row>(sqlx::AssertSqlSafe(sql))
75 .fetch_all(p)
76 .await
77 }
78 DatabasePool::Postgres(p) => {
79 sqlx::query_as::<_, Row>(sqlx::AssertSqlSafe(sql))
80 .fetch_all(p)
81 .await
82 }
83 }
84 .map_err(db_error)?;
85
86 Ok(rows.into_iter().map(device).collect())
87 }
88
89 pub async fn find_by_token(&self, token: &str) -> DomainResult<Option<PairedDevice>> {
90 let sql = format!(
91 "SELECT {COLUMNS} FROM paired_devices WHERE token_hash = {}",
92 self.placeholder(1)
93 );
94 let token_hash = PairedDevice::hash_token(token);
95 let row = match &self.pool {
96 DatabasePool::Sqlite(p) => {
97 sqlx::query_as::<_, Row>(sqlx::AssertSqlSafe(sql))
98 .bind(token_hash)
99 .fetch_optional(p)
100 .await
101 }
102 DatabasePool::Postgres(p) => {
103 sqlx::query_as::<_, Row>(sqlx::AssertSqlSafe(sql))
104 .bind(token_hash)
105 .fetch_optional(p)
106 .await
107 }
108 }
109 .map_err(db_error)?;
110
111 Ok(row.map(device))
112 }
113
114 pub async fn touch(&self, id: &str) -> DomainResult<()> {
115 let sql = format!(
116 "UPDATE paired_devices SET last_seen_at = {} WHERE id = {}",
117 self.placeholder(1),
118 self.placeholder(2)
119 );
120 let now = Utc::now();
121 match &self.pool {
122 DatabasePool::Sqlite(p) => sqlx::query(sqlx::AssertSqlSafe(sql))
123 .bind(now)
124 .bind(id)
125 .execute(p)
126 .await
127 .map(|_| ()),
128 DatabasePool::Postgres(p) => sqlx::query(sqlx::AssertSqlSafe(sql))
129 .bind(now)
130 .bind(id)
131 .execute(p)
132 .await
133 .map(|_| ()),
134 }
135 .map_err(db_error)
136 }
137
138 pub async fn delete(&self, id: &str) -> DomainResult<bool> {
139 let sql = format!(
140 "DELETE FROM paired_devices WHERE id = {}",
141 self.placeholder(1)
142 );
143 let deleted = match &self.pool {
144 DatabasePool::Sqlite(p) => sqlx::query(sqlx::AssertSqlSafe(sql))
145 .bind(id)
146 .execute(p)
147 .await
148 .map(|result| result.rows_affected()),
149 DatabasePool::Postgres(p) => sqlx::query(sqlx::AssertSqlSafe(sql))
150 .bind(id)
151 .execute(p)
152 .await
153 .map(|result| result.rows_affected()),
154 }
155 .map_err(db_error)?;
156
157 Ok(deleted > 0)
158 }
159
160 pub async fn delete_all(&self) -> DomainResult<u64> {
161 let sql = "DELETE FROM paired_devices";
162 match &self.pool {
163 DatabasePool::Sqlite(p) => sqlx::query(sql)
164 .execute(p)
165 .await
166 .map(|result| result.rows_affected()),
167 DatabasePool::Postgres(p) => sqlx::query(sql)
168 .execute(p)
169 .await
170 .map(|result| result.rows_affected()),
171 }
172 .map_err(db_error)
173 }
174
175 pub async fn import(&self, devices: &[PairedDevice]) -> DomainResult<()> {
176 let sql = format!(
177 "INSERT INTO paired_devices ({COLUMNS}) VALUES ({}, {}, {}, {}, {}, {})
178 ON CONFLICT (id) DO NOTHING",
179 self.placeholder(1),
180 self.placeholder(2),
181 self.placeholder(3),
182 self.placeholder(4),
183 self.placeholder(5),
184 self.placeholder(6),
185 );
186 for device in devices {
187 self.execute_insert(sql.clone(), device).await?;
188 }
189
190 Ok(())
191 }
192
193 async fn execute_insert(&self, sql: String, device: &PairedDevice) -> DomainResult<()> {
194 match &self.pool {
195 DatabasePool::Sqlite(p) => sqlx::query(sqlx::AssertSqlSafe(sql))
196 .bind(&device.id)
197 .bind(&device.token_hash)
198 .bind(&device.name)
199 .bind(&device.platform)
200 .bind(device.created_at)
201 .bind(device.last_seen_at)
202 .execute(p)
203 .await
204 .map(|_| ()),
205 DatabasePool::Postgres(p) => sqlx::query(sqlx::AssertSqlSafe(sql))
206 .bind(&device.id)
207 .bind(&device.token_hash)
208 .bind(&device.name)
209 .bind(&device.platform)
210 .bind(device.created_at)
211 .bind(device.last_seen_at)
212 .execute(p)
213 .await
214 .map(|_| ()),
215 }
216 .map_err(db_error)
217 }
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223 use crate::infrastructure::database::init_database;
224
225 #[tokio::test]
226 async fn sqlite_devices_are_found_by_token_touched_and_deleted() {
227 let dir = tempfile::tempdir().unwrap();
228 let pool = init_database(dir.path().join("devices.db")).await.unwrap();
229 let repository = DeviceRepository::new(DatabasePool::Sqlite(pool));
230 let (device, token) = PairedDevice::issue("Pixel", "android");
231
232 repository.insert(&device).await.unwrap();
233
234 let found = repository.find_by_token(&token).await.unwrap().unwrap();
235 assert_eq!(found.id, device.id);
236 assert!(found.last_seen_at.is_some());
237 assert_eq!(repository.find_by_token("wrong").await.unwrap(), None);
238 assert_eq!(
239 repository.find_by_token(&device.token_hash).await.unwrap(),
240 None
241 );
242
243 repository.touch(&device.id).await.unwrap();
244 let listed = repository.list().await.unwrap();
245 assert_eq!(listed.len(), 1);
246 assert!(listed[0].last_seen_at.is_some());
247
248 assert!(repository.delete(&device.id).await.unwrap());
249 assert!(!repository.delete(&device.id).await.unwrap());
250 assert_eq!(repository.find_by_token(&token).await.unwrap(), None);
251
252 repository.import(&listed).await.unwrap();
253 repository.import(&listed).await.unwrap();
254 assert_eq!(repository.list().await.unwrap(), listed);
255 assert_eq!(repository.delete_all().await.unwrap(), 1);
256 assert!(repository.list().await.unwrap().is_empty());
257 }
258}