use chrono::{
DateTime,
Utc,
};
use super::DatabasePool;
use crate::domain::{
DomainError,
DomainResult,
PairedDevice,
};
type Row = (
String,
String,
String,
String,
DateTime<Utc>,
Option<DateTime<Utc>>,
);
const COLUMNS: &str = "id, token_hash, name, platform, created_at, last_seen_at";
fn db_error(error: sqlx::Error) -> DomainError {
DomainError::DatabaseError(error.to_string())
}
fn device(row: Row) -> PairedDevice {
let (id, token_hash, name, platform, created_at, last_seen_at) = row;
PairedDevice {
id,
token_hash,
name,
platform,
created_at,
last_seen_at,
}
}
pub struct DeviceRepository {
pool: DatabasePool,
}
impl DeviceRepository {
pub fn new(pool: DatabasePool) -> Self {
Self { pool }
}
fn placeholder(&self, index: usize) -> String {
match &self.pool {
DatabasePool::Sqlite(_) => "?".to_string(),
DatabasePool::Postgres(_) => format!("${index}"),
}
}
pub async fn insert(&self, device: &PairedDevice) -> DomainResult<()> {
let sql = format!(
"INSERT INTO paired_devices ({COLUMNS}) VALUES ({}, {}, {}, {}, {}, {})",
self.placeholder(1),
self.placeholder(2),
self.placeholder(3),
self.placeholder(4),
self.placeholder(5),
self.placeholder(6),
);
self.execute_insert(sql, device).await
}
pub async fn list(&self) -> DomainResult<Vec<PairedDevice>> {
let sql = format!("SELECT {COLUMNS} FROM paired_devices ORDER BY created_at, id");
let rows = match &self.pool {
DatabasePool::Sqlite(p) => {
sqlx::query_as::<_, Row>(sqlx::AssertSqlSafe(sql))
.fetch_all(p)
.await
}
DatabasePool::Postgres(p) => {
sqlx::query_as::<_, Row>(sqlx::AssertSqlSafe(sql))
.fetch_all(p)
.await
}
}
.map_err(db_error)?;
Ok(rows.into_iter().map(device).collect())
}
pub async fn find_by_token(&self, token: &str) -> DomainResult<Option<PairedDevice>> {
let sql = format!(
"SELECT {COLUMNS} FROM paired_devices WHERE token_hash = {}",
self.placeholder(1)
);
let token_hash = PairedDevice::hash_token(token);
let row = match &self.pool {
DatabasePool::Sqlite(p) => {
sqlx::query_as::<_, Row>(sqlx::AssertSqlSafe(sql))
.bind(token_hash)
.fetch_optional(p)
.await
}
DatabasePool::Postgres(p) => {
sqlx::query_as::<_, Row>(sqlx::AssertSqlSafe(sql))
.bind(token_hash)
.fetch_optional(p)
.await
}
}
.map_err(db_error)?;
Ok(row.map(device))
}
pub async fn touch(&self, id: &str) -> DomainResult<()> {
let sql = format!(
"UPDATE paired_devices SET last_seen_at = {} WHERE id = {}",
self.placeholder(1),
self.placeholder(2)
);
let now = Utc::now();
match &self.pool {
DatabasePool::Sqlite(p) => sqlx::query(sqlx::AssertSqlSafe(sql))
.bind(now)
.bind(id)
.execute(p)
.await
.map(|_| ()),
DatabasePool::Postgres(p) => sqlx::query(sqlx::AssertSqlSafe(sql))
.bind(now)
.bind(id)
.execute(p)
.await
.map(|_| ()),
}
.map_err(db_error)
}
pub async fn delete(&self, id: &str) -> DomainResult<bool> {
let sql = format!(
"DELETE FROM paired_devices WHERE id = {}",
self.placeholder(1)
);
let deleted = match &self.pool {
DatabasePool::Sqlite(p) => sqlx::query(sqlx::AssertSqlSafe(sql))
.bind(id)
.execute(p)
.await
.map(|result| result.rows_affected()),
DatabasePool::Postgres(p) => sqlx::query(sqlx::AssertSqlSafe(sql))
.bind(id)
.execute(p)
.await
.map(|result| result.rows_affected()),
}
.map_err(db_error)?;
Ok(deleted > 0)
}
pub async fn delete_all(&self) -> DomainResult<u64> {
let sql = "DELETE FROM paired_devices";
match &self.pool {
DatabasePool::Sqlite(p) => sqlx::query(sql)
.execute(p)
.await
.map(|result| result.rows_affected()),
DatabasePool::Postgres(p) => sqlx::query(sql)
.execute(p)
.await
.map(|result| result.rows_affected()),
}
.map_err(db_error)
}
pub async fn import(&self, devices: &[PairedDevice]) -> DomainResult<()> {
let sql = format!(
"INSERT INTO paired_devices ({COLUMNS}) VALUES ({}, {}, {}, {}, {}, {})
ON CONFLICT (id) DO NOTHING",
self.placeholder(1),
self.placeholder(2),
self.placeholder(3),
self.placeholder(4),
self.placeholder(5),
self.placeholder(6),
);
for device in devices {
self.execute_insert(sql.clone(), device).await?;
}
Ok(())
}
async fn execute_insert(&self, sql: String, device: &PairedDevice) -> DomainResult<()> {
match &self.pool {
DatabasePool::Sqlite(p) => sqlx::query(sqlx::AssertSqlSafe(sql))
.bind(&device.id)
.bind(&device.token_hash)
.bind(&device.name)
.bind(&device.platform)
.bind(device.created_at)
.bind(device.last_seen_at)
.execute(p)
.await
.map(|_| ()),
DatabasePool::Postgres(p) => sqlx::query(sqlx::AssertSqlSafe(sql))
.bind(&device.id)
.bind(&device.token_hash)
.bind(&device.name)
.bind(&device.platform)
.bind(device.created_at)
.bind(device.last_seen_at)
.execute(p)
.await
.map(|_| ()),
}
.map_err(db_error)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::infrastructure::database::init_database;
#[tokio::test]
async fn sqlite_devices_are_found_by_token_touched_and_deleted() {
let dir = tempfile::tempdir().unwrap();
let pool = init_database(dir.path().join("devices.db")).await.unwrap();
let repository = DeviceRepository::new(DatabasePool::Sqlite(pool));
let (device, token) = PairedDevice::issue("Pixel", "android");
repository.insert(&device).await.unwrap();
let found = repository.find_by_token(&token).await.unwrap().unwrap();
assert_eq!(found.id, device.id);
assert!(found.last_seen_at.is_some());
assert_eq!(repository.find_by_token("wrong").await.unwrap(), None);
assert_eq!(
repository.find_by_token(&device.token_hash).await.unwrap(),
None
);
repository.touch(&device.id).await.unwrap();
let listed = repository.list().await.unwrap();
assert_eq!(listed.len(), 1);
assert!(listed[0].last_seen_at.is_some());
assert!(repository.delete(&device.id).await.unwrap());
assert!(!repository.delete(&device.id).await.unwrap());
assert_eq!(repository.find_by_token(&token).await.unwrap(), None);
repository.import(&listed).await.unwrap();
repository.import(&listed).await.unwrap();
assert_eq!(repository.list().await.unwrap(), listed);
assert_eq!(repository.delete_all().await.unwrap(), 1);
assert!(repository.list().await.unwrap().is_empty());
}
}