aquarium_control/database/sql_interface_balling.rs
1/* Copyright 2024 Uwe Martin
2
3Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
5The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
7THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
8*/
9
10//! Manages all database interactions for the Balling dosing system.
11//!
12//! This module provides a dedicated interface, `SqlInterfaceBalling`, for handling
13//! all SQL operations related to the Balling feature. It encapsulates logic for
14//! reading pump configurations (`ballingsetvals`), logging dosing events
15//! (`ballingdosinglog`), and retrieving dosing history.
16//!
17//! ## Key Components
18//!
19//! - **`SqlInterfaceBalling` Struct**: The primary struct that holds a database
20//! connection and provides methods for all Balling-related database operations.
21//!
22//! - **`BallingSetVal` Struct**: A data structure representing the configuration
23//! parameters for a single Balling pump, such as dosing speed and volume.
24//!
25//! - **`new()` Constructor**: A critical entry point that not only creates an
26//! `SqlInterfaceBalling` instance but also performs essential "fail-fast"
27//! validation. At initialization, it checks the database for:
28//! - The presence of `NULL` values in the configuration table.
29//! - Whether the number of rows in the `ballingsetvals` and `ballingdosinglog`
30//! tables exceeds the limits defined in the application configuration.
31//! This ensures the system doesn't start in an invalid or inconsistent state.
32//!
33//! ## Design and Purpose
34//!
35//! The main goal of this module is to provide a robust, encapsulated, and safe
36//! interface for the Balling subsystem's data.
37//!
38//! - **Encapsulation**: All SQL queries and logic specific to Balling are contained
39//! within this module, separating concerns and making the overall database
40//! architecture cleaner.
41//!
42//! - **Data Integrity**: The constructor's validation logic enforces data integrity
43//! rules at startup, preventing runtime errors caused by misconfigured or
44//! overgrown database tables.
45
46#[cfg(test)]
47use log::error;
48
49use chrono::NaiveDateTime;
50use mysql::prelude::*;
51use mysql::*;
52use std::time::Duration;
53
54use crate::database::sql_query_strings::{
55 SQL_QUERY_CHECK_BALLING_DOSING_LOG_COUNT, SQL_QUERY_CHECK_BALLING_SETVALS_COUNT,
56 SQL_QUERY_CHECK_BALLING_SETVALS_NULL,
57};
58use crate::database::{
59 sql_interface::SqlInterface, sql_interface_error::SqlInterfaceError, sql_query_strings,
60};
61
62/// Contains the configuration and the implementation of the SQL interface for Balling.
63#[derive(Debug)]
64pub struct SqlInterfaceBalling {
65 /// Connection to the database
66 pub conn: PooledConn,
67}
68
69/// Contains set value data for each pump.
70pub struct BallingSetVal {
71 /// ID of the pump
72 /// Attribute is public because access from within test cases.
73 /// Data is not used in the implementation. Therefore, it is tagged as unused.
74 #[allow(unused)]
75 pub pump_id: i32,
76
77 /// flow rate of the pump in ml/sec
78 pub dosing_speed: f32,
79
80 /// dosing volume in ml which the pump shall deliver each hour
81 pub dosing_volume: f32,
82
83 /// A label which describes the fluid that the pump is delivering
84 /// Attribute is public because access from within test cases.
85 /// Data is not used in the implementation. Therefore, it is tagged as unused.
86 #[allow(unused)]
87 pub label: String,
88}
89
90impl SqlInterfaceBalling {
91 /// Creates a new `SqlInterfaceBalling` instance.
92 ///
93 /// This constructor initializes the Balling SQL interface with an established
94 /// database connection. It performs several pre-flight checks to ensure data
95 /// integrity and adherence to configured limits, such as verifying that the
96 /// Balling set value table contains no NULL values and that table row counts
97 /// are within their specified maximums.
98 ///
99 /// # Arguments
100 /// * `conn` - An active, pooled database connection.
101 /// * `max_rows_balling_set_values` - Maximum allowed number of rows in the Balling set value table.
102 /// * `max_rows_balling_dosing_log` - Maximum allowed number of rows in the Balling dosing log table.
103 ///
104 /// # Returns
105 /// A `Result` containing a new `SqlInterfaceBalling` instance on success.
106 ///
107 /// # Errors
108 /// This function will return an error if:
109 /// - Any of the initial database queries to get table counts fail (`DatabaseCheckBallingSetValsFailure`).
110 /// - Any of the retrieved counts are negative, indicating a database issue (`DatabaseBallingSetValTableNegativeValue`).
111 /// - The `ballingsetvals` table contains entries with `NULL` values (`DatabaseBallingSetValTableContainsNull`).
112 /// - The number of rows in the `ballingsetvals` table exceeds `max_rows_balling_set_values` (`DatabaseBallingSetValTableContainsTooManyRows`).
113 /// - The number of rows in the `ballingdosinglog` table exceeds `max_rows_balling_dosing_log` (`DatabaseBallingDosingLogTableContainsTooManyRows`).
114 pub fn new(
115 mut conn: PooledConn,
116 max_rows_balling_set_values: u64,
117 max_rows_balling_dosing_log: u64,
118 ) -> Result<Self, SqlInterfaceError> {
119 // query the database
120 let count_null_values = SqlInterface::get_single_integer_from_database(
121 &mut conn,
122 SQL_QUERY_CHECK_BALLING_SETVALS_NULL,
123 )
124 .map_err(|e| SqlInterfaceError::DatabaseCheckBallingSetValsFailure {
125 location: module_path!().to_string(),
126 source: Box::new(e),
127 })?;
128 let count_rows_setvals = SqlInterface::get_single_integer_from_database(
129 &mut conn,
130 SQL_QUERY_CHECK_BALLING_SETVALS_COUNT,
131 )
132 .map_err(|e| SqlInterfaceError::DatabaseCheckBallingSetValsFailure {
133 location: module_path!().to_string(),
134 source: Box::new(e),
135 })?;
136 let count_rows_dosing_log = SqlInterface::get_single_integer_from_database(
137 &mut conn,
138 SQL_QUERY_CHECK_BALLING_DOSING_LOG_COUNT,
139 )
140 .map_err(|e| SqlInterfaceError::DatabaseCheckBallingSetValsFailure {
141 location: module_path!().to_string(),
142 source: Box::new(e),
143 })?;
144
145 // check the query results
146 if count_null_values < 0 || count_rows_setvals < 0 || count_rows_dosing_log < 0 {
147 return Err(SqlInterfaceError::DatabaseBallingSetValTableNegativeValue(
148 module_path!().to_string(),
149 count_null_values,
150 count_rows_setvals,
151 count_rows_dosing_log,
152 ));
153 }
154
155 if count_null_values > 0 {
156 return Err(SqlInterfaceError::DatabaseBallingSetValTableContainsNull(
157 module_path!().to_string(),
158 count_null_values,
159 ));
160 }
161
162 if max_rows_balling_set_values > 0 {
163 // execute the check only when the limit is greater than zero
164 if count_rows_setvals as u64 > max_rows_balling_set_values {
165 return Err(
166 SqlInterfaceError::DatabaseBallingSetValTableContainsTooManyRows(
167 module_path!().to_string(),
168 count_rows_setvals.cast_unsigned(),
169 max_rows_balling_set_values,
170 ),
171 );
172 }
173 }
174
175 if max_rows_balling_dosing_log > 0 {
176 // execute the check only when the limit is greater than zero
177 if count_rows_dosing_log as u64 > max_rows_balling_dosing_log {
178 return Err(
179 SqlInterfaceError::DatabaseBallingDosingLogTableContainsTooManyRows(
180 module_path!().to_string(),
181 count_rows_dosing_log.cast_unsigned(),
182 max_rows_balling_dosing_log,
183 ),
184 );
185 }
186 }
187
188 Ok(SqlInterfaceBalling { conn })
189 }
190
191 /// Retrieves the timestamp of the last Balling dosing event for a specific pump.
192 ///
193 /// This function queries the database for the most recent dosing timestamp associated
194 /// with the given `pump_id`.
195 ///
196 /// # Arguments
197 /// * `pump_id` - The unique identifier of the pump.
198 ///
199 /// # Returns
200 /// A `Result` containing an `Option<NaiveDateTime>`:
201 /// - `Ok(Some(NaiveDateTime))` if a last dosing timestamp is found for the pump.
202 /// - `Ok(None)` if no previous dosing events are recorded for the specified pump.
203 ///
204 /// # Errors
205 /// This function will return an error if the underlying database query fails. This
206 /// could be due to a connection issue, a syntax error, or if the query unexpectedly
207 /// returns more than one row. The specific error is propagated from the underlying
208 /// `get_optional_timestamp` call.
209 fn get_last_balling_dosing_timestamp(
210 &mut self,
211 pump_id: i64,
212 ) -> Result<Option<NaiveDateTime>, SqlInterfaceError> {
213 let sql_query = str::replace(
214 sql_query_strings::SQL_QUERY_READ_LAST_BALLING_DOSING_TIMESTAMP,
215 "#pump_id",
216 &pump_id.to_string(),
217 );
218 SqlInterface::get_optional_timestamp(&mut self.conn, &sql_query)
219 }
220
221 /// Calculates the duration in seconds since the last Balling dosing event for a given pump.
222 ///
223 /// This function retrieves both the current database timestamp and the last dosing
224 /// timestamp for the specified `pump_id`. It then computes the time difference.
225 ///
226 /// # Arguments
227 /// * `pump_id` - The unique identifier of the pump.
228 ///
229 /// # Returns
230 /// A `Result` containing an `Option<i64>`:
231 /// - `Ok(Some(Duration::new))`: The duration that has passed since the last dosing event.
232 /// - `Ok(None)`: If no previous dosing event is recorded for this pump.
233 ///
234 /// # Errors
235 /// This function will return an error if it fails to retrieve either the current
236 /// timestamp or the last dosing timestamp from the database. This could be due to
237 /// a connection loss or other database-side issues. It also will return an error if the
238 /// database contains any entries that are in the future.
239 pub fn get_duration_since_last_balling_dosing(
240 &mut self,
241 pump_id: i64,
242 ) -> Result<Option<Duration>, SqlInterfaceError> {
243 let current_datetime = SqlInterface::get_current_timestamp(&mut self.conn)?;
244 let last_dosing_datetime_opt = self.get_last_balling_dosing_timestamp(pump_id)?;
245
246 match last_dosing_datetime_opt {
247 Some(last_dosing_datetime) => {
248 if last_dosing_datetime > current_datetime {
249 Err(SqlInterfaceError::BallingDosingLogEntryInFuture(
250 module_path!().to_string(),
251 pump_id,
252 last_dosing_datetime,
253 ))
254 } else {
255 let duration =
256 (current_datetime - last_dosing_datetime)
257 .to_std()
258 .map_err(|e| {
259 SqlInterfaceError::BallingDosingTimestampConversionFailure {
260 location: module_path!().to_string(),
261 pump_id,
262 current_datetime,
263 last_dosing_datetime,
264 source: e,
265 }
266 })?;
267
268 Ok(Some(duration))
269 }
270 }
271 None => Ok(None),
272 }
273 }
274
275 /// Inserts a new Balling dosing event record into the SQL database.
276 ///
277 /// This function logs details of a Balling dosing operation, including the
278 /// exact time, the pump used, and the volume of fluid dispensed.
279 ///
280 /// # Arguments
281 /// * `timestamp` - The `NaiveDateTime` when the dosing event occurred.
282 /// * `pump_id` - The identifier of the pump that performed the dosing.
283 /// * `dosing_volume` - The volume (in milliliters) of fluid dispensed.
284 ///
285 /// # Returns
286 /// An empty `Result` (`Ok(())`) if the event record was successfully inserted.
287 ///
288 /// # Errors
289 /// Returns `SqlInterfaceError::InsertBallingEventFailure` if the `INSERT`
290 /// query fails. This can happen due to a lost connection, constraint violation
291 /// (e.g., foreign key), or incorrect data types. The original `mysql::Error`
292 /// is included as the source.
293 pub fn insert_balling_event(
294 &mut self,
295 timestamp: NaiveDateTime,
296 pump_id: i64,
297 dosing_volume: f64,
298 ) -> Result<(), SqlInterfaceError> {
299 self.conn
300 .exec_drop(
301 sql_query_strings::SQL_QUERY_WRITE_BALLING_EVENT.to_owned(),
302 params! {
303 "timestamp" => timestamp,
304 "pump_id" => pump_id,
305 "dosing_volume" => dosing_volume,
306 },
307 )
308 .map_err(|e| SqlInterfaceError::InsertBallingEventFailure {
309 location: module_path!().to_string(),
310 source: e,
311 })
312 }
313
314 /// Retrieves a single set of Balling dosing parameters for a specified pump from the database.
315 ///
316 /// This function queries the database for the Balling set values (e.g., dosing speed, volume, label)
317 /// associated with a given `pump_id`. It strictly expects to find exactly one matching dataset.
318 ///
319 /// # Arguments
320 /// * `pump_id` - The unique identifier of the pump for which to retrieve the set values.
321 ///
322 /// # Returns
323 /// A `Result` containing the `BallingSetVal` for the specified pump on success.
324 ///
325 /// # Errors
326 /// This function will return an error if:
327 /// - The database query fails for any reason (`SingleBallingSetValRequestFailure`).
328 /// - The query returns zero rows (`SingleBallingSetValEmptyResponse`).
329 /// - The query returns more than one row (`SingleBallingSetValNoSingleResponse`), which
330 /// indicates a data consistency issue.
331 pub fn get_single_balling_setval_from_database(
332 &mut self,
333 pump_id: i64,
334 ) -> Result<BallingSetVal, SqlInterfaceError> {
335 let params = params! { "pump_id" => pump_id };
336
337 // Get a single data set from the database
338 let mut single_balling_setval_array = match self.conn.exec_map(
339 sql_query_strings::SQL_QUERY_READ_BALLING_SETVALS,
340 params,
341 |(pump_id, dosing_speed, dosing_volume, label)| BallingSetVal {
342 pump_id,
343 dosing_speed,
344 dosing_volume,
345 label,
346 },
347 ) {
348 Ok(c) => c,
349 Err(e) => {
350 return Err(SqlInterfaceError::SingleBallingSetValRequestFailure {
351 location: module_path!().to_string(),
352 query: sql_query_strings::SQL_QUERY_READ_BALLING_SETVALS.to_string(),
353 pump_id,
354 source: e,
355 });
356 }
357 };
358
359 // check the error case if there is no response
360 if single_balling_setval_array.is_empty() {
361 return Err(SqlInterfaceError::SingleBallingSetValEmptyResponse(
362 module_path!().to_string(),
363 sql_query_strings::SQL_QUERY_READ_BALLING_SETVALS.to_string(),
364 pump_id,
365 ));
366 }
367
368 // check the error case if there is more than one response
369 if single_balling_setval_array.len() > 1 {
370 return Err(SqlInterfaceError::SingleBallingSetValNoSingleResponse(
371 module_path!().to_string(),
372 sql_query_strings::SQL_QUERY_READ_BALLING_SETVALS.to_string(),
373 pump_id,
374 ));
375 }
376 Ok(single_balling_setval_array.remove(0))
377 }
378
379 #[cfg(test)]
380 // Inserts a pump configuration dataset into the database.
381 //
382 // This helper function is used exclusively in test environments to
383 // pre-populate the database with specific pump configuration data,
384 // allowing for consistent and isolated testing of related functionalities.
385 //
386 // # Arguments
387 // * `pump_id` - The unique identifier for the pump.
388 // * `dosing_speed` - The flow rate of the pump in milliliters per second.
389 // * `dosing_volume` - The volume (in milliliters) intended to be dosed in a single event.
390 // * `label` - A descriptive label for the fluid being dispensed by the pump.
391 //
392 // # Returns
393 // An empty `Result` (`Ok(())`) if the configuration was successfully inserted.
394 //
395 // # Errors
396 // Returns `SqlInterfaceError::InsertBallingPumpConfigurationFailure` if the
397 // `INSERT` query fails. This error should be updated to include the source
398 // `mysql::Error` for better diagnostics in test failures.
399 pub fn insert_pump_configuration(
400 &mut self,
401 pump_id: i64,
402 dosing_speed: f32,
403 dosing_volume: f32,
404 label: String,
405 ) -> Result<(), SqlInterfaceError> {
406 let sql_query_string = sql_query_strings::SQL_QUERY_WRITE_PUMP_CONFIGURATION.to_owned();
407 let sql_query_string_for_error = sql_query_string.clone();
408 match self.conn.exec_drop::<_, _>(
409 sql_query_string,
410 params! {
411 "pump_id" => pump_id,
412 "dosing_speed" => dosing_speed,
413 "dosing_volume" => dosing_volume,
414 "label" => label,
415 },
416 ) {
417 Ok(_) => Ok(()),
418 Err(e) => {
419 error!( // logging for testing purposes only
420 target: module_path!(),
421 "could not insert Balling pump configuration into database ({e:?})"
422 );
423 Err(SqlInterfaceError::InsertBallingPumpConfigurationFailure(
424 module_path!().to_string(),
425 sql_query_string_for_error,
426 ))
427 }
428 }
429 }
430}
431
432#[cfg(test)]
433pub mod tests {
434 use crate::database::sql_interface_balling::SqlInterfaceBalling;
435 use crate::database::{
436 sql_interface::SqlInterface, sql_interface_error::SqlInterfaceError, sql_query_strings,
437 };
438 use crate::utilities::config::{read_config_file_with_test_database, ConfigData};
439 use all_asserts::assert_le;
440 use chrono::Local;
441 use chrono::NaiveDateTime;
442 use mysql::{params, prelude::Queryable};
443 use spin_sleep::SpinSleeper;
444 use std::time::Duration;
445
446 pub struct SqlBallingEvent {
447 timestamp: String,
448 pump_id: i32,
449 dosing_volume: f32,
450 }
451
452 pub struct BallingEvent {
453 timestamp: NaiveDateTime,
454 pump_id: i32,
455 dosing_volume: f32,
456 }
457
458 #[test]
459 // This test case verifies the validation logic within the `SqlInterfaceBalling::new()` constructor.
460 // It covers the following scenarios:
461 // 1. Happy Path: Initialization succeeds with valid data and row counts.
462 // 2. Failure on Row Limit (SetVals): Fails when `ballingsetvals` exceeds its max row count.
463 // 3. Failure on Row Limit (DosingLog): Fails when `ballingdosinglog` exceeds its max row count.
464 // 4. Deactivated Checks: Succeeds even with excess rows if the limits are set to 0.
465 // Test case uses test database #57.
466 fn test_sql_interface_balling_new() {
467 // --- Common Setup ---
468 let config: ConfigData = read_config_file_with_test_database(
469 "/config/aquarium_control_test_generic.toml".to_string(),
470 57,
471 );
472 println!("Testing with database {}", config.sql_interface.db_name);
473 let mut sql_interface: SqlInterface = SqlInterface::new(config.sql_interface)
474 .expect("Initialization of SQL interface for test failed.");
475
476 // --- Test Case 1: Happy Path ---
477 println!("* Testing new() with valid data (Happy Path)...");
478 SqlInterface::truncate_table(
479 &mut sql_interface,
480 sql_query_strings::SQL_TABLE_BALLING_SETVALS.to_string(),
481 )
482 .unwrap();
483 SqlInterface::truncate_table(
484 &mut sql_interface,
485 sql_query_strings::SQL_TABLE_BALLING_DOSING_LOG.to_string(),
486 )
487 .unwrap();
488
489 // Insert one valid row into each table
490 let mut conn = sql_interface.get_connection().unwrap();
491 conn.exec_drop(
492 sql_query_strings::SQL_QUERY_WRITE_BALLING_SETVALS,
493 params! { "pump_id" => 1, "dosing_speed" => 1.0, "dosing_volume" => 1.0, "label" => "valid" },
494 ).unwrap();
495 conn.exec_drop(
496 sql_query_strings::SQL_QUERY_WRITE_BALLING_EVENT,
497 params! { "timestamp" => Local::now().naive_local(), "pump_id" => 1, "dosing_volume" => 1.0 },
498 ).unwrap();
499
500 let result = SqlInterfaceBalling::new(conn, 10, 10);
501 assert!(
502 result.is_ok(),
503 "Expected new() to succeed on happy path, but it failed: {:?}",
504 result.err()
505 );
506 println!("* Succeeded: Happy path initialization is successful.");
507
508 // --- Test Case 2: Failure on ballingsetvals row limit exceeded ---
509 println!("* Testing new() with too many rows in ballingsetvals...");
510 SqlInterface::truncate_table(
511 &mut sql_interface,
512 sql_query_strings::SQL_TABLE_BALLING_SETVALS.to_string(),
513 )
514 .unwrap();
515 let mut conn = sql_interface.get_connection().unwrap();
516 conn.exec_drop(sql_query_strings::SQL_QUERY_WRITE_BALLING_SETVALS, params! { "pump_id" => 1, "dosing_speed" => 1.0, "dosing_volume" => 1.0, "label" => "row1" }).unwrap();
517 conn.exec_drop(sql_query_strings::SQL_QUERY_WRITE_BALLING_SETVALS, params! { "pump_id" => 2, "dosing_speed" => 1.0, "dosing_volume" => 1.0, "label" => "row2" }).unwrap();
518
519 let result = SqlInterfaceBalling::new(conn, 1, 10); // Set the limit to 1
520 assert!(
521 matches!(
522 result,
523 Err(SqlInterfaceError::DatabaseBallingSetValTableContainsTooManyRows(_, _, _))
524 ),
525 "Expected row limit error for setvals, but got {:?}",
526 result
527 );
528 println!("* Succeeded: Initialization fails if ballingsetvals exceeds row limit.");
529
530 // --- Test Case 3: Failure on ballingdosinglog row limit exceeded ---
531 println!("* Testing new() with too many rows in ballingdosinglog...");
532 let spin_sleeper = SpinSleeper::default();
533 SqlInterface::truncate_table(
534 &mut sql_interface,
535 sql_query_strings::SQL_TABLE_BALLING_DOSING_LOG.to_string(),
536 )
537 .unwrap();
538 let mut conn = sql_interface.get_connection().unwrap();
539 conn.exec_drop(sql_query_strings::SQL_QUERY_WRITE_BALLING_EVENT, params! { "timestamp" => Local::now().naive_local(), "pump_id" => 1, "dosing_volume" => 1.0 }).unwrap();
540 spin_sleeper.sleep(Duration::from_millis(500));
541 conn.exec_drop(sql_query_strings::SQL_QUERY_WRITE_BALLING_EVENT, params! { "timestamp" => Local::now().naive_local(), "pump_id" => 2, "dosing_volume" => 1.0 }).unwrap();
542
543 let result = SqlInterfaceBalling::new(conn, 10, 1); // Set the limit to 1
544 assert!(
545 matches!(
546 result,
547 Err(SqlInterfaceError::DatabaseBallingDosingLogTableContainsTooManyRows(_, _, _))
548 ),
549 "Expected row limit error for the dosing log, but got {:?}",
550 result
551 );
552 println!("* Succeeded: Initialization fails if ballingdosinglog exceeds row limit.");
553
554 // --- Test Case 4: Deactivated checks (limits are 0) ---
555 println!("* Testing new() with deactivated row limit checks (limits = 0)...");
556 // We reuse the state from the previous test (2 rows in the dosing log)
557 let conn = sql_interface.get_connection().unwrap();
558 let result = SqlInterfaceBalling::new(conn, 0, 0); // Set limits to 0
559 assert!(
560 result.is_ok(),
561 "Expected new() to succeed with deactivated checks, but it failed: {:?}",
562 result.err()
563 );
564 println!("* Succeeded: Initialization passes when row limit checks are deactivated.");
565 }
566
567 #[test]
568 // Test case verifies the behavior of all functions accessing the Balling event log table.
569 // They are all combined in one function to limit the number of additional databases.
570 // Test case uses test database #30.
571 pub fn test_sql_interface_balling_events() {
572 let pump_id = 1;
573 let config: ConfigData = read_config_file_with_test_database(
574 "/config/aquarium_control_test_generic.toml".to_string(),
575 30,
576 );
577 println!("Testing with database {}", config.sql_interface.db_name);
578 let max_rows_balling_set_values = config.sql_interface.max_rows_balling_set_values;
579 let max_rows_balling_dosing_log = config.sql_interface.max_rows_balling_dosing_log;
580 let mut sql_interface: SqlInterface = SqlInterface::new(config.sql_interface)
581 .expect("Initialization of SQL interface for test failed.");
582 let mut sql_interface_balling = SqlInterfaceBalling::new(
583 sql_interface.get_connection().unwrap(),
584 max_rows_balling_set_values,
585 max_rows_balling_dosing_log,
586 )
587 .unwrap();
588
589 // *** check the timestamp of last Balling dosing ******************************************
590 match SqlInterface::truncate_table(
591 &mut sql_interface,
592 sql_query_strings::SQL_TABLE_BALLING_DOSING_LOG.to_string(),
593 ) {
594 Ok(_) => {}
595 Err(e) => panic!("Could not prepare test case: {e:?}"),
596 }
597 assert!(matches!(
598 sql_interface_balling.get_last_balling_dosing_timestamp(pump_id),
599 Ok(None)
600 ));
601 println!("* checking timestamp of last Balling dosing log succeeded.");
602 // *****************************************************************************************
603
604 // *** check timestamp of last Balling dosing **********************************************
605 let balling_timestamp =
606 NaiveDateTime::parse_from_str("2025-04-12 11:00:00", "%Y-%m-%d %H:%M:%S").unwrap();
607 match sql_interface_balling.insert_balling_event(balling_timestamp, pump_id, 0.5) {
608 Ok(_) => {}
609 Err(e) => panic!("Could not insert Balling dosing event into database: {e:?}"),
610 };
611 assert!(matches!(
612 sql_interface_balling.get_last_balling_dosing_timestamp(pump_id),
613 Ok(_)
614 ));
615 println!("* checking timestamp of last Balling dosing succeeded.");
616 // *****************************************************************************************
617
618 // *** check the content of last Balling dosing ********************************************
619 // Read back Balling dosing event entry from the database
620 let single_balling_event_entry_array = match sql_interface_balling.conn.query_map(
621 sql_query_strings::SQL_QUERY_READ_BALLING_EVENT,
622 |(timestamp, pump_id, dosing_volume)| SqlBallingEvent {
623 timestamp,
624 pump_id,
625 dosing_volume,
626 },
627 ) {
628 Ok(c) => c,
629 Err(e) => {
630 panic!("Could not read Balling dosing event from data base: {e:?}");
631 }
632 };
633 // check if SQL response has the right structure
634 assert_eq!(single_balling_event_entry_array.len(), 1);
635 // check if SQL response contains the correct data
636 let balling_event = BallingEvent {
637 timestamp: NaiveDateTime::parse_from_str(
638 single_balling_event_entry_array[0].timestamp.as_str(),
639 "%Y-%m-%d %H:%M:%S",
640 )
641 .expect("conversion of timestamp to NaiveDate failed."),
642 pump_id: single_balling_event_entry_array[0].pump_id,
643 dosing_volume: single_balling_event_entry_array[0].dosing_volume,
644 };
645 assert_eq!(balling_event.timestamp, balling_timestamp);
646 assert_eq!(balling_event.pump_id, 1);
647 assert_eq!(balling_event.dosing_volume, 0.5);
648 // *****************************************************************************************
649
650 // *** check duration since last Balling dosing ********************************************
651 let duration_since_last_balling_dosing =
652 match sql_interface_balling.get_duration_since_last_balling_dosing(1) {
653 Ok(c) => c,
654 Err(e) => panic!("Could not calculate duration since last Balling dosing: {e:?}"),
655 }
656 .unwrap();
657 let reference_duration_since_last_balling_dosing =
658 SqlInterface::get_duration_from_naive_timestamp_to_local_now(balling_timestamp)
659 .unwrap();
660 let duration_delta_seconds = duration_since_last_balling_dosing
661 .as_secs()
662 .abs_diff(reference_duration_since_last_balling_dosing.num_seconds() as u64);
663 assert_le!(duration_delta_seconds, 1);
664 println!("* checking duration since last Balling dosing succeeded.");
665 // *****************************************************************************************
666 }
667
668 #[test]
669 // Test case verifies the behavior of all functions accessing the Balling set value table.
670 // They are all combined in one function to limit the number of additional databases.
671 // Test case uses test database #31.
672 pub fn test_sql_interface_balling_setvals() {
673 let config: ConfigData = read_config_file_with_test_database(
674 "/config/aquarium_control_test_generic.toml".to_string(),
675 31,
676 );
677 println!("Testing with database {}", config.sql_interface.db_name);
678 let max_rows_balling_set_values = config.sql_interface.max_rows_balling_set_values;
679 let max_rows_balling_dosing_log = config.sql_interface.max_rows_balling_dosing_log;
680 let mut sql_interface: SqlInterface = SqlInterface::new(config.sql_interface)
681 .expect("Initialization of SQL interface for test failed.");
682 let mut sql_interface_balling = SqlInterfaceBalling::new(
683 sql_interface.get_connection().unwrap(),
684 max_rows_balling_set_values,
685 max_rows_balling_dosing_log,
686 )
687 .unwrap();
688
689 // *** read balling set vals from empty table **********************************************
690 match SqlInterface::truncate_table(
691 &mut sql_interface,
692 sql_query_strings::SQL_TABLE_BALLING_SETVALS.to_string(),
693 ) {
694 Ok(_) => {}
695 Err(e) => panic!("Could not prepare test case: {e:?}"),
696 }
697 match sql_interface_balling.get_single_balling_setval_from_database(1) {
698 Ok(_) => {
699 panic!("Reading from empty set value table returned value.");
700 }
701 Err(e) => {
702 assert!(matches!(
703 e,
704 SqlInterfaceError::SingleBallingSetValEmptyResponse(_, _, _)
705 ));
706 }
707 }
708 println!("* checking reading from empty Balling dosing set value table succeeded.");
709 // *****************************************************************************************
710
711 // *** read balling set vals from pre-filled table *****************************************
712 // prepare database: insert Balling set vals
713 let sql_query_string = sql_query_strings::SQL_QUERY_WRITE_BALLING_SETVALS.to_string();
714 match sql_interface_balling.conn.exec_drop::<_, _>(
715 sql_query_string.clone(),
716 params! {
717 "pump_id" => 1,
718 "dosing_speed" => 2.0,
719 "dosing_volume" => 0.5,
720 "label" => "test_pump".to_string(),
721 },
722 ) {
723 Ok(_) => {}
724 Err(e) => {
725 panic!("Error when inserting Balling set values into database: {e:?}");
726 }
727 };
728 match sql_interface_balling.get_single_balling_setval_from_database(1) {
729 Ok(c) => {
730 assert_eq!(c.pump_id, 1);
731 assert_eq!(c.dosing_speed, 2.0);
732 assert_eq!(c.dosing_volume, 0.5);
733 assert_eq!(c.label, "test_pump");
734 }
735 Err(e) => {
736 panic!("Error when reading from pre-filled Balling set value table: {e:?}");
737 }
738 }
739 println!("* checking reading existing Balling dosing set values succeeded.");
740
741 // *** check if instantiation of SqlInterfaceBalling checks maximum number of rows ******
742 // add one more Balling set value row
743 match sql_interface_balling.conn.exec_drop::<_, _>(
744 sql_query_string,
745 params! {
746 "pump_id" => 2,
747 "dosing_speed" => 3.0,
748 "dosing_volume" => 0.5,
749 "label" => "test_pump2".to_string(),
750 },
751 ) {
752 Ok(_) => {}
753 Err(e) => {
754 panic!("Error when inserting Balling set values into database: {e:?}");
755 }
756 };
757 let test_result = SqlInterfaceBalling::new(sql_interface.get_connection().unwrap(), 1, 0);
758 assert!(matches!(
759 test_result,
760 Err(SqlInterfaceError::DatabaseBallingSetValTableContainsTooManyRows(_, _, _))
761 ));
762
763 // **************************************************************************************
764 }
765}