Skip to main content

vespertide_query/sql/
remap_enum_values.rs

1//! SQL generator for `MigrationAction::RemapEnumValues`.
2//!
3//! Integer-backed enums in vespertide are stored as plain `INTEGER` on
4//! every supported backend. When the user changes a variant's numeric
5//! `value` (e.g. `medium: 5 → 10`), the DB does not detect the drift and
6//! the next ORM-side deserialisation silently re-interprets every
7//! existing row. This module emits a single atomic `UPDATE ... SET col =
8//! CASE WHEN ... END WHERE col IN (...)` that re-stamps affected rows
9//! before the new ORM mapping takes effect.
10//!
11//! The SQL is portable across `PostgreSQL`, `MySQL` and `SQLite` —
12//! `CASE WHEN` and `IN (...)` are universal.
13
14use std::collections::BTreeMap;
15
16use super::helpers::quote_ident;
17use super::types::{BuiltQuery, DatabaseBackend, RawSql};
18use crate::error::QueryError;
19
20/// Build a single atomic UPDATE that re-stamps every row whose stored
21/// integer value is being remapped. Empty `mapping` yields `Ok(vec![])`
22/// so callers can blindly forward zero-change actions without special
23/// casing them.
24pub fn build_remap_enum_values(
25    backend: DatabaseBackend,
26    table: &str,
27    column: &str,
28    mapping: &BTreeMap<i64, i64>,
29) -> Result<Vec<BuiltQuery>, QueryError> {
30    if mapping.is_empty() {
31        return Ok(vec![]);
32    }
33    let qt = quote_ident(table, backend);
34    let qc = quote_ident(column, backend);
35    let case_arms: Vec<String> = mapping
36        .iter()
37        .map(|(old, new)| format!("WHEN {qc} = {old} THEN {new}"))
38        .collect();
39    let in_list: Vec<String> = mapping.keys().map(i64::to_string).collect();
40    let sql = format!(
41        "UPDATE {qt} SET {qc} = CASE {arms} END WHERE {qc} IN ({in_list})",
42        arms = case_arms.join(" "),
43        in_list = in_list.join(", "),
44    );
45    Ok(vec![BuiltQuery::Raw(RawSql::uniform(sql))])
46}
47
48#[cfg(test)]
49mod tests {
50    use super::*;
51    use insta::{assert_snapshot, with_settings};
52
53    fn run(backend: DatabaseBackend, mapping: &[(i64, i64)]) -> String {
54        let map: BTreeMap<i64, i64> = mapping.iter().copied().collect();
55        build_remap_enum_values(backend, "tickets", "priority", &map)
56            .expect("supported")
57            .iter()
58            .map(|q| q.build(backend))
59            .collect::<Vec<_>>()
60            .join(";\n")
61    }
62
63    fn snap(name: &str, sql: &str) {
64        // This module lives at `sql/remap_enum_values.rs`, one level above
65        // the snapshot directory (unlike `modify_column_type/*` which is
66        // two levels deep). Use `./snapshots` instead of `../snapshots`
67        // so insta resolves the shared `sql/snapshots/` location.
68        with_settings!(
69            { snapshot_path => "./snapshots", snapshot_suffix => format!("remap_{}", name) },
70            { assert_snapshot!(sql); }
71        );
72    }
73
74    /// Each test case fans out across `PG` / `MySQL` / `SQLite` so snapshots
75    /// always come in 3-of-a-kind triples (same convention as F20).
76    macro_rules! remap_all_backends {
77        ($name:ident, $mapping:expr) => {
78            #[test]
79            fn $name() {
80                for (backend, tag) in [
81                    (DatabaseBackend::Postgres, "postgres"),
82                    (DatabaseBackend::MySql, "mysql"),
83                    (DatabaseBackend::Sqlite, "sqlite"),
84                ] {
85                    let sql = run(backend, $mapping);
86                    snap(&format!("{}_{}", stringify!($name), tag), &sql);
87                }
88            }
89        };
90    }
91
92    // Single pair — the simplest case (medium: 5 -> 100).
93    remap_all_backends!(single_pair, &[(5_i64, 100_i64)]);
94
95    // Multi pair — medium + high shifted to new values.
96    remap_all_backends!(multi_pair, &[(5_i64, 100_i64), (10_i64, 200_i64)]);
97
98    // Atomic value swap — high/medium exchange. CASE WHEN reads pre-swap
99    // values for every row, so this is safe in a single statement.
100    remap_all_backends!(value_swap, &[(5_i64, 10_i64), (10_i64, 5_i64)]);
101
102    // Negative-value remap (e.g. status codes from -1 to 0).
103    remap_all_backends!(negative_values, &[(-1_i64, 0_i64), (0_i64, 1_i64)]);
104
105    #[test]
106    fn empty_mapping_emits_no_query() {
107        let queries = build_remap_enum_values(
108            DatabaseBackend::Postgres,
109            "tickets",
110            "priority",
111            &BTreeMap::new(),
112        )
113        .expect("empty mapping is a valid no-op");
114        assert!(queries.is_empty());
115    }
116}