use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(untagged)]
pub enum DataMigrationSql {
Uniform(String),
PerBackend {
postgres: String,
mysql: String,
sqlite: String,
},
}
impl DataMigrationSql {
#[must_use]
pub fn postgres(&self) -> &str {
match self {
Self::Uniform(sql) => sql,
Self::PerBackend { postgres, .. } => postgres,
}
}
#[must_use]
pub fn mysql(&self) -> &str {
match self {
Self::Uniform(sql) => sql,
Self::PerBackend { mysql, .. } => mysql,
}
}
#[must_use]
pub fn sqlite(&self) -> &str {
match self {
Self::Uniform(sql) => sql,
Self::PerBackend { sqlite, .. } => sqlite,
}
}
pub fn statements(&self) -> impl Iterator<Item = &str> {
match self {
Self::Uniform(sql) => [Some(sql.as_str()), None, None],
Self::PerBackend {
postgres,
mysql,
sqlite,
} => [
Some(postgres.as_str()),
Some(mysql.as_str()),
Some(sqlite.as_str()),
],
}
.into_iter()
.flatten()
}
}
impl From<&str> for DataMigrationSql {
fn from(sql: &str) -> Self {
Self::Uniform(sql.to_string())
}
}
impl From<String> for DataMigrationSql {
fn from(sql: String) -> Self {
Self::Uniform(sql)
}
}
const DDL_KEYWORDS: [&str; 4] = ["CREATE", "ALTER", "DROP", "TRUNCATE"];
fn strip_leading_trivia(sql: &str) -> &str {
let mut rest = sql.trim_start();
loop {
rest = if let Some(after) = rest.strip_prefix("--") {
after.split_once('\n').map_or("", |(_, tail)| tail)
} else if let Some(after) = rest.strip_prefix("/*") {
after.split_once("*/").map_or("", |(_, tail)| tail)
} else {
return rest;
};
rest = rest.trim_start();
}
}
fn starts_with_keyword(body: &str, keyword: &str) -> bool {
let bytes = body.as_bytes();
let keyword = keyword.as_bytes();
if bytes.len() < keyword.len() {
return false;
}
if !bytes[..keyword.len()].eq_ignore_ascii_case(keyword) {
return false;
}
match bytes.get(keyword.len()) {
None => true,
Some(next) => !(next.is_ascii_alphanumeric() || *next == b'_'),
}
}
#[must_use]
pub fn leading_ddl_keyword(sql: &str) -> Option<&'static str> {
let body = strip_leading_trivia(sql);
DDL_KEYWORDS
.into_iter()
.find(|keyword| starts_with_keyword(body, keyword))
}
#[must_use]
pub fn sql_preview(sql: &str) -> String {
let collapsed = strip_leading_trivia(sql)
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
if collapsed.char_indices().nth(60).is_some() {
let head: String = collapsed.chars().take(57).collect();
format!("{head}...")
} else {
collapsed
}
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
#[test]
fn uniform_sql_wire_format_is_a_bare_string() {
let canonical = r#""UPDATE user SET active = true""#;
let parsed: DataMigrationSql = serde_json::from_str(canonical).expect("parse");
assert_eq!(
parsed,
DataMigrationSql::Uniform("UPDATE user SET active = true".to_string())
);
assert_eq!(
serde_json::to_string(&parsed).expect("serialize"),
canonical
);
}
#[test]
fn per_backend_sql_wire_format_is_a_three_key_object() {
let canonical = r#"{"postgres":"UPDATE a","mysql":"UPDATE b","sqlite":"UPDATE c"}"#;
let parsed: DataMigrationSql = serde_json::from_str(canonical).expect("parse");
assert_eq!(
parsed,
DataMigrationSql::PerBackend {
postgres: "UPDATE a".to_string(),
mysql: "UPDATE b".to_string(),
sqlite: "UPDATE c".to_string(),
}
);
assert_eq!(
serde_json::to_string(&parsed).expect("serialize"),
canonical
);
}
#[test]
fn per_backend_sql_requires_every_backend_key() {
let missing_sqlite = r#"{"postgres":"UPDATE a","mysql":"UPDATE b"}"#;
let parsed: Result<DataMigrationSql, _> = serde_json::from_str(missing_sqlite);
assert!(
parsed.is_err(),
"a per-backend form missing a key must not deserialize: {parsed:?}"
);
}
#[test]
fn uniform_sql_is_returned_for_every_backend() {
let sql = DataMigrationSql::from("UPDATE user SET x = 1");
assert_eq!(sql.postgres(), "UPDATE user SET x = 1");
assert_eq!(sql.mysql(), "UPDATE user SET x = 1");
assert_eq!(sql.sqlite(), "UPDATE user SET x = 1");
assert_eq!(
sql.statements().collect::<Vec<_>>(),
["UPDATE user SET x = 1"]
);
}
#[test]
fn per_backend_sql_routes_each_backend_to_its_own_statement() {
let sql = DataMigrationSql::PerBackend {
postgres: "UPDATE pg".to_string(),
mysql: "UPDATE my".to_string(),
sqlite: "UPDATE lite".to_string(),
};
assert_eq!(sql.postgres(), "UPDATE pg");
assert_eq!(sql.mysql(), "UPDATE my");
assert_eq!(sql.sqlite(), "UPDATE lite");
assert_eq!(
sql.statements().collect::<Vec<_>>(),
["UPDATE pg", "UPDATE my", "UPDATE lite"]
);
}
#[test]
fn from_owned_string_builds_the_uniform_form() {
let sql = DataMigrationSql::from("UPDATE user SET x = 1".to_string());
assert_eq!(
sql,
DataMigrationSql::Uniform("UPDATE user SET x = 1".to_string())
);
}
#[rstest]
#[case::create("CREATE TABLE t (id int)", Some("CREATE"))]
#[case::alter("ALTER TABLE t ADD COLUMN c int", Some("ALTER"))]
#[case::drop("DROP TABLE t", Some("DROP"))]
#[case::truncate("TRUNCATE TABLE t", Some("TRUNCATE"))]
#[case::lowercase("drop table t", Some("DROP"))]
#[case::mixed_case("CrEaTe TABLE t (id int)", Some("CREATE"))]
#[case::leading_whitespace("\n\t DROP TABLE t", Some("DROP"))]
#[case::line_comment("-- clean up\nDROP TABLE t", Some("DROP"))]
#[case::block_comment("/* clean up */ DROP TABLE t", Some("DROP"))]
#[case::stacked_comments("-- one\n/* two */\n-- three\nALTER TABLE t", Some("ALTER"))]
#[case::unterminated_line_comment("-- only a comment", None)]
#[case::unterminated_block_comment("/* never closed", None)]
#[case::update("UPDATE user SET active = true", None)]
#[case::insert("INSERT INTO audit SELECT * FROM user", None)]
#[case::delete("DELETE FROM session WHERE expired", None)]
#[case::with_cte("WITH d AS (SELECT 1) UPDATE t SET x = 1", None)]
#[case::ddl_word_inside_body("UPDATE t SET note = 'DROP TABLE'", None)]
#[case::identifier_prefix("CREATED_AT_FIXUP()", None)]
#[case::empty("", None)]
#[case::whitespace_only(" \n ", None)]
fn leading_ddl_keyword_classifies_statements(
#[case] sql: &str,
#[case] expected: Option<&'static str>,
) {
assert_eq!(leading_ddl_keyword(sql), expected);
}
#[test]
fn ddl_keyword_needs_a_token_boundary_not_just_a_prefix() {
assert_eq!(leading_ddl_keyword("DROPLET the_table"), None);
assert_eq!(leading_ddl_keyword("DRO"), None);
assert_eq!(leading_ddl_keyword("DROP"), Some("DROP"));
assert_eq!(leading_ddl_keyword("DROP\u{ad6d}"), Some("DROP"));
}
#[rstest]
#[case::short("UPDATE t SET x = 1", "UPDATE t SET x = 1")]
#[case::collapses_newlines("UPDATE t\n SET x = 1", "UPDATE t SET x = 1")]
#[case::strips_leading_comment("-- why\nUPDATE t SET x = 1", "UPDATE t SET x = 1")]
fn sql_preview_normalises_short_statements(#[case] sql: &str, #[case] expected: &str) {
assert_eq!(sql_preview(sql), expected);
}
#[test]
fn sql_preview_truncates_at_the_60_character_boundary() {
let exactly_60 = "0123456789".repeat(6);
assert_eq!(sql_preview(&exactly_60), exactly_60);
let sixty_one = format!("{exactly_60}X");
let head: String = sixty_one.chars().take(57).collect();
assert_eq!(sql_preview(&sixty_one), format!("{head}..."));
}
#[test]
fn sql_preview_counts_characters_not_bytes() {
let multibyte = "한".repeat(61);
let head: String = multibyte.chars().take(57).collect();
assert_eq!(sql_preview(&multibyte), format!("{head}..."));
}
}