diff options
| author | Mikkel Thestrup <mikkel@mithe.dk> | 2026-01-27 17:07:13 +0100 |
|---|---|---|
| committer | Mikkel Thestrup <mikkel@mithe.dk> | 2026-01-27 17:08:24 +0100 |
| commit | b35c8cca57811050536a4fa6c1cb5675453ad463 (patch) | |
| tree | 622bce9ef4021701ab845fd88ff0fc86b47905aa /src/infrastructure/persistence/mappers.rs | |
| parent | 4e78fd83349c95711cdee5acc56f248f81ebd25c (diff) | |
| download | kal-b35c8cca57811050536a4fa6c1cb5675453ad463.tar.gz kal-b35c8cca57811050536a4fa6c1cb5675453ad463.zip | |
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
Diffstat (limited to '')
| -rw-r--r-- | src/infrastructure/persistence/mappers.rs | 278 |
1 files changed, 278 insertions, 0 deletions
diff --git a/src/infrastructure/persistence/mappers.rs b/src/infrastructure/persistence/mappers.rs new file mode 100644 index 0000000..4260038 --- /dev/null +++ b/src/infrastructure/persistence/mappers.rs @@ -0,0 +1,278 @@ +use chrono::{DateTime, Utc}; +use crate::domain::{ + calendar::Calendar, + event::Event, + recurrence::{ + ExceptionModification, + RecurrenceException, + RecurrenceRule, + RecurringEvent, + }, + value_objects::{ + CalendarId, + EventColor, + EventId, + Frequency, + TimeRange, + }, +}; +use super::models::{ + CalendarModel, + EventModel, + RecurrenceModel, + RecurrenceExceptionModel +}; +use std::{collections::HashMap, str::FromStr}; + +pub struct CalendarMapper; + +impl CalendarMapper { + pub fn to_domain(model: CalendarModel) -> Result<Calendar, String> { + let id = CalendarId::from_str(&model.id) + .map_err(|e| format!("Invalid calendar ID: {}", e))?; + + let created_at = DateTime::parse_from_rfc3339(&model.created_at) + .map_err(|e| format!("Invalid created_at: {}", e))? + .with_timezone(&Utc); + + let updated_at = DateTime::parse_from_rfc3339(&model.updated_at) + .map_err(|e| format!("Invalid updated_at: {}", e))? + .with_timezone(&Utc); + + Ok(Calendar::with_id( + id, + model.name, + model.description, + model.is_archived != 0, + created_at, + updated_at, + )) + } + + pub fn to_model(calendar: &Calendar) -> CalendarModel { + CalendarModel { + id: calendar.id().to_string(), + name: calendar.name().to_string(), + description: calendar.description().clone(), + is_archived: if *calendar.is_archived() { 1 } else { 0 }, + created_at: calendar.created_at().to_rfc3339(), + updated_at: calendar.updated_at().to_rfc3339(), + } + } +} + +pub struct EventMapper; + +impl EventMapper { + pub fn to_domain(model: EventModel) -> Result<Event, String> { + let id = EventId::from_str(&model.id) + .map_err(|e| format!("Invalid event ID: {}", e))?; + + let calendar_id = CalendarId::from_str(&model.calendar_id) + .map_err(|e| format!("Invalid calendar ID: {}", e))?; + + let starts_at = DateTime::parse_from_rfc3339(&model.starts_at) + .map_err(|e| format!("Invalid starts_at: {}", e))? + .with_timezone(&Utc); + + let ends_at = DateTime::parse_from_rfc3339(&model.ends_at) + .map_err(|e| format!("Invalid ends_at: {}", e))? + .with_timezone(&Utc); + + let created_at = DateTime::parse_from_rfc3339(&model.created_at) + .map_err(|e| format!("Invalid created_at: {}", e))? + .with_timezone(&Utc); + + let updated_at = DateTime::parse_from_rfc3339(&model.updated_at) + .map_err(|e| format!("Invalid updated_at: {}", e))? + .with_timezone(&Utc); + + let time_range = TimeRange::new(starts_at, ends_at) + .map_err(|e| e.to_string())?; + + let color = EventColor::from(model.color as u8); + + Ok(Event::with_id( + id, + calendar_id, + model.title, + model.description, + time_range, + color, + model.is_all_day != 0, + model.is_cancelled != 0, + created_at, + updated_at, + )) + } + + pub fn to_model(event: &Event) -> EventModel { + EventModel { + id: event.id().to_string(), + calendar_id: event.calendar_id().to_string(), + title: event.title().to_string(), + description: event.description().clone(), + starts_at: event.time_range().starts_at().to_rfc3339(), + ends_at: event.time_range().ends_at().to_rfc3339(), + color: u8::from(*event.color()) as i64, + is_all_day: if *event.is_all_day() { 1 } else { 0 }, + is_cancelled: if *event.is_cancelled() { 1 } else { 0 }, + created_at: event.created_at().to_rfc3339(), + updated_at: event.updated_at().to_rfc3339(), + } + } +} + +pub struct RecurrenceMapper; + +impl RecurrenceMapper { + pub fn to_domain( + model: RecurrenceModel, + exceptions: Vec<RecurrenceExceptionModel>, + ) -> Result<RecurringEvent, String> { + let id = EventId::from_str(&model.id) + .map_err(|e| format!("Invalid event ID: {}", e))?; + + let calendar_id = CalendarId::from_str(&model.calendar_id) + .map_err(|e| format!("Invalid calendar ID: {}", e))?; + + let starts_at = DateTime::parse_from_rfc3339(&model.starts_at) + .map_err(|e| format!("Invalid starts_at: {}", e))? + .with_timezone(&Utc); + + let ends_at = DateTime::parse_from_rfc3339(&model.ends_at) + .map_err(|e| format!("Invalid ends_at: {}", e))? + .with_timezone(&Utc); + + let created_at = DateTime::parse_from_rfc3339(&model.created_at) + .map_err(|e| format!("Invalid created_at: {}", e))? + .with_timezone(&Utc); + + let updated_at = DateTime::parse_from_rfc3339(&model.updated_at) + .map_err(|e| format!("Invalid updated_at: {}", e))? + .with_timezone(&Utc); + + let base_time_range = TimeRange::new(starts_at, ends_at) + .map_err(|e| e.to_string())?; + + let color = EventColor::from(model.color as u8); + + let frequency = Frequency::from_str(&model.frequency) + .map_err(|e| e.to_string())?; + + let until = if let Some(until_str) = model.until { + Some(DateTime::parse_from_rfc3339(&until_str) + .map_err(|e| format!("Invalid until: {}", e))? + .with_timezone(&Utc)) + } else { + None + }; + + let recurrence_rule = RecurrenceRule::new( + frequency, + model.interval as u32, + until, + ).map_err(|e| e.to_string())?; + + let exception_map = exceptions + .into_iter() + .map(Self::exception_to_domain) + .collect::<Result<Vec<_>, _>>()? + .into_iter() + .map(|ex| (*ex.original_starts_at(), ex)) + .collect::<HashMap<DateTime<Utc>, RecurrenceException>>(); + + Ok(RecurringEvent::with_id( + id, + calendar_id, + model.title, + model.description, + base_time_range, + recurrence_rule, + exception_map, + color, + model.is_all_day != 0, + model.is_cancelled != 0, + created_at, + updated_at, + )) + } + + pub fn to_model(event: &RecurringEvent) -> RecurrenceModel { + RecurrenceModel { + id: event.id().to_string(), + calendar_id: event.calendar_id().to_string(), + title: event.title().to_string(), + description: event.description().clone(), + starts_at: event.time_range().starts_at().to_rfc3339(), + ends_at: event.time_range().ends_at().to_rfc3339(), + frequency: event.rule().frequency().to_string(), + interval: *event.rule().interval() as i64, + until: event.rule().until().map(|dt| dt.to_rfc3339()), + color: u8::from(*event.color()) as i64, + is_all_day: if *event.is_all_day() { 1 } else { 0 }, + is_cancelled: if *event.is_cancelled() { 1 } else { 0 }, + created_at: event.created_at().to_rfc3339(), + updated_at: event.updated_at().to_rfc3339(), + } + } + + fn exception_to_domain( + model: RecurrenceExceptionModel, + ) -> Result<RecurrenceException, String> { + let original_starts_at = DateTime::parse_from_rfc3339( + &model.original_starts_at + ) + .map_err(|e| format!("Invalid original_starts_at: {}", e))? + .with_timezone(&Utc); + + if model.is_cancelled != 0 { + Ok(RecurrenceException::cancelled(original_starts_at)) + } else if let (Some(new_starts), Some(new_ends)) = + (model.new_starts_at, model.new_ends_at) { + let starts = DateTime::parse_from_rfc3339(&new_starts) + .map_err(|e| format!("Invalid new_starts_at: {}", e))? + .with_timezone(&Utc); + + let ends = DateTime::parse_from_rfc3339(&new_ends) + .map_err(|e| format!("Invalid new_ends_at: {}", e))? + .with_timezone(&Utc); + + let new_time_range = TimeRange::new(starts, ends) + .map_err(|e| e.to_string())?; + + Ok(RecurrenceException::rescheduled( + original_starts_at, + new_time_range, + )) + } else { + Err( + "Exception must be either cancelled or have new time range" + .to_string() + ) + } + } + + pub fn exception_to_model( + exception: &RecurrenceException, + recurrence_id: &EventId, + ) -> RecurrenceExceptionModel { + let (new_starts_at, new_ends_at, is_cancelled) = + match exception.modification() { + ExceptionModification::Cancelled => (None, None, 1), + ExceptionModification::Rescheduled { new_time_range } => ( + Some(new_time_range.starts_at().to_rfc3339()), + Some(new_time_range.ends_at().to_rfc3339()), + 0, + ), + }; + + RecurrenceExceptionModel { + recurrence_id: recurrence_id.to_string(), + original_starts_at: exception.original_starts_at().to_rfc3339(), + new_starts_at, + new_ends_at, + is_cancelled, + } + } +} |