adapter-common: shared DB mapping helpers

This commit is contained in:
2026-07-12 02:26:42 +02:00
parent 4cdde568f6
commit 1428f264bb
4 changed files with 1158 additions and 7 deletions

969
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
[workspace] [workspace]
members = ["crates/domain", "crates/application", "crates/api-types", "crates/infra-wiring"] members = ["crates/domain", "crates/application", "crates/api-types", "crates/infra-wiring", "crates/adapters/adapter-common"]
exclude = ["k-tv-backend", "k-tv-frontend"] exclude = ["k-tv-backend", "k-tv-frontend"]
resolver = "2" resolver = "2"

View File

@@ -0,0 +1,13 @@
[package]
name = "adapter-common"
version = "0.1.0"
edition = "2024"
[dependencies]
domain = { workspace = true }
sqlx = { workspace = true }
chrono = { workspace = true }
uuid = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tracing = { workspace = true }

View File

@@ -0,0 +1,181 @@
//! 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(_)));
}
}