feat(infrastructure): implement SQLite repository for relay labels
Add complete SQLite implementation of RelayLabelRepository trait with all CRUD operations (get_label, save_label, delete_label, get_all_labels). Key changes: - Create infrastructure entities module with RelayLabelRecord struct - Implement TryFrom traits for converting between database records and domain types - Add From<sqlx::Error> and From<RelayLabelError> for RepositoryError - Write comprehensive functional tests covering all repository operations - Verify proper handling of edge cases (empty results, overwrites, max length) TDD phase: GREEN - All repository trait tests now passing with SQLite implementation Ref: T036 (specs/001-modbus-relay-control/tasks.md)
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
mod label;
|
||||
pub use label::RelayLabelRepository;
|
||||
|
||||
use super::types::RelayId;
|
||||
use super::types::{RelayId, RelayLabelError};
|
||||
|
||||
/// Errors that can occur during repository operations.
|
||||
///
|
||||
@@ -16,3 +16,15 @@ pub enum RepositoryError {
|
||||
#[error("Relay not found: {0}")]
|
||||
NotFound(RelayId),
|
||||
}
|
||||
|
||||
impl From<sqlx::Error> for RepositoryError {
|
||||
fn from(value: sqlx::Error) -> Self {
|
||||
Self::DatabaseError(value.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<RelayLabelError> for RepositoryError {
|
||||
fn from(value: RelayLabelError) -> Self {
|
||||
Self::DatabaseError(value.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,5 +3,5 @@ mod relaylabel;
|
||||
mod relaystate;
|
||||
|
||||
pub use relayid::RelayId;
|
||||
pub use relaylabel::RelayLabel;
|
||||
pub use relaylabel::{RelayLabel, RelayLabelError};
|
||||
pub use relaystate::RelayState;
|
||||
|
||||
@@ -8,10 +8,19 @@ use thiserror::Error;
|
||||
#[repr(transparent)]
|
||||
pub struct RelayLabel(String);
|
||||
|
||||
/// Errors that can occur when creating or validating relay labels.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum RelayLabelError {
|
||||
/// The label string is empty.
|
||||
///
|
||||
/// Relay labels must contain at least one character.
|
||||
#[error("Label cannot be empty")]
|
||||
Empty,
|
||||
|
||||
/// The label string exceeds the maximum allowed length.
|
||||
///
|
||||
/// Contains the actual length of the invalid label.
|
||||
/// Maximum allowed length is 50 characters.
|
||||
#[error("Label exceeds maximum length of 50 characters: {0}")]
|
||||
TooLong(usize),
|
||||
}
|
||||
|
||||
33
backend/src/infrastructure/persistence/entities/mod.rs
Normal file
33
backend/src/infrastructure/persistence/entities/mod.rs
Normal file
@@ -0,0 +1,33 @@
|
||||
//! Infrastructure entities for database persistence.
|
||||
//!
|
||||
//! This module defines entities that directly map to database tables,
|
||||
//! providing a clear separation between the persistence layer and the
|
||||
//! domain layer. These entities represent raw database records without
|
||||
//! domain validation or business logic.
|
||||
//!
|
||||
//! # Conversion Pattern
|
||||
//!
|
||||
//! Infrastructure entities implement `TryFrom` traits to convert between
|
||||
//! database records and domain types:
|
||||
//!
|
||||
//! ```rust
|
||||
//! # use sta::domain::relay::types::{RelayId, RelayLabel};
|
||||
//! # use sta::infrastructure::persistence::entities::relay_label_record::RelayLabelRecord;
|
||||
//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
//! // Database Record -> Domain Types
|
||||
//! // ... from database
|
||||
//! let record: RelayLabelRecord = RelayLabelRecord { relay_id: 2, label: "label".to_string() };
|
||||
//! let (relay_id, relay_label): (RelayId, RelayLabel) = record.try_into()?;
|
||||
//!
|
||||
//! // Domain Types -> Database Record
|
||||
//! let domain_record= RelayLabelRecord::new(relay_id, &relay_label);
|
||||
//! # Ok(())
|
||||
//! # }
|
||||
//! ```
|
||||
|
||||
/// Database entity for relay labels.
|
||||
///
|
||||
/// This module contains the `RelayLabelRecord` struct which represents
|
||||
/// a single row in the `RelayLabels` database table, along with conversion
|
||||
/// traits to and from domain types.
|
||||
pub mod relay_label_record;
|
||||
@@ -0,0 +1,62 @@
|
||||
use crate::domain::relay::{
|
||||
controller::ControllerError,
|
||||
repository::RepositoryError,
|
||||
types::{RelayId, RelayLabel, RelayLabelError},
|
||||
};
|
||||
|
||||
/// Database record representing a relay label.
|
||||
///
|
||||
/// This struct directly maps to the `RelayLabels` table in the
|
||||
/// database. It represents the raw data as stored in the database,
|
||||
/// without domain validation or business logic.
|
||||
#[derive(Debug, Clone, sqlx::FromRow)]
|
||||
pub struct RelayLabelRecord {
|
||||
/// The relay ID (1-8) as stored in the database
|
||||
pub relay_id: i64,
|
||||
/// The label text as stored in the database
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
impl RelayLabelRecord {
|
||||
/// Creates a new `RecordLabelRecord` from domain types.
|
||||
#[must_use]
|
||||
pub fn new(relay_id: RelayId, label: &RelayLabel) -> Self {
|
||||
Self {
|
||||
relay_id: i64::from(relay_id.as_u8()),
|
||||
label: label.as_str().to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<RelayLabelRecord> for RelayId {
|
||||
type Error = ControllerError;
|
||||
|
||||
fn try_from(value: RelayLabelRecord) -> Result<Self, Self::Error> {
|
||||
let value = u8::try_from(value.relay_id).map_err(|e| {
|
||||
Self::Error::InvalidInput(format!("Got value {} from database: {e}", value.relay_id))
|
||||
})?;
|
||||
Self::new(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<RelayLabelRecord> for RelayLabel {
|
||||
type Error = RelayLabelError;
|
||||
|
||||
fn try_from(value: RelayLabelRecord) -> Result<Self, Self::Error> {
|
||||
Self::new(value.label)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<RelayLabelRecord> for (RelayId, RelayLabel) {
|
||||
type Error = RepositoryError;
|
||||
|
||||
fn try_from(value: RelayLabelRecord) -> Result<Self, Self::Error> {
|
||||
let record_id: RelayId = value
|
||||
.clone()
|
||||
.try_into()
|
||||
.map_err(|e: ControllerError| RepositoryError::DatabaseError(e.to_string()))?;
|
||||
let label: RelayLabel = RelayLabel::new(value.label)
|
||||
.map_err(|e| RepositoryError::DatabaseError(e.to_string()))?;
|
||||
Ok((record_id, label))
|
||||
}
|
||||
}
|
||||
@@ -12,3 +12,5 @@ pub mod label_repository_tests;
|
||||
|
||||
/// `SQLite` repository implementation for relay labels.
|
||||
pub mod sqlite_repository;
|
||||
|
||||
pub mod entities;
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
use sqlx::SqlitePool;
|
||||
use async_trait::async_trait;
|
||||
use sqlx::{SqlitePool, query_as};
|
||||
|
||||
use crate::domain::relay::repository::RepositoryError;
|
||||
use crate::{
|
||||
domain::relay::{
|
||||
repository::{RelayLabelRepository, RepositoryError},
|
||||
types::{RelayId, RelayLabel},
|
||||
},
|
||||
infrastructure::persistence::entities::relay_label_record::RelayLabelRecord,
|
||||
};
|
||||
|
||||
/// `SQLite` implementation of the relay label repository.
|
||||
///
|
||||
@@ -62,3 +69,56 @@ impl SqliteRelayLabelRepository {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl RelayLabelRepository for SqliteRelayLabelRepository {
|
||||
async fn get_label(&self, id: RelayId) -> Result<Option<RelayLabel>, RepositoryError> {
|
||||
let id = i64::from(id.as_u8());
|
||||
let result = sqlx::query_as!(
|
||||
RelayLabelRecord,
|
||||
"SELECT * FROM RelayLabels WHERE relay_id = ?1",
|
||||
id
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|e| RepositoryError::DatabaseError(e.to_string()))?;
|
||||
|
||||
match result {
|
||||
Some(record) => Ok(Some(record.try_into()?)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
async fn save_label(&self, id: RelayId, label: RelayLabel) -> Result<(), RepositoryError> {
|
||||
let record = RelayLabelRecord::new(id, &label);
|
||||
sqlx::query!(
|
||||
"INSERT OR REPLACE INTO RelayLabels (relay_id, label) VALUES (?1, ?2)",
|
||||
record.relay_id,
|
||||
record.label
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(RepositoryError::from)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_label(&self, id: RelayId) -> Result<(), RepositoryError> {
|
||||
let id = i64::from(id.as_u8());
|
||||
sqlx::query!("DELETE FROM RelayLabels WHERE relay_id = ?1", id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(RepositoryError::from)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_all_labels(&self) -> Result<Vec<(RelayId, RelayLabel)>, RepositoryError> {
|
||||
let result: Vec<RelayLabelRecord> = query_as!(
|
||||
RelayLabelRecord,
|
||||
"SELECT * FROM RelayLabels ORDER BY relay_id"
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(RepositoryError::from)?;
|
||||
result.iter().map(|r| r.clone().try_into()).collect()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user