ursa-engine 0.8.0

Ursa's SQL engine session: a DataFusion session that reads files by path, locally or in object storage.
Documentation
use std::time::Duration;

use super::*;

fn quick() -> OpenOptions {
    OpenOptions {
        lock_timeout: Duration::from_millis(200),
        ..OpenOptions::default()
    }
}

#[test]
fn open_creates_the_file_the_sidecar_and_the_lock() {
    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("club.ursa");
    let db = Database::open(&path, &quick()).unwrap();
    assert!(db.statements().is_empty());
    assert!(dir.path().join("club.ursa.d").is_dir());
    assert!(dir.path().join("club.ursa.d/lock").is_file());
    assert_eq!(db.index_dir(), dir.path().join("club.ursa.d/indexes"));
    let text = std::fs::read_to_string(&path).unwrap();
    assert_eq!(
        text,
        "{\n  \"format\": \"ursa\",\n  \"version\": 1,\n  \"statements\": []\n}\n"
    );
}

#[test]
fn the_index_storage_limit_is_kept_in_the_catalog() {
    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("club.ursa");
    {
        let db = Database::open(&path, &quick()).unwrap();
        assert_eq!(db.index_storage_limit(), DEFAULT_INDEX_STORAGE_LIMIT);
        let store = db.index_store();
        db.save(vec!["CREATE VIEW v AS SELECT 1".into()]).unwrap();
        db.set_setting(INDEX_STORAGE_LIMIT, Some("32GiB")).unwrap();
        assert_eq!(db.index_storage_limit(), 32 << 30);
        // The store obeys it at once.
        assert_eq!(store.limit(), 32 << 30);
        assert!(db.set_setting(INDEX_STORAGE_LIMIT, Some("lots")).is_err());
        // Saving statements keeps the setting.
        db.save(vec![]).unwrap();
    }
    let text = std::fs::read_to_string(&path).unwrap();
    assert_eq!(
        text,
        "{\n  \"format\": \"ursa\",\n  \"version\": 1,\n  \"statements\": [],\n  \"settings\": \
         {\n    \"ursa.index_storage_limit\": \"32GiB\"\n  }\n}\n"
    );
    assert_eq!(
        CatalogFile::parse(&text, "club.ursa").unwrap().to_text(),
        text
    );
    // A reader obeys it too, and cannot change it.
    let reader = Database::open(&path, &OpenOptions::read_only()).unwrap();
    assert_eq!(reader.index_storage_limit(), 32 << 30);
    assert!(reader.set_setting(INDEX_STORAGE_LIMIT, None).is_err());
    drop(reader);
    let db = Database::open(&path, &quick()).unwrap();
    db.set_setting(INDEX_STORAGE_LIMIT, None).unwrap();
    assert_eq!(db.index_storage_limit(), DEFAULT_INDEX_STORAGE_LIMIT);
    assert!(!std::fs::read_to_string(&path).unwrap().contains("settings"));
}

#[test]
fn save_rewrites_the_catalog_and_a_later_open_reads_it() {
    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("club.ursa");
    {
        let db = Database::open(&path, &quick()).unwrap();
        db.save(vec!["CREATE VIEW v AS SELECT 1".into()]).unwrap();
        // No temporary file is left behind.
        let leftovers: Vec<_> = std::fs::read_dir(dir.path().join("club.ursa.d"))
            .unwrap()
            .map(|e| e.unwrap().file_name().into_string().unwrap())
            .collect();
        assert_eq!(leftovers, ["lock"]);
    }
    let db = Database::open(&path, &quick()).unwrap();
    assert_eq!(db.statements(), ["CREATE VIEW v AS SELECT 1"]);
}

#[test]
fn a_second_writer_is_refused_while_the_first_has_it_open() {
    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("club.ursa");
    let first = Database::open(&path, &quick()).unwrap();
    let err = Database::open(&path, &quick()).unwrap_err();
    assert_eq!(
        crate::user_message(&err),
        format!(
            "{name} is in use by another process (process {}), which has it open for \
             writing; open it read-only to query it meanwhile (ursa --readonly {name}), or \
             wait for that process to finish",
            std::process::id(),
            name = path.display()
        )
    );
    // A reader takes no lock.
    let reader = Database::open(&path, &OpenOptions::read_only()).unwrap();
    assert!(reader.is_read_only());
    assert!(reader.save(vec![]).is_err());
    drop(first);
    Database::open(&path, &quick()).unwrap();
}

#[test]
fn read_only_needs_an_existing_file_and_creates_nothing() {
    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("club.ursa");
    let err = Database::open(&path, &OpenOptions::read_only()).unwrap_err();
    assert!(
        crate::user_message(&err).contains("the file does not exist"),
        "{err}"
    );
    assert!(!dir.path().join("club.ursa.d").exists());
    assert!(!path.exists());
}

