94
crates/adapters/sqlite/src/repositories/dimension/content.rs
Normal file
94
crates/adapters/sqlite/src/repositories/dimension/content.rs
Normal file
@@ -0,0 +1,94 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use domain::dimension::{DimensionKind, DimensionValue};
|
||||
use domain::entry::{Content, MoodEntryId};
|
||||
use domain::errors::DomainError;
|
||||
|
||||
use super::super::shared::db_err;
|
||||
|
||||
pub struct SqliteContentDimensionRepository {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteContentDimensionRepository {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct ContentRow {
|
||||
entry_id: String,
|
||||
content: String,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::EntryDimensionPort for SqliteContentDimensionRepository {
|
||||
async fn load(
|
||||
&self,
|
||||
entry_ids: &[MoodEntryId],
|
||||
) -> Result<HashMap<MoodEntryId, DimensionValue>, DomainError> {
|
||||
if entry_ids.is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
let placeholders = vec!["?"; entry_ids.len()].join(",");
|
||||
let sql = format!(
|
||||
"SELECT entry_id, content FROM entry_content WHERE entry_id IN ({placeholders})"
|
||||
);
|
||||
|
||||
let mut query = sqlx::query_as::<_, ContentRow>(sqlx::AssertSqlSafe(sql));
|
||||
for id in entry_ids {
|
||||
query = query.bind(id.value().to_string());
|
||||
}
|
||||
|
||||
let rows = query.fetch_all(&self.pool).await.map_err(db_err)?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.filter_map(|row| {
|
||||
let id = row.entry_id.parse().ok()?;
|
||||
Some((
|
||||
MoodEntryId::from_uuid(id),
|
||||
DimensionValue::Content(Content::from_persistence(row.content)),
|
||||
))
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn save(
|
||||
&self,
|
||||
entry_id: &MoodEntryId,
|
||||
values: &[DimensionValue],
|
||||
) -> Result<(), DomainError> {
|
||||
let id = entry_id.value().to_string();
|
||||
|
||||
match values
|
||||
.iter()
|
||||
.find(|value| value.kind() == DimensionKind::Content)
|
||||
{
|
||||
Some(DimensionValue::Content(content)) => {
|
||||
sqlx::query(
|
||||
"INSERT INTO entry_content (entry_id, content) VALUES (?, ?)
|
||||
ON CONFLICT(entry_id) DO UPDATE SET content = excluded.content",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(content.value())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
}
|
||||
_ => {
|
||||
sqlx::query("DELETE FROM entry_content WHERE entry_id = ?")
|
||||
.bind(&id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
101
crates/adapters/sqlite/src/repositories/dimension/location.rs
Normal file
101
crates/adapters/sqlite/src/repositories/dimension/location.rs
Normal file
@@ -0,0 +1,101 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use domain::dimension::{DimensionKind, DimensionValue};
|
||||
use domain::entry::MoodEntryId;
|
||||
use domain::errors::DomainError;
|
||||
use domain::location::Coordinates;
|
||||
|
||||
use super::super::shared::db_err;
|
||||
|
||||
pub struct SqliteLocationDimensionRepository {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteLocationDimensionRepository {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct LocationRow {
|
||||
entry_id: String,
|
||||
latitude: f64,
|
||||
longitude: f64,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::EntryDimensionPort for SqliteLocationDimensionRepository {
|
||||
async fn load(
|
||||
&self,
|
||||
entry_ids: &[MoodEntryId],
|
||||
) -> Result<HashMap<MoodEntryId, DimensionValue>, DomainError> {
|
||||
if entry_ids.is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
let placeholders = vec!["?"; entry_ids.len()].join(",");
|
||||
let sql = format!(
|
||||
"SELECT entry_id, latitude, longitude FROM entry_location WHERE entry_id IN ({placeholders})"
|
||||
);
|
||||
|
||||
let mut query = sqlx::query_as::<_, LocationRow>(sqlx::AssertSqlSafe(sql));
|
||||
for id in entry_ids {
|
||||
query = query.bind(id.value().to_string());
|
||||
}
|
||||
|
||||
let rows = query.fetch_all(&self.pool).await.map_err(db_err)?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.filter_map(|row| {
|
||||
let entry_id = row.entry_id.parse().ok()?;
|
||||
Some((
|
||||
MoodEntryId::from_uuid(entry_id),
|
||||
DimensionValue::Location(Coordinates::from_persistence(
|
||||
row.latitude,
|
||||
row.longitude,
|
||||
)),
|
||||
))
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn save(
|
||||
&self,
|
||||
entry_id: &MoodEntryId,
|
||||
values: &[DimensionValue],
|
||||
) -> Result<(), DomainError> {
|
||||
let id = entry_id.value().to_string();
|
||||
|
||||
match values
|
||||
.iter()
|
||||
.find(|value| value.kind() == DimensionKind::Location)
|
||||
{
|
||||
Some(DimensionValue::Location(coordinates)) => {
|
||||
sqlx::query(
|
||||
"INSERT INTO entry_location (entry_id, latitude, longitude) VALUES (?, ?, ?)
|
||||
ON CONFLICT(entry_id) DO UPDATE SET
|
||||
latitude = excluded.latitude, longitude = excluded.longitude",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(coordinates.latitude().value())
|
||||
.bind(coordinates.longitude().value())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
}
|
||||
_ => {
|
||||
sqlx::query("DELETE FROM entry_location WHERE entry_id = ?")
|
||||
.bind(&id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
11
crates/adapters/sqlite/src/repositories/dimension/mod.rs
Normal file
11
crates/adapters/sqlite/src/repositories/dimension/mod.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
mod content;
|
||||
mod location;
|
||||
mod relation;
|
||||
mod song;
|
||||
mod weather;
|
||||
|
||||
pub use content::SqliteContentDimensionRepository;
|
||||
pub use location::SqliteLocationDimensionRepository;
|
||||
pub use relation::SqliteRelationDimensionRepository;
|
||||
pub use song::SqliteSongDimensionRepository;
|
||||
pub use weather::SqliteWeatherDimensionRepository;
|
||||
166
crates/adapters/sqlite/src/repositories/dimension/relation.rs
Normal file
166
crates/adapters/sqlite/src/repositories/dimension/relation.rs
Normal file
@@ -0,0 +1,166 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use sqlx::SqlitePool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use domain::activity::ActivityId;
|
||||
use domain::attachment::{PhotoId, VoiceMemoId};
|
||||
use domain::dimension::{DimensionKind, DimensionValue};
|
||||
use domain::entry::MoodEntryId;
|
||||
use domain::errors::DomainError;
|
||||
|
||||
use super::super::shared::db_err;
|
||||
|
||||
pub struct SqliteRelationDimensionRepository {
|
||||
pool: SqlitePool,
|
||||
table: &'static str,
|
||||
column: &'static str,
|
||||
kind: DimensionKind,
|
||||
}
|
||||
|
||||
impl SqliteRelationDimensionRepository {
|
||||
pub fn activities(pool: SqlitePool) -> Self {
|
||||
Self {
|
||||
pool,
|
||||
table: "entry_activities",
|
||||
column: "activity_id",
|
||||
kind: DimensionKind::Activities,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn photos(pool: SqlitePool) -> Self {
|
||||
Self {
|
||||
pool,
|
||||
table: "entry_photos",
|
||||
column: "photo_id",
|
||||
kind: DimensionKind::Photos,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn voice_memos(pool: SqlitePool) -> Self {
|
||||
Self {
|
||||
pool,
|
||||
table: "entry_voice_memos",
|
||||
column: "voice_memo_id",
|
||||
kind: DimensionKind::VoiceMemos,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_value(&self, ids: Vec<Uuid>) -> DimensionValue {
|
||||
match self.kind {
|
||||
DimensionKind::Activities => {
|
||||
DimensionValue::Activities(ids.into_iter().map(ActivityId::from_uuid).collect())
|
||||
}
|
||||
DimensionKind::Photos => {
|
||||
DimensionValue::Photos(ids.into_iter().map(PhotoId::from_uuid).collect())
|
||||
}
|
||||
DimensionKind::VoiceMemos => {
|
||||
DimensionValue::VoiceMemos(ids.into_iter().map(VoiceMemoId::from_uuid).collect())
|
||||
}
|
||||
DimensionKind::Content
|
||||
| DimensionKind::Location
|
||||
| DimensionKind::Song
|
||||
| DimensionKind::Weather => {
|
||||
unreachable!("relation repository serves only id-list dimensions")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn related_ids(value: &DimensionValue) -> Vec<String> {
|
||||
match value {
|
||||
DimensionValue::Activities(ids) => ids.iter().map(|id| id.value().to_string()).collect(),
|
||||
DimensionValue::Photos(ids) => ids.iter().map(|id| id.value().to_string()).collect(),
|
||||
DimensionValue::VoiceMemos(ids) => ids.iter().map(|id| id.value().to_string()).collect(),
|
||||
DimensionValue::Content(_)
|
||||
| DimensionValue::Location(_)
|
||||
| DimensionValue::Song(_)
|
||||
| DimensionValue::Weather(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct RelationRow {
|
||||
entry_id: String,
|
||||
related_id: String,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::EntryDimensionPort for SqliteRelationDimensionRepository {
|
||||
async fn load(
|
||||
&self,
|
||||
entry_ids: &[MoodEntryId],
|
||||
) -> Result<HashMap<MoodEntryId, DimensionValue>, DomainError> {
|
||||
if entry_ids.is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
let placeholders = vec!["?"; entry_ids.len()].join(",");
|
||||
let sql = format!(
|
||||
"SELECT entry_id, {} AS related_id FROM {} WHERE entry_id IN ({placeholders})",
|
||||
self.column, self.table
|
||||
);
|
||||
|
||||
let mut query = sqlx::query_as::<_, RelationRow>(sqlx::AssertSqlSafe(sql));
|
||||
for id in entry_ids {
|
||||
query = query.bind(id.value().to_string());
|
||||
}
|
||||
|
||||
let rows = query.fetch_all(&self.pool).await.map_err(db_err)?;
|
||||
|
||||
let mut grouped: HashMap<MoodEntryId, Vec<Uuid>> = HashMap::new();
|
||||
for row in rows {
|
||||
let (Ok(entry_id), Ok(related_id)) =
|
||||
(row.entry_id.parse::<Uuid>(), row.related_id.parse::<Uuid>())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
grouped
|
||||
.entry(MoodEntryId::from_uuid(entry_id))
|
||||
.or_default()
|
||||
.push(related_id);
|
||||
}
|
||||
|
||||
Ok(grouped
|
||||
.into_iter()
|
||||
.map(|(entry_id, ids)| (entry_id, self.to_value(ids)))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn save(
|
||||
&self,
|
||||
entry_id: &MoodEntryId,
|
||||
values: &[DimensionValue],
|
||||
) -> Result<(), DomainError> {
|
||||
let id = entry_id.value().to_string();
|
||||
|
||||
let mut tx = self.pool.begin().await.map_err(db_err)?;
|
||||
|
||||
sqlx::query(sqlx::AssertSqlSafe(format!(
|
||||
"DELETE FROM {} WHERE entry_id = ?",
|
||||
self.table
|
||||
)))
|
||||
.bind(&id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
if let Some(value) = values.iter().find(|value| value.kind() == self.kind) {
|
||||
let insert = format!(
|
||||
"INSERT OR IGNORE INTO {} (entry_id, {}) VALUES (?, ?)",
|
||||
self.table, self.column
|
||||
);
|
||||
for related in related_ids(value) {
|
||||
sqlx::query(sqlx::AssertSqlSafe(insert.clone()))
|
||||
.bind(&id)
|
||||
.bind(related)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
}
|
||||
}
|
||||
|
||||
tx.commit().await.map_err(db_err)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
109
crates/adapters/sqlite/src/repositories/dimension/song.rs
Normal file
109
crates/adapters/sqlite/src/repositories/dimension/song.rs
Normal file
@@ -0,0 +1,109 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use domain::dimension::{DimensionKind, DimensionValue};
|
||||
use domain::entry::MoodEntryId;
|
||||
use domain::errors::DomainError;
|
||||
use domain::song::{AlbumName, ArtistName, RecordingId, Song, SongTitle};
|
||||
|
||||
use super::super::shared::db_err;
|
||||
|
||||
pub struct SqliteSongDimensionRepository {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteSongDimensionRepository {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct SongRow {
|
||||
entry_id: String,
|
||||
title: String,
|
||||
artist: String,
|
||||
album: Option<String>,
|
||||
recording_id: Option<String>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::EntryDimensionPort for SqliteSongDimensionRepository {
|
||||
async fn load(
|
||||
&self,
|
||||
entry_ids: &[MoodEntryId],
|
||||
) -> Result<HashMap<MoodEntryId, DimensionValue>, DomainError> {
|
||||
if entry_ids.is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
let placeholders = vec!["?"; entry_ids.len()].join(",");
|
||||
let sql = format!(
|
||||
"SELECT entry_id, title, artist, album, recording_id FROM entry_song WHERE entry_id IN ({placeholders})"
|
||||
);
|
||||
|
||||
let mut query = sqlx::query_as::<_, SongRow>(sqlx::AssertSqlSafe(sql));
|
||||
for id in entry_ids {
|
||||
query = query.bind(id.value().to_string());
|
||||
}
|
||||
|
||||
let rows = query.fetch_all(&self.pool).await.map_err(db_err)?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.filter_map(|row| {
|
||||
let entry_id = row.entry_id.parse().ok()?;
|
||||
let song = Song::from_persistence(
|
||||
SongTitle::from_persistence(row.title),
|
||||
ArtistName::from_persistence(row.artist),
|
||||
row.album.map(AlbumName::from_persistence),
|
||||
row.recording_id
|
||||
.and_then(|id| id.parse().ok())
|
||||
.map(RecordingId::from_uuid),
|
||||
);
|
||||
Some((MoodEntryId::from_uuid(entry_id), DimensionValue::Song(song)))
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn save(
|
||||
&self,
|
||||
entry_id: &MoodEntryId,
|
||||
values: &[DimensionValue],
|
||||
) -> Result<(), DomainError> {
|
||||
let id = entry_id.value().to_string();
|
||||
|
||||
match values
|
||||
.iter()
|
||||
.find(|value| value.kind() == DimensionKind::Song)
|
||||
{
|
||||
Some(DimensionValue::Song(song)) => {
|
||||
sqlx::query(
|
||||
"INSERT INTO entry_song (entry_id, title, artist, album, recording_id)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(entry_id) DO UPDATE SET
|
||||
title = excluded.title, artist = excluded.artist,
|
||||
album = excluded.album, recording_id = excluded.recording_id",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(song.title().value())
|
||||
.bind(song.artist().value())
|
||||
.bind(song.album().map(|album| album.value().to_string()))
|
||||
.bind(song.recording_id().map(|id| id.value().to_string()))
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
}
|
||||
_ => {
|
||||
sqlx::query("DELETE FROM entry_song WHERE entry_id = ?")
|
||||
.bind(&id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
109
crates/adapters/sqlite/src/repositories/dimension/weather.rs
Normal file
109
crates/adapters/sqlite/src/repositories/dimension/weather.rs
Normal file
@@ -0,0 +1,109 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use domain::dimension::{DimensionKind, DimensionValue};
|
||||
use domain::entry::MoodEntryId;
|
||||
use domain::errors::DomainError;
|
||||
use domain::provider::ProviderName;
|
||||
use domain::weather::{Celsius, Condition, Weather};
|
||||
|
||||
use super::super::shared::db_err;
|
||||
|
||||
pub struct SqliteWeatherDimensionRepository {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteWeatherDimensionRepository {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct WeatherRow {
|
||||
entry_id: String,
|
||||
condition: String,
|
||||
temperature: f64,
|
||||
observed_by: String,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::EntryDimensionPort for SqliteWeatherDimensionRepository {
|
||||
async fn load(
|
||||
&self,
|
||||
entry_ids: &[MoodEntryId],
|
||||
) -> Result<HashMap<MoodEntryId, DimensionValue>, DomainError> {
|
||||
if entry_ids.is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
let placeholders = vec!["?"; entry_ids.len()].join(",");
|
||||
let sql = format!(
|
||||
"SELECT entry_id, condition, temperature, observed_by
|
||||
FROM entry_weather WHERE entry_id IN ({placeholders})"
|
||||
);
|
||||
|
||||
let mut query = sqlx::query_as::<_, WeatherRow>(sqlx::AssertSqlSafe(sql));
|
||||
for id in entry_ids {
|
||||
query = query.bind(id.value().to_string());
|
||||
}
|
||||
|
||||
let rows = query.fetch_all(&self.pool).await.map_err(db_err)?;
|
||||
|
||||
Ok(rows.iter().filter_map(readable).collect())
|
||||
}
|
||||
|
||||
async fn save(
|
||||
&self,
|
||||
entry_id: &MoodEntryId,
|
||||
values: &[DimensionValue],
|
||||
) -> Result<(), DomainError> {
|
||||
let observed = values
|
||||
.iter()
|
||||
.find(|value| value.kind() == DimensionKind::Weather);
|
||||
|
||||
let Some(DimensionValue::Weather(weather)) = observed else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO entry_weather (entry_id, condition, temperature, observed_by)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(entry_id) DO UPDATE SET
|
||||
condition = excluded.condition,
|
||||
temperature = excluded.temperature,
|
||||
observed_by = excluded.observed_by",
|
||||
)
|
||||
.bind(entry_id.value().to_string())
|
||||
.bind(weather.condition().name())
|
||||
.bind(weather.temperature().value())
|
||||
.bind(weather.observed_by().value())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_err)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn readable(row: &WeatherRow) -> Option<(MoodEntryId, DimensionValue)> {
|
||||
let entry_id = MoodEntryId::from_uuid(row.entry_id.parse().ok()?);
|
||||
let condition = Condition::from_name(&row.condition);
|
||||
|
||||
if condition.is_none() {
|
||||
tracing::warn!(
|
||||
entry_id = %row.entry_id,
|
||||
condition = %row.condition,
|
||||
"skipped stored weather this build cannot read"
|
||||
);
|
||||
}
|
||||
|
||||
let weather = Weather::new(
|
||||
condition?,
|
||||
Celsius::from_persistence(row.temperature),
|
||||
ProviderName::from_persistence(row.observed_by.clone()),
|
||||
);
|
||||
|
||||
Some((entry_id, DimensionValue::Weather(weather)))
|
||||
}
|
||||
Reference in New Issue
Block a user