Skip to main content

bhtune_db/
convert.rs

1//! Converts `bhtune-core`'s `serde`-tagged enums to/from the plain TEXT values SQLite stores
2//! them as.
3//!
4//! `bhtune-core` deliberately has zero non-`serde` dependencies (see its crate docs), so it
5//! cannot derive `sqlx::Type` itself, and Rust's orphan rules mean `bhtune-db` cannot
6//! implement a foreign trait (`sqlx::Type`) for a foreign type (e.g.
7//! `bhtune_core::ProcessType`) either. Rather than defining a parallel `sqlx`-aware enum for
8//! every `bhtune-core` enum (eight of them, and rising), these two functions reuse each
9//! enum's existing `#[serde(rename_all = "snake_case")]` implementation as the single source
10//! of truth for its wire form — the same string a `ProcessType` would serialize to over the
11//! CLI's `--output json` or the web GUI's HTTP API already matches what gets stored in a
12//! `TEXT` column.
13
14use serde::{Serialize, de::DeserializeOwned};
15
16use crate::error::{DbError, DbResult};
17
18/// Encodes a fieldless, `serde`-tagged enum as the bare string SQLite stores it as.
19///
20/// # Panics
21/// Panics if `T`'s `Serialize` impl doesn't produce a bare JSON string (i.e. `T` isn't a
22/// fieldless enum with `#[serde(rename_all = "snake_case")]` or equivalent). Every
23/// `bhtune-core` enum stored in the database satisfies this; a panic here means a new enum
24/// was wired into a TEXT column without checking that assumption first.
25pub fn enum_to_text<T: Serialize>(value: &T) -> String {
26    enum_to_text_with(value, |value| serde_json::to_value(value))
27}
28
29fn enum_to_text_with<T, E>(
30    value: &T,
31    serialize: impl FnOnce(&T) -> Result<serde_json::Value, E>,
32) -> String
33where
34    T: Serialize,
35    E: std::fmt::Debug,
36{
37    match serialize(value).expect("enum serialization is infallible") {
38        serde_json::Value::String(s) => s,
39        other => panic!(
40            "enum_to_text called on a type that doesn't serialize to a bare string, got: {other}"
41        ),
42    }
43}
44
45/// Decodes a value read from `column` back into a fieldless, `serde`-tagged enum.
46///
47/// Only fails if `value` doesn't match any of `T`'s variants — which the migration's `CHECK`
48/// constraint on every enum-shaped column should make unreachable in practice, but the
49/// database file is plain and open (see AGENTS.md), so nothing stops something else from
50/// writing a row that bypasses it.
51pub fn text_to_enum<T: DeserializeOwned>(column: &'static str, value: &str) -> DbResult<T> {
52    serde_json::from_value(enum_text_value(value)).map_err(|_| invalid_enum_value(column, value))
53}
54
55fn enum_text_value(value: &str) -> serde_json::Value {
56    serde_json::Value::String(value.to_string())
57}
58
59fn invalid_enum_value(column: &'static str, value: &str) -> DbError {
60    DbError::InvalidEnumValue {
61        column,
62        value: value.to_string(),
63    }
64}
65
66#[cfg(test)]
67mod tests {
68    use super::*;
69    use crate::models::{
70        MvActuationKind, MvActuationStatus, RestoreStatus, RollbackState, SampleQuality,
71        TemplateOrigin, TuneDriver, TuneOutcome, WriteKind,
72    };
73    use bhtune_core::{
74        ControllerDirection, ControllerType, DerivativeType, IntegralType, ProcessType,
75        ProportionalType, ResponseLevel, TimeUnit,
76    };
77
78    /// Round-trips every variant of every `bhtune-core` enum stored in the database, and
79    /// pins the exact literal each one produces. These literals must stay in sync with the
80    /// `CHECK (... IN (...))` lists in `migrations/0001_initial_schema.sql` — this test is
81    /// the one place both are written down side by side, so a rename on either side is easy
82    /// to spot and fix in the other.
83    #[test]
84    fn process_type_round_trips_and_matches_check_constraint() {
85        let cases = [
86            (ProcessType::Flow, "flow"),
87            (ProcessType::PressureLine, "pressure_line"),
88            (ProcessType::PressureVessel, "pressure_vessel"),
89            (ProcessType::Level, "level"),
90            (ProcessType::TemperatureMixing, "temperature_mixing"),
91            (
92                ProcessType::TemperatureHeatExchange,
93                "temperature_heat_exchange",
94            ),
95        ];
96        for (variant, text) in cases {
97            assert_eq!(enum_to_text(&variant), text);
98            assert_eq!(
99                text_to_enum::<ProcessType>("process_type", text).unwrap(),
100                variant
101            );
102        }
103    }
104
105    #[test]
106    #[should_panic(expected = "doesn't serialize to a bare string")]
107    fn enum_to_text_rejects_non_string_serialization() {
108        enum_to_text(&42u8);
109    }
110
111    #[test]
112    #[should_panic(expected = "enum serialization is infallible")]
113    fn enum_to_text_rejects_a_serialization_failure() {
114        enum_to_text_with(&ProcessType::Flow, |_| {
115            Err::<serde_json::Value, _>("injected serialization failure")
116        });
117    }
118
119    #[test]
120    fn controller_type_round_trips_and_matches_check_constraint() {
121        let cases = [
122            (ControllerType::P, "p"),
123            (ControllerType::Pi, "pi"),
124            (ControllerType::Pid, "pid"),
125        ];
126        for (variant, text) in cases {
127            assert_eq!(enum_to_text(&variant), text);
128            assert_eq!(
129                text_to_enum::<ControllerType>("controller_type", text).unwrap(),
130                variant
131            );
132        }
133    }
134
135    #[test]
136    fn controller_direction_round_trips_and_matches_check_constraint() {
137        let cases = [
138            (ControllerDirection::Direct, "direct"),
139            (ControllerDirection::Reverse, "reverse"),
140        ];
141        for (variant, text) in cases {
142            assert_eq!(enum_to_text(&variant), text);
143            assert_eq!(
144                text_to_enum::<ControllerDirection>("controller_direction", text).unwrap(),
145                variant
146            );
147        }
148    }
149
150    #[test]
151    fn response_level_round_trips_and_matches_check_constraint() {
152        let cases = [
153            (ResponseLevel::Aggressive, "aggressive"),
154            (ResponseLevel::Moderate, "moderate"),
155            (ResponseLevel::Sluggish, "sluggish"),
156        ];
157        for (variant, text) in cases {
158            assert_eq!(enum_to_text(&variant), text);
159            assert_eq!(
160                text_to_enum::<ResponseLevel>("response_level", text).unwrap(),
161                variant
162            );
163        }
164    }
165
166    #[test]
167    fn proportional_type_round_trips_and_matches_check_constraint() {
168        let cases = [
169            (ProportionalType::Gain, "gain"),
170            (ProportionalType::Band, "band"),
171        ];
172        for (variant, text) in cases {
173            assert_eq!(enum_to_text(&variant), text);
174            assert_eq!(
175                text_to_enum::<ProportionalType>("proportional_type", text).unwrap(),
176                variant
177            );
178        }
179    }
180
181    #[test]
182    fn integral_type_round_trips_and_matches_check_constraint() {
183        let cases = [
184            (IntegralType::ResetTime, "reset_time"),
185            (IntegralType::ResetRate, "reset_rate"),
186            (IntegralType::ResetGain, "reset_gain"),
187        ];
188        for (variant, text) in cases {
189            assert_eq!(enum_to_text(&variant), text);
190            assert_eq!(
191                text_to_enum::<IntegralType>("integral_type", text).unwrap(),
192                variant
193            );
194        }
195    }
196
197    #[test]
198    fn derivative_type_round_trips_and_matches_check_constraint() {
199        let cases = [
200            (DerivativeType::DerivativeTime, "derivative_time"),
201            (DerivativeType::DerivativeGain, "derivative_gain"),
202        ];
203        for (variant, text) in cases {
204            assert_eq!(enum_to_text(&variant), text);
205            assert_eq!(
206                text_to_enum::<DerivativeType>("derivative_type", text).unwrap(),
207                variant
208            );
209        }
210    }
211
212    #[test]
213    fn time_unit_round_trips_and_matches_check_constraint() {
214        let cases = [
215            (TimeUnit::Seconds, "seconds"),
216            (TimeUnit::Minutes, "minutes"),
217        ];
218        for (variant, text) in cases {
219            assert_eq!(enum_to_text(&variant), text);
220            assert_eq!(
221                text_to_enum::<TimeUnit>("integral_unit", text).unwrap(),
222                variant
223            );
224        }
225    }
226
227    #[test]
228    fn unrecognized_value_is_a_typed_error_not_a_panic() {
229        let err = text_to_enum::<ProcessType>("process_type", "not_a_real_variant").unwrap_err();
230        assert!(matches!(
231            err,
232            DbError::InvalidEnumValue {
233                column: "process_type",
234                value,
235            } if value == "not_a_real_variant"
236        ));
237    }
238
239    #[test]
240    fn database_enum_types_round_trip_through_the_shared_text_codec() {
241        assert_eq!(
242            text_to_enum::<TuneDriver>("driver", "opcda").unwrap(),
243            TuneDriver::Opcda
244        );
245        assert_eq!(
246            text_to_enum::<TuneOutcome>("outcome", "completed").unwrap(),
247            TuneOutcome::Completed
248        );
249        assert_eq!(
250            text_to_enum::<TemplateOrigin>("origin", "builtin").unwrap(),
251            TemplateOrigin::Builtin
252        );
253        assert_eq!(
254            text_to_enum::<RestoreStatus>("restore_status", "confirmed").unwrap(),
255            RestoreStatus::Confirmed
256        );
257        assert_eq!(
258            text_to_enum::<RollbackState>("rollback_state", "succeeded").unwrap(),
259            RollbackState::Succeeded
260        );
261        assert_eq!(
262            text_to_enum::<SampleQuality>("pv_quality", "good").unwrap(),
263            SampleQuality::Good
264        );
265        assert_eq!(
266            text_to_enum::<MvActuationKind>("kind", "relay").unwrap(),
267            MvActuationKind::Relay
268        );
269        assert_eq!(
270            text_to_enum::<MvActuationStatus>("status", "confirmed").unwrap(),
271            MvActuationStatus::Confirmed
272        );
273        assert_eq!(
274            text_to_enum::<WriteKind>("kind", "write").unwrap(),
275            WriteKind::Write
276        );
277    }
278}