use super::helpers::{
TIMESTAMP_FUNCTION_SPELLINGS, UUID_FUNCTION_SPELLINGS, convert_default_for_backend,
find_last_top_level_cast, matches_any_spelling,
};
use super::types::DatabaseBackend;
const COMPOSITE_SQL_KEYWORDS: [&str; 11] = [
"case", "when", "then", "else", "end", "select", "from", "where", "union", "join", "between",
];
#[must_use]
pub(crate) fn convert_fill_with_for_backend(fill: &str, backend: DatabaseBackend) -> String {
if backend == DatabaseBackend::Postgres || !is_simple_literal_fill(fill) {
return fill.to_string();
}
convert_default_for_backend(fill, backend)
}
fn is_simple_literal_fill(fill: &str) -> bool {
let trimmed = fill.trim();
if matches_any_spelling(trimmed, &UUID_FUNCTION_SPELLINGS)
|| matches_any_spelling(trimmed, &TIMESTAMP_FUNCTION_SPELLINGS)
{
return true;
}
if contains_composite_keyword(trimmed) {
return false;
}
let value = match find_last_top_level_cast(trimmed) {
Some(split) => trimmed[..split].trim(),
None => trimmed,
};
is_single_sql_atom(value)
}
fn is_single_sql_atom(value: &str) -> bool {
if value.is_empty() {
return false;
}
if value.starts_with('\'') {
return is_one_complete_quoted_literal(value);
}
!value
.chars()
.any(|c| c.is_whitespace() || matches!(c, '(' | ')' | ',' | ';' | '\'' | '"'))
}
fn is_one_complete_quoted_literal(value: &str) -> bool {
let mut in_quote = false;
for byte in value.bytes() {
if byte == b'\'' {
in_quote = !in_quote;
} else if !in_quote {
return false;
}
}
!in_quote
}
fn contains_composite_keyword(value: &str) -> bool {
value
.split(|c: char| !c.is_ascii_alphanumeric() && c != '_')
.any(|word| matches_any_spelling(word, &COMPOSITE_SQL_KEYWORDS))
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
#[rstest]
#[case::enum_cast(
"(CASE WHEN plan_key::text = 'API' THEN 'MONTHLY_QUOTA' ELSE 'SEAT' END)::billing_metric"
)]
#[case::json_array(
"CASE WHEN device_os = 'win' THEN json_build_array('WINDOWS', device_family::text) ELSE '[]'::json END"
)]
#[case::cast_inside_quotes("CASE WHEN plan_tag = 'legacy::v1' THEN 1 ELSE 2 END::integer")]
fn composite_expressions_survive_verbatim(#[case] fill: &str) {
for backend in [
DatabaseBackend::Postgres,
DatabaseBackend::MySql,
DatabaseBackend::Sqlite,
] {
assert_eq!(
convert_fill_with_for_backend(fill, backend),
fill,
"{backend:?} must not rewrite a fill_with expression"
);
}
}
#[rstest]
#[case("NOW()")]
#[case("gen_random_uuid()")]
#[case("'[]'::json")]
#[case("0")]
fn postgres_never_rewrites(#[case] fill: &str) {
assert_eq!(
convert_fill_with_for_backend(fill, DatabaseBackend::Postgres),
fill
);
}
#[rstest]
#[case::now_mysql("NOW()", DatabaseBackend::MySql, "CURRENT_TIMESTAMP")]
#[case::now_sqlite("NOW()", DatabaseBackend::Sqlite, "CURRENT_TIMESTAMP")]
#[case::uuid_mysql("gen_random_uuid()", DatabaseBackend::MySql, "(UUID())")]
#[case::uuid_sqlite(
"gen_random_uuid()",
DatabaseBackend::Sqlite,
"lower(hex(randomblob(16)))"
)]
#[case::json_cast_mysql("'[]'::json", DatabaseBackend::MySql, "CAST('[]' AS JSON)")]
#[case::json_cast_sqlite("'[]'::json", DatabaseBackend::Sqlite, "'[]'")]
#[case::int_cast_mysql("0::integer", DatabaseBackend::MySql, "CAST(0 AS SIGNED)")]
#[case::identifier_cast_sqlite("legacy_id::text", DatabaseBackend::Sqlite, "legacy_id")]
#[case::empty_literal_mysql("''", DatabaseBackend::MySql, "''")]
#[case::plain_number_sqlite("0", DatabaseBackend::Sqlite, "0")]
fn simple_literals_still_convert_cross_backend(
#[case] fill: &str,
#[case] backend: DatabaseBackend,
#[case] expected: &str,
) {
assert_eq!(convert_fill_with_for_backend(fill, backend), expected);
}
#[rstest]
#[case::plain_number("0", true)]
#[case::quoted_literal("'active'", true)]
#[case::quoted_literal_with_space("'in progress'", true)]
#[case::quoted_literal_with_keyword_inside("'not_started'::user_status", true)]
#[case::identifier_cast("legacy_id::text", true)]
#[case::portable_function("NOW()", true)]
#[case::nested_uuid_function("lower(hex(randomblob(16)))", true)]
#[case::empty("", false)]
#[case::whitespace_only(" ", false)]
#[case::function_call("json_build_array('a')", false)]
#[case::concatenation("'a' || 'b'", false)]
#[case::bare_keyword("END", false)]
#[case::case_expression("CASE WHEN a = 1 THEN 'x' ELSE 'y' END", false)]
#[case::parenthesised_cast("(a + b)::integer", false)]
#[case::unterminated_literal("'oops", false)]
fn simple_literal_classification(#[case] fill: &str, #[case] expected: bool) {
assert_eq!(is_simple_literal_fill(fill), expected, "input: {fill}");
}
#[test]
fn composite_keywords_match_whole_words_only() {
assert!(contains_composite_keyword(
"CASE WHEN a = 1 THEN 'x' ELSE 'y' END"
));
assert!(contains_composite_keyword("SELECT 1 FROM t"));
assert!(!contains_composite_keyword("'not_started'::user_status"));
assert!(!contains_composite_keyword("weekend_total::integer"));
assert!(!contains_composite_keyword("''"));
}
}