182 lines
6.4 KiB
Rust
182 lines
6.4 KiB
Rust
//! Shared helpers for database adapter crates (SQLite, PostgreSQL).
|
|
//!
|
|
//! Provides error mapping, datetime parsing, UUID parsing, and JSON
|
|
//! deserialization helpers that are identical across database backends.
|
|
|
|
use chrono::{DateTime, Utc};
|
|
use domain::{DomainError, RecyclePolicy, ScheduleConfig, ScheduleConfigCompat};
|
|
use serde::de::DeserializeOwned;
|
|
use uuid::Uuid;
|
|
|
|
// ============================================================================
|
|
// Error mapping
|
|
// ============================================================================
|
|
|
|
/// Map a [`sqlx::Error`] into a [`DomainError::RepositoryError`].
|
|
pub fn map_sqlx_error(err: sqlx::Error) -> DomainError {
|
|
tracing::error!(error = %err, "database error");
|
|
DomainError::RepositoryError(err.to_string())
|
|
}
|
|
|
|
// ============================================================================
|
|
// Datetime parsing
|
|
// ============================================================================
|
|
|
|
/// Parse a datetime string stored in the database.
|
|
///
|
|
/// Tries RFC 3339 first (e.g. `2026-03-19T00:00:00Z`), then falls back to
|
|
/// the bare SQLite format `%Y-%m-%d %H:%M:%S` (no timezone, assumed UTC).
|
|
pub fn parse_dt(s: &str) -> Result<DateTime<Utc>, DomainError> {
|
|
DateTime::parse_from_rfc3339(s)
|
|
.map(|dt| dt.with_timezone(&Utc))
|
|
.or_else(|_| {
|
|
chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S").map(|dt| dt.and_utc())
|
|
})
|
|
.map_err(|e| DomainError::RepositoryError(format!("Invalid datetime '{}': {}", s, e)))
|
|
}
|
|
|
|
// ============================================================================
|
|
// UUID parsing
|
|
// ============================================================================
|
|
|
|
/// Parse a UUID string from the database, wrapping errors in [`DomainError::RepositoryError`].
|
|
///
|
|
/// The `context` parameter is included in the error message for diagnostics
|
|
/// (e.g. `"channel id"`, `"slot id"`).
|
|
pub fn parse_uuid(s: &str, context: &str) -> Result<Uuid, DomainError> {
|
|
Uuid::parse_str(s)
|
|
.map_err(|e| DomainError::RepositoryError(format!("Invalid {} UUID '{}': {}", context, s, e)))
|
|
}
|
|
|
|
// ============================================================================
|
|
// JSON deserialization helpers
|
|
// ============================================================================
|
|
|
|
/// Deserialize a JSON string from the database into `T`.
|
|
///
|
|
/// The `context` parameter is included in the error message for diagnostics
|
|
/// (e.g. `"schedule_config"`, `"slot item"`).
|
|
pub fn parse_json<T: DeserializeOwned>(json: &str, context: &str) -> Result<T, DomainError> {
|
|
serde_json::from_str(json)
|
|
.map_err(|e| DomainError::RepositoryError(format!("Invalid {} JSON: {}", context, e)))
|
|
}
|
|
|
|
/// Parse a `schedule_config` JSON column, handling V1/V2 compat migration.
|
|
pub fn parse_schedule_config(json: &str) -> Result<ScheduleConfig, DomainError> {
|
|
let compat: ScheduleConfigCompat = parse_json(json, "schedule_config")?;
|
|
Ok(ScheduleConfig::from(compat))
|
|
}
|
|
|
|
/// Parse a `recycle_policy` JSON column.
|
|
pub fn parse_recycle_policy(json: &str) -> Result<RecyclePolicy, DomainError> {
|
|
parse_json(json, "recycle_policy")
|
|
}
|
|
|
|
/// Deserialize a string-encoded enum, returning `T::default()` on failure.
|
|
///
|
|
/// Used for columns like `access_mode` and `logo_position` that are stored as
|
|
/// bare strings (e.g. `"public"`, `"top_left"`) and deserialized via serde.
|
|
pub fn parse_enum_or_default<T: DeserializeOwned + Default>(value: String) -> T {
|
|
serde_json::from_value(serde_json::Value::String(value)).unwrap_or_default()
|
|
}
|
|
|
|
// ============================================================================
|
|
// Tests
|
|
// ============================================================================
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use chrono::Datelike;
|
|
|
|
#[test]
|
|
fn parse_dt_rfc3339() {
|
|
let dt = parse_dt("2026-03-19T12:30:00Z").unwrap();
|
|
assert_eq!(dt.year(), 2026);
|
|
assert_eq!(dt.month(), 3);
|
|
assert_eq!(dt.day(), 19);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_dt_sqlite_format() {
|
|
let dt = parse_dt("2026-03-19 12:30:00").unwrap();
|
|
assert_eq!(dt.year(), 2026);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_dt_invalid() {
|
|
assert!(parse_dt("not-a-date").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn parse_uuid_valid() {
|
|
let u = Uuid::new_v4();
|
|
let parsed = parse_uuid(&u.to_string(), "test").unwrap();
|
|
assert_eq!(parsed, u);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_uuid_invalid() {
|
|
let err = parse_uuid("not-a-uuid", "channel id").unwrap_err();
|
|
let msg = err.to_string();
|
|
assert!(msg.contains("channel id"), "error should contain context: {msg}");
|
|
}
|
|
|
|
#[test]
|
|
fn parse_json_valid() {
|
|
let val: Vec<i32> = parse_json("[1,2,3]", "test").unwrap();
|
|
assert_eq!(val, vec![1, 2, 3]);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_json_invalid() {
|
|
let err = parse_json::<Vec<i32>>("not json", "test_field").unwrap_err();
|
|
let msg = err.to_string();
|
|
assert!(msg.contains("test_field"), "error should contain context: {msg}");
|
|
}
|
|
|
|
#[test]
|
|
fn parse_schedule_config_v2() {
|
|
let json = r#"{"day_blocks":{}}"#;
|
|
let cfg = parse_schedule_config(json).unwrap();
|
|
assert!(cfg.day_blocks().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn parse_schedule_config_v1_compat() {
|
|
let json = r#"{"blocks":[]}"#;
|
|
let cfg = parse_schedule_config(json).unwrap();
|
|
// V1 promotes blocks to all 7 days
|
|
assert_eq!(cfg.day_blocks().len(), 7);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_recycle_policy_valid() {
|
|
let json = r#"{"cooldown_days":7,"cooldown_generations":3,"min_available_ratio":0.3}"#;
|
|
let policy = parse_recycle_policy(json).unwrap();
|
|
assert_eq!(policy.cooldown_days, Some(7));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_enum_or_default_valid() {
|
|
use domain::AccessMode;
|
|
let mode: AccessMode = parse_enum_or_default("public".to_string());
|
|
assert!(matches!(mode, AccessMode::Public));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_enum_or_default_fallback() {
|
|
use domain::AccessMode;
|
|
let mode: AccessMode = parse_enum_or_default("garbage".to_string());
|
|
// Should return default (Public)
|
|
assert!(matches!(mode, AccessMode::Public));
|
|
}
|
|
|
|
#[test]
|
|
fn map_sqlx_error_produces_repository_error() {
|
|
let sqlx_err = sqlx::Error::RowNotFound;
|
|
let domain_err = map_sqlx_error(sqlx_err);
|
|
assert!(matches!(domain_err, DomainError::RepositoryError(_)));
|
|
}
|
|
}
|