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;
15use std::fmt::Write as _;
16
17use super::helpers::quote_ident;
18use super::types::{BuiltQuery, DatabaseBackend, RawSql};
19use crate::error::QueryError;
20
21/// Build a single atomic UPDATE that re-stamps every row whose stored
22/// integer value is being remapped. Empty `mapping` yields `Ok(vec![])`
23/// so callers can blindly forward zero-change actions without special
24/// casing them.
25pub fn build_remap_enum_values(
26    backend: DatabaseBackend,
27    table: &str,
28    column: &str,
29    mapping: &BTreeMap<i64, i64>,
30) -> Result<Vec<BuiltQuery>, QueryError> {
31    if mapping.is_empty() {
32        return Ok(vec![]);
33    }
34    let qt = quote_ident(table, backend);
35    let qc = quote_ident(column, backend);
36    // Write the CASE arms and the IN list into two reusable buffers in a
37    // single pass over `mapping` — no intermediate `Vec<String>` per pair.
38    let mut arms = String::new();
39    let mut in_list = String::new();
40    for (old, new) in mapping {
41        if !arms.is_empty() {
42            arms.push(' ');
43        }
44        let _ = write!(arms, "WHEN {qc} = {old} THEN {new}");
45        if !in_list.is_empty() {
46            in_list.push_str(", ");
47        }
48        let _ = write!(in_list, "{old}");
49    }
50    let sql = format!("UPDATE {qt} SET {qc} = CASE {arms} END WHERE {qc} IN ({in_list})");
51    Ok(vec![BuiltQuery::Raw(RawSql::uniform(sql))])
52}
53
54#[cfg(test)]
55mod tests {
56    use super::*;
57    use crate::test_support::backend_tag;
58    use insta::{assert_snapshot, with_settings};
59
60    fn run(backend: DatabaseBackend, mapping: &[(i64, i64)]) -> String {
61        let map: BTreeMap<i64, i64> = mapping.iter().copied().collect();
62        build_remap_enum_values(backend, "tickets", "priority", &map)
63            .expect("supported")
64            .iter()
65            .map(|q| q.build(backend))
66            .collect::<Vec<_>>()
67            .join(";\n")
68    }
69
70    fn snap(name: &str, sql: &str) {
71        // This module lives at `sql/remap_enum_values.rs`, one level above
72        // the snapshot directory (unlike `modify_column_type/*` which is
73        // two levels deep). Use `./snapshots` instead of `../snapshots`
74        // so insta resolves the shared `sql/snapshots/` location.
75        with_settings!(
76            { snapshot_path => "./snapshots", snapshot_suffix => format!("remap_{}", name) },
77            { assert_snapshot!(sql); }
78        );
79    }
80
81    /// Each test case fans out across `PG` / `MySQL` / `SQLite` so snapshots
82    /// always come in 3-of-a-kind triples (same convention as F20).
83    macro_rules! remap_all_backends {
84        ($name:ident, $mapping:expr) => {
85            #[test]
86            fn $name() {
87                for backend in [
88                    DatabaseBackend::Postgres,
89                    DatabaseBackend::MySql,
90                    DatabaseBackend::Sqlite,
91                ] {
92                    let tag = backend_tag(backend);
93                    let sql = run(backend, $mapping);
94                    snap(&format!("{}_{}", stringify!($name), tag), &sql);
95                }
96            }
97        };
98    }
99
100    // Single pair — the simplest case (medium: 5 -> 100).
101    remap_all_backends!(single_pair, &[(5_i64, 100_i64)]);
102
103    // Multi pair — medium + high shifted to new values.
104    remap_all_backends!(multi_pair, &[(5_i64, 100_i64), (10_i64, 200_i64)]);
105
106    // Atomic value swap — high/medium exchange. CASE WHEN reads pre-swap
107    // values for every row, so this is safe in a single statement.
108    remap_all_backends!(value_swap, &[(5_i64, 10_i64), (10_i64, 5_i64)]);
109
110    // Negative-value remap (e.g. status codes from -1 to 0).
111    remap_all_backends!(negative_values, &[(-1_i64, 0_i64), (0_i64, 1_i64)]);
112
113    #[test]
114    fn empty_mapping_emits_no_query() {
115        let queries = build_remap_enum_values(
116            DatabaseBackend::Postgres,
117            "tickets",
118            "priority",
119            &BTreeMap::new(),
120        )
121        .expect("empty mapping is a valid no-op");
122        assert!(queries.is_empty());
123    }
124}