bsql 0.6.0

Safe SQL for Rust — if it compiles, the SQL is correct
Documentation
//! v0.2 integration tests: feature-gated types and pg_enum.
//!
//! These tests require specific features. Run with:
//!   BSQL_DATABASE_URL=postgres://sasql:sasql@localhost/sasql_test \
//!     cargo test -p bsql --test v02_types --features "time,uuid"

use bsql::Pool;

async fn pool() -> Pool {
    Pool::connect("postgres://sasql:sasql@localhost/sasql_test")
        .await
        .expect("Failed to connect to test database. Is PostgreSQL running?")
}

// ---------------------------------------------------------------------------
// pg_enum tests
// ---------------------------------------------------------------------------

#[bsql::pg_enum]
pub enum TicketStatus {
    #[sql("new")]
    New,
    #[sql("in_progress")]
    InProgress,
    #[sql("resolved")]
    Resolved,
    #[sql("closed")]
    Closed,
}

#[tokio::test]
async fn pg_enum_select_returns_string() {
    // Without pg_enum integration in query!, enum columns map to String.
    let pool = pool().await;
    let id = 1i32;
    let ticket = bsql::query!("SELECT id, status FROM tickets WHERE id = $id: i32")
        .fetch_one(&pool)
        .await
        .unwrap();

    assert_eq!(ticket.id, 1);
    assert_eq!(ticket.status, "new");
}

#[tokio::test]
async fn pg_enum_display() {
    assert_eq!(format!("{}", TicketStatus::New), "new");
    assert_eq!(format!("{}", TicketStatus::InProgress), "in_progress");
    assert_eq!(format!("{}", TicketStatus::Resolved), "resolved");
    assert_eq!(format!("{}", TicketStatus::Closed), "closed");
}

#[tokio::test]
async fn pg_enum_equality() {
    assert_eq!(TicketStatus::New, TicketStatus::New);
    assert_ne!(TicketStatus::New, TicketStatus::Closed);
}

#[tokio::test]
async fn pg_enum_clone_copy() {
    // Prove Copy: use after move
    let status = TicketStatus::InProgress;
    let copied = status;
    assert_eq!(status, copied); // status still usable → Copy works

    // Prove Clone: function that requires Clone bound
    fn needs_clone<T: Clone>(v: &T) -> T {
        v.clone()
    }
    let cloned = needs_clone(&status);
    assert_eq!(status, cloned);
}

#[tokio::test]
async fn pg_enum_debug() {
    assert_eq!(format!("{:?}", TicketStatus::Resolved), "Resolved");
}

// ---------------------------------------------------------------------------
// UUID tests (feature = "uuid")
// ---------------------------------------------------------------------------

#[cfg(feature = "uuid")]
mod uuid_tests {
    use super::*;

    #[tokio::test]
    async fn select_uuid_column() {
        let pool = pool().await;
        let id = 1i32;
        let ticket = bsql::query!("SELECT id, ticket_uuid FROM tickets WHERE id = $id: i32")
            .fetch_one(&pool)
            .await
            .unwrap();

        assert_eq!(ticket.id, 1);
        // UUID was generated by gen_random_uuid() — just verify it's valid
        let _uuid: uuid::Uuid = ticket.ticket_uuid;
    }

    #[tokio::test]
    async fn uuid_round_trip() {
        let pool = pool().await;
        let test_uuid = uuid::Uuid::new_v4();
        let title = "UUID round-trip test";
        let uid = 1i32;

        // Insert with explicit UUID
        let ticket = bsql::query!(
            "INSERT INTO tickets (title, status, created_by_user_id, ticket_uuid)
             VALUES ($title: &str, 'new', $uid: i32, $test_uuid: uuid::Uuid)
             RETURNING id, ticket_uuid"
        )
        .fetch_one(&pool)
        .await
        .unwrap();

        assert_eq!(ticket.ticket_uuid, test_uuid);

        // Clean up
        let inserted_id = ticket.id;
        bsql::query!("DELETE FROM tickets WHERE id = $inserted_id: i32")
            .execute(&pool)
            .await
            .unwrap();
    }

    #[tokio::test]
    async fn uuid_param_filter() {
        let pool = pool().await;
        let id = 1i32;

        // First get the UUID of ticket 1
        let ticket = bsql::query!("SELECT id, ticket_uuid FROM tickets WHERE id = $id: i32")
            .fetch_one(&pool)
            .await
            .unwrap();

        let target_uuid = ticket.ticket_uuid;

        // Now query by UUID
        let found =
            bsql::query!("SELECT id FROM tickets WHERE ticket_uuid = $target_uuid: uuid::Uuid")
                .fetch_one(&pool)
                .await
                .unwrap();

        assert_eq!(found.id, 1);
    }
}

// ---------------------------------------------------------------------------
// Time tests (feature = "time")
// ---------------------------------------------------------------------------

#[cfg(feature = "time")]
mod time_tests {
    use super::*;

