Skip to main content

cufflink_types/manifest/
migration.rs

1use serde::{Deserialize, Serialize};
2
3/// One `V{version}__{name}.sql` file of the service's migrations directory.
4#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
5pub struct SqlMigration {
6    pub version: u32,
7    pub name: String,
8    /// The file's exact text: its checksum covers every byte, so it is
9    /// never normalised.
10    pub sql: String,
11    /// Run outside a transaction, as `CREATE INDEX CONCURRENTLY` requires:
12    /// the file's first line is [`SqlMigration::NO_TRANSACTION_MARKER`].
13    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
14    pub no_transaction: bool,
15}
16
17impl SqlMigration {
18    pub const NO_TRANSACTION_MARKER: &'static str = "-- cufflink:no-transaction";
19
20    /// Whether `sql` opts out of the transaction a migration runs in.
21    pub fn declares_no_transaction(sql: &str) -> bool {
22        sql.trim_start_matches('\u{feff}')
23            .lines()
24            .next()
25            .is_some_and(|line| line.trim() == Self::NO_TRANSACTION_MARKER)
26    }
27
28    /// `V{version}__{name}`, the name the migration's checksum covers.
29    pub fn file_stem(&self) -> String {
30        format!("V{}__{}", self.version, self.name)
31    }
32}
33
34/// Where the service's applied migrations are recorded, for a service
35/// continuing a history another runner began.
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37pub struct MigrationHistory {
38    /// `table`, in the service's schema, or `schema.table`.
39    pub table: String,
40    /// Applied versions up to this one are accepted without a local file.
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub baseline: Option<u32>,
43    #[serde(default, skip_serializing_if = "MigrationLock::is_platform")]
44    pub lock: MigrationLock,
45    #[serde(default, skip_serializing_if = "HistoryMode::is_apply")]
46    pub mode: HistoryMode,
47}
48
49impl MigrationHistory {
50    /// The history table of a service that declares none.
51    pub const DEFAULT_TABLE: &'static str = "refinery_schema_history";
52}
53
54impl Default for MigrationHistory {
55    fn default() -> Self {
56        Self {
57            table: Self::DEFAULT_TABLE.to_string(),
58            baseline: None,
59            lock: MigrationLock::default(),
60            mode: HistoryMode::default(),
61        }
62    }
63}
64
65/// The advisory lock a migration run holds.
66#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
67#[serde(rename_all = "snake_case")]
68pub enum MigrationLock {
69    #[default]
70    Platform,
71    /// The key a legacy refinery runner derives from the history table's
72    /// name, so the two never run at once.
73    LegacyTableHash,
74}
75
76impl MigrationLock {
77    pub fn is_platform(&self) -> bool {
78        *self == MigrationLock::Platform
79    }
80}
81
82#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
83#[serde(rename_all = "snake_case")]
84pub enum HistoryMode {
85    #[default]
86    Apply,
87    /// Check the history against the local files and apply nothing: another
88    /// runner still owns the migrations.
89    VerifyOnly,
90}
91
92impl HistoryMode {
93    pub fn is_apply(&self) -> bool {
94        *self == HistoryMode::Apply
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101    use serde_json::json;
102
103    #[test]
104    fn a_migration_keeps_its_bytes() {
105        let sql = "\u{feff}CREATE TABLE a (\r\n  id INT\r\n);\r\n";
106        let migration = SqlMigration {
107            version: 6,
108            name: "add_loan_kind".into(),
109            sql: sql.into(),
110            no_transaction: false,
111        };
112        let wire = json!({"version": 6, "name": "add_loan_kind", "sql": sql});
113        assert_eq!(serde_json::to_value(&migration).unwrap(), wire);
114        let parsed: SqlMigration = serde_json::from_value(wire).unwrap();
115        assert_eq!(parsed.sql.as_bytes(), sql.as_bytes());
116        assert_eq!(parsed.file_stem(), "V6__add_loan_kind");
117    }
118
119    #[test]
120    fn the_marker_is_the_first_line() {
121        for sql in [
122            "-- cufflink:no-transaction\nCREATE INDEX CONCURRENTLY i ON t (c);",
123            "-- cufflink:no-transaction",
124            "  -- cufflink:no-transaction  \r\nCREATE INDEX CONCURRENTLY i ON t (c);",
125            "\u{feff}-- cufflink:no-transaction\nSELECT 1;",
126        ] {
127            assert!(SqlMigration::declares_no_transaction(sql), "{sql:?}");
128        }
129        for sql in [
130            "",
131            "SELECT 1;\n-- cufflink:no-transaction",
132            "\n-- cufflink:no-transaction\nSELECT 1;",
133            "-- cufflink:no-transaction please",
134            "--cufflink:no-transaction",
135        ] {
136            assert!(!SqlMigration::declares_no_transaction(sql), "{sql:?}");
137        }
138        let migration = SqlMigration {
139            version: 7,
140            name: "renewal_index".into(),
141            sql: "-- cufflink:no-transaction\nSELECT 1;".into(),
142            no_transaction: true,
143        };
144        assert_eq!(
145            serde_json::to_value(&migration).unwrap()["no_transaction"],
146            json!(true)
147        );
148    }
149
150    #[test]
151    fn history_round_trips() {
152        let default = MigrationHistory::default();
153        assert_eq!(
154            serde_json::to_value(&default).unwrap(),
155            json!({"table": "refinery_schema_history"})
156        );
157        let legacy = MigrationHistory {
158            table: "public.schema_history_deferred".into(),
159            baseline: Some(15),
160            lock: MigrationLock::LegacyTableHash,
161            mode: HistoryMode::VerifyOnly,
162        };
163        let wire = json!({
164            "table": "public.schema_history_deferred",
165            "baseline": 15,
166            "lock": "legacy_table_hash",
167            "mode": "verify_only"
168        });
169        assert_eq!(serde_json::to_value(&legacy).unwrap(), wire);
170        assert_eq!(
171            serde_json::from_value::<MigrationHistory>(wire).unwrap(),
172            legacy
173        );
174    }
175}