aquarium_control/database/
database_interface_feed_trait.rs

1/* Copyright 2025 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//! Defines the database interaction contract for the `feed` module.
11//!
12//! This module is central to decoupling the application's feeding logic from the
13//! underlying database implementation. It achieves this by defining the
14//! `DatabaseInterfaceFeedTrait`, which specifies all the database operations
15//! that the `feed` module requires.
16//!
17//! ## Key Components
18//!
19//! - **`DatabaseInterfaceFeedTrait`**: An abstraction layer that defines a set of methods
20//!   for querying and modifying feed-related data, such as feed schedules and
21//!   historical feed events. By depending on this trait instead of a concrete
22//!   database struct, the `feed` module can be tested in isolation using mock
23//!   implementations.
24//!
25//! - **`impl DatabaseInterfaceFeedTrait for SqlInterfaceFeed`**: The production
26//!   implementation of the trait. This block contains the actual SQL queries and
27//!   logic required to interact with an SQL database, translating the high-level
28//!   trait methods into concrete database operations.
29//!
30//! ## Design and Purpose
31//!
32//! The primary goal of this module is to enable **testability and modularity**.
33//! The `feed` thread's logic is complex, and testing it against a live database
34//! is slow, brittle, and requires significant setup. By using a trait object,
35//! we can inject a mock database during testing.
36
37#[cfg(test)]
38use log::error;
39
40use crate::database::pingable::Pingable;
41use crate::database::sql_interface::SqlInterface;
42use crate::database::sql_interface_error::SqlInterfaceError;
43use crate::database::sql_interface_feed::SqlInterfaceFeed;
44use crate::database::sql_query_strings;
45use crate::food::feed_pattern::{FeedPhase, Feedpattern};
46use crate::food::feed_schedule_entry::FeedScheduleEntry;
47use chrono::NaiveDateTime;
48use mysql::params;
49use mysql::prelude::Queryable;
50
51pub const NUM_FEEDPHASES: i32 = 10;
52
53/// Trait for interfacing between feed and SQL database
54/// This trait is prerequisite to use mock implementation for testing
55pub trait DatabaseInterfaceFeedTrait: Pingable {
56    fn get_current_timestamp(&mut self) -> mysql::Result<NaiveDateTime, SqlInterfaceError>;
57
58    fn get_past_feedschedule_entries_from_database(
59        &mut self,
60    ) -> mysql::Result<Option<Vec<FeedScheduleEntry>>, SqlInterfaceError>;
61
62    fn insert_feed_event(
63        &mut self,
64        timestamp: NaiveDateTime,
65        feeder_run_time: f64,
66        profile_name: String,
67        profile_id: i32,
68    ) -> mysql::Result<(), SqlInterfaceError>;
69
70    fn get_single_feedpattern_from_database(
71        &mut self,
72        profile_id: i32,
73    ) -> mysql::Result<Feedpattern, SqlInterfaceError>;
74
75    fn update_feedschedule_entries_in_database(
76        &mut self,
77        feedschedule_entries: &mut Vec<FeedScheduleEntry>,
78    ) -> mysql::Result<(), SqlInterfaceError>;
79
80    #[cfg(test)]
81    fn insert_feed_schedule_entry(
82        &mut self,
83        timestamp: NaiveDateTime,
84        profile_id: i32,
85        profile_name: String,
86        is_weekly: bool,
87        is_daily: bool,
88    ) -> mysql::Result<(), SqlInterfaceError>;
89}
90
91impl DatabaseInterfaceFeedTrait for SqlInterfaceFeed {
92    /// Retrieves the current timestamp from the database using the internal connection.
93    ///
94    /// This function acts as a proxy, calling the general `SqlInterface::get_current_timestamp`
95    /// method with the `SqlInterfaceFeed`'s established database connection.
96    ///
97    /// # Returns
98    /// A `Result` which is:
99    /// - `Ok(NaiveDateTime)`: The current timestamp as reported by the database.
100    /// - `Err(SqlInterfaceError)`: If any error occurs during the retrieval or parsing
101    ///   of the timestamp from the database.
102    fn get_current_timestamp(&mut self) -> mysql::Result<NaiveDateTime, SqlInterfaceError> {
103        SqlInterface::get_current_timestamp(&mut self.conn)
104    }
105
106    /// Retrieves feed schedule entries from the database that are scheduled in the past.
107    ///
108    /// This function queries the database for all feed schedule entries whose timestamps
109    /// are before the current time. The raw database entries are then post-processed
110    /// into `FeedScheduleEntry` structs.
111    ///
112    /// # Returns
113    /// A `Result` which is:
114    /// - `Ok(Some(Vec<FeedScheduleEntry>))` if one or more past feed schedule entries are found.
115    /// - `Ok(None)` if no past feed schedule entries are found in the database.
116    /// - `Err(SqlInterfaceError)` if any error occurs during the database query
117    ///   or during the conversion of raw database data into `FeedScheduleEntry` structs.
118    fn get_past_feedschedule_entries_from_database(
119        &mut self,
120    ) -> mysql::Result<Option<Vec<FeedScheduleEntry>>, SqlInterfaceError> {
121        self.get_feedschedule_entries_from_database(
122            sql_query_strings::SQL_QUERY_READ_PAST_FEED_SCHEDULE_ENTRY,
123        )
124    }
125
126    /// Inserts a new feed event record into the database.
127    ///
128    /// This function logs details about a completed feed operation, including the
129    /// timestamp, how long the feeder ran, and which feed profile was used.
130    ///
131    /// # Arguments
132    /// * `timestamp` - The `NaiveDateTime` when the feed event occurred.
133    /// * `feeder_run_time` - The duration (in seconds) the feeder motor was active.
134    /// * `profile_name` - The name of the feed profile that was executed for this event.
135    /// * `profile_id` - The unique identifier of the feed profile that was executed.
136    ///
137    /// # Returns
138    /// An empty `Result` (`Ok(())`) if the feed event record was successfully inserted.
139    ///
140    /// # Errors
141    /// Returns `SqlInterfaceError::InsertFeedEventFailure` if the `INSERT` query fails.
142    /// This can happen due to a lost connection, constraint violation (e.g., foreign key),
143    /// or incorrect data types. The original `mysql::Error` is included as the source.
144    fn insert_feed_event(
145        &mut self,
146        timestamp: NaiveDateTime,
147        feeder_run_time: f64,
148        profile_name: String,
149        profile_id: i32,
150    ) -> mysql::Result<(), SqlInterfaceError> {
151        self.conn
152            .exec_drop::<_, _>(
153                sql_query_strings::SQL_QUERY_WRITE_FEED_EVENT.to_owned(),
154                params! {
155                "timestamp" => timestamp,
156                "feeder_run_time" => feeder_run_time,
157                "profile_name" => profile_name,
158                "profile_id" => profile_id,
159                },
160            )
161            .map_err(|e| SqlInterfaceError::InsertFeedEventFailure {
162                location: module_path!().to_string(),
163                source: e,
164            })?;
165
166        Ok(())
167    }
168
169    /// Retrieves a complete `Feedpattern` from the SQL database, including its header and all associated phases.
170    ///
171    /// This function first fetches the header information for the specified `profile_id`.
172    /// It then retrieves data for each of the 10 expected feed phases, converting raw
173    /// database flags into booleans.
174    ///
175    /// # Arguments
176    /// * `profile_id` - The unique identifier of the feed profile to retrieve.
177    ///
178    /// # Returns
179    /// A `Result` containing a fully constructed `Feedpattern` object on success.
180    ///
181    /// # Errors
182    /// This function will return an error if:
183    /// - The query for the feed pattern header fails, the header is not found, or multiple headers are found.
184    /// - The query for any of the 10 feed phases fails, a phase is not found, or multiple phases are found.
185    /// - A retrieved feed phase contains an invalid (negative) duration value.
186    fn get_single_feedpattern_from_database(
187        &mut self,
188        profile_id: i32,
189    ) -> mysql::Result<Feedpattern, SqlInterfaceError> {
190        let sql_feedpattern_header = self.get_feedpattern_header_from_database(profile_id)?;
191
192        let mut feed_phases = Vec::new();
193        for nr in 1..=NUM_FEEDPHASES {
194            let sql_feed_phase = self.get_feed_phase_from_database(profile_id, nr)?;
195
196            let feed_phase = FeedPhase {
197                pause_duration: sql_feed_phase.pause_duration,
198                pause_skimmer: sql_feed_phase.pause_skimmer > 0,
199                pause_main_pump_1: sql_feed_phase.pause_main_pump_1 > 0,
200                pause_main_pump_2: sql_feed_phase.pause_main_pump_2 > 0,
201                pause_aux_pump_1: sql_feed_phase.pause_aux_pump_1 > 0,
202                pause_aux_pump_2: sql_feed_phase.pause_aux_pump_2 > 0,
203                feed_duration: sql_feed_phase.feed_duration,
204                feed_skimmer: sql_feed_phase.feed_skimmer > 0,
205                feed_main_pump_1: sql_feed_phase.feed_main_pump_1 > 0,
206                feed_main_pump_2: sql_feed_phase.feed_main_pump_2 > 0,
207                feed_aux_pump_1: sql_feed_phase.feed_aux_pump_1 > 0,
208                feed_aux_pump_2: sql_feed_phase.feed_aux_pump_2 > 0,
209            };
210            feed_phases.push(feed_phase);
211        }
212        Ok(Feedpattern {
213            profile_id: sql_feedpattern_header.profile_id,
214            profile_name: sql_feedpattern_header.profile_name,
215            feedphases: feed_phases,
216        })
217    }
218
219    /// Updates or deletes multiple feed schedule entries in the database.
220    ///
221    /// This function iterates through a list of `FeedScheduleEntry` structs. For each entry:
222    /// - If `repeat_daily` is `true`, it updates the entry's timestamp in the database to tomorrow's date at the same time.
223    /// - If `repeat_daily` is `false`, it deletes the entry from the database.
224    ///
225    /// The entire operation is transactional in nature: it will stop and return an error on the first failure.
226    ///
227    /// # Arguments
228    /// * `feedschedule_entries` - A mutable reference to a vector of `FeedScheduleEntry` structs to be processed.
229    ///
230    /// # Returns
231    /// An empty `Result` (`Ok(())`) if all update and delete operations were successful.
232    ///
233    /// # Errors
234    /// This function will return an error if an operation fails. This can be:
235    /// - `FeedScheduleUpdateFailure`: If fetching tomorrow's date fails.
236    /// - `FeedScheduleDropFailure`: If a database `UPDATE` or `DELETE` command fails. This can be due to a lost connection or other database-side issues.
237    fn update_feedschedule_entries_in_database(
238        &mut self,
239        feedschedule_entries: &mut Vec<FeedScheduleEntry>,
240    ) -> mysql::Result<(), SqlInterfaceError> {
241        let mut result = Ok(()); // an initial value - will be overwritten by any error occurred
242        for feedschedule_entry in feedschedule_entries {
243            if feedschedule_entry.repeat_daily {
244                // the feed schedule shall be repeated daily, so update the entry with
245                // - tomorrow's date
246                // - the same time of day as the entry
247                let sql_query_string =
248                    sql_query_strings::SQL_QUERY_UPDATE_FEED_SCHEDULE_ENTRY.to_owned();
249
250                let timestamp_tomorrow_date_only =
251                    match SqlInterface::get_tomorrow_date(&mut self.conn) {
252                        Ok(c) => c,
253                        Err(e) => {
254                            result = Err(SqlInterfaceError::FeedScheduleUpdateFailure {
255                                location: module_path!().to_string(),
256                                source: Box::new(e),
257                            });
258                            continue;
259                        }
260                    };
261
262                let timestamp_tomorrow = NaiveDateTime::new(
263                    timestamp_tomorrow_date_only,
264                    feedschedule_entry.timestamp.time(),
265                );
266                match self.conn.exec_drop::<_, _>(
267                    sql_query_string,
268                    params! {
269                        "timestamp_new" => timestamp_tomorrow,
270                        "timestamp_old" => feedschedule_entry.timestamp,
271                    },
272                ) {
273                    Ok(_) => { /* do nothing */ }
274                    Err(e) => {
275                        result = Err(SqlInterfaceError::FeedScheduleDropFailure {
276                            location: module_path!().to_string(),
277                            source: e,
278                        });
279                    }
280                };
281            } else {
282                // feed schedule shall not be repeated daily, delete the entry from the database
283                let sql_query_string =
284                    sql_query_strings::SQL_QUERY_DELETE_FEED_SCHEDULE_ENTRY.to_owned();
285                match self.conn.exec_drop::<_, _>(
286                    sql_query_string,
287                    params! {
288                        "timestamp" => feedschedule_entry.timestamp,
289                    },
290                ) {
291                    Ok(_) => { /* do nothing */ }
292                    Err(e) => {
293                        result = Err(SqlInterfaceError::FeedScheduleDropFailure {
294                            location: module_path!().to_string(),
295                            source: e,
296                        });
297                    }
298                };
299            }
300        }
301        result
302    }
303
304    #[cfg(test)]
305    // Inserts a single feed schedule entry into the database.
306    //
307    // This helper function is exclusively used in test environments to
308    // programmatically set up specific feed schedule configurations.
309    //
310    // # Arguments
311    // * `timestamp` - The `NaiveDateTime` when the feed event is scheduled to occur.
312    // * `profile_id` - The unique identifier of the feed profile associated with this schedule.
313    // * `profile_name` - The name of the feed profile associated with this schedule.
314    // * `is_weekly` - A boolean flag indicating if this schedule entry should repeat weekly.
315    // * `is_daily` - A boolean flag indicating if this schedule entry should repeat daily.
316    //
317    // # Returns
318    // An empty `Result` (`Ok(())`) if operation was successful.
319    //
320    // # Errors
321    // This function will return an `InsertFeedEventFailure` if the database operation fails.
322    #[cfg(test)]
323    fn insert_feed_schedule_entry(
324        &mut self,
325        timestamp: NaiveDateTime,
326        profile_id: i32,
327        profile_name: String,
328        is_weekly: bool,
329        is_daily: bool,
330    ) -> mysql::Result<(), SqlInterfaceError> {
331        let sql_query_string = sql_query_strings::SQL_QUERY_WRITE_FEED_SCHEDULE_ENTRY.to_owned();
332        match self.conn.exec_drop::<_, _>(
333            sql_query_string,
334            params! {
335            "timestamp" => timestamp,
336            "profile_id" => profile_id,
337            "profile_name" => profile_name,
338            "is_weekly" => is_weekly,
339            "is_daily" => is_daily,
340            },
341        ) {
342            Ok(_) => Ok(()),
343            Err(e) => {
344                error!( // logging for testing purposes only
345                    target: module_path!(),
346                    "could not insert feed schedule entry ({e:?})"
347                );
348                Err(SqlInterfaceError::InsertFeedEventFailure {
349                    location: module_path!().to_string(),
350                    source: e,
351                })
352            }
353        }
354    }
355}