diff --git a/.github/workflows/sqlx-cli.yml b/.github/workflows/sqlx-cli.yml index 927616c69b..f0c3630356 100644 --- a/.github/workflows/sqlx-cli.yml +++ b/.github/workflows/sqlx-cli.yml @@ -45,7 +45,6 @@ jobs: - ubuntu-latest # FIXME: migrations tests fail on Windows for whatever reason # - windows-latest - - macOS-13 - macOS-latest timeout-minutes: 30 @@ -302,7 +301,6 @@ jobs: os: - ubuntu-latest - windows-latest - - macOS-13 - macOS-latest include: - os: ubuntu-latest @@ -312,9 +310,6 @@ jobs: - os: windows-latest target: x86_64-pc-windows-msvc bin: target/debug/cargo-sqlx.exe - - os: macOS-13 - target: x86_64-apple-darwin - bin: target/debug/cargo-sqlx - os: macOS-latest target: aarch64-apple-darwin bin: target/debug/cargo-sqlx diff --git a/.github/workflows/sqlx.yml b/.github/workflows/sqlx.yml index b2f81b75ad..fea21b1629 100644 --- a/.github/workflows/sqlx.yml +++ b/.github/workflows/sqlx.yml @@ -371,6 +371,17 @@ jobs: SQLX_OFFLINE_DIR: .sqlx RUSTFLAGS: --cfg mysql_${{ matrix.mysql }} + # Run template database cloning tests + - run: > + cargo test + --test mysql-template + --no-default-features + --features any,mysql,macros,migrate,_unstable-all-types,runtime-${{ matrix.runtime }},tls-${{ matrix.tls }} + env: + DATABASE_URL: mysql://root:password@localhost:3306/sqlx?ssl-mode=disabled + SQLX_OFFLINE_DIR: .sqlx + RUSTFLAGS: --cfg mysql_${{ matrix.mysql }} + # MySQL 5.7 supports TLS but not TLSv1.3 as required by RusTLS. - if: ${{ !(matrix.mysql == '5_7' && matrix.tls == 'rustls') }} run: > @@ -472,6 +483,17 @@ jobs: SQLX_OFFLINE_DIR: .sqlx RUSTFLAGS: --cfg mariadb="${{ matrix.mariadb }}" + # Run template database cloning tests + - run: > + cargo test + --test mysql-template + --no-default-features + --features any,mysql,macros,migrate,_unstable-all-types,runtime-${{ matrix.runtime }},tls-${{ matrix.tls }} + env: + DATABASE_URL: mysql://root:password@localhost:3306/sqlx + SQLX_OFFLINE_DIR: .sqlx + RUSTFLAGS: --cfg mariadb="${{ matrix.mariadb }}" + # Remove test artifacts - run: cargo clean -p sqlx diff --git a/Cargo.toml b/Cargo.toml index 00d5d656c1..595e7e3051 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -391,6 +391,11 @@ name = "mysql-test-attr" path = "tests/mysql/test-attr.rs" required-features = ["mysql", "macros", "migrate"] +[[test]] +name = "mysql-template" +path = "tests/mysql/template.rs" +required-features = ["mysql", "macros", "migrate"] + [[test]] name = "mysql-migrate" path = "tests/mysql/migrate.rs" diff --git a/sqlx-core/src/testing/mod.rs b/sqlx-core/src/testing/mod.rs index 17022b4652..887c382c26 100644 --- a/sqlx-core/src/testing/mod.rs +++ b/sqlx-core/src/testing/mod.rs @@ -1,9 +1,12 @@ use std::future::Future; use std::time::Duration; -use base64::{engine::general_purpose::URL_SAFE, Engine as _}; +use base64::{ + engine::general_purpose::{URL_SAFE, URL_SAFE_NO_PAD}, + Engine as _, +}; pub use fixtures::FixtureSnapshot; -use sha2::{Digest, Sha512}; +use sha2::{Digest, Sha256, Sha512}; use crate::connection::{ConnectOptions, Connection}; use crate::database::Database; @@ -14,6 +17,35 @@ use crate::pool::{Pool, PoolConnection, PoolOptions}; mod fixtures; +/// Compute a combined hash of all migrations for template invalidation. +/// +/// This hash is used to name template databases. When migrations change, +/// a new hash is generated, resulting in a new template being created. +pub fn migrations_hash(migrator: &Migrator) -> String { + let mut hasher = Sha256::new(); + + for migration in migrator.iter() { + // Include version, type, and checksum in the hash + hasher.update(migration.version.to_le_bytes()); + hasher.update([migration.migration_type as u8]); + hasher.update(&*migration.checksum); + } + + let hash = hasher.finalize(); + // Use first 16 bytes (128 bits) for a reasonably short but unique name + URL_SAFE_NO_PAD.encode(&hash[..16]) +} + +/// Generate a template database name from a migrations hash. +/// +/// Template names follow the pattern `_sqlx_template_` and are kept +/// under 63 characters to respect database identifier limits. +pub fn template_db_name(migrations_hash: &str) -> String { + // Replace any characters that might cause issues in database names + let safe_hash = migrations_hash.replace(['-', '+', '/'], "_"); + format!("_sqlx_template_{}", safe_hash) +} + pub trait TestSupport: Database { /// Get parameters to construct a `Pool` suitable for testing. /// @@ -82,6 +114,9 @@ pub struct TestContext { pub pool_opts: PoolOptions, pub connect_opts: ::Options, pub db_name: String, + /// Whether this test database was created from a template. + /// When true, migrations have already been applied and should be skipped. + pub from_template: bool, } impl TestFn for fn(Pool) -> Fut @@ -226,7 +261,12 @@ where .await .expect("failed to connect to setup test database"); - setup_test_db::(&test_context.connect_opts, &args).await; + setup_test_db::( + &test_context.connect_opts, + &args, + test_context.from_template, + ) + .await; let res = test_fn(test_context.pool_opts, test_context.connect_opts).await; @@ -246,6 +286,7 @@ where async fn setup_test_db( copts: &::Options, args: &TestArgs, + from_template: bool, ) where DB::Connection: Migrate + Sized, for<'c> &'c mut DB::Connection: Executor<'c, Database = DB>, @@ -255,11 +296,15 @@ async fn setup_test_db( .await .expect("failed to connect to test database"); - if let Some(migrator) = args.migrator { - migrator - .run_direct(None, &mut conn) - .await - .expect("failed to apply migrations"); + // Skip migrations if the database was cloned from a template + // (migrations were already applied to the template) + if !from_template { + if let Some(migrator) = args.migrator { + migrator + .run_direct(None, &mut conn) + .await + .expect("failed to apply migrations"); + } } for fixture in args.fixtures { diff --git a/sqlx-mysql/src/testing/mod.rs b/sqlx-mysql/src/testing/mod.rs index f509f9da45..03766be7e2 100644 --- a/sqlx-mysql/src/testing/mod.rs +++ b/sqlx-mysql/src/testing/mod.rs @@ -1,5 +1,6 @@ use std::future::Future; use std::ops::Deref; +use std::process::Stdio; use std::str::FromStr; use std::sync::OnceLock; use std::time::Duration; @@ -9,16 +10,308 @@ use crate::executor::Executor; use crate::pool::{Pool, PoolOptions}; use crate::query::query; use crate::{MySql, MySqlConnectOptions, MySqlConnection, MySqlDatabaseError}; -use sqlx_core::connection::Connection; +use sqlx_core::connection::{ConnectOptions, Connection}; use sqlx_core::query_builder::QueryBuilder; use sqlx_core::query_scalar::query_scalar; use sqlx_core::sql_str::AssertSqlSafe; +use sqlx_core::testing::{migrations_hash, template_db_name}; pub(crate) use sqlx_core::testing::*; // Using a blocking `OnceLock` here because the critical sections are short. static MASTER_POOL: OnceLock> = OnceLock::new(); +/// Environment variable to disable template cloning. +const SQLX_TEST_NO_TEMPLATE: &str = "SQLX_TEST_NO_TEMPLATE"; + +/// Check if template cloning is enabled. +fn templates_enabled() -> bool { + std::env::var(SQLX_TEST_NO_TEMPLATE).is_err() +} + +/// Get or create a template database with migrations applied. +/// Returns the template database name if successful, or None if templates are disabled. +async fn get_or_create_template( + conn: &mut MySqlConnection, + master_opts: &MySqlConnectOptions, + migrator: &sqlx_core::migrate::Migrator, +) -> Result, Error> { + if !templates_enabled() { + return Ok(None); + } + + let hash = migrations_hash(migrator); + let tpl_name = template_db_name(&hash); + + // Use MySQL's GET_LOCK for synchronization across processes + // Use 300 second timeout (MariaDB may not handle -1 correctly) + // GET_LOCK returns 1 if successful, 0 if timed out, NULL on error + let lock_result: Option = query_scalar("SELECT GET_LOCK(?, 300)") + .bind("sqlx_template_lock") + .fetch_one(&mut *conn) + .await?; + + if lock_result != Some(1) { + return Err(Error::Protocol(format!( + "Failed to acquire template lock, GET_LOCK returned: {:?}", + lock_result + ))); + } + + // Ensure template tracking table exists + conn.execute( + r#" + CREATE TABLE IF NOT EXISTS _sqlx_test_templates ( + template_name VARCHAR(255) PRIMARY KEY, + migrations_hash VARCHAR(64) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_used_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY (migrations_hash) + ) + "#, + ) + .await?; + + // Check if template already exists in tracking table + let existing: Option = + query_scalar("SELECT template_name FROM _sqlx_test_templates WHERE migrations_hash = ?") + .bind(&hash) + .fetch_optional(&mut *conn) + .await?; + + if let Some(existing_name) = existing { + // Template exists, update last_used_at and return + query("UPDATE _sqlx_test_templates SET last_used_at = CURRENT_TIMESTAMP WHERE template_name = ?") + .bind(&existing_name) + .execute(&mut *conn) + .await?; + + // Release lock + query("SELECT RELEASE_LOCK(?)") + .bind("sqlx_template_lock") + .execute(&mut *conn) + .await?; + + return Ok(Some(existing_name)); + } + + // Create new template database (use IF NOT EXISTS for idempotency) + // The database might exist from a previous run without being registered + conn.execute(AssertSqlSafe(format!( + "CREATE DATABASE IF NOT EXISTS `{tpl_name}`" + ))) + .await?; + + // Check if this is a fresh database or one left over from a previous run + // by checking if it already has migrations recorded + let template_opts = master_opts.clone().database(&tpl_name); + let mut template_conn: MySqlConnection = template_opts.connect().await?; + + // Try to count migrations - if the table doesn't exist or is empty, we need to run migrations + let migration_count: Result = query_scalar("SELECT COUNT(*) FROM _sqlx_migrations") + .fetch_one(&mut template_conn) + .await; + + let needs_migrations = match &migration_count { + Ok(count) => { + eprintln!("template {tpl_name}: found {} existing migrations", count); + *count == 0 + } + Err(e) => { + eprintln!( + "template {tpl_name}: migration check error (table may not exist): {}", + e + ); + true + } + }; + + // Only run migrations if the database is fresh (no migrations table) + if needs_migrations { + if let Err(e) = migrator.run_direct(None, &mut template_conn).await { + // Clean up on failure + template_conn.close().await.ok(); + conn.execute(AssertSqlSafe(format!( + "DROP DATABASE IF EXISTS `{tpl_name}`" + ))) + .await + .ok(); + query("SELECT RELEASE_LOCK(?)") + .bind("sqlx_template_lock") + .execute(&mut *conn) + .await?; + return Err(Error::Protocol(format!( + "Failed to apply migrations to template: {}", + e + ))); + } + } + + template_conn.close().await?; + + // Register template (use INSERT IGNORE in case it was already registered by another process) + query("INSERT IGNORE INTO _sqlx_test_templates (template_name, migrations_hash) VALUES (?, ?)") + .bind(&tpl_name) + .bind(&hash) + .execute(&mut *conn) + .await?; + + // Release lock + query("SELECT RELEASE_LOCK(?)") + .bind("sqlx_template_lock") + .execute(&mut *conn) + .await?; + + eprintln!("created template database {tpl_name}"); + + Ok(Some(tpl_name)) +} + +/// Clone a template database to a new test database using mysqldump. +/// Falls back to in-process schema copy if mysqldump is not available. +async fn clone_database( + conn: &mut MySqlConnection, + master_opts: &MySqlConnectOptions, + template_name: &str, + new_db_name: &str, +) -> Result<(), Error> { + // First, create the new empty database + conn.execute(AssertSqlSafe(format!("CREATE DATABASE `{new_db_name}`"))) + .await?; + + // Try mysqldump approach first (faster for large schemas) + if clone_with_mysqldump(master_opts, template_name, new_db_name).is_ok() { + return Ok(()); + } + + // Fall back to in-process schema copy + clone_in_process(conn, master_opts, template_name, new_db_name).await +} + +/// Clone database using mysqldump and mysql commands. +/// Uses synchronous process execution for cross-runtime compatibility. +fn clone_with_mysqldump( + opts: &MySqlConnectOptions, + template_name: &str, + new_db_name: &str, +) -> Result<(), Error> { + use std::io::Write; + use std::process::Command; + + let host = &opts.host; + let port = opts.port.to_string(); + let user = &opts.username; + + // Build mysqldump command + let mut dump_cmd = Command::new("mysqldump"); + dump_cmd + .arg("--no-data") + .arg("--routines") + .arg("--triggers") + .arg("-h") + .arg(host) + .arg("-P") + .arg(&port) + .arg("-u") + .arg(user); + + if let Some(ref password) = opts.password { + dump_cmd.arg(format!("-p{}", password)); + } + + dump_cmd.arg(template_name); + dump_cmd.stdout(Stdio::piped()); + dump_cmd.stderr(Stdio::null()); + + let dump_output = dump_cmd + .output() + .map_err(|e| Error::Protocol(format!("Failed to run mysqldump: {}", e)))?; + + if !dump_output.status.success() { + return Err(Error::Protocol("mysqldump failed".into())); + } + + // Build mysql command to import + let mut import_cmd = Command::new("mysql"); + import_cmd + .arg("-h") + .arg(host) + .arg("-P") + .arg(&port) + .arg("-u") + .arg(user); + + if let Some(ref password) = opts.password { + import_cmd.arg(format!("-p{}", password)); + } + + import_cmd.arg(new_db_name); + import_cmd.stdin(Stdio::piped()); + import_cmd.stdout(Stdio::null()); + import_cmd.stderr(Stdio::null()); + + let mut import_child = import_cmd + .spawn() + .map_err(|e| Error::Protocol(format!("Failed to spawn mysql: {}", e)))?; + + // Write dump output to mysql stdin + if let Some(ref mut stdin) = import_child.stdin { + stdin + .write_all(&dump_output.stdout) + .map_err(|e| Error::Protocol(format!("Failed to write to mysql stdin: {}", e)))?; + } + + let import_status = import_child + .wait() + .map_err(|e| Error::Protocol(format!("Failed to wait for mysql: {}", e)))?; + + if !import_status.success() { + return Err(Error::Protocol("mysql import failed".into())); + } + + Ok(()) +} + +/// Clone database using in-process SQL commands (fallback). +async fn clone_in_process( + conn: &mut MySqlConnection, + master_opts: &MySqlConnectOptions, + template_name: &str, + new_db_name: &str, +) -> Result<(), Error> { + // Get all tables from template + let tables: Vec = query_scalar( + "SELECT table_name FROM information_schema.tables WHERE table_schema = ? AND table_type = 'BASE TABLE'", + ) + .bind(template_name) + .fetch_all(&mut *conn) + .await?; + + // Connect to the new database for copying + let new_db_opts = master_opts.clone().database(new_db_name); + let mut new_conn: MySqlConnection = new_db_opts.connect().await?; + + for table in &tables { + // Copy table structure + new_conn + .execute(AssertSqlSafe(format!( + "CREATE TABLE `{new_db_name}`.`{table}` LIKE `{template_name}`.`{table}`" + ))) + .await?; + + // Copy table data (for migrations table, etc.) + new_conn + .execute(AssertSqlSafe(format!( + "INSERT INTO `{new_db_name}`.`{table}` SELECT * FROM `{template_name}`.`{table}`" + ))) + .await?; + } + + new_conn.close().await?; + + Ok(()) +} + impl TestSupport for MySql { fn test_context( args: &TestArgs, @@ -108,7 +401,7 @@ async fn test_context(args: &TestArgs) -> Result, Error> { .max_connections(20) // Immediately close master connections. Tokio's I/O streams don't like hopping runtimes. .after_release(|_conn, _| Box::pin(async move { Ok(false) })) - .connect_lazy_with(master_opts); + .connect_lazy_with(master_opts.clone()); let master_pool = match once_lock_try_insert_polyfill(&MASTER_POOL, pool) { Ok(inserted) => inserted, @@ -144,7 +437,7 @@ async fn test_context(args: &TestArgs) -> Result, Error> { -- BLOB/TEXT columns can only be used as index keys with a prefix length: -- https://dev.mysql.com/doc/refman/8.4/en/column-indexes.html#column-indexes-prefix primary key(db_name(63)) - ); + ); "#, ) .await?; @@ -158,10 +451,60 @@ async fn test_context(args: &TestArgs) -> Result, Error> { .execute(&mut *conn) .await?; - conn.execute(AssertSqlSafe(format!("create database {db_name}"))) - .await?; - - eprintln!("created database {db_name}"); + // Try to use template cloning if migrations are provided + let from_template = if let Some(migrator) = args.migrator { + match get_or_create_template(&mut conn, &master_opts, migrator).await { + Ok(Some(template_name)) => { + // Clone from template (fast path) + match clone_database(&mut conn, &master_opts, &template_name, &db_name).await { + Ok(()) => { + eprintln!("cloned database {db_name} from template {template_name}"); + true + } + Err(e) => { + // Clean up partial database and fall back to empty database + eprintln!( + "failed to clone template, falling back to empty database: {}", + e + ); + conn.execute(AssertSqlSafe(format!( + "drop database if exists `{db_name}`" + ))) + .await + .ok(); + conn.execute(AssertSqlSafe(format!("create database `{db_name}`"))) + .await?; + eprintln!("created database {db_name}"); + false + } + } + } + Ok(None) => { + // Templates disabled or not available + conn.execute(AssertSqlSafe(format!("create database `{db_name}`"))) + .await?; + eprintln!("created database {db_name}"); + false + } + Err(e) => { + // Template creation failed, fall back to empty database + eprintln!( + "failed to create template, falling back to empty database: {}", + e + ); + conn.execute(AssertSqlSafe(format!("create database `{db_name}`"))) + .await?; + eprintln!("created database {db_name}"); + false + } + } + } else { + // No migrations, create empty database + conn.execute(AssertSqlSafe(format!("create database `{db_name}`"))) + .await?; + eprintln!("created database {db_name}"); + false + }; Ok(TestContext { pool_opts: PoolOptions::new() @@ -178,6 +521,7 @@ async fn test_context(args: &TestArgs) -> Result, Error> { .clone() .database(&db_name), db_name, + from_template, }) } diff --git a/sqlx-postgres/src/testing/mod.rs b/sqlx-postgres/src/testing/mod.rs index 3e1cf0ddf7..985a7eb60f 100644 --- a/sqlx-postgres/src/testing/mod.rs +++ b/sqlx-postgres/src/testing/mod.rs @@ -183,6 +183,8 @@ async fn test_context(args: &TestArgs) -> Result, Error> { .clone() .database(&db_name), db_name, + // TODO: Implement template cloning for Postgres in a follow-up PR + from_template: false, }) } diff --git a/sqlx-sqlite/src/testing/mod.rs b/sqlx-sqlite/src/testing/mod.rs index 058a24c52b..19d53e09b3 100644 --- a/sqlx-sqlite/src/testing/mod.rs +++ b/sqlx-sqlite/src/testing/mod.rs @@ -58,6 +58,8 @@ async fn test_context(args: &TestArgs) -> Result, Error> { // The main limitation is going to be the number of concurrent running tests. pool_opts: PoolOptions::new().max_connections(1000), db_name: db_path, + // SQLite doesn't use template cloning (file copy could be added in the future) + from_template: false, }) } diff --git a/tests/mysql/template.rs b/tests/mysql/template.rs new file mode 100644 index 0000000000..b44a7d46c7 --- /dev/null +++ b/tests/mysql/template.rs @@ -0,0 +1,249 @@ +// Tests for template database cloning functionality in MySQL. +// +// These tests verify that: +// 1. Template databases are created when migrations are used +// 2. Multiple tests with the same migrations share a template +// 3. SQLX_TEST_NO_TEMPLATE disables template cloning +// 4. Different migrations create different templates + +use sqlx::mysql::MySqlPool; +use sqlx::Connection; + +/// Verify that the template tracking table exists and contains entries +/// after running a test with migrations. +#[sqlx::test(migrations = "tests/mysql/migrations")] +async fn it_creates_template_database(pool: MySqlPool) -> sqlx::Result<()> { + // Get the master database connection to check for templates + let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set"); + let mut master_conn = sqlx::mysql::MySqlConnection::connect(&database_url).await?; + + // Check that the template tracking table exists and has at least one entry + let template_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM _sqlx_test_templates WHERE template_name LIKE '_sqlx_template_%'", + ) + .fetch_one(&mut master_conn) + .await?; + + // If templates are enabled, we should have at least one template + // (unless SQLX_TEST_NO_TEMPLATE is set) + if std::env::var("SQLX_TEST_NO_TEMPLATE").is_err() { + assert!( + template_count > 0, + "Expected at least one template database to be created" + ); + } + + // Verify the test database has the expected tables from migrations + let table_count: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = database() AND table_name IN ('user', 'post', 'comment')") + .fetch_one(&pool) + .await?; + + assert_eq!(table_count, 3, "Expected user, post, and comment tables"); + + Ok(()) +} + +/// Verify that the migrations table is properly cloned from template. +/// When cloning from a template, the _sqlx_migrations table should already +/// exist and contain the migration history. +#[sqlx::test(migrations = "tests/mysql/migrations")] +async fn it_clones_migrations_table(pool: MySqlPool) -> sqlx::Result<()> { + // Check that _sqlx_migrations table exists and has entries + let migration_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM _sqlx_migrations") + .fetch_one(&pool) + .await?; + + // We have 3 migrations in tests/mysql/migrations + assert_eq!( + migration_count, 3, + "Expected 3 migrations to be recorded in _sqlx_migrations" + ); + + // Verify the specific migrations are recorded + let versions: Vec = + sqlx::query_scalar("SELECT version FROM _sqlx_migrations ORDER BY version") + .fetch_all(&pool) + .await?; + + assert_eq!(versions, vec![1, 2, 3], "Expected migrations 1, 2, 3"); + + Ok(()) +} + +/// Test that multiple tests with the same migrations share a template. +/// This test runs alongside other tests with the same migrations and +/// verifies that only one template exists. +#[sqlx::test(migrations = "tests/mysql/migrations")] +async fn it_reuses_template_for_same_migrations_1(pool: MySqlPool) -> sqlx::Result<()> { + // This test shares migrations with other tests, so they should all + // use the same template database. + let db_name: String = sqlx::query_scalar("SELECT database()") + .fetch_one(&pool) + .await?; + + assert!( + db_name.starts_with("_sqlx_test_"), + "Test database should start with _sqlx_test_" + ); + + // Verify tables exist + let user_exists: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_schema = database() AND table_name = 'user')", + ) + .fetch_one(&pool) + .await?; + + assert!(user_exists, "user table should exist"); + + Ok(()) +} + +/// Second test with same migrations - should reuse the same template. +#[sqlx::test(migrations = "tests/mysql/migrations")] +async fn it_reuses_template_for_same_migrations_2(pool: MySqlPool) -> sqlx::Result<()> { + // Same test as above - verifies template reuse + let db_name: String = sqlx::query_scalar("SELECT database()") + .fetch_one(&pool) + .await?; + + assert!( + db_name.starts_with("_sqlx_test_"), + "Test database should start with _sqlx_test_" + ); + + // Verify tables exist + let post_exists: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_schema = database() AND table_name = 'post')", + ) + .fetch_one(&pool) + .await?; + + assert!(post_exists, "post table should exist"); + + Ok(()) +} + +/// Verify that template databases have the correct naming pattern. +#[sqlx::test(migrations = "tests/mysql/migrations")] +async fn it_names_templates_correctly(_pool: MySqlPool) -> sqlx::Result<()> { + if std::env::var("SQLX_TEST_NO_TEMPLATE").is_ok() { + // Skip this test if templates are disabled + return Ok(()); + } + + let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set"); + let mut master_conn = sqlx::mysql::MySqlConnection::connect(&database_url).await?; + + // Get template names + let template_names: Vec = sqlx::query_scalar( + "SELECT template_name FROM _sqlx_test_templates WHERE template_name LIKE '_sqlx_template_%'", + ) + .fetch_all(&mut master_conn) + .await?; + + for name in &template_names { + assert!( + name.starts_with("_sqlx_template_"), + "Template name should start with _sqlx_template_, got: {}", + name + ); + // Template names should be reasonably short (under 63 chars for MySQL) + assert!( + name.len() < 63, + "Template name should be under 63 chars, got: {} ({})", + name, + name.len() + ); + } + + Ok(()) +} + +/// Test that fixtures are applied on top of the cloned template. +/// Fixtures should be applied per-test, not stored in the template. +#[sqlx::test(migrations = "tests/mysql/migrations", fixtures("users"))] +async fn it_applies_fixtures_after_clone(pool: MySqlPool) -> sqlx::Result<()> { + // The users fixture should have been applied + let user_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM user") + .fetch_one(&pool) + .await?; + + assert!(user_count > 0, "users fixture should have inserted users"); + + // Verify specific users from the fixture + let usernames: Vec = sqlx::query_scalar("SELECT username FROM user ORDER BY username") + .fetch_all(&pool) + .await?; + + assert_eq!(usernames, vec!["alice", "bob"]); + + Ok(()) +} + +/// Test that different tests get different fixture data even when +/// sharing the same template. +#[sqlx::test(migrations = "tests/mysql/migrations", fixtures("users", "posts"))] +async fn it_isolates_fixtures_between_tests(pool: MySqlPool) -> sqlx::Result<()> { + // This test has different fixtures than the previous one + let post_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM post") + .fetch_one(&pool) + .await?; + + assert!(post_count > 0, "posts fixture should have inserted posts"); + + Ok(()) +} + +/// Unit test for migrations_hash function - verify it produces consistent hashes. +#[test] +fn test_migrations_hash_consistency() { + use sqlx_core::migrate::Migrator; + use sqlx_core::testing::migrations_hash; + + // Create a migrator with the test migrations + let migrator: Migrator = sqlx::migrate!("tests/mysql/migrations"); + + // Hash should be consistent across calls + let hash1 = migrations_hash(&migrator); + let hash2 = migrations_hash(&migrator); + + assert_eq!(hash1, hash2, "migrations_hash should be deterministic"); + + // Hash should be non-empty and reasonable length + assert!(!hash1.is_empty(), "hash should not be empty"); + assert!( + hash1.len() < 30, + "hash should be reasonably short for use in database names" + ); +} + +/// Unit test for template_db_name function. +#[test] +fn test_template_db_name_format() { + use sqlx_core::testing::template_db_name; + + let name = template_db_name("abc123xyz"); + + assert!( + name.starts_with("_sqlx_template_"), + "template name should have correct prefix" + ); + assert!( + name.contains("abc123xyz"), + "template name should contain hash" + ); + assert!( + name.len() < 63, + "template name should fit in MySQL identifier limit" + ); + + // Test with special characters that need escaping + let name_with_special = template_db_name("a-b+c/d"); + assert!( + !name_with_special.contains('-'), + "should not contain hyphen" + ); + assert!(!name_with_special.contains('+'), "should not contain plus"); + assert!(!name_with_special.contains('/'), "should not contain slash"); +}