use crate::sql::Pool;
#[cfg_attr(not(feature = "mysql"), allow(dead_code))]
const MYSQL_DUPLICATES: &[u16] = &[1050, 1061, 1826];
#[cfg_attr(not(feature = "mysql"), allow(dead_code))]
const MYSQL_DUP_FIELDNAME: u16 = 1060;
#[cfg_attr(not(feature = "mysql"), allow(dead_code))]
fn is_mysql_duplicate(number: u16, stmt: &str) -> bool {
MYSQL_DUPLICATES.contains(&number) || (number == MYSQL_DUP_FIELDNAME && is_add_column(stmt))
}
fn is_add_column(stmt: &str) -> bool {
stmt.starts_with("ALTER TABLE ") && stmt.contains(" ADD COLUMN ")
}
#[cfg_attr(not(feature = "mysql"), allow(unused_variables))]
fn is_already_exists(e: &crate::sql::ExecError, dialect: &str, stmt: &str) -> bool {
let crate::sql::ExecError::Driver(err) = e else {
return false;
};
match dialect {
"postgres" => crate::sql::is_pg_dup_object_error(err),
#[cfg(feature = "mysql")]
"mysql" => err
.as_database_error()
.and_then(|db| db.try_downcast_ref::<sqlx::mysql::MySqlDatabaseError>())
.is_some_and(|my| is_mysql_duplicate(my.number(), stmt)),
_ => {
let msg = format!("{e}").to_lowercase();
msg.contains("already exists") || msg.contains("duplicate")
}
}
}
fn idempotent<'a>(stmt: &'a str, dialect: &str) -> std::borrow::Cow<'a, str> {
if dialect != "postgres" {
return std::borrow::Cow::Borrowed(stmt);
}
if let Some(rest) = stmt.strip_prefix("CREATE TABLE ") {
if !rest.starts_with("IF NOT EXISTS") {
return std::borrow::Cow::Owned(format!("CREATE TABLE IF NOT EXISTS {rest}"));
}
}
if is_add_column(stmt) && !stmt.contains(" ADD COLUMN IF NOT EXISTS ") {
if let Some((head, tail)) = stmt.split_once(" ADD COLUMN ") {
return std::borrow::Cow::Owned(format!("{head} ADD COLUMN IF NOT EXISTS {tail}"));
}
}
std::borrow::Cow::Borrowed(stmt)
}
pub(crate) async fn apply_idempotent(
pool: &Pool,
snapshot: &super::SchemaSnapshot,
) -> Result<(), sqlx::Error> {
let schema = creation_schema(pool).await?;
let changes = super::detect_changes(&super::SchemaSnapshot::default(), snapshot);
let batch = super::diff::render_changes_split_in_schema(
&changes,
snapshot,
pool.dialect(),
schema.as_deref(),
)
.map_err(sqlx::Error::Protocol)?;
apply_batch(pool, &batch).await
}
#[cfg(all(feature = "admin", feature = "totp"))]
pub(crate) async fn add_columns_idempotent(
pool: &Pool,
snapshot: &super::SchemaSnapshot,
columns: &[(&str, &str)],
) -> Result<(), sqlx::Error> {
let changes: Vec<_> = columns
.iter()
.map(|(table, column)| super::SchemaChange::AddColumn {
table: (*table).to_owned(),
column: (*column).to_owned(),
})
.collect();
let schema = creation_schema(pool).await?;
let batch = super::diff::render_changes_split_in_schema(
&changes,
snapshot,
pool.dialect(),
schema.as_deref(),
)
.map_err(sqlx::Error::Protocol)?;
apply_batch(pool, &batch).await
}
async fn creation_schema(pool: &Pool) -> Result<Option<String>, sqlx::Error> {
match pool {
#[cfg(feature = "postgres")]
Pool::Postgres(pg) => sqlx::query_scalar::<_, Option<String>>("SELECT current_schema()")
.fetch_one(pg)
.await?
.map(Some)
.ok_or_else(|| sqlx::Error::Protocol("search_path names no existing schema".into())),
#[allow(unreachable_patterns)]
_ => Ok(None),
}
}
async fn apply_batch(pool: &Pool, batch: &super::RenderedBatch) -> Result<(), sqlx::Error> {
let dialect = pool.dialect().name();
for stmt in batch.immediate.iter().chain(batch.deferred_fks.iter()) {
let stmt = idempotent(stmt, dialect);
if let Err(e) = crate::sql::raw_execute_pool(pool, &stmt, Vec::new()).await {
if is_already_exists(&e, dialect, &stmt) {
continue;
}
return Err(match e {
crate::sql::ExecError::Driver(err) => err,
other => sqlx::Error::Protocol(format!("{other}")),
});
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::{idempotent, is_mysql_duplicate};
#[test]
fn create_table_gains_if_not_exists_on_postgres() {
assert_eq!(
idempotent(r#"CREATE TABLE "t" ("id" BIGSERIAL)"#, "postgres"),
r#"CREATE TABLE IF NOT EXISTS "t" ("id" BIGSERIAL)"#
);
}
#[test]
fn other_dialects_are_left_alone() {
let s = r#"CREATE TABLE "t" ("id" BIGSERIAL)"#;
assert_eq!(idempotent(s, "mysql"), s);
assert_eq!(idempotent(s, "sqlite"), s);
}
#[test]
fn already_guarded_create_is_left_alone() {
let s = r#"CREATE TABLE IF NOT EXISTS "t" ("id" BIGSERIAL)"#;
assert_eq!(idempotent(s, "postgres"), s, "must not double up the guard");
}
#[test]
fn non_create_table_statements_are_untouched() {
let s =
r#"ALTER TABLE "a" ADD CONSTRAINT "a_b_fkey" FOREIGN KEY ("b") REFERENCES "b" ("id")"#;
assert_eq!(idempotent(s, "postgres"), s);
let idx = r#"CREATE UNIQUE INDEX "i" ON "t" ("a")"#;
assert_eq!(idempotent(idx, "postgres"), idx);
}
#[test]
fn add_column_gains_if_not_exists_on_postgres_only() {
let s = r#"ALTER TABLE "t" ADD COLUMN "c" BIGINT"#;
let guarded = r#"ALTER TABLE "t" ADD COLUMN IF NOT EXISTS "c" BIGINT"#;
assert_eq!(idempotent(s, "postgres"), guarded);
assert_eq!(idempotent(guarded, "postgres"), guarded);
assert_eq!(idempotent(s, "mysql"), s);
}
#[test]
fn the_renderer_output_is_actually_rewritten() {
use crate::core::Model as _;
let snapshot = crate::migrate::SchemaSnapshot::from_models(&[
crate::contenttypes::ContentType::SCHEMA,
]);
let changes =
crate::migrate::detect_changes(&crate::migrate::SchemaSnapshot::default(), &snapshot);
let batch = crate::migrate::render_changes_split_with_dialect(
&changes,
&snapshot,
&crate::sql::Postgres,
)
.expect("render");
let creates: Vec<_> = batch
.immediate
.iter()
.filter(|s| s.contains("CREATE TABLE"))
.collect();
assert!(!creates.is_empty(), "expected at least one CREATE TABLE");
for stmt in creates {
assert!(
idempotent(stmt, "postgres").starts_with("CREATE TABLE IF NOT EXISTS"),
"renderer emits a shape `idempotent` does not rewrite, so the \
ensure paths are back to erroring per call: {stmt}"
);
}
}
#[cfg(feature = "sqlite")]
#[tokio::test]
async fn apply_idempotent_runs_both_lists_and_still_propagates() {
let pool = crate::sql::Pool::connect("sqlite::memory:")
.await
.expect("sqlite");
let batch = super::super::RenderedBatch {
immediate: vec!["CREATE TABLE a (id INTEGER PRIMARY KEY)".to_owned()],
deferred_fks: vec!["CREATE TABLE b (id INTEGER PRIMARY KEY)".to_owned()],
..Default::default()
};
super::apply_batch(&pool, &batch)
.await
.expect("first run creates both");
for t in ["a", "b"] {
crate::sql::raw_execute_pool(&pool, &format!("SELECT 1 FROM {t}"), Vec::new())
.await
.unwrap_or_else(|e| panic!("{t} was never created — deferred_fks skipped? {e}"));
}
super::apply_batch(&pool, &batch)
.await
.expect("re-running an ensure must be idempotent");
let bad = super::super::RenderedBatch {
immediate: vec!["CREATE TABLE c (id INTEGER PRIMARY KEY".to_owned()],
..Default::default()
};
assert!(
super::apply_batch(&pool, &bad).await.is_err(),
"a syntax error must propagate; swallowing everything would \
make every ensure silently succeed"
);
}
#[test]
fn mysql_duplicates_are_numbers() {
let create = "CREATE TABLE t (id INT)";
assert!(is_mysql_duplicate(1050, create), "ER_TABLE_EXISTS_ERROR");
assert!(is_mysql_duplicate(1061, create), "ER_DUP_KEYNAME");
assert!(is_mysql_duplicate(1826, create), "ER_FK_DUP_NAME");
assert!(
!is_mysql_duplicate(1062, create),
"ER_DUP_ENTRY must not swallow"
);
assert!(
!is_mysql_duplicate(1064, create),
"syntax error must not swallow"
);
assert!(
!is_mysql_duplicate(1170, create),
"TEXT-in-index must not swallow"
);
}
#[test]
fn mysql_dup_fieldname_only_for_add_column() {
assert!(is_mysql_duplicate(
1060,
"ALTER TABLE `t` ADD COLUMN `c` BIGINT"
));
assert!(!is_mysql_duplicate(
1060,
"CREATE TABLE `t` (`c` INT, `c` INT)"
));
}
}