citadeldb-sql 0.16.1

SQL parser, planner, and executor for Citadel encrypted database
Documentation
use citadel::{Argon2Profile, DatabaseBuilder};
use citadel_sql::{Connection, ExecutionResult, SqlError, Value};

fn create_db(dir: &std::path::Path) -> citadel::Database {
    DatabaseBuilder::new(dir.join("test.db"))
        .passphrase(b"x")
        .argon2_profile(Argon2Profile::Iot)
        .create()
        .unwrap()
}

fn assert_ok(r: ExecutionResult) {
    matches!(r, ExecutionResult::Ok | ExecutionResult::RowsAffected(_));
}

#[test]
fn create_table_with_json_columns() {
    let dir = tempfile::tempdir().unwrap();
    let db = create_db(dir.path());
    let conn = Connection::open(&db).unwrap();
    assert_ok(
        conn.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, j JSON, jb JSONB)")
            .unwrap(),
    );
}

#[test]
fn cast_text_to_jsonb() {
    let dir = tempfile::tempdir().unwrap();
    let db = create_db(dir.path());
    let conn = Connection::open(&db).unwrap();
    let qr = conn.query("SELECT CAST('{\"a\":1}' AS JSONB)").unwrap();
    assert!(matches!(qr.rows[0][0], Value::Jsonb(_)));
}

#[test]
fn cast_text_to_json() {
    let dir = tempfile::tempdir().unwrap();
    let db = create_db(dir.path());
    let conn = Connection::open(&db).unwrap();
    let qr = conn.query("SELECT CAST('{\"a\":1}' AS JSON)").unwrap();
    assert!(matches!(qr.rows[0][0], Value::Json(_)));
}

#[test]
fn cast_invalid_json_rejected() {
    let dir = tempfile::tempdir().unwrap();
    let db = create_db(dir.path());
    let conn = Connection::open(&db).unwrap();
    let err = conn.query("SELECT CAST('not json' AS JSONB)").unwrap_err();
    assert!(matches!(err, SqlError::InvalidValue(_)));
}

#[test]
fn op_arrow_object_key() {
    let dir = tempfile::tempdir().unwrap();
    let db = create_db(dir.path());
    let conn = Connection::open(&db).unwrap();
    let qr = conn
        .query("SELECT CAST('{\"a\":1, \"b\":\"x\"}' AS JSONB) -> 'a'")
        .unwrap();
    let bytes = match &qr.rows[0][0] {
        Value::Jsonb(b) => b.clone(),
        _ => panic!("expected jsonb"),
    };
    let s = citadel_sql::json::decode_to_text(&bytes).unwrap();
    assert_eq!(s, "1");
}

#[test]
fn op_arrow_text() {
    let dir = tempfile::tempdir().unwrap();
    let db = create_db(dir.path());
    let conn = Connection::open(&db).unwrap();
    let qr = conn
        .query("SELECT CAST('{\"name\":\"alice\"}' AS JSONB) ->> 'name'")
        .unwrap();
    assert_eq!(qr.rows[0][0], Value::Text("alice".into()));
}

#[test]
fn op_arrow_text_missing_key_is_null() {
    let dir = tempfile::tempdir().unwrap();
    let db = create_db(dir.path());
    let conn = Connection::open(&db).unwrap();
    let qr = conn
        .query("SELECT CAST('{\"a\":1}' AS JSONB) ->> 'missing'")
        .unwrap();
    assert_eq!(qr.rows[0][0], Value::Null);
}

#[test]
fn op_arrow_text_json_null_yields_sql_null() {
    let dir = tempfile::tempdir().unwrap();
    let db = create_db(dir.path());
    let conn = Connection::open(&db).unwrap();
    let qr = conn
        .query("SELECT CAST('{\"a\":null}' AS JSONB) ->> 'a'")
        .unwrap();
    assert_eq!(qr.rows[0][0], Value::Null);
}

#[test]
fn op_array_index() {
    let dir = tempfile::tempdir().unwrap();
    let db = create_db(dir.path());
    let conn = Connection::open(&db).unwrap();
    let qr = conn
        .query("SELECT CAST('[10,20,30]' AS JSONB) ->> 1")
        .unwrap();
    assert_eq!(qr.rows[0][0], Value::Text("20".into()));
}

#[test]
fn op_contains_true() {
    let dir = tempfile::tempdir().unwrap();
    let db = create_db(dir.path());
    let conn = Connection::open(&db).unwrap();
    let qr = conn
        .query("SELECT CAST('{\"a\":1,\"b\":2}' AS JSONB) @> CAST('{\"a\":1}' AS JSONB)")
        .unwrap();
    assert_eq!(qr.rows[0][0], Value::Boolean(true));
}