#[test]
fn newer_and_corrupt_files_are_refused() {
    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("club.ursa");
    let message = |text: &str| {
        std::fs::write(&path, text).unwrap();
        crate::user_message(&Database::open(&path, &quick()).unwrap_err())
    };
    let name = path.display();
    assert_eq!(
        message(r#"{"format": "ursa", "version": 2, "statements": []}"#),
        format!(
            "{name} was written by a newer version of Ursa (database format version 2; this \
             version of ursa reads version 1); upgrade ursa to open it"
        )
    );
    assert!(message("not json").starts_with(&format!(
        "{name} is not a valid Ursa database: it is not JSON"
    )));
    assert_eq!(
        message(r#"{"format": "duck", "version": 1, "statements": []}"#),
        format!("{name} is not a valid Ursa database: its \"format\" is not \"ursa\"")
    );
    assert_eq!(
        message(r#"{"format": "ursa", "version": 1, "statements": [1]}"#),
        format!("{name} is not a valid Ursa database: an entry of \"statements\" is not a string")
    );
    // An empty file is an empty database.
    std::fs::write(&path, "").unwrap();
    assert!(Database::open(&path, &quick())
        .unwrap()
        .statements()
        .is_empty());
}

#[test]
fn stored_paths_are_relative_to_the_database_directory() {
    let dir = tempfile::tempdir().unwrap();
    let db = Database::open(dir.path().join("club.ursa"), &quick()).unwrap();
    let base = std::path::absolute(dir.path()).unwrap();
    let elsewhere = base.join("work");
    let stored = |location: &str, cwd: &Path| db.stored_location(location, cwd);

    // Run from the database's directory, a relative path is already stored.
    assert_eq!(stored("data/edges.csv", &base), None);
    assert_eq!(stored("./data/edges.csv", &base).unwrap(), "data/edges.csv");
    // Run from elsewhere, it is resolved there first.
    assert_eq!(stored("edges.csv", &elsewhere).unwrap(), "work/edges.csv");
    assert_eq!(stored("../data/*.csv", &elsewhere).unwrap(), "data/*.csv");
    assert_eq!(stored("../data/", &elsewhere).unwrap(), "data/");
    assert_eq!(stored("..", &elsewhere).unwrap(), ".");
    // An absolute path inside the directory is stored relative.
    let inside = base.join("data/edges.csv");
    assert_eq!(
        stored(inside.to_str().unwrap(), &elsewhere).unwrap(),
        "data/edges.csv"
    );
    // A path outside it is stored absolute.
    let outside = base.parent().unwrap().join("other/edges.csv");
    assert_eq!(
        stored("../../other/edges.csv", &elsewhere).unwrap(),
        outside.to_string_lossy()
    );
    assert_eq!(stored(outside.to_str().unwrap(), &elsewhere), None);
    for unchanged in [
        "s3://bucket/edges.parquet",
        "https://example.com/edges.csv",
        "file:///tmp/x.csv",
        "~/edges.csv",
    ] {
        assert_eq!(stored(unchanged, &elsewhere), None, "{unchanged}");
    }
}

#[test]
fn replay_resolves_stored_paths_against_the_database_directory() {
    let dir = tempfile::tempdir().unwrap();
    let db = Database::open(dir.path().join("club.ursa"), &quick()).unwrap();
    let base = std::path::absolute(dir.path()).unwrap();
    assert_eq!(
        db.replayed_location("data/edges.csv").unwrap(),
        base.join("data/edges.csv").to_string_lossy()
    );
    assert_eq!(
        db.replayed_location("*.csv").unwrap(),
        base.join("*.csv").to_string_lossy()
    );
    assert_eq!(
        db.replayed_location("data/").unwrap(),
        format!("{}/", base.join("data").display())
    );
    for unchanged in [
        "/abs/edges.csv",
        "s3://bucket/edges.parquet",
        "file:///tmp/x.csv",
        "~/edges.csv",
    ] {
        assert_eq!(db.replayed_location(unchanged), None, "{unchanged}");
    }
}

#[test]
fn a_session_reaches_its_database_through_the_config() {
    let dir = tempfile::tempdir().unwrap();
    let ctx = crate::new_session();
    assert!(database(&ctx.state()).is_none());
    let db = Arc::new(Database::open(dir.path().join("club.ursa"), &quick()).unwrap());
    slot(&ctx.state()).set(Arc::clone(&db)).unwrap();
    let state = ctx.state();
    let found = database_of_config(state.config()).unwrap();
    assert!(Arc::ptr_eq(&found, &db));
    assert!(slot(&state).set(db).is_err());
}