arcature-data 2026.2.0

Arcature high-level data layer: explicit-ownership model/query ergonomics over SeaORM/SQLx, N+1 detection, and migration lint.
Documentation
//! Isolated PostgreSQL test database for `arcature-data` integration tests.
//!
//! Mirrors the `arcature-test` `TestDb` fixture pattern (isolated PG, UUID DB
//! name, localhost-only guard, drop on stop) but is self-contained here to
//! avoid a dev-dependency cycle (`arcature` → `arcature-data` → dev
//! `arcature-test` → dev `arcature`). It uses `arcature-db` directly — the
//! same certified one-pool engine — so the tests exercise the real path.
//!
//! Tests are gated on `ARCATURE_TEST_DB_URL`: if it is not set, a test calls
//! [`require_db`] and returns early (it is *not* a fake pass — the test prints
//! a skip notice). Remote CI sets `ARCATURE_TEST_DB_URL` and runs them.

#![allow(dead_code)]

use uuid::Uuid;

use arcature_db::{Db, DbConfig};

/// An isolated PostgreSQL database for one test, over `arcature-db`.
pub struct TestDb {
    db: Db,
    maintenance: Option<Maintenance>,
}

struct Maintenance {
    db: Db,
    db_name: String,
}

/// The result of [`require_db`]: either a live [`TestDb`] or `None` when
/// `ARCATURE_TEST_DB_URL` is unset (the test skips).
pub enum DbOrSkip {
    /// A live isolated database.
    Db(TestDb),
    /// No live database; the test should return early.
    Skipped,
}

/// Acquire an isolated test database, or signal a skip.
///
/// Returns [`DbOrSkip::Skipped`] (and prints a skip notice) when
/// `ARCATURE_TEST_DB_URL` is not set. Otherwise it creates a unique
/// `arcature_data_test_<uuid>` database and connects an `arcature-db` `Db`.
/// A non-localhost URL is refused (the production-safety guard).
pub async fn require_db() -> DbOrSkip {
    let Ok(maintenance_url) = std::env::var("ARCATURE_TEST_DB_URL") else {
        eprintln!("skipping: ARCATURE_TEST_DB_URL not set");
        return DbOrSkip::Skipped;
    };
    if !is_localhost(&maintenance_url) {
        eprintln!("skipping: ARCATURE_TEST_DB_URL is not localhost");
        return DbOrSkip::Skipped;
    }
    let db_name = format!("arcature_data_test_{}", Uuid::new_v4().simple());
    let maintenance_config = match DbConfig::new(&maintenance_url) {
        Ok(config) => config,
        Err(error) => {
            eprintln!("skipping: cannot parse maintenance URL: {error}");
            return DbOrSkip::Skipped;
        }
    };
    let maintenance = match Db::connect(maintenance_config).await {
        Ok(db) => db,
        Err(error) => {
            eprintln!("skipping: cannot connect maintenance pool: {error}");
            return DbOrSkip::Skipped;
        }
    };
    // CREATE DATABASE is DDL; the name is `arcature_data_test_<uuid-hex>` so it
    // is injection-safe by construction. AssertSqlSafe is the SQLx 0.9 seam.
    let create = arcature_db::sqlx::query(arcature_db::sqlx::AssertSqlSafe(format!(
        "CREATE DATABASE \"{db_name}\""
    )))
    .execute(maintenance.sqlx())
    .await;
    if let Err(error) = create {
        eprintln!("skipping: cannot create test database: {error}");
        maintenance.close().await;
        return DbOrSkip::Skipped;
    }
    let test_url = replace_database(&maintenance_url, &db_name);
    let test_config = match DbConfig::new(&test_url) {
        Ok(config) => config,
        Err(error) => {
            eprintln!("skipping: cannot parse test URL: {error}");
            let _ = drop_test_db(&maintenance, &db_name).await;
            maintenance.close().await;
            return DbOrSkip::Skipped;
        }
    };
    let db = match Db::connect(test_config).await {
        Ok(db) => db,
        Err(error) => {
            eprintln!("skipping: cannot connect test pool: {error}");
            let _ = drop_test_db(&maintenance, &db_name).await;
            maintenance.close().await;
            return DbOrSkip::Skipped;
        }
    };
    DbOrSkip::Db(TestDb {
        db,
        maintenance: Some(Maintenance {
            db: maintenance,
            db_name,
        }),
    })
}

impl TestDb {
    /// The Arcature database handle.
    #[must_use]
    pub fn db(&self) -> &Db {
        &self.db
    }

