Skip to main content

bhtune_db/models/
dcs_templates.rs

1use bhtune_core::{DcsTemplate, PidRounding};
2use chrono::{DateTime, Utc};
3use sqlx::{Row, SqlitePool, sqlite::SqliteRow};
4
5use crate::{
6    convert::{enum_to_text, json_text, text_to_enum},
7    error::{DbError, DbResult},
8};
9
10#[cfg(doc)]
11use super::tune_runs::TuneRunRow;
12
13// dcs_templates {{{1
14
15/// Where a `dcs_templates` row came from, and -- since [`TuneRunRow`] snapshots a copy of one
16/// at [`TuneRunRow::start`] time -- where a run's snapshotted template came from too. Kept as
17/// one definition reused by both tables (`dcs_templates.origin`, `tune_runs.template_origin`)
18/// rather than two, so they can never drift on what the possible origins even are, and so a
19/// run's history never needs to look the original row back up to know its provenance --
20/// which matters precisely because that row might no longer exist, or might have been
21/// re-imported under a different origin since.
22///
23/// `builtin` and `catalog` rows are re-upserted from their respective data files on every
24/// startup ([`crate::seed::seed_templates`]); `user` rows (hand-imported via `bhtune template
25/// import`, or created through a future GUI editor) are never auto-touched -- auto-reseeding
26/// a hand-edited row would silently discard someone's own customization, while *not*
27/// reseeding a shipped preset would mean a suffix/unit fix in a later release never reaches
28/// existing installs.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
30#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
31#[serde(rename_all = "snake_case")]
32pub enum TemplateOrigin {
33    /// One of the templates `bhtune-core` ships embedded in its own binary (see
34    /// `template-catalog`).
35    Builtin,
36    /// Loaded from a user-supplied catalog file (`$XDG_CONFIG_HOME/bhtune/templates.toml`
37    /// and platform equivalents, or an explicit `--templates`/`BHTUNE_TEMPLATES` override --
38    /// see `template-user-catalog`), auto-seeded on every startup the same way `Builtin`
39    /// rows are.
40    Catalog,
41    /// Hand-imported (`bhtune template import`) or otherwise created by whoever is running
42    /// bhtune.
43    User,
44}
45
46/// One row of `dcs_templates`: a [`DcsTemplate`] plus the database bookkeeping fields that
47/// don't belong on the pure domain type itself.
48#[derive(Debug, Clone, PartialEq)]
49pub struct DcsTemplateRow {
50    pub id: i64,
51    pub origin: TemplateOrigin,
52    pub template: DcsTemplate,
53    pub created_at: DateTime<Utc>,
54    pub updated_at: DateTime<Utc>,
55}
56
57impl DcsTemplateRow {
58    /// Inserts `template`, returning the persisted row (with its assigned `id`). `now` is
59    /// used for both `created_at` and `updated_at`; the caller supplies it rather than this
60    /// function reading the clock, keeping "who reads the clock" consistent with the rest of
61    /// bhtune's architecture (see `bhtune_core`'s crate docs).
62    pub async fn insert(
63        pool: &SqlitePool,
64        template: &DcsTemplate,
65        origin: TemplateOrigin,
66        now: DateTime<Utc>,
67    ) -> DbResult<DcsTemplateRow> {
68        let query = sqlx::query(
69            r"
70            INSERT INTO dcs_templates (
71                name, origin, revert_mode, proportional_type, integral_type,
72                integral_unit, derivative_type, derivative_unit,
73                pid_rounding_kind, pid_rounding_digits,
74                process_variable_suffix, manipulated_variable_suffix, setpoint_variable_suffix,
75                controller_direction_suffix, controller_mode_suffix, mode_attribute_suffix,
76                upper_pv_range_suffix, lower_pv_range_suffix, upper_mv_range_suffix,
77                lower_mv_range_suffix, proportional_constant_suffix, integral_constant_suffix,
78                derivative_constant_suffix, mode_manual_value, mode_auto_value,
79                mode_attribute_program_value, controller_action_direct_value,
80                versions_json, description, source,
81                created_at, updated_at
82            )
83            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
84            RETURNING *
85            ",
86        )
87        .bind(&template.name)
88        .bind(enum_to_text(&origin)?);
89        let row = bind_shared_template_fields(query, template)?
90            .bind(now)
91            .bind(now)
92            .fetch_one(pool)
93            .await
94            .map_err(DbError::Query)?;
95
96        row_to_dcs_template(row)
97    }
98
99    /// Fetches one row by id, or `None` if it doesn't exist.
100    pub async fn get(pool: &SqlitePool, id: i64) -> DbResult<Option<DcsTemplateRow>> {
101        let row = sqlx::query("SELECT * FROM dcs_templates WHERE id = ?")
102            .bind(id)
103            .fetch_optional(pool)
104            .await
105            .map_err(DbError::Query)?;
106        row.map(row_to_dcs_template).transpose()
107    }
108
109    /// Fetches one row by its (unique) `name`, or `None` if it doesn't exist. Used by
110    /// [`crate::seed::seed_templates`] to find any existing row before deciding whether to
111    /// insert or update.
112    pub async fn get_by_name(pool: &SqlitePool, name: &str) -> DbResult<Option<DcsTemplateRow>> {
113        let row = sqlx::query("SELECT * FROM dcs_templates WHERE name = ?")
114            .bind(name)
115            .fetch_optional(pool)
116            .await
117            .map_err(DbError::Query)?;
118        row.map(row_to_dcs_template).transpose()
119    }
120
121    /// Lists every row, ordered by `name`, for the loop-editor's template picker.
122    pub async fn list(pool: &SqlitePool) -> DbResult<Vec<DcsTemplateRow>> {
123        let rows = sqlx::query("SELECT * FROM dcs_templates ORDER BY name")
124            .fetch_all(pool)
125            .await
126            .map_err(DbError::Query)?;
127        rows.into_iter().map(row_to_dcs_template).collect()
128    }
129
130    /// Overwrites every template field of the row at `id` with `template`'s, bumping
131    /// `updated_at` to `now`. Deliberately does not touch `name` (the match key callers
132    /// already looked the row up by) or `origin` (ownership of a row never changes after
133    /// creation) — only [`Self::insert`] sets those.
134    pub async fn update(
135        pool: &SqlitePool,
136        id: i64,
137        template: &DcsTemplate,
138        now: DateTime<Utc>,
139    ) -> DbResult<DcsTemplateRow> {
140        let query = sqlx::query(
141            r"
142            UPDATE dcs_templates SET
143                revert_mode = ?, proportional_type = ?, integral_type = ?,
144                integral_unit = ?, derivative_type = ?, derivative_unit = ?,
145                pid_rounding_kind = ?, pid_rounding_digits = ?,
146                process_variable_suffix = ?, manipulated_variable_suffix = ?,
147                setpoint_variable_suffix = ?, controller_direction_suffix = ?,
148                controller_mode_suffix = ?, mode_attribute_suffix = ?,
149                upper_pv_range_suffix = ?, lower_pv_range_suffix = ?,
150                upper_mv_range_suffix = ?, lower_mv_range_suffix = ?,
151                proportional_constant_suffix = ?, integral_constant_suffix = ?,
152                derivative_constant_suffix = ?, mode_manual_value = ?, mode_auto_value = ?,
153                mode_attribute_program_value = ?, controller_action_direct_value = ?,
154                versions_json = ?, description = ?, source = ?,
155                updated_at = ?
156            WHERE id = ?
157            RETURNING *
158            ",
159        );
160        let row = bind_shared_template_fields(query, template)?
161            .bind(now)
162            .bind(id)
163            .fetch_one(pool)
164            .await
165            .map_err(DbError::Query)?;
166
167        row_to_dcs_template(row)
168    }
169
170    /// Deletes the row at `id`. Returns `Ok(true)` if a row existed and was removed,
171    /// `Ok(false)` if no row with that id existed (not an error -- deciding whether "nothing
172    /// to delete" should itself be an error is the caller's call; the `bhtune` CLI's `template
173    /// delete` already resolves `id` from a name via [`Self::get_by_name`] and produces its
174    /// own "no template named" error before ever calling this).
175    ///
176    /// Fails with [`DbError::TemplateInUse`] if `loops.dcs_template_id`'s `ON DELETE
177    /// RESTRICT` foreign key rejects the delete because a saved loop still references this
178    /// template. Classified as "any database-level error on this specific statement",
179    /// rather than solely `sqlx`'s `DatabaseError::is_foreign_key_violation()` -- confirmed
180    /// empirically that SQLite's C implementation reports an *immediate* `RESTRICT`
181    /// violation like this one under the extended result code `SQLITE_CONSTRAINT_TRIGGER`
182    /// (its FK-action enforcement runs through the same internal machinery as a trigger
183    /// body), not `SQLITE_CONSTRAINT_FOREIGNKEY` -- the latter is what `sqlx-sqlite` maps to
184    /// `is_foreign_key_violation()`, and it's only what a *deferred* FK check reports at
185    /// commit time, which this crate never uses. This is safe to broaden to "any database
186    /// error" specifically because `dcs_templates` has exactly one foreign key pointing at
187    /// it (`loops.dcs_template_id`), no triggers exist anywhere in the schema, and a bare
188    /// `DELETE` can't violate this table's own `CHECK` constraints (they all apply to
189    /// column values, which a delete-by-id never touches) -- so a database-level failure of
190    /// this exact statement structurally has only one possible cause.
191    pub async fn delete(pool: &SqlitePool, id: i64) -> DbResult<bool> {
192        let result = sqlx::query("DELETE FROM dcs_templates WHERE id = ?")
193            .bind(id)
194            .execute(pool)
195            .await
196            .map_err(|e| {
197                if e.as_database_error().is_some() {
198                    DbError::TemplateInUse { id }
199                } else {
200                    DbError::Query(e)
201                }
202            })?;
203        Ok(result.rows_affected() > 0)
204    }
205}
206
207fn bind_shared_template_fields<'q>(
208    query: sqlx::query::Query<'q, sqlx::Sqlite, sqlx::sqlite::SqliteArguments>,
209    template: &DcsTemplate,
210) -> DbResult<sqlx::query::Query<'q, sqlx::Sqlite, sqlx::sqlite::SqliteArguments>> {
211    template
212        .pid_rounding
213        .validate()
214        .map_err(DbError::InvalidPidRounding)?;
215    let versions_json = json_text("template versions", &template.versions)?;
216    Ok(query
217        .bind(template.revert_mode)
218        .bind(enum_to_text(&template.proportional_type)?)
219        .bind(enum_to_text(&template.integral_type)?)
220        .bind(enum_to_text(&template.integral_unit)?)
221        .bind(enum_to_text(&template.derivative_type)?)
222        .bind(enum_to_text(&template.derivative_unit)?)
223        .bind(enum_to_text(&template.pid_rounding.kind)?)
224        .bind(template.pid_rounding.digits)
225        .bind(&template.process_variable_suffix)
226        .bind(&template.manipulated_variable_suffix)
227        .bind(&template.setpoint_variable_suffix)
228        .bind(&template.controller_direction_suffix)
229        .bind(&template.controller_mode_suffix)
230        .bind(&template.mode_attribute_suffix)
231        .bind(&template.upper_pv_range_suffix)
232        .bind(&template.lower_pv_range_suffix)
233        .bind(&template.upper_mv_range_suffix)
234        .bind(&template.lower_mv_range_suffix)
235        .bind(&template.proportional_constant_suffix)
236        .bind(&template.integral_constant_suffix)
237        .bind(&template.derivative_constant_suffix)
238        .bind(&template.mode_manual_value)
239        .bind(&template.mode_auto_value)
240        .bind(&template.mode_attribute_program_value)
241        .bind(&template.controller_action_direct_value)
242        .bind(versions_json)
243        .bind(&template.description)
244        .bind(&template.source))
245}
246
247fn row_to_dcs_template(row: SqliteRow) -> DbResult<DcsTemplateRow> {
248    let get_enum = |column: &'static str| -> DbResult<String> {
249        row.try_get::<String, _>(column).map_err(DbError::Query)
250    };
251
252    let template = DcsTemplate {
253        name: row.try_get("name").map_err(DbError::Query)?,
254        revert_mode: row.try_get("revert_mode").map_err(DbError::Query)?,
255        proportional_type: text_to_enum("proportional_type", &get_enum("proportional_type")?)?,
256        integral_type: text_to_enum("integral_type", &get_enum("integral_type")?)?,
257        integral_unit: text_to_enum("integral_unit", &get_enum("integral_unit")?)?,
258        derivative_type: text_to_enum("derivative_type", &get_enum("derivative_type")?)?,
259        derivative_unit: text_to_enum("derivative_unit", &get_enum("derivative_unit")?)?,
260        pid_rounding: PidRounding {
261            kind: text_to_enum("pid_rounding_kind", &get_enum("pid_rounding_kind")?)?,
262            digits: row.try_get("pid_rounding_digits").map_err(DbError::Query)?,
263        },
264        process_variable_suffix: row
265            .try_get("process_variable_suffix")
266            .map_err(DbError::Query)?,
267        manipulated_variable_suffix: row
268            .try_get("manipulated_variable_suffix")
269            .map_err(DbError::Query)?,
270        setpoint_variable_suffix: row
271            .try_get("setpoint_variable_suffix")
272            .map_err(DbError::Query)?,
273        controller_direction_suffix: row
274            .try_get("controller_direction_suffix")
275            .map_err(DbError::Query)?,
276        controller_mode_suffix: row
277            .try_get("controller_mode_suffix")
278            .map_err(DbError::Query)?,
279        mode_attribute_suffix: row
280            .try_get("mode_attribute_suffix")
281            .map_err(DbError::Query)?,
282        upper_pv_range_suffix: row
283            .try_get("upper_pv_range_suffix")
284            .map_err(DbError::Query)?,
285        lower_pv_range_suffix: row
286            .try_get("lower_pv_range_suffix")
287            .map_err(DbError::Query)?,
288        upper_mv_range_suffix: row
289            .try_get("upper_mv_range_suffix")
290            .map_err(DbError::Query)?,
291        lower_mv_range_suffix: row
292            .try_get("lower_mv_range_suffix")
293            .map_err(DbError::Query)?,
294        proportional_constant_suffix: row
295            .try_get("proportional_constant_suffix")
296            .map_err(DbError::Query)?,
297        integral_constant_suffix: row
298            .try_get("integral_constant_suffix")
299            .map_err(DbError::Query)?,
300        derivative_constant_suffix: row
301            .try_get("derivative_constant_suffix")
302            .map_err(DbError::Query)?,
303        mode_manual_value: row.try_get("mode_manual_value").map_err(DbError::Query)?,
304        mode_auto_value: row.try_get("mode_auto_value").map_err(DbError::Query)?,
305        mode_attribute_program_value: row
306            .try_get("mode_attribute_program_value")
307            .map_err(DbError::Query)?,
308        controller_action_direct_value: row
309            .try_get("controller_action_direct_value")
310            .map_err(DbError::Query)?,
311        versions: {
312            let versions_json: String = row.try_get("versions_json").map_err(DbError::Query)?;
313            serde_json::from_str(&versions_json).map_err(|source| DbError::InvalidJsonShape {
314                column: "versions_json",
315                source,
316            })?
317        },
318        description: row.try_get("description").map_err(DbError::Query)?,
319        source: row.try_get("source").map_err(DbError::Query)?,
320    };
321    template
322        .pid_rounding
323        .validate()
324        .map_err(DbError::InvalidPidRounding)?;
325
326    Ok(DcsTemplateRow {
327        id: row.try_get("id").map_err(DbError::Query)?,
328        origin: text_to_enum("origin", &get_enum("origin")?)?,
329        template,
330        created_at: row.try_get("created_at").map_err(DbError::Query)?,
331        updated_at: row.try_get("updated_at").map_err(DbError::Query)?,
332    })
333}
334// }}}1