#[test]
fn op_contains_false() {
    let dir = tempfile::tempdir().unwrap();
    let db = create_db(dir.path());
    let conn = Connection::open(&db).unwrap();
    let qr = conn
        .query("SELECT CAST('{\"a\":1}' AS JSONB) @> CAST('{\"a\":2}' AS JSONB)")
        .unwrap();
    assert_eq!(qr.rows[0][0], Value::Boolean(false));
}

#[test]
fn op_has_key() {
    let dir = tempfile::tempdir().unwrap();
    let db = create_db(dir.path());
    let conn = Connection::open(&db).unwrap();
    let qr = conn
        .query("SELECT CAST('{\"a\":1}' AS JSONB) ? 'a'")
        .unwrap();
    assert_eq!(qr.rows[0][0], Value::Boolean(true));
}

#[test]
fn op_has_key_on_null_value_still_true() {
    let dir = tempfile::tempdir().unwrap();
    let db = create_db(dir.path());
    let conn = Connection::open(&db).unwrap();
    let qr = conn
        .query("SELECT CAST('{\"a\":null}' AS JSONB) ? 'a'")
        .unwrap();
    assert_eq!(qr.rows[0][0], Value::Boolean(true));
}

#[test]
fn op_concat_objects() {
    let dir = tempfile::tempdir().unwrap();
    let db = create_db(dir.path());
    let conn = Connection::open(&db).unwrap();
    let qr = conn
        .query("SELECT CAST('{\"a\":1}' AS JSONB) || CAST('{\"b\":2}' AS JSONB)")
        .unwrap();
    let bytes = match &qr.rows[0][0] {
        Value::Jsonb(b) => b.clone(),
        _ => panic!("expected jsonb"),
    };
    let s = citadel_sql::json::decode_to_text(&bytes).unwrap();
    assert!(s.contains("\"a\":1"));
    assert!(s.contains("\"b\":2"));
}

#[test]
fn op_delete_key() {
    let dir = tempfile::tempdir().unwrap();
    let db = create_db(dir.path());
    let conn = Connection::open(&db).unwrap();
    let qr = conn
        .query("SELECT CAST('{\"a\":1,\"b\":2}' AS JSONB) - 'a'")
        .unwrap();
    let bytes = match &qr.rows[0][0] {
        Value::Jsonb(b) => b.clone(),
        _ => panic!("expected jsonb"),
    };
    let s = citadel_sql::json::decode_to_text(&bytes).unwrap();
    assert!(!s.contains("\"a\""));
    assert!(s.contains("\"b\":2"));
}

#[test]
fn op_null_propagation_on_get() {
    let dir = tempfile::tempdir().unwrap();
    let db = create_db(dir.path());
    let conn = Connection::open(&db).unwrap();
    let qr = conn.query("SELECT CAST(NULL AS JSONB) -> 'a'").unwrap();
    assert_eq!(qr.rows[0][0], Value::Null);
}

#[test]
fn round_trip_insert_select() {
    let dir = tempfile::tempdir().unwrap();
    let db = create_db(dir.path());
    let conn = Connection::open(&db).unwrap();
    conn.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, jb JSONB)")
        .unwrap();
    conn.execute("INSERT INTO t VALUES (1, CAST('{\"foo\":\"bar\"}' AS JSONB))")
        .unwrap();
    let qr = conn
        .query("SELECT jb ->> 'foo' FROM t WHERE id = 1")
        .unwrap();
    assert_eq!(qr.rows[0][0], Value::Text("bar".into()));
}

#[test]
fn schema_persists_jsonb_value_across_reopen() {
    let dir = tempfile::tempdir().unwrap();
    {
        let db = create_db(dir.path());
        let conn = Connection::open(&db).unwrap();
        conn.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, jb JSONB)")
            .unwrap();
        conn.execute("INSERT INTO t VALUES (1, CAST('{\"k\":42}' AS JSONB))")
            .unwrap();
    }
    let db = DatabaseBuilder::new(dir.path().join("test.db"))
        .passphrase(b"x")
        .argon2_profile(Argon2Profile::Iot)
        .open()
        .unwrap();
    let conn = Connection::open(&db).unwrap();
    let qr = conn.query("SELECT jb ->> 'k' FROM t WHERE id = 1").unwrap();
    assert_eq!(qr.rows[0][0], Value::Text("42".into()));
}