fsqlite-core 0.3.8

Core engine: connection, prepare, schema, DDL/DML codegen
//! bd-pgqsk L5 + M7: `PRAGMA encoding` setter fidelity and cross-encoding
//! ATTACH of an empty file.
//!
//! L5 — the encoding setter must accept every stock spelling (case-insensitive,
//! optional dash; `UTF-16` => UTF-16le), reject an unsupported name with
//! "unsupported encoding: <name>" (empty DB or not), and honour a valid choice
//! only on an empty database.
//!
//! M7 — attaching a brand-new / EMPTY file to a UTF-16 main must succeed (the
//! empty file adopts main's encoding), where a populated cross-encoding file is
//! still rejected.

use fsqlite_core::connection::Connection;
use fsqlite_types::value::SqliteValue;

fn scalar_text(rows: &[fsqlite_core::connection::Row]) -> String {
    assert_eq!(rows.len(), 1, "expected one row");
    match &rows[0].values()[0] {
        SqliteValue::Text(s) => s.to_string(),
        other => panic!("expected TEXT, got {other:?}"),
    }
}

#[test]
fn bd_pgqsk_l5_encoding_setter_spellings_and_rejection() {
    asupersync::test_utils::run_test(|| async {
        // Every accepted spelling resolves like stock.
        for (spelling, expected) in [
            ("UTF-8", "UTF-8"),
            ("UTF8", "UTF-8"),
            ("utf8", "UTF-8"),
            ("UTF-16", "UTF-16le"),
            ("UTF16", "UTF-16le"),
            ("UTF-16le", "UTF-16le"),
            ("UTF16le", "UTF-16le"),
            ("UTF-16be", "UTF-16be"),
            ("UTF16be", "UTF-16be"),
        ] {
            let conn = Connection::open(":memory:").await.unwrap();
            conn.execute(&format!("PRAGMA encoding = '{spelling}';"))
                .await
                .unwrap_or_else(|e| panic!("'{spelling}' should be accepted: {e:?}"));
            let got = scalar_text(&conn.query("PRAGMA encoding;").await.unwrap());
            assert_eq!(
                got, expected,
                "spelling '{spelling}' resolves to {expected}"
            );
        }

        // An unsupported name is rejected (empty DB), matching stock — INCLUDING
        // mangled dash spellings: stock accepts only a SINGLE optional dash after
        // `UTF`, never arbitrary dash placement (bd-ntuz0 b).
        for bad in [
            "bogus",
            "UTF-32",
            "latin1",
            "U-T-F-8",
            "UTF--16",
            "utf-16-le",
        ] {
            let conn = Connection::open(":memory:").await.unwrap();
            let err = conn
                .execute(&format!("PRAGMA encoding = '{bad}';"))
                .await
                .expect_err(&format!("'{bad}' must be rejected"));
            assert!(
                err.to_string().contains("unsupported encoding"),
                "'{bad}' error should mention unsupported encoding: {err}"
            );
        }

        // A NON-STRING encoding argument (`PRAGMA encoding = 5`) is rejected, not
        // silently ignored (bd-ntuz0 b).
        let conn = Connection::open(":memory:").await.unwrap();
        let err = conn
            .execute("PRAGMA encoding = 5;")
            .await
            .expect_err("non-string encoding must be rejected");
        assert!(
            err.to_string().contains("unsupported encoding"),
            "non-string error should mention unsupported encoding: {err}"
        );
    });
}

#[test]
fn bd_pgqsk_l5_encoding_valid_name_noop_on_nonempty_db() {
    asupersync::test_utils::run_test(|| async {
        let conn = Connection::open(":memory:").await.unwrap();
        conn.execute("CREATE TABLE t(a);").await.unwrap();
        // A VALID encoding on a non-empty DB is a silent no-op (never an error).
        conn.execute("PRAGMA encoding = 'UTF-16le';").await.unwrap();
        assert_eq!(
            scalar_text(&conn.query("PRAGMA encoding;").await.unwrap()),
            "UTF-8",
            "encoding cannot change on a non-empty DB"
        );
        // But an unsupported name still errors even on a non-empty DB.
        let err = conn
            .execute("PRAGMA encoding = 'bogus';")
            .await
            .expect_err("bogus must be rejected on a non-empty DB too");
        assert!(
            err.to_string().contains("unsupported encoding"),
            "got: {err}"
        );
    });
}

