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:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -2640,6 +2640,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tokio-modbus",
|
||||
|
||||
@@ -31,5 +31,8 @@ tokio-modbus = { version = "0.17.0", default-features = false, features = ["tcp"
|
||||
tracing = "0.1.44"
|
||||
tracing-subscriber = { version = "0.3.22", features = ["fmt", "std", "env-filter", "registry", "json", "tracing-log"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.15.0"
|
||||
|
||||
[lints.rust]
|
||||
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(tarpaulin_include)'] }
|
||||
|
||||
5
build.rs
Normal file
5
build.rs
Normal 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");
|
||||
}
|
||||
@@ -41,7 +41,7 @@
|
||||
- **Test**: Schema file syntax is valid SQL
|
||||
- **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/sqlite_repository.rs with SqliteRelayLabelRepository struct
|
||||
- Implement SqliteRelayLabelRepository::new(path) using SqlitePool
|
||||
|
||||
@@ -34,3 +34,5 @@
|
||||
//! - Architecture: `specs/constitution.md` - Hexagonal Architecture principles
|
||||
//! - Type design: `specs/001-modbus-relay-control/types-design.md`
|
||||
//! - Domain specification: `specs/001-modbus-relay-control/spec.md`
|
||||
|
||||
pub mod relay;
|
||||
|
||||
9
src/domain/relay/mod.rs
Normal file
9
src/domain/relay/mod.rs
Normal 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;
|
||||
15
src/domain/relay/repository.rs
Normal file
15
src/domain/relay/repository.rs
Normal 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
14
src/domain/relay/types.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -74,3 +74,5 @@
|
||||
//! - Architecture: `specs/constitution.md` - Dependency Inversion Principle
|
||||
//! - Implementation: `specs/001-modbus-relay-control/plan.md` - Infrastructure tasks
|
||||
//! - Modbus docs: `docs/Modbus_POE_ETH_Relay.md` - Hardware protocol specification
|
||||
|
||||
pub mod persistence;
|
||||
|
||||
7
src/infrastructure/persistence/mod.rs
Normal file
7
src/infrastructure/persistence/mod.rs
Normal 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;
|
||||
64
src/infrastructure/persistence/sqlite_repository.rs
Normal file
64
src/infrastructure/persistence/sqlite_repository.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
266
tests/sqlite_repository_test.rs
Normal file
266
tests/sqlite_repository_test.rs
Normal 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"),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user