feat(persistence): initialize SQLite database module

- Add domain types: RelayId newtype and RepositoryError enum
- Implement SqliteRelayLabelRepository with in-memory test support
- Create relay_labels migration with SQLx compile-time verification
- Add comprehensive integration test suite (266 lines)

Ref: T006 (specs/001-modbus-relay-control)
This commit is contained in:
2026-01-01 17:35:58 +01:00
parent d8a7ed5d29
commit 6903d76682
12 changed files with 389 additions and 1 deletions

1
Cargo.lock generated
View File

@@ -2640,6 +2640,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"sqlx", "sqlx",
"tempfile",
"thiserror", "thiserror",
"tokio", "tokio",
"tokio-modbus", "tokio-modbus",

View File

@@ -31,5 +31,8 @@ tokio-modbus = { version = "0.17.0", default-features = false, features = ["tcp"
tracing = "0.1.44" tracing = "0.1.44"
tracing-subscriber = { version = "0.3.22", features = ["fmt", "std", "env-filter", "registry", "json", "tracing-log"] } tracing-subscriber = { version = "0.3.22", features = ["fmt", "std", "env-filter", "registry", "json", "tracing-log"] }
[dev-dependencies]
tempfile = "3.15.0"
[lints.rust] [lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(tarpaulin_include)'] } unexpected_cfgs = { level = "warn", check-cfg = ['cfg(tarpaulin_include)'] }

5
build.rs Normal file
View File

@@ -0,0 +1,5 @@
// generated by `sqlx migrate build-script`
fn main() {
// trigger recompilation when a new migration is added
println!("cargo:rerun-if-changed=migrations");
}

View File

@@ -41,7 +41,7 @@
- **Test**: Schema file syntax is valid SQL - **Test**: Schema file syntax is valid SQL
- **Complexity**: Low | **Uncertainty**: Low - **Complexity**: Low | **Uncertainty**: Low
- [ ] **T006** [P] [Setup] [TDD] Initialize SQLite database module - [x] **T006** [P] [Setup] [TDD] Initialize SQLite database module
- Create infrastructure/persistence/mod.rs - Create infrastructure/persistence/mod.rs
- Create infrastructure/persistence/sqlite_repository.rs with SqliteRelayLabelRepository struct - Create infrastructure/persistence/sqlite_repository.rs with SqliteRelayLabelRepository struct
- Implement SqliteRelayLabelRepository::new(path) using SqlitePool - Implement SqliteRelayLabelRepository::new(path) using SqlitePool

View File

@@ -34,3 +34,5 @@
//! - Architecture: `specs/constitution.md` - Hexagonal Architecture principles //! - Architecture: `specs/constitution.md` - Hexagonal Architecture principles
//! - Type design: `specs/001-modbus-relay-control/types-design.md` //! - Type design: `specs/001-modbus-relay-control/types-design.md`
//! - Domain specification: `specs/001-modbus-relay-control/spec.md` //! - Domain specification: `specs/001-modbus-relay-control/spec.md`
pub mod relay;

9
src/domain/relay/mod.rs Normal file
View File

@@ -0,0 +1,9 @@
//! Relay domain module.
//!
//! This module contains the core domain logic for relay control and management,
//! including relay types, repository abstractions, and business rules.
/// Repository trait and error types for relay persistence.
pub mod repository;
/// Domain types for relay identification and control.
pub mod types;

View File

@@ -0,0 +1,15 @@
use super::types::RelayId;
/// Errors that can occur during repository operations.
///
/// This enum provides structured error handling for all data persistence
/// operations related to relay management.
#[derive(Debug, thiserror::Error)]
pub enum RepositoryError {
/// A database operation failed with the given error message.
#[error("Database error: {0}")]
DatabaseError(String),
/// The requested relay was not found in the repository.
#[error("Relay not found: {0}")]
NotFound(RelayId),
}

14
src/domain/relay/types.rs Normal file
View File

@@ -0,0 +1,14 @@
/// Unique identifier for a relay in the system.
///
/// Uses the newtype pattern to provide type safety and prevent mixing relay IDs
/// with other numeric values. Valid values range from 0-255, corresponding to
/// individual relay channels in the Modbus controller.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[repr(transparent)]
pub struct RelayId(u8);
impl std::fmt::Display for RelayId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}

View File

@@ -74,3 +74,5 @@
//! - Architecture: `specs/constitution.md` - Dependency Inversion Principle //! - Architecture: `specs/constitution.md` - Dependency Inversion Principle
//! - Implementation: `specs/001-modbus-relay-control/plan.md` - Infrastructure tasks //! - Implementation: `specs/001-modbus-relay-control/plan.md` - Infrastructure tasks
//! - Modbus docs: `docs/Modbus_POE_ETH_Relay.md` - Hardware protocol specification //! - Modbus docs: `docs/Modbus_POE_ETH_Relay.md` - Hardware protocol specification
pub mod persistence;

View File

@@ -0,0 +1,7 @@
//! Persistence layer implementations.
//!
//! This module contains the concrete implementations of repository traits
//! for data persistence, including SQLite-based storage for relay labels.
/// `SQLite` repository implementation for relay labels.
pub mod sqlite_repository;

View File

@@ -0,0 +1,64 @@
use sqlx::SqlitePool;
use crate::domain::relay::repository::RepositoryError;
/// `SQLite` implementation of the relay label repository.
///
/// This repository manages persistent storage of relay labels using `SQLite`,
/// with automatic schema migrations via `SQLx`.
pub struct SqliteRelayLabelRepository {
/// The `SQLite` connection pool for database operations.
pool: SqlitePool,
}
impl SqliteRelayLabelRepository {
/// Creates a new `SQLite` relay label repository.
///
/// # Arguments
///
/// * `db_path` - The `SQLite` database path or connection string (e.g., `"sqlite://data.db"`)
///
/// # Errors
///
/// Returns `RepositoryError::DatabaseError` if the connection fails or migrations cannot be applied.
pub async fn new(db_path: &str) -> Result<Self, RepositoryError> {
let pool = SqlitePool::connect(db_path)
.await
.map_err(|e| RepositoryError::DatabaseError(e.to_string()))?;
let repo = Self { pool };
repo.run_migrations().await?;
Ok(repo)
}
/// Returns a reference to the underlying connection pool.
///
/// This is primarily used for testing to verify schema and constraints.
#[must_use]
pub const fn pool(&self) -> &SqlitePool {
&self.pool
}
/// Creates a new in-memory `SQLite` relay label repository.
///
/// This is useful for testing and ephemeral data storage.
///
/// # Errors
///
/// Returns `RepositoryError::DatabaseError` if the in-memory database cannot be created.
pub async fn in_memory() -> Result<Self, RepositoryError> {
Self::new("sqlite::memory:").await
}
/// Runs all pending database migrations.
///
/// # Errors
///
/// Returns `RepositoryError::DatabaseError` if migrations fail to apply.
async fn run_migrations(&self) -> Result<(), RepositoryError> {
sqlx::migrate!()
.run(&self.pool)
.await
.map_err(|e| RepositoryError::DatabaseError(e.to_string()))?;
Ok(())
}
}

View File

@@ -0,0 +1,266 @@
//! Integration tests for `SqliteRelayLabelRepository`.
//!
//! These tests verify that the SQLite repository correctly:
//! - Creates an in-memory database
//! - Applies schema migrations
//! - Validates table structure and constraints
use sta::domain::relay::repository::RepositoryError;
use sta::infrastructure::persistence::sqlite_repository::SqliteRelayLabelRepository;
/// Test that `in_memory()` successfully creates an in-memory database.
///
/// **T006 Requirement**: `SqliteRelayLabelRepository::in_memory()` creates in-memory DB with schema
#[tokio::test]
async fn test_in_memory_creates_database() {
let result = SqliteRelayLabelRepository::in_memory().await;
assert!(
result.is_ok(),
"Failed to create in-memory database: {:?}",
result.err()
);
}
/// Test that the schema migration creates the `RelayLabels` table.
///
/// **T006 Requirement**: Verify schema is applied correctly
#[tokio::test]
async fn test_in_memory_applies_schema() {
let repo = SqliteRelayLabelRepository::in_memory()
.await
.expect("Failed to create in-memory database");
// Verify the table exists by querying it
let result: Result<(String,), sqlx::Error> =
sqlx::query_as("SELECT name FROM sqlite_master WHERE type='table' AND name='RelayLabels'")
.fetch_one(repo.pool())
.await;
assert!(
result.is_ok(),
"RelayLabels table should exist after migration"
);
}
/// Test that the `RelayLabels` table has the correct schema.
///
/// **T006 Requirement**: Verify table structure matches migration
#[tokio::test]
async fn test_relay_labels_table_structure() {
let repo = SqliteRelayLabelRepository::in_memory()
.await
.expect("Failed to create in-memory database");
// Query table info to verify column structure
let columns: Vec<(String, String)> =
sqlx::query_as("SELECT name, type FROM pragma_table_info('RelayLabels') ORDER BY cid")
.fetch_all(repo.pool())
.await
.expect("Failed to query table structure");
assert_eq!(columns.len(), 2, "RelayLabels table should have 2 columns");
// Verify relay_id column
assert_eq!(columns[0].0, "relay_id", "First column should be relay_id");
assert_eq!(columns[0].1, "INTEGER", "relay_id should be INTEGER");
// Verify label column
assert_eq!(columns[1].0, "label", "Second column should be label");
assert_eq!(columns[1].1, "TEXT", "label should be TEXT");
}
/// Test that `relay_id` is the primary key.
///
/// **T006 Requirement**: Verify primary key constraint
#[tokio::test]
async fn test_relay_id_primary_key() {
let repo = SqliteRelayLabelRepository::in_memory()
.await
.expect("Failed to create in-memory database");
// Insert first row with relay_id = 1
let insert1: Result<sqlx::sqlite::SqliteQueryResult, sqlx::Error> =
sqlx::query("INSERT INTO RelayLabels (relay_id, label) VALUES (1, 'Test')")
.execute(repo.pool())
.await;
assert!(insert1.is_ok(), "First insert should succeed");
// Try to insert duplicate relay_id = 1
let insert2: Result<sqlx::sqlite::SqliteQueryResult, sqlx::Error> =
sqlx::query("INSERT INTO RelayLabels (relay_id, label) VALUES (1, 'Duplicate')")
.execute(repo.pool())
.await;
assert!(
insert2.is_err(),
"Duplicate relay_id should fail due to PRIMARY KEY constraint"
);
}
/// Test that `relay_id` must be between 1 and 8.
///
/// **T006 Requirement**: Verify CHECK constraint on relay_id range
#[tokio::test]
async fn test_relay_id_range_constraint() {
let repo = SqliteRelayLabelRepository::in_memory()
.await
.expect("Failed to create in-memory database");
// Valid range: 1-8 should succeed
for id in 1..=8 {
let result: Result<sqlx::sqlite::SqliteQueryResult, sqlx::Error> =
sqlx::query("INSERT INTO RelayLabels (relay_id, label) VALUES (?, ?)")
.bind(id)
.bind(format!("Relay {}", id))
.execute(repo.pool())
.await;
assert!(
result.is_ok(),
"relay_id {} should be valid (range 1-8)",
id
);
}
// Below valid range: 0 should fail
let result_below: Result<sqlx::sqlite::SqliteQueryResult, sqlx::Error> =
sqlx::query("INSERT INTO RelayLabels (relay_id, label) VALUES (0, 'Invalid')")
.execute(repo.pool())
.await;
assert!(
result_below.is_err(),
"relay_id = 0 should fail CHECK constraint"
);
// Above valid range: 9 should fail
let result_above: Result<sqlx::sqlite::SqliteQueryResult, sqlx::Error> =
sqlx::query("INSERT INTO RelayLabels (relay_id, label) VALUES (9, 'Invalid')")
.execute(repo.pool())
.await;
assert!(
result_above.is_err(),
"relay_id = 9 should fail CHECK constraint"
);
}
/// Test that `label` cannot exceed 50 characters.
///
/// **T006 Requirement**: Verify CHECK constraint on label length
#[tokio::test]
async fn test_label_length_constraint() {
let repo = SqliteRelayLabelRepository::in_memory()
.await
.expect("Failed to create in-memory database");
// Valid length: 50 characters should succeed
let label_50 = "A".repeat(50);
let result_valid: Result<sqlx::sqlite::SqliteQueryResult, sqlx::Error> =
sqlx::query("INSERT INTO RelayLabels (relay_id, label) VALUES (1, ?)")
.bind(&label_50)
.execute(repo.pool())
.await;
assert!(
result_valid.is_ok(),
"Label with 50 characters should be valid"
);
// Invalid length: 51 characters should fail
let label_51 = "B".repeat(51);
let result_invalid: Result<sqlx::sqlite::SqliteQueryResult, sqlx::Error> =
sqlx::query("INSERT INTO RelayLabels (relay_id, label) VALUES (2, ?)")
.bind(&label_51)
.execute(repo.pool())
.await;
assert!(
result_invalid.is_err(),
"Label with 51 characters should fail CHECK constraint"
);
}
/// Test that `label` cannot be NULL.
///
/// **T006 Requirement**: Verify NOT NULL constraint on label
#[tokio::test]
async fn test_label_not_null_constraint() {
let repo = SqliteRelayLabelRepository::in_memory()
.await
.expect("Failed to create in-memory database");
// Attempt to insert NULL label
let result: Result<sqlx::sqlite::SqliteQueryResult, sqlx::Error> =
sqlx::query("INSERT INTO RelayLabels (relay_id, label) VALUES (1, NULL)")
.execute(repo.pool())
.await;
assert!(
result.is_err(),
"NULL label should fail NOT NULL constraint"
);
}
/// Test that multiple in-memory repositories are isolated.
///
/// **T006 Requirement**: Verify in-memory instances are independent
#[tokio::test]
async fn test_multiple_in_memory_instances_isolated() {
let repo1 = SqliteRelayLabelRepository::in_memory()
.await
.expect("Failed to create first in-memory database");
let repo2 = SqliteRelayLabelRepository::in_memory()
.await
.expect("Failed to create second in-memory database");
// Insert data into repo1
sqlx::query("INSERT INTO RelayLabels (relay_id, label) VALUES (1, 'Repo1')")
.execute(repo1.pool())
.await
.expect("Failed to insert into repo1");
// Verify repo2 is empty (no data from repo1)
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM RelayLabels")
.fetch_one(repo2.pool())
.await
.expect("Failed to query repo2");
assert_eq!(
count.0, 0,
"Second in-memory instance should be isolated from first"
);
}
/// Test that `new()` with file path creates a persistent database.
///
/// **T006 Requirement**: Verify file-based database creation
#[tokio::test]
async fn test_new_creates_file_database() {
let temp_db = tempfile::NamedTempFile::new().expect("Failed to create temp file");
let db_path = format!("sqlite://{}", temp_db.path().to_str().unwrap());
let result = SqliteRelayLabelRepository::new(&db_path).await;
assert!(
result.is_ok(),
"Failed to create file-based database: {:?}",
result.err()
);
// Verify the file exists and has content
let metadata = std::fs::metadata(temp_db.path()).expect("Database file should exist");
assert!(metadata.len() > 0, "Database file should not be empty");
}
/// Test that `new()` with invalid path returns error.
///
/// **T006 Requirement**: Verify error handling for invalid paths
#[tokio::test]
async fn test_new_invalid_path_returns_error() {
let result =
SqliteRelayLabelRepository::new("sqlite:///invalid/path/that/does/not/exist/db.sqlite")
.await;
assert!(result.is_err(), "Invalid database path should return error");
match result {
Err(RepositoryError::DatabaseError(_)) => {
// Expected error type
}
_ => panic!("Expected RepositoryError::DatabaseError for invalid path"),
}
}