1use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
35#[serde(untagged)]
36pub enum DataMigrationSql {
37 Uniform(String),
39 PerBackend {
42 postgres: String,
44 mysql: String,
46 sqlite: String,
48 },
49}
50
51impl DataMigrationSql {
52 #[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 #[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 #[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 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
114const DDL_KEYWORDS: [&str; 4] = ["CREATE", "ALTER", "DROP", "TRUNCATE"];
119
120fn 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
140fn 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#[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#[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 assert_eq!(leading_ddl_keyword("DROPLET the_table"), None);
306 assert_eq!(leading_ddl_keyword("DRO"), None);
308 assert_eq!(leading_ddl_keyword("DROP"), Some("DROP"));
310 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}