#[test]
fn bd_pgqsk_m7_empty_attach_adopts_main_encoding() {
    asupersync::test_utils::run_test(|| async {
        let dir = tempfile::tempdir().unwrap();
        let main_path = dir.path().join("main16.db");
        let aux_path = dir.path().join("aux_new.db");
        let main_str = main_path.to_string_lossy().into_owned();
        let aux_str = aux_path.to_string_lossy().into_owned();

        let conn = Connection::open(&main_str).await.unwrap();
        conn.execute("PRAGMA encoding = 'UTF-16le';").await.unwrap();
        conn.execute("CREATE TABLE m(a);").await.unwrap();

        // Attaching a brand-new EMPTY file must succeed — it adopts main's UTF-16.
        conn.execute(&format!("ATTACH '{aux_str}' AS aux;"))
            .await
            .unwrap_or_else(|e| panic!("empty cross-encoding ATTACH must succeed: {e:?}"));
        conn.execute("CREATE TABLE aux.x(y TEXT);").await.unwrap();
        conn.execute("INSERT INTO aux.x VALUES ('café');")
            .await
            .unwrap();
        let got = conn.query("SELECT y FROM aux.x;").await.unwrap();
        assert_eq!(scalar_text(&got), "café", "attached row round-trips");
        conn.close().await.unwrap();

        // Stock reopens the aux file and reads it (written in main's encoding).
        let stock = rusqlite::Connection::open(&aux_path).unwrap();
        // bd-ntuz0 (a): the adopted encoding must be PERSISTED to the aux header,
        // so a standalone reopen reports UTF-16le — stock creates a UTF-16le aux
        // from a UTF-16le main. Before the fix the aux header kept the UTF-8 open
        // default while only the live value adopted UTF-16.
        let aux_encoding: String = stock
            .query_row("PRAGMA encoding;", [], |r| r.get(0))
            .unwrap();
        assert_eq!(
            aux_encoding, "UTF-16le",
            "empty aux must adopt AND persist main's UTF-16le encoding"
        );
        let integrity: String = stock
            .query_row("PRAGMA integrity_check;", [], |r| r.get(0))
            .unwrap();
        assert_eq!(integrity, "ok", "stock integrity_check on the aux image");
        let val: String = stock
            .query_row("SELECT y FROM x;", [], |r| r.get(0))
            .unwrap();
        assert_eq!(val, "café", "stock reads the attached-then-written row");
    });
}

/// bd-lzbku (a): attaching a brand-new EMPTY file to a UTF-16 main adopts the
/// encoding only IN MEMORY and DEFERS the on-disk header persist to the aux's
/// first write. If the aux is DETACHed before any write, it was never
/// materialized as UTF-16, so it re-ATTACHes cleanly to a UTF-8 main. Before the
/// fix the eager ATTACH-time persist stamped UTF-16 into the still-empty aux and
/// this re-ATTACH was rejected as cross-encoding.
// bd-lzbku (correct deferred fix): the header persist is deferred to the aux's
// first write (pending_adopted_header_encoding), so an unwritten empty aux is never
// materialized and re-ATTACHes to a UTF-8 main. Re-enabled (was #[ignore]d under
// the eager decision-B revert that materialized page 1 at ATTACH).
#[test]
fn bd_lzbku_unwritten_empty_aux_reattaches_to_utf8_main() {
    asupersync::test_utils::run_test(|| async {
        let dir = tempfile::tempdir().unwrap();
        let main16 = dir.path().join("m16.db").to_string_lossy().into_owned();
        let main8 = dir.path().join("m8.db").to_string_lossy().into_owned();
        let aux = dir.path().join("aux.db");
        let aux_str = aux.to_string_lossy().into_owned();

        // UTF-16 main; ATTACH empty aux (adopts UTF-16 in-memory, defers persist);
        // DETACH WITHOUT writing to the aux -> never materialized as UTF-16.
        let c16 = Connection::open(&main16).await.unwrap();
        c16.execute("PRAGMA encoding = 'UTF-16le';").await.unwrap();
        c16.execute("CREATE TABLE m(a);").await.unwrap();
        c16.execute(&format!("ATTACH '{aux_str}' AS aux;"))
            .await
            .unwrap_or_else(|e| panic!("empty cross-encoding ATTACH must succeed: {e:?}"));
        c16.execute("DETACH aux;").await.unwrap();
        c16.close().await.unwrap();

        // A UTF-8 (default) main re-ATTACHes the same, still-UTF-8, empty aux.
        let c8 = Connection::open(&main8).await.unwrap();
        c8.execute("CREATE TABLE m8(a);").await.unwrap();
        c8.execute(&format!("ATTACH '{aux_str}' AS aux;"))
            .await
            .unwrap_or_else(|e| {
                panic!(
                    "re-ATTACH of an un-materialized empty aux to a UTF-8 main must succeed: {e:?}"
                )
            });
        c8.close().await.unwrap();
    });
}

