Skip to main content

vespertide_core/action/
data_migration.rs

1//! Wire format and DDL guard for [`MigrationAction::DataMigration`].
2//!
3//! [`MigrationAction::DataMigration`]: super::MigrationAction::DataMigration
4
5use serde::{Deserialize, Serialize};
6
7/// The SQL body of a [`MigrationAction::DataMigration`], either one portable
8/// statement or one statement per backend.
9///
10/// The wire format is untagged, so both shapes are written naturally in a
11/// migration file:
12///
13/// ```json
14/// { "type": "data_migration", "sql": "UPDATE product SET price = 0 WHERE price IS NULL" }
15/// ```
16///
17/// ```json
18/// {
19///   "type": "data_migration",
20///   "sql": {
21///     "postgres": "UPDATE t SET j = jsonb_build_object('ko', c)",
22///     "mysql":    "UPDATE t SET j = JSON_OBJECT('ko', c)",
23///     "sqlite":   "UPDATE t SET j = json_object('ko', c)"
24///   }
25/// }
26/// ```
27///
28/// All three backend keys are required in the per-backend form. A missing key
29/// would silently emit nothing for that backend — exactly the class of silent
30/// data loss this action exists to prevent.
31///
32/// [`MigrationAction::DataMigration`]: super::MigrationAction::DataMigration
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
35#[serde(untagged)]
36pub enum DataMigrationSql {
37    /// One portable statement executed verbatim on every backend.
38    Uniform(String),
39    /// One statement per backend, for data changes that cannot be expressed
40    /// portably (JSON constructors, string functions, upsert syntax, …).
41    PerBackend {
42        /// Statement emitted for `PostgreSQL`.
43        postgres: String,
44        /// Statement emitted for `MySQL`.
45        mysql: String,
46        /// Statement emitted for `SQLite`.
47        sqlite: String,
48    },
49}
50
51impl DataMigrationSql {
52    /// The statement emitted for `PostgreSQL`, verbatim.
53    #[must_use]
54    pub fn postgres(&self) -> &str {
55        match self {
56            Self::Uniform(sql) => sql,
57            Self::PerBackend { postgres, .. } => postgres,
58        }
59    }
60
61    /// The statement emitted for `MySQL`, verbatim.
62    #[must_use]
63    pub fn mysql(&self) -> &str {
64        match self {
65            Self::Uniform(sql) => sql,
66            Self::PerBackend { mysql, .. } => mysql,
67        }
68    }
69
70    /// The statement emitted for `SQLite`, verbatim.
71    #[must_use]
72    pub fn sqlite(&self) -> &str {
73        match self {
74            Self::Uniform(sql) => sql,
75            Self::PerBackend { sqlite, .. } => sqlite,
76        }
77    }
78
79    /// Every statement this value can emit, in backend order.
80    ///
81    /// The DDL guard checks *all* of them: a per-backend form whose `sqlite`
82    /// branch smuggles in a `DROP TABLE` is just as fatal to baseline replay
83    /// as a uniform one.
84    pub fn statements(&self) -> impl Iterator<Item = &str> {
85        match self {
86            Self::Uniform(sql) => [Some(sql.as_str()), None, None],
87            Self::PerBackend {
88                postgres,
89                mysql,
90                sqlite,
91            } => [
92                Some(postgres.as_str()),
93                Some(mysql.as_str()),
94                Some(sqlite.as_str()),
95            ],
96        }
97        .into_iter()
98        .flatten()
99    }
100}
101
102impl From<&str> for DataMigrationSql {
103    fn from(sql: &str) -> Self {
104        Self::Uniform(sql.to_string())
105    }
106}
107
108impl From<String> for DataMigrationSql {
109    fn from(sql: String) -> Self {
110        Self::Uniform(sql)
111    }
112}
113
114/// Statement keywords that change *schema* rather than *data*.
115///
116/// A `data_migration` starting with any of these breaks the action's
117/// schema-neutrality contract, so it is rejected at load and plan time.
118const DDL_KEYWORDS: [&str; 4] = ["CREATE", "ALTER", "DROP", "TRUNCATE"];
119
120/// Strip leading whitespace and SQL comments (`-- line`, `/* block */`) so the
121/// DDL guard sees the first real token.
122///
123/// Block comments are treated as non-nesting (ANSI SQL behaviour). A
124/// deliberately nested comment can therefore hide a keyword from the guard;
125/// that is a false *negative* in a pathological case, never a false positive.
126fn strip_leading_trivia(sql: &str) -> &str {
127    let mut rest = sql.trim_start();
128    loop {
129        rest = if let Some(after) = rest.strip_prefix("--") {
130            after.split_once('\n').map_or("", |(_, tail)| tail)
131        } else if let Some(after) = rest.strip_prefix("/*") {
132            after.split_once("*/").map_or("", |(_, tail)| tail)
133        } else {
134            return rest;
135        };
136        rest = rest.trim_start();
137    }
138}
139
140/// True when `body` opens with `keyword` as a complete token.
141///
142/// Matching is ASCII-case-insensitive and requires a token boundary after the
143/// keyword, so `CREATED_AT` is not mistaken for `CREATE`.
144fn starts_with_keyword(body: &str, keyword: &str) -> bool {
145    let bytes = body.as_bytes();
146    let keyword = keyword.as_bytes();
147    if bytes.len() < keyword.len() {
148        return false;
149    }
150    if !bytes[..keyword.len()].eq_ignore_ascii_case(keyword) {
151        return false;
152    }
153    match bytes.get(keyword.len()) {
154        None => true,
155        Some(next) => !(next.is_ascii_alphanumeric() || *next == b'_'),
156    }
157}
158
159/// Return the DDL keyword a statement opens with, if any.
160///
161/// Leading whitespace and comments are trimmed first, and the comparison is
162/// case-insensitive, so `/* fix up */ drop table t` is caught just like
163/// `DROP TABLE t`.
164#[must_use]
165pub fn leading_ddl_keyword(sql: &str) -> Option<&'static str> {
166    let body = strip_leading_trivia(sql);
167    DDL_KEYWORDS
168        .into_iter()
169        .find(|keyword| starts_with_keyword(body, keyword))
170}
171
172/// Bounded single-line preview of a SQL statement for error and warning text.
173///
174/// Leading trivia is dropped, internal whitespace runs collapse to one space,
175/// and the result is truncated to 60 characters followed by `...`.
176#[must_use]
177pub fn sql_preview(sql: &str) -> String {
178    let collapsed = strip_leading_trivia(sql)
179        .split_whitespace()
180        .collect::<Vec<_>>()
181        .join(" ");
182    if collapsed.char_indices().nth(60).is_some() {
183        let head: String = collapsed.chars().take(57).collect();
184        format!("{head}...")
185    } else {
186        collapsed
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193    use rstest::rstest;
194
195    #[test]
196    fn uniform_sql_wire_format_is_a_bare_string() {
197        let canonical = r#""UPDATE user SET active = true""#;
198        let parsed: DataMigrationSql = serde_json::from_str(canonical).expect("parse");
199        assert_eq!(
200            parsed,
201            DataMigrationSql::Uniform("UPDATE user SET active = true".to_string())
202        );
203        assert_eq!(
204            serde_json::to_string(&parsed).expect("serialize"),
205            canonical
206        );
207    }
208
209    #[test]
210    fn per_backend_sql_wire_format_is_a_three_key_object() {
211        let canonical = r#"{"postgres":"UPDATE a","mysql":"UPDATE b","sqlite":"UPDATE c"}"#;
212        let parsed: DataMigrationSql = serde_json::from_str(canonical).expect("parse");
213        assert_eq!(
214            parsed,
215            DataMigrationSql::PerBackend {
216                postgres: "UPDATE a".to_string(),
217                mysql: "UPDATE b".to_string(),
218                sqlite: "UPDATE c".to_string(),
219            }
220        );
221        assert_eq!(
222            serde_json::to_string(&parsed).expect("serialize"),
223            canonical
224        );
225    }
226
227    #[test]
228    fn per_backend_sql_requires_every_backend_key() {
229        let missing_sqlite = r#"{"postgres":"UPDATE a","mysql":"UPDATE b"}"#;
230        let parsed: Result<DataMigrationSql, _> = serde_json::from_str(missing_sqlite);
231        assert!(
232            parsed.is_err(),
233            "a per-backend form missing a key must not deserialize: {parsed:?}"
234        );
235    }
236
237    #[test]
238    fn uniform_sql_is_returned_for_every_backend() {
239        let sql = DataMigrationSql::from("UPDATE user SET x = 1");
240        assert_eq!(sql.postgres(), "UPDATE user SET x = 1");
241        assert_eq!(sql.mysql(), "UPDATE user SET x = 1");
242        assert_eq!(sql.sqlite(), "UPDATE user SET x = 1");
243        assert_eq!(
244            sql.statements().collect::<Vec<_>>(),
245            ["UPDATE user SET x = 1"]
246        );
247    }
248
249    #[test]
250    fn per_backend_sql_routes_each_backend_to_its_own_statement() {
251        let sql = DataMigrationSql::PerBackend {
252            postgres: "UPDATE pg".to_string(),
253            mysql: "UPDATE my".to_string(),
254            sqlite: "UPDATE lite".to_string(),
255        };
256        assert_eq!(sql.postgres(), "UPDATE pg");
257        assert_eq!(sql.mysql(), "UPDATE my");
258        assert_eq!(sql.sqlite(), "UPDATE lite");
259        assert_eq!(
260            sql.statements().collect::<Vec<_>>(),
261            ["UPDATE pg", "UPDATE my", "UPDATE lite"]
262        );
263    }
264
265    #[test]
266    fn from_owned_string_builds_the_uniform_form() {
267        let sql = DataMigrationSql::from("UPDATE user SET x = 1".to_string());
268        assert_eq!(
269            sql,
270            DataMigrationSql::Uniform("UPDATE user SET x = 1".to_string())
271        );
272    }
273
274    #[rstest]
275    #[case::create("CREATE TABLE t (id int)", Some("CREATE"))]
276    #[case::alter("ALTER TABLE t ADD COLUMN c int", Some("ALTER"))]
277    #[case::drop("DROP TABLE t", Some("DROP"))]
278    #[case::truncate("TRUNCATE TABLE t", Some("TRUNCATE"))]
279    #[case::lowercase("drop table t", Some("DROP"))]
280    #[case::mixed_case("CrEaTe TABLE t (id int)", Some("CREATE"))]
281    #[case::leading_whitespace("\n\t  DROP TABLE t", Some("DROP"))]
282    #[case::line_comment("-- clean up\nDROP TABLE t", Some("DROP"))]
283    #[case::block_comment("/* clean up */ DROP TABLE t", Some("DROP"))]
284    #[case::stacked_comments("-- one\n/* two */\n-- three\nALTER TABLE t", Some("ALTER"))]
285    #[case::unterminated_line_comment("-- only a comment", None)]
286    #[case::unterminated_block_comment("/* never closed", None)]
287    #[case::update("UPDATE user SET active = true", None)]
288    #[case::insert("INSERT INTO audit SELECT * FROM user", None)]
289    #[case::delete("DELETE FROM session WHERE expired", None)]
290    #[case::with_cte("WITH d AS (SELECT 1) UPDATE t SET x = 1", None)]
291    #[case::ddl_word_inside_body("UPDATE t SET note = 'DROP TABLE'", None)]
292    #[case::identifier_prefix("CREATED_AT_FIXUP()", None)]
293    #[case::empty("", None)]
294    #[case::whitespace_only("   \n  ", None)]
295    fn leading_ddl_keyword_classifies_statements(
296        #[case] sql: &str,
297        #[case] expected: Option<&'static str>,
298    ) {
299        assert_eq!(leading_ddl_keyword(sql), expected);
300    }
301
302    #[test]
303    fn ddl_keyword_needs_a_token_boundary_not_just_a_prefix() {
304        // `DROPLET` starts with `DROP` but is a different token.
305        assert_eq!(leading_ddl_keyword("DROPLET the_table"), None);
306        // A shorter body than the keyword must not index out of bounds.
307        assert_eq!(leading_ddl_keyword("DRO"), None);
308        // End-of-input immediately after the keyword is a valid boundary.
309        assert_eq!(leading_ddl_keyword("DROP"), Some("DROP"));
310        // A non-ASCII byte right after the keyword still counts as a boundary.
311        assert_eq!(leading_ddl_keyword("DROP\u{ad6d}"), Some("DROP"));
312    }
313
314    #[rstest]
315    #[case::short("UPDATE t SET x = 1", "UPDATE t SET x = 1")]
316    #[case::collapses_newlines("UPDATE t\n  SET x = 1", "UPDATE t SET x = 1")]
317    #[case::strips_leading_comment("-- why\nUPDATE t SET x = 1", "UPDATE t SET x = 1")]
318    fn sql_preview_normalises_short_statements(#[case] sql: &str, #[case] expected: &str) {
319        assert_eq!(sql_preview(sql), expected);
320    }
321
322    #[test]
323    fn sql_preview_truncates_at_the_60_character_boundary() {
324        let exactly_60 = "0123456789".repeat(6);
325        assert_eq!(sql_preview(&exactly_60), exactly_60);
326
327        let sixty_one = format!("{exactly_60}X");
328        let head: String = sixty_one.chars().take(57).collect();
329        assert_eq!(sql_preview(&sixty_one), format!("{head}..."));
330    }
331
332    #[test]
333    fn sql_preview_counts_characters_not_bytes() {
334        let multibyte = "한".repeat(61);
335        let head: String = multibyte.chars().take(57).collect();
336        assert_eq!(sql_preview(&multibyte), format!("{head}..."));
337    }
338}