    #[tokio::test]
    async fn select_nullable_timestamptz() {
        // deadline is nullable TIMESTAMPTZ
        let pool = pool().await;
        let id = 1i32;
        let ticket = bsql::query!("SELECT id, deadline FROM tickets WHERE id = $id: i32")
            .fetch_one(&pool)
            .await
            .unwrap();

        assert_eq!(ticket.id, 1);
        // Default is NULL
        assert!(ticket.deadline.is_none());
    }

    #[tokio::test]
    async fn timestamptz_round_trip() {
        let pool = pool().await;
        let now = time::OffsetDateTime::now_utc();
        let id = 1i32;

        // Set deadline
        bsql::query!(
            "UPDATE tickets SET deadline = $now: time::OffsetDateTime WHERE id = $id: i32"
        )
        .execute(&pool)
        .await
        .unwrap();

        // Read it back
        let ticket = bsql::query!("SELECT id, deadline FROM tickets WHERE id = $id: i32")
            .fetch_one(&pool)
            .await
            .unwrap();

        let deadline = ticket.deadline.expect("deadline should be set");
        // PostgreSQL stores microsecond precision, so we compare to within 1ms
        let diff = (deadline - now).whole_milliseconds().unsigned_abs();
        assert!(diff < 2, "timestamps differ by {diff}ms");

        // Clean up — set back to NULL
        bsql::query!("UPDATE tickets SET deadline = NULL WHERE id = $id: i32")
            .execute(&pool)
            .await
            .unwrap();
    }

    #[tokio::test]
    async fn select_date_column() {
        let pool = pool().await;
        let id = 1i32;
        let ticket = bsql::query!("SELECT id, created_date FROM tickets WHERE id = $id: i32")
            .fetch_one(&pool)
            .await
            .unwrap();

        assert_eq!(ticket.id, 1);
        // Should be today's date (or whenever setup.sql ran)
        let _date: time::Date = ticket.created_date;
    }

    #[tokio::test]
    async fn select_nullable_time_column() {
        let pool = pool().await;
        // Insert a fresh ticket with no start_time (NULL by default)
        let title = "nullable_time_test";
        let uid = 1i32;
        let ticket = bsql::query!(
            "INSERT INTO tickets (title, status, created_by_user_id)
             VALUES ($title: &str, 'new', $uid: i32)
             RETURNING id, start_time"
        )
        .fetch_one(&pool)
        .await
        .unwrap();

        assert!(ticket.id > 0);
        // start_time has no default → NULL
        assert!(ticket.start_time.is_none());
    }

    #[tokio::test]
    async fn time_round_trip() {
        let pool = pool().await;
        let t = time::Time::from_hms(14, 30, 0).expect("valid time");

        // Create own ticket to avoid interfering with other tests
        let title = "time_round_trip_test";
        let uid = 1i32;
        let ticket = bsql::query!(
            "INSERT INTO tickets (title, status, created_by_user_id, start_time)
             VALUES ($title: &str, 'new', $uid: i32, $t: time::Time)
             RETURNING id, start_time"
        )
        .fetch_one(&pool)
        .await
        .unwrap();

        let start_time = ticket.start_time.expect("start_time should be set");
        assert_eq!(start_time.hour(), 14);
        assert_eq!(start_time.minute(), 30);

        // Clean up — delete our test ticket
        let ticket_id = ticket.id;
        bsql::query!("DELETE FROM tickets WHERE id = $ticket_id: i32")
            .execute(&pool)
            .await
            .unwrap();
    }

    #[tokio::test]
    async fn date_param() {
        let pool = pool().await;
        let today = time::OffsetDateTime::now_utc().date();
        // Query tickets created today
        let tickets =
            bsql::query!("SELECT id FROM tickets WHERE created_date = $today: time::Date")
                .fetch_all(&pool)
                .await
                .unwrap();

        // All seed tickets were created today
        assert!(!tickets.is_empty());
    }
}

// ---------------------------------------------------------------------------
// Enum column as String (no feature needed)
// ---------------------------------------------------------------------------

#[tokio::test]
async fn enum_column_maps_to_string() {
    let pool = pool().await;
    let id = 1i32;
    let ticket = bsql::query!("SELECT id, status FROM tickets WHERE id = $id: i32")
        .fetch_one(&pool)
        .await
        .unwrap();

    assert_eq!(ticket.id, 1);
    assert_eq!(ticket.status, "new");
}

#[tokio::test]
async fn insert_with_enum_literal() {
    // PG accepts text literals for enum types
    let pool = pool().await;
    let title = "Enum literal test";
    let uid = 1i32;
    let ticket = bsql::query!(
        "INSERT INTO tickets (title, status, created_by_user_id)
         VALUES ($title: &str, 'resolved', $uid: i32)
         RETURNING id, status"
    )
    .fetch_one(&pool)
    .await
    .unwrap();

    assert_eq!(ticket.status, "resolved");

    // Clean up
    let del_id = ticket.id;
    bsql::query!("DELETE FROM tickets WHERE id = $del_id: i32")
        .execute(&pool)
        .await
        .unwrap();
}