From b35c8cca57811050536a4fa6c1cb5675453ad463 Mon Sep 17 00:00:00 2001 From: Mikkel Thestrup Date: Tue, 27 Jan 2026 17:07:13 +0100 Subject: feat(infrastructure): implement SQLite persistence layer for calendar domain Add infrastructure layer with SQLite repositories for calendars, events, and recurring events. Implements the repository pattern with proper domain/infrastructure separation. - Add CalendarModel, EventModel, RecurrenceModel, and RecurrenceExceptionModel for database persistence - Implement SqliteCalendarRepository with CRUD operations - Implement SqliteEventRepository with calendar filtering and time range queries - Implement SqliteRecurringEventRepository with exception handling and transactions - Add bidirectional mappers between domain entities and persistence models - Use sqlx query_as for type-safe database queries with FromRow derivation - Support upsert operations for all entities using ON CONFLICT clauses --- src/infrastructure/persistence/event_repository.rs | 168 +++++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 src/infrastructure/persistence/event_repository.rs (limited to 'src/infrastructure/persistence/event_repository.rs') diff --git a/src/infrastructure/persistence/event_repository.rs b/src/infrastructure/persistence/event_repository.rs new file mode 100644 index 0000000..cd83e99 --- /dev/null +++ b/src/infrastructure/persistence/event_repository.rs @@ -0,0 +1,168 @@ +use async_trait::async_trait; +use sqlx::SqlitePool; +use crate::domain::{ + event::Event, + repository::{EventRepository, RepositoryError, Result}, + value_objects::{CalendarId, EventId, TimeRange}, +}; +use super::{models::EventModel, mappers::EventMapper}; + +pub struct SqliteEventRepository { + pool: SqlitePool, +} + +impl SqliteEventRepository { + pub fn new(pool: SqlitePool) -> Self { + Self { pool } + } +} + +#[async_trait] +impl EventRepository for SqliteEventRepository { + async fn save(&self, event: &Event) -> Result<()> { + let model = EventMapper::to_model(event); + + sqlx::query!( + r#" + INSERT INTO events ( + id, calendar_id, title, description, starts_at, ends_at, + color, is_all_day, is_cancelled, created_at, updated_at + ) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11) + ON CONFLICT(id) DO UPDATE SET + title = excluded.title, + description = excluded.description, + starts_at = excluded.starts_at, + ends_at = excluded.ends_at, + color = excluded.color, + is_all_day = excluded.is_all_day, + is_cancelled = excluded.is_cancelled, + updated_at = excluded.updated_at + "#, + model.id, + model.calendar_id, + model.title, + model.description, + model.starts_at, + model.ends_at, + model.color, + model.is_all_day, + model.is_cancelled, + model.created_at, + model.updated_at, + ) + .execute(&self.pool) + .await + .map_err(|e| RepositoryError::DatabaseError(e.to_string()))?; + + Ok(()) + } + + async fn find_by_id(&self, id: &EventId) -> Result> { + let id_str = id.to_string(); + + let model = sqlx::query_as::<_, EventModel>( + r#" + SELECT id, calendar_id, title, description, starts_at, ends_at, + color, is_all_day, is_cancelled, created_at, updated_at + FROM events + WHERE id = ?1 + "# + ) + .bind(&id_str) + .fetch_optional(&self.pool) + .await + .map_err(|e| RepositoryError::DatabaseError(e.to_string()))?; + + match model { + Some(m) => { + let event = EventMapper::to_domain(m) + .map_err(|e| RepositoryError::DatabaseError(e))?; + Ok(Some(event)) + } + None => Ok(None), + } + } + + async fn find_by_calendar( + &self, + calendar_id: &CalendarId + ) -> Result> { + let calendar_id_str = calendar_id.to_string(); + + let models = sqlx::query_as::<_, EventModel>( + r#" + SELECT id, calendar_id, title, description, starts_at, ends_at, + color, is_all_day, is_cancelled, created_at, updated_at + FROM events + WHERE calendar_id = ?1 + ORDER BY starts_at + "# + ) + .bind(&calendar_id_str) + .fetch_all(&self.pool) + .await + .map_err(|e| RepositoryError::DatabaseError(e.to_string()))?; + + models + .into_iter() + .map(|m| EventMapper::to_domain(m) + .map_err(|e| RepositoryError::DatabaseError(e))) + .collect() + } + + async fn find_in_range( + &self, + calendar_id: &CalendarId, + range: &TimeRange, + ) -> Result> { + let calendar_id_str = calendar_id.to_string(); + let range_start = range.starts_at().to_rfc3339(); + let range_end = range.ends_at().to_rfc3339(); + + let models = sqlx::query_as::<_, EventModel>( + r#" + SELECT id, calendar_id, title, description, starts_at, ends_at, + color, is_all_day, is_cancelled, created_at, updated_at + FROM events + WHERE calendar_id = ?1 + AND is_cancelled = 0 + AND starts_at < ?3 + AND ends_at > ?2 + ORDER BY starts_at + "# + ) + .bind(&calendar_id_str) + .bind(&range_start) + .bind(&range_end) + .fetch_all(&self.pool) + .await + .map_err(|e| RepositoryError::DatabaseError(e.to_string()))?; + + models + .into_iter() + .map(|m| EventMapper::to_domain(m) + .map_err(|e| RepositoryError::DatabaseError(e))) + .collect() + } + + async fn delete(&self, id: &EventId) -> Result<()> { + let id_str = id.to_string(); + + let result = sqlx::query!( + r#" + DELETE FROM events WHERE id = ?1 + "#, + id_str, + ) + .execute(&self.pool) + .await + .map_err(|e| RepositoryError::DatabaseError(e.to_string()))?; + + if result.rows_affected() == 0 { + return Err(RepositoryError::NotFound); + } + + Ok(()) + } +} -- cgit v1.2.3-70-g09d2