    /// Stop the fixture: close the test pool, terminate connections, drop the
    /// test database, and close the maintenance pool.
    pub async fn stop(mut self) {
        self.db.close().await;
        if let Some(maintenance) = self.maintenance.take() {
            let _ = drop_test_db(&maintenance.db, &maintenance.db_name).await;
            maintenance.db.close().await;
        }
    }
}

async fn drop_test_db(maintenance: &Db, db_name: &str) -> Result<(), arcature_db::sqlx::Error> {
    arcature_db::sqlx::query(
        "SELECT pg_terminate_backend(pid) FROM pg_stat_activity \
         WHERE datname = $1 AND pid <> pg_backend_pid()",
    )
    .bind(db_name)
    .execute(maintenance.sqlx())
    .await?;
    arcature_db::sqlx::query(arcature_db::sqlx::AssertSqlSafe(format!(
        "DROP DATABASE IF EXISTS \"{db_name}\""
    )))
    .execute(maintenance.sqlx())
    .await?;
    Ok(())
}

/// Create the `users` and `posts` tables on the test database (idempotent).
/// Called by integration tests after [`require_db`].
pub async fn setup_schema(db: &Db) -> Result<(), arcature_db::sqlx::Error> {
    arcature_db::sqlx::query(
        "CREATE TABLE IF NOT EXISTS users (\
            id SERIAL PRIMARY KEY,\
            email TEXT NOT NULL UNIQUE,\
            name TEXT NOT NULL,\
            created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()\
        )",
    )
    .execute(db.sqlx())
    .await?;
    arcature_db::sqlx::query(
        "CREATE TABLE IF NOT EXISTS posts (\
            id SERIAL PRIMARY KEY,\
            user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,\
            title TEXT NOT NULL,\
            body TEXT,\
            active BOOLEAN NOT NULL DEFAULT TRUE,\
            created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()\
        )",
    )
    .execute(db.sqlx())
    .await?;
    Ok(())
}

/// Reset the `users`/`posts` tables to empty (called between test phases so
/// each test starts from a known state).
pub async fn reset_rows(db: &Db) -> Result<(), arcature_db::sqlx::Error> {
    arcature_db::sqlx::query("TRUNCATE TABLE posts, users RESTART IDENTITY CASCADE")
        .execute(db.sqlx())
        .await?;
    Ok(())
}

/// `true` when the URL host is loopback or a `*.localhost` name.
fn is_localhost(url: &str) -> bool {
    // Minimal host extraction without the `url` crate (kept off the dep
    // surface). Extract the authority component `host[:port]` between the
    // scheme separator and the next `/`, `?`, or `#`.
    let after_scheme = url.split("://").nth(1).unwrap_or(url);
    let authority_end = after_scheme
        .find(['/', '?', '#'])
        .unwrap_or(after_scheme.len());
    let authority = &after_scheme[..authority_end];
    // Strip userinfo (`user:pass@`).
    let host = authority.rsplit('@').next().unwrap_or(authority);
    // Strip the port (everything after the last `:` only when it looks like a
    // port — not an IPv6 address; test URLs are simple).
    let host = host.rsplit_once(':').map(|(h, _)| h).unwrap_or(host);
    host == "localhost" || host == "127.0.0.1" || host == "::1" || host.ends_with(".localhost")
}

/// Replace the database path in a URL, preserving the rest of the string.
/// Reuses the `arcature-test` approach: split on the last `/` after the host.
fn replace_database(url: &str, db_name: &str) -> String {
    // Find the start of the path component (after the host[:port]).
    let scheme_end = url.find("://").map(|i| i + 3).unwrap_or(0);
    let path_start = url[scheme_end..]
        .find('/')
        .map(|i| i + scheme_end)
        .unwrap_or(url.len());
    let query_start = url[path_start..].find('?').map(|i| i + path_start);
    // Base is the scheme://authority with no path — the old database name
    // (if any) is dropped and replaced by `db_name` below.
    let base = &url[..path_start];
    let query = match query_start {
        Some(qs) => &url[qs..],
        None => "",
    };
    format!("{base}/{db_name}{query}")
}

#[cfg(test)]
mod tests {
    use super::replace_database;

    #[test]
    fn replace_database_basic() {
        assert_eq!(
            replace_database("postgres://u:p@localhost:5432/main", "newdb"),
            "postgres://u:p@localhost:5432/newdb"
        );
    }

    #[test]
    fn replace_database_preserves_query() {
        assert_eq!(
            replace_database("postgres://localhost/main?sslmode=disable", "newdb"),
            "postgres://localhost/newdb?sslmode=disable"
        );
    }

    #[test]
    fn replace_database_when_no_path() {
        assert_eq!(
            replace_database("postgres://localhost", "newdb"),
            "postgres://localhost/newdb"
        );
    }
}