use super::Platform;
use crate::db::plan::Column;
use crate::db::reference::TableRef;
const INTROSPECT_SQL: &str = "\
SELECT COLUMN_NAME AS name,
DATA_TYPE AS type_name,
CHARACTER_MAXIMUM_LENGTH AS length,
(IS_NULLABLE = 'NO') AS not_null,
((COLUMN_DEFAULT IS NOT NULL AND COLUMN_DEFAULT <> 'NULL') OR EXTRA LIKE '%GENERATED') AS has_default,
(EXTRA = 'auto_increment') AS is_identity,
(COLUMN_KEY = 'PRI') AS is_pk
FROM information_schema.columns
WHERE TABLE_SCHEMA = COALESCE(?, DATABASE()) AND TABLE_NAME = ?
ORDER BY ORDINAL_POSITION";
pub struct MySql {
name: &'static str,
returning: bool,
sequences: bool,
native_uuid: bool,
}
pub static MYSQL: MySql = MySql {
name: "mysql",
returning: false,
sequences: false,
native_uuid: false,
};
pub static MARIADB: MySql = MySql {
name: "mariadb",
returning: true,
sequences: true,
native_uuid: true,
};
impl Platform for MySql {
fn name(&self) -> &'static str {
self.name
}
fn bind(&self, _n: usize, _ty: &str) -> String {
"?".to_string()
}
fn placeholder(&self, _n: usize) -> String {
"?".to_string()
}
fn cast_text(&self, expr: &str) -> String {
format!("CAST({expr} AS CHAR)")
}
fn insert_no_columns(&self, table: &str) -> String {
format!("INSERT INTO {table} () VALUES ()")
}
fn returning(&self, pk: &[&Column]) -> Option<String> {
if !self.returning || pk.is_empty() {
return None;
}
let cols: Vec<String> = pk.iter().map(|c| self.cast_text(&c.name)).collect();
Some(format!("RETURNING {}", cols.join(", ")))
}
fn is_timestamplike(&self, ty: &str) -> bool {
matches!(ty, "timestamp" | "datetime" | "date")
}
fn wants_client_uuid(&self, col: &Column) -> bool {
if self.native_uuid && col.type_name == "uuid" {
return true;
}
matches!(col.type_name.as_str(), "char" | "varchar") && col.length == Some(36)
}
fn check_bindable(&self, col: &Column) -> Result<(), String> {
if !matches!(col.type_name.as_str(), "binary" | "varbinary") {
return Ok(());
}
let mut msg = format!(
"column {} ({}) is a binary column; this layer binds and compares everything as \
text, so a value here would be the ASCII bytes of what the .feature spells — an \
INSERT can silently store the wrong bytes and a WHERE against it matches nothing",
col.name, col.type_name
);
if col.length == Some(16) {
msg.push_str(
". UUID_TO_BIN(uuid, swap_flag) takes a flag that reorders time-low and \
time-high, BIN_TO_UUID must be given the same flag, and information_schema \
does not record which flag the application chose — bddkit refuses to guess \
and write disagreeing bytes. Store the UUID as char(36) instead, or keep this \
column out of the step",
);
} else {
msg.push_str(
". Keep this column out of the step, or expose it through a text/hex column \
or view instead",
);
}
Err(msg)
}
fn introspect(&self, tref: &TableRef) -> (String, Vec<Option<String>>) {
(
INTROSPECT_SQL.to_string(),
vec![tref.schema.clone(), Some(tref.table.clone())],
)
}
fn next_sequence(&self, seq: &str) -> Option<(String, Vec<Option<String>>)> {
if !self.sequences {
return None;
}
Some((
format!("SELECT {}", self.cast_text(&format!("NEXTVAL({seq})"))),
Vec::new(),
))
}
fn session_setup(&self, search_path: &[String]) -> Result<Vec<String>, String> {
if search_path.is_empty() {
return Ok(Vec::new());
}
Err(format!(
"{}: a schema is a database, there is no session-level search_path; \
qualify each table reference as \"schema.table\" instead of setting search_path",
self.name()
))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn col(name: &str, ty: &str, length: Option<i64>) -> Column {
Column {
name: name.into(),
type_name: ty.into(),
length,
not_null: false,
has_default: false,
is_identity: false,
is_pk: true,
}
}
#[test]
fn mysql_binds_without_a_cast() {
assert_eq!(MYSQL.bind(1, "int"), "?");
assert_eq!(MYSQL.placeholder(3), "?");
assert_eq!(MYSQL.cast_text("id"), "CAST(id AS CHAR)");
assert_eq!(MYSQL.insert_no_columns("t"), "INSERT INTO t () VALUES ()");
}
#[test]
fn names_identify_the_dialect() {
assert_eq!(MYSQL.name(), "mysql");
assert_eq!(MARIADB.name(), "mariadb");
}
#[test]
fn only_mariadb_has_returning_and_sequences() {
let id = col("id", "char", Some(36));
assert_eq!(MYSQL.returning(&[&id]), None);
assert_eq!(
MARIADB.returning(&[&id]),
Some("RETURNING CAST(id AS CHAR)".to_string())
);
assert_eq!(MYSQL.next_sequence("s"), None);
assert_eq!(
MARIADB.next_sequence("s"),
Some(("SELECT CAST(NEXTVAL(s) AS CHAR)".to_string(), Vec::new()))
);
}
#[test]
fn returning_is_none_for_an_empty_pk_even_with_the_flag_on() {
assert_eq!(MARIADB.returning(&[]), None);
}
#[test]
fn returning_joins_a_composite_pk_on_mariadb() {
let a = col("a", "int", None);
let b = col("b", "int", None);
assert_eq!(
MARIADB.returning(&[&a, &b]),
Some("RETURNING CAST(a AS CHAR), CAST(b AS CHAR)".to_string())
);
}
#[test]
fn mariadb_names_the_sequence_inline_because_it_cannot_bind_one() {
let (sql, binds) = MARIADB.next_sequence("apibdd_it.thing_seq").unwrap();
assert_eq!(sql, "SELECT CAST(NEXTVAL(apibdd_it.thing_seq) AS CHAR)");
assert!(binds.is_empty(), "an identifier cannot be a bind parameter");
}
#[test]
fn a_text_uuid_pk_is_generated_but_only_where_it_is_text() {
assert!(MYSQL.wants_client_uuid(&col("id", "char", Some(36))));
assert!(MYSQL.wants_client_uuid(&col("id", "varchar", Some(36))));
assert!(!MYSQL.wants_client_uuid(&col("id", "char", Some(12))));
}
#[test]
fn native_uuid_type_only_counts_on_mariadb() {
let native = col("id", "uuid", None);
assert!(MARIADB.wants_client_uuid(&native));
assert!(!MYSQL.wants_client_uuid(&native));
}
#[test]
fn a_binary_column_is_refused_at_any_length_with_the_reason_in_the_message() {
let binary16 = col("id", "binary", Some(16));
let err = MYSQL.check_bindable(&binary16).unwrap_err();
assert!(err.contains("swap_flag"), "{err}");
assert!(err.contains("char(36)"), "{err}");
let varbinary16 = col("id", "varbinary", Some(16));
assert!(MARIADB.check_bindable(&varbinary16).is_err());
let sha1 = col("digest", "binary", Some(20));
let err = MYSQL.check_bindable(&sha1).unwrap_err();
assert!(err.contains("digest"), "{err}");
assert!(!err.contains("swap_flag"), "{err}");
assert!(MYSQL.check_bindable(&col("id", "binary", Some(8))).is_err());
assert!(
MYSQL
.check_bindable(&col("name", "varchar", Some(255)))
.is_ok()
);
}
#[test]
fn search_path_is_rejected_rather_than_ignored() {
assert!(MYSQL.session_setup(&[]).unwrap().is_empty());
assert!(MARIADB.session_setup(&[]).unwrap().is_empty());
let mysql_err = MYSQL.session_setup(&["apibdd_it".into()]).unwrap_err();
assert!(mysql_err.contains("schema.table"), "{mysql_err}");
assert!(mysql_err.starts_with("mysql:"), "{mysql_err}");
let mariadb_err = MARIADB.session_setup(&["apibdd_it".into()]).unwrap_err();
assert!(mariadb_err.starts_with("mariadb:"), "{mariadb_err}");
assert_ne!(mysql_err, mariadb_err, "each engine names itself");
}
#[test]
fn mysql_timestamplike_names_are_its_own_not_postgres() {
assert!(MYSQL.is_timestamplike("timestamp"));
assert!(MYSQL.is_timestamplike("datetime"));
assert!(MYSQL.is_timestamplike("date"));
assert!(!MYSQL.is_timestamplike("timestamptz"));
}
#[test]
fn introspect_binds_schema_and_table_separately() {
let tref = TableRef::parse("audit.log").unwrap();
let (sql, binds) = MYSQL.introspect(&tref);
assert!(sql.contains("TABLE_SCHEMA"), "{sql}");
assert_eq!(
binds,
vec![Some("audit".to_string()), Some("log".to_string())]
);
let bare = TableRef::parse("log").unwrap();
let (_, binds) = MYSQL.introspect(&bare);
assert_eq!(binds, vec![None, Some("log".to_string())]);
}
#[test]
fn introspect_sql_produces_every_alias_the_trait_requires() {
for alias in [
"AS name",
"AS type_name",
"AS length",
"AS not_null",
"AS has_default",
"AS is_identity",
"AS is_pk",
] {
assert!(
INTROSPECT_SQL.contains(alias),
"missing {alias:?} in:\n{INTROSPECT_SQL}"
);
}
}
}