pg-upsert 0.1.1

PostgreSQL UPSERT operations using sqlx
Documentation
use pg_embed::pg_enums::PgAuthMethod;
use pg_embed::pg_fetch::{PG_V15, PgFetchSettings};
use pg_embed::postgres::{PgEmbed, PgSettings};
use pg_upsert::{UpsertOptions, fields, upsert};
use sqlx::PgPool;
use sqlx::postgres::PgPoolOptions;
use std::path::PathBuf;
use std::time::Duration;

async fn setup_test_db() -> (PgEmbed, PgPool) {
    use std::sync::atomic::{AtomicU64, Ordering};
    use std::time::{SystemTime, UNIX_EPOCH};

    static COUNTER: AtomicU64 = AtomicU64::new(0);

    let timestamp = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap()
        .as_millis();

    let counter = COUNTER.fetch_add(1, Ordering::SeqCst);
    let thread_id = std::thread::current().id();

    let database_dir = PathBuf::from(format!(
        "target/pg_data_{}_{:?}_{}",
        timestamp, thread_id, counter
    ));

    let pg_settings = PgSettings {
        database_dir,
        port: portpicker::pick_unused_port().expect("no free port"),
        user: "postgres".to_string(),
        password: "postgres".to_string(),
        auth_method: PgAuthMethod::Plain,
        persistent: false,
        timeout: Some(Duration::from_secs(30)),
        migration_dir: None,
    };

    let fetch_settings = PgFetchSettings {
        version: PG_V15,
        ..Default::default()
    };

    let mut pg = PgEmbed::new(pg_settings, fetch_settings)
        .await
        .expect("failed to create pg-embed");

    pg.setup().await.expect("failed to setup pg-embed");
    pg.start_db().await.expect("failed to start pg-embed");

    let database_name = "test_db";
    pg.create_database(database_name)
        .await
        .expect("failed to create database");

    let connection_string = pg.full_db_uri(database_name);

    let pool = PgPoolOptions::new()
        .max_connections(5)
        .connect(&connection_string)
        .await
        .expect("failed to connect to database");

    (pg, pool)
}

async fn create_users_table(pool: &PgPool) {
    sqlx::query(
        r#"
        CREATE TABLE IF NOT EXISTS users (
            id INTEGER PRIMARY KEY,
            name TEXT NOT NULL,
            email TEXT,
            version INTEGER DEFAULT 1
        )
        "#,
    )
    .execute(pool)
    .await
    .expect("failed to create users table");
}

async fn get_user(pool: &PgPool, id: i32) -> Option<(i32, String, Option<String>, i32)> {
    sqlx::query_as::<_, (i32, String, Option<String>, i32)>(
        "SELECT id, name, email, version FROM users WHERE id = $1",
    )
    .bind(id)
    .fetch_optional(pool)
    .await
    .expect("failed to fetch user")
}

async fn count_users(pool: &PgPool) -> i64 {
    sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM users")
        .fetch_one(pool)
        .await
        .expect("failed to count users")
}

#[tokio::test]
async fn test_upsert_insert_new_row() {
    let (_pg, pool) = setup_test_db().await;
    create_users_table(&pool).await;

    let rows_affected = upsert(
        &pool,
        "users",
        &["id"],
        vec![fields![
            "id" => 1_i32,
            "name" => "Alice",
            "email" => "alice@example.com",
            "version" => 1_i32
        ]],
        UpsertOptions::default(),
    )
    .await
    .expect("upsert failed");

    assert_eq!(rows_affected, 1);

    let user = get_user(&pool, 1).await.expect("user not found");
    assert_eq!(user.0, 1);
    assert_eq!(user.1, "Alice");
    assert_eq!(user.2, Some("alice@example.com".to_string()));
}

#[tokio::test]
async fn test_upsert_update_existing_row() {
    let (_pg, pool) = setup_test_db().await;
    create_users_table(&pool).await;

    upsert(
        &pool,
        "users",
        &["id"],
        vec![fields![
            "id" => 1_i32,
            "name" => "Alice",
            "email" => "alice@example.com",
            "version" => 1_i32
        ]],
        UpsertOptions::default(),
    )
    .await
    .expect("first upsert failed");

    let rows_affected = upsert(
        &pool,
        "users",
        &["id"],
        vec![fields![
            "id" => 1_i32,
            "name" => "Alice Updated",
            "email" => "alice.new@example.com",
            "version" => 2_i32
        ]],
        UpsertOptions::default(),
    )
    .await
    .expect("second upsert failed");

    assert_eq!(rows_affected, 1);

    let user = get_user(&pool, 1).await.expect("user not found");
    assert_eq!(user.1, "Alice Updated");
    assert_eq!(user.2, Some("alice.new@example.com".to_string()));
}

