citadeldb-sql 0.16.0

SQL parser, planner, and executor for Citadel encrypted database
Documentation
use citadel::{Argon2Profile, DatabaseBuilder};
use citadel_sql::{Connection, ExecutionResult, 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 jsonb_rejects_null_byte_escape() {
    let dir = tempfile::tempdir().unwrap();
    let db = create_db(dir.path());
    let conn = Connection::open(&db).unwrap();
    let err = conn
        .query(r#"SELECT CAST('{"a":"\u0000"}' AS JSONB)"#)
        .unwrap_err();
    let msg = format!("{err}");
    assert!(
        msg.to_ascii_lowercase().contains("null") || msg.contains("\\u0000"),
        "expected null-byte rejection, got: {msg}"
    );
}

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

#[test]
fn json_preserves_duplicate_keys_text() {
    let dir = tempfile::tempdir().unwrap();
    let db = create_db(dir.path());
    let conn = Connection::open(&db).unwrap();
    let qr = conn
        .query(r#"SELECT CAST('{"a":1,"a":2}' AS JSON)"#)
        .unwrap();
    match &qr.rows[0][0] {
        Value::Json(s) => assert!(s.contains("\"a\":1") && s.contains("\"a\":2")),
        v => panic!("expected JSON text, got {v:?}"),
    }
}

#[test]
fn jsonb_rejects_trailing_comma() {
    let dir = tempfile::tempdir().unwrap();
    let db = create_db(dir.path());
    let conn = Connection::open(&db).unwrap();
    assert!(conn.query(r#"SELECT CAST('{"a":1,}' AS JSONB)"#).is_err());
    assert!(conn.query(r#"SELECT CAST('[1,2,]' AS JSONB)"#).is_err());
}

#[test]
fn jsonb_rejects_orphan_surrogate() {
    let dir = tempfile::tempdir().unwrap();
    let db = create_db(dir.path());
    let conn = Connection::open(&db).unwrap();
    assert!(conn
        .query(r#"SELECT CAST('{"s":"\uD834"}' AS JSONB)"#)
        .is_err());
}

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

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

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

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

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

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

#[test]
fn jsonb_normalizes_scientific_notation() {
    let dir = tempfile::tempdir().unwrap();
    let db = create_db(dir.path());
    let conn = Connection::open(&db).unwrap();
    let qr = conn
        .query(r#"SELECT CAST('{"n":1e5}' AS JSONB) ->> 'n'"#)
        .unwrap();
    let text = match &qr.rows[0][0] {
        Value::Text(s) => s.to_string(),
        v => panic!("expected text, got {v:?}"),
    };
    let parsed: f64 = text.parse().unwrap();
    assert!((parsed - 100000.0).abs() < 1e-9);
}

#[test]
fn json_preserves_source_text_verbatim() {
    let dir = tempfile::tempdir().unwrap();
    let db = create_db(dir.path());
    let conn = Connection::open(&db).unwrap();
    let qr = conn
        .query(r#"SELECT CAST('{ "b" : 1 , "a" : 2 }' AS JSON)"#)
        .unwrap();
    match &qr.rows[0][0] {
        Value::Json(s) => {
            assert!(s.contains("  ") || s.contains(" "));
            let b_pos = s.find("\"b\"").expect("b key present");
            let a_pos = s.find("\"a\"").expect("a key present");
            assert!(b_pos < a_pos, "JSON should preserve insertion order");
        }
        v => panic!("expected json text, got {v:?}"),
    }
}

#[test]
fn jsonb_sorts_keys_canonical() {
    let dir = tempfile::tempdir().unwrap();
    let db = create_db(dir.path());
    let conn = Connection::open(&db).unwrap();
    let qr = conn
        .query(r#"SELECT CAST('{"z":3,"a":1,"m":2}' AS JSONB)"#)
        .unwrap();
    let bytes = match &qr.rows[0][0] {
        Value::Jsonb(b) => b.clone(),
        v => panic!("expected jsonb, got {v:?}"),
    };
    let s = citadel_sql::json::decode_to_text(&bytes).unwrap();
    let a_pos = s.find("\"a\"").unwrap();
    let m_pos = s.find("\"m\"").unwrap();
    let z_pos = s.find("\"z\"").unwrap();
    assert!(
        a_pos < m_pos && m_pos < z_pos,
        "JSONB should sort keys: {s}"
    );
}

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

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

#[test]
fn check_constraint_with_json_op() {
    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, data JSONB, \
             CHECK (data ->> 'name' IS NOT NULL))",
        )
        .unwrap(),
    );
    conn.execute(r#"INSERT INTO t VALUES (1, CAST('{"name":"ok"}' AS JSONB))"#)
        .unwrap();
    assert!(conn
        .execute(r#"INSERT INTO t VALUES (2, CAST('{"other":"x"}' AS JSONB))"#)
        .is_err());
}

#[test]
fn generated_column_with_json_op() {
    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, data JSONB, \
             name TEXT GENERATED ALWAYS AS (data ->> 'name') STORED)",
        )
        .unwrap(),
    );
    conn.execute(r#"INSERT INTO t (id, data) VALUES (1, CAST('{"name":"alice"}' AS JSONB))"#)
        .unwrap();
    let qr = conn.query("SELECT name FROM t WHERE id = 1").unwrap();
    assert_eq!(qr.rows[0][0], Value::Text("alice".into()));
}

#[test]
fn json_in_returning_clause() {
    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, data JSONB)")
            .unwrap(),
    );
    let r = conn
        .query(r#"INSERT INTO t VALUES (1, CAST('{"k":42}' AS JSONB)) RETURNING data ->> 'k'"#)
        .unwrap();
    assert_eq!(r.rows[0][0], Value::Text("42".into()));
}

#[test]
fn large_inline_jsonb_round_trip() {
    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, data JSONB)")
            .unwrap(),
    );
    let big: String = (0..80)
        .map(|i| format!("\"k{i}\":\"{}\"", "x".repeat(10)))
        .collect::<Vec<_>>()
        .join(",");
    let payload = format!("{{{big}}}");
    let stmt = format!(
        "INSERT INTO t VALUES (1, CAST('{}' AS JSONB))",
        payload.replace('\'', "''")
    );
    conn.execute(&stmt).unwrap();
    let qr = conn
        .query("SELECT data ->> 'k0', data ->> 'k79' FROM t")
        .unwrap();
    assert_eq!(qr.rows[0][0], Value::Text("x".repeat(10).into()));
    assert_eq!(qr.rows[0][1], Value::Text("x".repeat(10).into()));
}

#[test]
fn large_jsonb_overflow_round_trip() {
    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, data JSONB)")
            .unwrap(),
    );
    let big: String = (0..1000)
        .map(|i| format!("\"k{i}\":\"{}\"", "x".repeat(20)))
        .collect::<Vec<_>>()
        .join(",");
    let payload = format!("{{{big}}}");
    let stmt = format!(
        "INSERT INTO t VALUES (1, CAST('{}' AS JSONB))",
        payload.replace('\'', "''")
    );
    conn.execute(&stmt).unwrap();
    let qr = conn
        .query("SELECT data ->> 'k0', data ->> 'k999' FROM t")
        .unwrap();
    assert_eq!(qr.rows[0][0], Value::Text("x".repeat(20).into()));
    assert_eq!(qr.rows[0][1], Value::Text("x".repeat(20).into()));
}

#[test]
fn multi_megabyte_jsonb_round_trip() {
    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, data JSONB)")
            .unwrap(),
    );
    let big: String = (0..10_000)
        .map(|i| format!("\"k{i}\":\"{}\"", "x".repeat(100)))
        .collect::<Vec<_>>()
        .join(",");
    let payload = format!("{{{big}}}");
    let stmt = format!(
        "INSERT INTO t VALUES (1, CAST('{}' AS JSONB))",
        payload.replace('\'', "''")
    );
    conn.execute(&stmt).unwrap();
    let qr = conn
        .query("SELECT data ->> 'k0', data ->> 'k9999' FROM t")
        .unwrap();
    assert_eq!(qr.rows[0][0], Value::Text("x".repeat(100).into()));
    assert_eq!(qr.rows[0][1], Value::Text("x".repeat(100).into()));
}

#[test]
fn deep_nesting_round_trip() {
    let dir = tempfile::tempdir().unwrap();
    let db = create_db(dir.path());
    let conn = Connection::open(&db).unwrap();
    let mut nested = String::from("\"leaf\"");
    for _ in 0..60 {
        nested = format!("{{\"x\":{nested}}}");
    }
    let stmt = format!("SELECT CAST('{nested}' AS JSONB)");
    let qr = conn.query(&stmt).unwrap();
    let bytes = match &qr.rows[0][0] {
        Value::Jsonb(b) => b.clone(),
        v => panic!("expected jsonb, got {v:?}"),
    };
    let decoded = citadel_sql::json::decode_to_text(&bytes).unwrap();
    assert!(decoded.contains("leaf"));
}