dactyl-db 0.8.2

Interchangeably read and write to a lightweight Rust local store or cloud-hosted Vercel Neon instances behind one facade.
Documentation
#![cfg(feature = "sqlite")]

use std::fs;

use dactyl_db::{AccessMode, AdapterErrorKind, Connection, DatastoreRoute, OpenOptions, Parameter};
use tempfile::{NamedTempFile, TempDir};

const FIXTURE: &str = "tests/fixtures/decapod_legacy.sqlite";

#[test]
fn existing_sqlite_fixture_opens_without_conversion() {
    let dir = TempDir::new().unwrap();
    let path = dir.path().join("decapod.db");
    fs::copy(FIXTURE, &path).unwrap();

    let db = Connection::open(DatastoreRoute::sqlite(path.to_string_lossy())).unwrap();
    assert_eq!(
        db.inspect_schema()
            .unwrap()
            .table("events")
            .unwrap()
            .row_count,
        1
    );
    assert_eq!(
        db.inspect_schema()
            .unwrap()
            .table("tasks")
            .unwrap()
            .row_count,
        1
    );

    let rows = db
        .read(
            "select event_id, payload from events where event_id = $1",
            &[Parameter::Text("evt1".into())],
        )
        .unwrap();
    assert_eq!(
        rows.as_slice()[0].get_str("payload").unwrap(),
        r#"{"title":"imported"}"#
    );

    let task = db
        .read(
            "select title, revision, payload from tasks where id = $1",
            &[Parameter::Text("task-1".into())],
        )
        .unwrap();
    assert_eq!(
        task.as_slice()[0].get_str("title").unwrap(),
        "imported task"
    );
    assert_eq!(task.as_slice()[0].get_int("revision").unwrap(), 3);
    assert_eq!(
        task.as_slice()[0].get_blob("payload").unwrap(),
        vec![0, 1, 255]
    );

    assert!(fs::read(&path).unwrap().starts_with(b"SQLite format 3\0"));
}

#[test]
fn existing_sqlite_fixture_is_writable_and_reopens() {
    let dir = TempDir::new().unwrap();
    let path = dir.path().join("decapod.db");
    fs::copy(FIXTURE, &path).unwrap();
    let route = DatastoreRoute::sqlite(path.to_string_lossy());

    let db = Connection::open(route.clone()).unwrap();
    assert_eq!(
        db.write(
            "update tasks set revision = $1 where id = $2",
            &[Parameter::Integer(4), Parameter::Text("task-1".into())],
        )
        .unwrap(),
        1
    );
    drop(db);

    let reopened = Connection::open(route).unwrap();
    let rows = reopened
        .read(
            "select revision from tasks where id = $1",
            &[Parameter::Text("task-1".into())],
        )
        .unwrap();
    assert_eq!(rows.as_slice()[0].get_int("revision").unwrap(), 4);
}

#[test]
fn real_sqlite_supports_null_real_blob_and_generated_keys() {
    let file = NamedTempFile::new().unwrap();
    let db = Connection::open(DatastoreRoute::sqlite(file.path().to_string_lossy())).unwrap();
    db.write(
        "create table values_test (id integer primary key, nullable text, real_value real, payload blob)",
        &[],
    )
    .unwrap();
    let result = db
        .write_result(
            "insert into values_test (nullable, real_value, payload) values ($1, $2, $3)",
            &[
                Parameter::Null,
                Parameter::Real(1.5),
                Parameter::Blob(vec![9, 8, 7]),
            ],
        )
        .unwrap();
    assert_eq!(result.affected_rows, 1);
    assert_eq!(
        result.generated_key(),
        Some(&dactyl_db::GeneratedKey::Integer(1))
    );

    let rows = db
        .read("select nullable, real_value, payload from values_test", &[])
        .unwrap();
    let row = &rows.as_slice()[0];
    assert!(row.is_null("nullable").unwrap());
    assert_eq!(row.get_real("real_value").unwrap(), 1.5);
    assert_eq!(row.get_blob("payload").unwrap(), vec![9, 8, 7]);
}

#[test]
fn sqlite_read_only_and_missing_paths_fail_closed() {
    let missing = TempDir::new().unwrap().path().join("missing.db");
    let error = match Connection::open_with_options(
        DatastoreRoute::sqlite(missing.to_string_lossy()),
        OpenOptions {
            access_mode: AccessMode::ReadOnly,
            ..OpenOptions::default()
        },
    ) {
        Ok(_) => panic!("read-only open unexpectedly created a missing database"),
        Err(error) => error,
    };
    assert_eq!(error.adapter_kind(), Some(AdapterErrorKind::NotFound));
    assert_eq!(error.adapter_code(), Some("missing_database"));

    let file = NamedTempFile::new().unwrap();
    let db = Connection::open(DatastoreRoute::sqlite(file.path().to_string_lossy())).unwrap();
    db.write("create table readonly_test (id integer primary key)", &[])
        .unwrap();
    drop(db);
    let readonly = Connection::open_with_options(
        DatastoreRoute::sqlite(file.path().to_string_lossy()),
        OpenOptions {
            access_mode: AccessMode::ReadOnly,
            ..OpenOptions::default()
        },
    )
    .unwrap();
    let error = readonly
        .write("insert into readonly_test default values", &[])
        .unwrap_err();
    assert_eq!(error.adapter_kind(), Some(AdapterErrorKind::ReadOnly));
}

#[test]
fn malformed_local_file_fails_closed_as_a_typed_capability_error() {
    let file = NamedTempFile::new().unwrap();
    fs::write(file.path(), b"not a SQLite database").unwrap();
    let error = match Connection::open(DatastoreRoute::sqlite(file.path().to_string_lossy())) {
        Ok(_) => panic!("malformed local file unexpectedly opened"),
        Err(error) => error,
    };
    assert_eq!(error.adapter_kind(), Some(AdapterErrorKind::Capability));
    assert_eq!(error.adapter_code(), Some("invalid_database"));
}