#[tokio::test]
async fn test_upsert_do_nothing_on_conflict() {
    let (_pg, pool) = setup_test_db().await;
    create_users_table(&pool).await;

    upsert(
        &pool,
        "users",
        &["id"],
        vec![fields![
            "id" => 1_i32,
            "name" => "Alice",
            "email" => "alice@example.com",
            "version" => 1_i32
        ]],
        UpsertOptions::default(),
    )
    .await
    .expect("first upsert failed");

    let rows_affected = upsert(
        &pool,
        "users",
        &["id"],
        vec![fields![
            "id" => 1_i32,
            "name" => "Alice Should Not Update",
            "email" => "nope@example.com",
            "version" => 2_i32
        ]],
        UpsertOptions {
            do_nothing_on_conflict: true,
            ..Default::default()
        },
    )
    .await
    .expect("second upsert failed");

    assert_eq!(rows_affected, 0);

    let user = get_user(&pool, 1).await.expect("user not found");
    assert_eq!(user.1, "Alice");
    assert_eq!(user.2, Some("alice@example.com".to_string()));
}

#[tokio::test]
async fn test_upsert_version_field_newer() {
    let (_pg, pool) = setup_test_db().await;
    create_users_table(&pool).await;

    upsert(
        &pool,
        "users",
        &["id"],
        vec![fields![
            "id" => 1_i32,
            "name" => "Alice",
            "email" => "alice@example.com",
            "version" => 1_i32
        ]],
        UpsertOptions::default(),
    )
    .await
    .expect("first upsert failed");

    let rows_affected = upsert(
        &pool,
        "users",
        &["id"],
        vec![fields![
            "id" => 1_i32,
            "name" => "Alice v5",
            "email" => "alice5@example.com",
            "version" => 5_i32
        ]],
        UpsertOptions {
            version_field: Some("version".into()),
            ..Default::default()
        },
    )
    .await
    .expect("second upsert failed");

    assert_eq!(rows_affected, 1);

    let user = get_user(&pool, 1).await.expect("user not found");
    assert_eq!(user.1, "Alice v5");
    assert_eq!(user.3, 5);
}

#[tokio::test]
async fn test_upsert_version_field_older() {
    let (_pg, pool) = setup_test_db().await;
    create_users_table(&pool).await;

    upsert(
        &pool,
        "users",
        &["id"],
        vec![fields![
            "id" => 1_i32,
            "name" => "Alice",
            "email" => "alice@example.com",
            "version" => 10_i32
        ]],
        UpsertOptions::default(),
    )
    .await
    .expect("first upsert failed");

    let rows_affected = upsert(
        &pool,
        "users",
        &["id"],
        vec![fields![
            "id" => 1_i32,
            "name" => "Alice Old",
            "email" => "old@example.com",
            "version" => 5_i32
        ]],
        UpsertOptions {
            version_field: Some("version".into()),
            ..Default::default()
        },
    )
    .await
    .expect("second upsert failed");

    assert_eq!(rows_affected, 0);

    let user = get_user(&pool, 1).await.expect("user not found");
    assert_eq!(user.1, "Alice");
    assert_eq!(user.3, 10);
}

#[tokio::test]
async fn test_upsert_multiple_rows() {
    let (_pg, pool) = setup_test_db().await;
    create_users_table(&pool).await;

    let rows_affected = upsert(
        &pool,
        "users",
        &["id"],
        vec![
            fields![
                "id" => 1_i32,
                "name" => "Alice",
                "email" => "alice@example.com",
                "version" => 1_i32
            ],
            fields![
                "id" => 2_i32,
                "name" => "Bob",
                "email" => "bob@example.com",
                "version" => 1_i32
            ],
            fields![
                "id" => 3_i32,
                "name" => "Charlie",
                "email" => "charlie@example.com",
                "version" => 1_i32
            ],
        ],
        UpsertOptions::default(),
    )
    .await
    .expect("upsert failed");

    assert_eq!(rows_affected, 3);
    assert_eq!(count_users(&pool).await, 3);

    let alice = get_user(&pool, 1).await.expect("alice not found");
    let bob = get_user(&pool, 2).await.expect("bob not found");
    let charlie = get_user(&pool, 3).await.expect("charlie not found");

    assert_eq!(alice.1, "Alice");
    assert_eq!(bob.1, "Bob");
    assert_eq!(charlie.1, "Charlie");
}