/// bd-lzbku (b): a read-only main opens its attached databases schema-only
/// (read-only pager). Attaching an empty aux to such a main must SUCCEED — the
/// aux adopts the main's encoding in memory, and the deferred header persist is
/// skipped entirely for a read-only aux (no write txn exists). Before the fix
/// the eager ATTACH-time header write failed outright on the read-only pager.
#[test]
fn bd_lzbku_readonly_main_attaches_empty_aux() {
    asupersync::test_utils::run_test(|| async {
        let dir = tempfile::tempdir().unwrap();
        let main16 = dir.path().join("m16.db").to_string_lossy().into_owned();
        let aux = dir.path().join("aux.db");
        let aux_str = aux.to_string_lossy().into_owned();

        // Build a UTF-16 main, then reopen it READ-ONLY.
        {
            let w = Connection::open(&main16).await.unwrap();
            w.execute("PRAGMA encoding = 'UTF-16le';").await.unwrap();
            w.execute("CREATE TABLE m(a);").await.unwrap();
            w.close().await.unwrap();
        }
        // A read-only main won't create the aux; pre-create a VALID empty aux DB.
        // (A 0-byte file cannot be opened read-only — no header.) PRAGMA
        // user_version writes page 1 but keeps schema_cookie == 0, so ATTACH still
        // reaches the empty-file adopt branch.
        {
            let a = Connection::open(&aux_str).await.unwrap();
            a.execute("PRAGMA user_version = 1;").await.unwrap();
            a.close().await.unwrap();
        }

        let ro = Connection::open_schema_only(&main16).await.unwrap();
        ro.execute(&format!("ATTACH '{aux_str}' AS aux;"))
            .await
            .unwrap_or_else(|e| {
                panic!("ATTACH of an empty aux to a read-only main must succeed: {e:?}")
            });
        ro.close().await.unwrap();
    });
}

/// bd-dbpl2: the encoding choice is re-settable while the database is EMPTY
/// (schema_cookie == 0) — a later `PRAGMA encoding` overrides the earlier one,
/// matching stock — but becomes one-way (locked) after the first schema object.
#[test]
fn bd_dbpl2_encoding_reset_on_empty_then_locked_after_schema() {
    asupersync::test_utils::run_test(|| async {
        // Empty DB: the last set wins (UTF-16le then UTF-8 => UTF-8).
        let conn = Connection::open(":memory:").await.unwrap();
        conn.execute("PRAGMA encoding = 'UTF-16le';").await.unwrap();
        conn.execute("PRAGMA encoding = 'UTF-8';").await.unwrap();
        assert_eq!(
            scalar_text(&conn.query("PRAGMA encoding;").await.unwrap()),
            "UTF-8",
            "encoding is re-settable while the DB is empty"
        );

        // After a schema object it is locked: a further set is a silent no-op.
        let conn2 = Connection::open(":memory:").await.unwrap();
        conn2
            .execute("PRAGMA encoding = 'UTF-16le';")
            .await
            .unwrap();
        conn2.execute("CREATE TABLE t(a);").await.unwrap();
        conn2.execute("PRAGMA encoding = 'UTF-8';").await.unwrap();
        assert_eq!(
            scalar_text(&conn2.query("PRAGMA encoding;").await.unwrap()),
            "UTF-16le",
            "encoding is locked once a schema object exists"
        );
    });
}