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",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
|
"tempfile",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-modbus",
|
"tokio-modbus",
|
||||||
|
|||||||
@@ -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
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
|
- **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
|
||||||
|
|||||||
@@ -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
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
|
//! - 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;
|
||||||
|
|||||||
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