sqlite: map the full practical C API
src/db/sqlite.sx grows from the P5.1 subset (~19 fns) to the complete practical surface (~100): open_v2 + flags, extended errcodes + error_offset, txn_state/autocommit, changes64/total_changes64, limits, the full bind/column families (double/blob/zeroblob), parameter and column introspection (built with SQLITE_ENABLE_COLUMN_METADATA), table_column_metadata, statement introspection (sql/expanded_sql/ readonly/busy/isexplain/status), incremental blob I/O (SqliteBlob), online backup (SqliteBackup + sqlite_backup_run), serialize/ deserialize, and library utilities (complete, strglob/strlike/stricmp, randomness, memory, compileoptions). One variant per duplicate family (modern/64-bit preferred; bind_text/blob keep the 32-bit length forms that skip text64's encoding arg). Not bound, by design: callback-taking APIs (hooks/UDFs/collations need C->sx callbacks), sqlite3_value_* (UDF-coupled), varargs config, UTF-16, and subsystems this build omits — the boundary list lives in the module header and vendor README. rename.h is now GENERATED by make into build/vendor/ from the bindings' #foreign names — src/db/sqlite.sx is the single source of truth and the rename list cannot drift (checked-in vendor/sqlite/rename.h removed). make test 21/21 (new: sqlite_api.sx — 15 cases over every wrapper family, including a blob round trip with interior NULs, UNIQUE constraint extended errcodes, txn_state through BEGIN IMMEDIATE, backup db->db, and a serialize->deserialize round trip). KNOWN sx BOUNDARY (filed as a followup): 'if !e' on an error binding evaluates true even when the error is set — negated error logic in tests routes through plain bools.
This commit is contained in:
425
tests/sqlite_api.sx
Normal file
425
tests/sqlite_api.sx
Normal file
@@ -0,0 +1,425 @@
|
||||
// Pinned acceptance for the FULL SQLite mapping (src/db/sqlite.sx) —
|
||||
// every wrapper family beyond the P5.1 smoke (which keeps owning the
|
||||
// vendored-version pin):
|
||||
//
|
||||
// * library: version_number, sourceid, threadsafe=false (built
|
||||
// THREADSAFE=0), compileoption_used, complete, errstr, strglob/
|
||||
// strlike/stricmp, randomness.
|
||||
// * connections: open_v2 flag behavior (READONLY on a missing file
|
||||
// refuses; CREATE works; db_readonly reflects the mode),
|
||||
// db_filename, busy_timeout, autocommit/txn_state through a real
|
||||
// transaction, changes/total_changes, set_last_insert_rowid,
|
||||
// limit (prior value answered), error_offset, extended errcodes
|
||||
// on a UNIQUE violation (19 / 2067).
|
||||
// * statements: double/blob round trips (blob with interior NULs),
|
||||
// named parameters (count/index/name), clear_bindings + reset
|
||||
// reuse, column_name/decltype/origin metadata, data_count,
|
||||
// sql/expanded_sql, stmt readonly/isexplain, table_column_metadata.
|
||||
// * blob I/O: bind_zeroblob reservation, open/write/read/bytes,
|
||||
// reopen onto another row.
|
||||
// * backup: full db -> db copy via sqlite_backup_run.
|
||||
// * serialization: serialize -> fresh connection -> deserialize ->
|
||||
// query answers match.
|
||||
#import "modules/std.sx";
|
||||
process :: #import "modules/std/process.sx";
|
||||
sq :: #import "../src/db/sqlite.sx";
|
||||
|
||||
DBDIR :: ".sx-tmp/sqlite_api";
|
||||
|
||||
run_case :: (label: string, ok: bool) -> i32 {
|
||||
if ok { print(" PASS {}\n", label); return 0; }
|
||||
print(" FAIL {}\n", label);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// ── library utilities ─────────────────────────────────────────────────
|
||||
|
||||
check_library_info :: () -> bool {
|
||||
if sq.sqlite_version_number() < 3053000 { return false; }
|
||||
if sq.sqlite_sourceid().len < 10 { return false; }
|
||||
if sq.sqlite_threadsafe() { return false; } // THREADSAFE=0 build
|
||||
if !sq.sqlite_compileoption_used("THREADSAFE=0") { return false; }
|
||||
if sq.sqlite_compileoption_get(0).len == 0 { return false; }
|
||||
return true;
|
||||
}
|
||||
|
||||
check_text_utils :: () -> bool {
|
||||
if !sq.sqlite_complete("SELECT 1;") { return false; }
|
||||
if sq.sqlite_complete("SELECT 1") { return false; } // no terminator
|
||||
if !sq.sqlite_strglob("acme-*", "acme-app") { return false; }
|
||||
if sq.sqlite_strglob("acme-*", "other") { return false; }
|
||||
if !sq.sqlite_strlike("acme-%", "ACME-APP", 0) { return false; } // LIKE is case-folding
|
||||
if sq.sqlite_stricmp("AbC", "abc") != 0 { return false; }
|
||||
if sq.sqlite_errstr(sq.SQLITE_BUSY).len == 0 { return false; }
|
||||
return true;
|
||||
}
|
||||
|
||||
check_randomness :: () -> bool {
|
||||
r := sq.sqlite_randomness(16);
|
||||
if r.len != 16 { return false; }
|
||||
all_zero := true;
|
||||
i := 0;
|
||||
while i < r.len {
|
||||
if r[i] != 0 { all_zero = false; break; }
|
||||
i += 1;
|
||||
}
|
||||
return !all_zero;
|
||||
}
|
||||
|
||||
// ── connections ───────────────────────────────────────────────────────
|
||||
|
||||
check_open_v2_flags :: () -> bool {
|
||||
// READONLY on a file that doesn't exist must refuse.
|
||||
refused := false;
|
||||
nope, ne := sq.Sqlite.open_v2(".sx-tmp/sqlite_api/nope.db", sq.SQLITE_OPEN_READONLY);
|
||||
if ne { refused = true; }
|
||||
if !refused { nope.close(); return false; }
|
||||
|
||||
// CREATE | READWRITE builds the file; db_readonly answers 0.
|
||||
db, oe := sq.Sqlite.open_v2(".sx-tmp/sqlite_api/v2.db", sq.SQLITE_OPEN_READWRITE | sq.SQLITE_OPEN_CREATE);
|
||||
if oe { return false; }
|
||||
ee := false;
|
||||
db.exec("CREATE TABLE t (x)") catch { ee = true; };
|
||||
rw := db.db_readonly();
|
||||
fname := db.db_filename();
|
||||
db.close();
|
||||
if ee or rw != 0 { return false; }
|
||||
|
||||
// contains check: the filename carries the path we opened
|
||||
found := false;
|
||||
i := 0;
|
||||
while i + 5 <= fname.len {
|
||||
v := string.{ ptr = @fname[i], len = 5 };
|
||||
if v == "v2.db" { found = true; break; }
|
||||
i += 1;
|
||||
}
|
||||
if !found { return false; }
|
||||
|
||||
// Reopened READONLY: db_readonly answers 1 and writes refuse.
|
||||
ro, roe := sq.Sqlite.open_v2(".sx-tmp/sqlite_api/v2.db", sq.SQLITE_OPEN_READONLY);
|
||||
if roe { return false; }
|
||||
wfail := false;
|
||||
ro.exec("INSERT INTO t VALUES (1)") catch { wfail = true; };
|
||||
is_ro := ro.db_readonly();
|
||||
ro.close();
|
||||
return wfail and is_ro == 1;
|
||||
}
|
||||
|
||||
check_txn_state :: () -> bool {
|
||||
db, oe := sq.Sqlite.open(".sx-tmp/sqlite_api/txn.db");
|
||||
if oe { return false; }
|
||||
db.busy_timeout(2000);
|
||||
ee := false;
|
||||
db.exec("CREATE TABLE t (x)") catch { ee = true; };
|
||||
|
||||
ok := db.get_autocommit() and db.txn_state() == sq.SQLITE_TXN_NONE;
|
||||
db.exec("BEGIN IMMEDIATE") catch { ee = true; };
|
||||
if ok { ok = !db.get_autocommit() and db.txn_state() == sq.SQLITE_TXN_WRITE; }
|
||||
db.exec("COMMIT") catch { ee = true; };
|
||||
if ok { ok = db.get_autocommit(); }
|
||||
db.close();
|
||||
return ok and !ee;
|
||||
}
|
||||
|
||||
check_changes_and_rowid :: () -> bool {
|
||||
db, oe := sq.Sqlite.open(".sx-tmp/sqlite_api/chg.db");
|
||||
if oe { return false; }
|
||||
ee := false;
|
||||
db.exec("CREATE TABLE t (x); INSERT INTO t VALUES (1),(2),(3)") catch { ee = true; };
|
||||
if ee { db.close(); return false; }
|
||||
ok := db.changes() == 3;
|
||||
db.exec("UPDATE t SET x = x + 1") catch { ee = true; };
|
||||
if ok { ok = db.changes() == 3 and db.total_changes() >= 6; }
|
||||
db.set_last_insert_rowid(424242);
|
||||
if ok { ok = db.last_insert_rowid() == 424242; }
|
||||
db.close();
|
||||
return ok and !ee;
|
||||
}
|
||||
|
||||
check_limit :: () -> bool {
|
||||
db, oe := sq.Sqlite.open(":memory:");
|
||||
if oe { return false; }
|
||||
prior := db.limit(sq.SQLITE_LIMIT_VARIABLE_NUMBER, 100);
|
||||
now := db.limit(sq.SQLITE_LIMIT_VARIABLE_NUMBER, -1); // read back
|
||||
db.close();
|
||||
return prior > 0 and now == 100;
|
||||
}
|
||||
|
||||
check_error_detail :: () -> bool {
|
||||
db, oe := sq.Sqlite.open(".sx-tmp/sqlite_api/err.db");
|
||||
if oe { return false; }
|
||||
ee := false;
|
||||
db.exec("CREATE TABLE u (name TEXT UNIQUE); INSERT INTO u VALUES ('a')") catch { ee = true; };
|
||||
if ee { db.close(); return false; }
|
||||
|
||||
dup := false;
|
||||
db.exec("INSERT INTO u VALUES ('a')") catch { dup = true; };
|
||||
ok := dup and db.errcode() == sq.SQLITE_CONSTRAINT
|
||||
and db.extended_errcode() == sq.SQLITE_CONSTRAINT_UNIQUE;
|
||||
|
||||
// error_offset points at the offending token of a syntax error
|
||||
perr := false;
|
||||
bad, bade := db.prepare("SELECT * FRM u");
|
||||
if bade { perr = true; }
|
||||
if !perr { bad.finalize(); }
|
||||
if ok { ok = perr and db.error_offset() >= 9; }
|
||||
db.close();
|
||||
return ok;
|
||||
}
|
||||
|
||||
// ── statements ────────────────────────────────────────────────────────
|
||||
|
||||
// A 6-byte blob with interior NULs.
|
||||
test_blob :: () -> string {
|
||||
raw : [*]u8 = xx context.allocator.alloc_bytes(7);
|
||||
raw[0] = 1; raw[1] = 0; raw[2] = 255; raw[3] = 0; raw[4] = 7; raw[5] = 42; raw[6] = 0;
|
||||
return string.{ ptr = raw, len = 6 };
|
||||
}
|
||||
|
||||
check_typed_round_trip :: () -> bool {
|
||||
db, oe := sq.Sqlite.open(".sx-tmp/sqlite_api/types.db");
|
||||
if oe { return false; }
|
||||
ee := false;
|
||||
db.exec("CREATE TABLE v (d REAL, b BLOB)") catch { ee = true; };
|
||||
if ee { db.close(); return false; }
|
||||
|
||||
ins, pe := db.prepare("INSERT INTO v VALUES (?1, ?2)");
|
||||
if pe { db.close(); return false; }
|
||||
berr := false;
|
||||
se := false;
|
||||
ins.bind_double(1, 3.5) catch { berr = true; };
|
||||
ins.bind_blob(2, test_blob()) catch { berr = true; };
|
||||
ins.step() catch { se = true; 0 };
|
||||
ins.finalize();
|
||||
if berr or se { db.close(); return false; }
|
||||
|
||||
sel, pe2 := db.prepare("SELECT d, b FROM v");
|
||||
if pe2 { db.close(); return false; }
|
||||
rc := sel.step() catch { se = true; 0 };
|
||||
ok := !se and rc == sq.SQLITE_ROW
|
||||
and sel.column_type(0) == sq.SQLITE_FLOAT
|
||||
and sel.column_double(0) == 3.5
|
||||
and sel.column_type(1) == sq.SQLITE_BLOB
|
||||
and sel.column_bytes(1) == 6
|
||||
and sel.column_blob(1) == test_blob()
|
||||
and sel.data_count() == 2;
|
||||
sel.finalize();
|
||||
db.close();
|
||||
return ok;
|
||||
}
|
||||
|
||||
check_named_parameters :: () -> bool {
|
||||
db, oe := sq.Sqlite.open(":memory:");
|
||||
if oe { return false; }
|
||||
st, pe := db.prepare("SELECT :alpha, ?, :beta");
|
||||
if pe { db.close(); return false; }
|
||||
ok := st.parameter_count() == 3
|
||||
and st.parameter_index(":alpha") == 1
|
||||
and st.parameter_index(":beta") == 3
|
||||
and st.parameter_index(":nope") == 0
|
||||
and st.parameter_name(1) == ":alpha"
|
||||
and st.parameter_name(2) == ""; // a bare ? is nameless (?N would be "?N")
|
||||
st.finalize();
|
||||
db.close();
|
||||
return ok;
|
||||
}
|
||||
|
||||
check_rebind_reuse :: () -> bool {
|
||||
db, oe := sq.Sqlite.open(":memory:");
|
||||
if oe { return false; }
|
||||
ee := false;
|
||||
db.exec("CREATE TABLE t (x)") catch { ee = true; };
|
||||
if ee { db.close(); return false; }
|
||||
st, pe := db.prepare("INSERT INTO t VALUES (?1)");
|
||||
if pe { db.close(); return false; }
|
||||
berr := false;
|
||||
se := false;
|
||||
st.bind_int64(1, 10) catch { berr = true; };
|
||||
st.step() catch { se = true; 0 };
|
||||
st.reset();
|
||||
st.clear_bindings(); // NULL now bound
|
||||
st.step() catch { se = true; 0 };
|
||||
st.reset();
|
||||
st.bind_int64(1, 20) catch { berr = true; };
|
||||
st.step() catch { se = true; 0 };
|
||||
st.finalize();
|
||||
if berr or se { db.close(); return false; }
|
||||
|
||||
sel, pe2 := db.prepare("SELECT COUNT(*), COUNT(x), SUM(x) FROM t");
|
||||
if pe2 { db.close(); return false; }
|
||||
rc := sel.step() catch { se = true; 0 };
|
||||
ok := !se and rc == sq.SQLITE_ROW
|
||||
and sel.column_int64(0) == 3 // three rows
|
||||
and sel.column_int64(1) == 2 // one is NULL
|
||||
and sel.column_int64(2) == 30;
|
||||
sel.finalize();
|
||||
db.close();
|
||||
return ok;
|
||||
}
|
||||
|
||||
check_statement_introspection :: () -> bool {
|
||||
db, oe := sq.Sqlite.open(".sx-tmp/sqlite_api/meta.db");
|
||||
if oe { return false; }
|
||||
ee := false;
|
||||
db.exec("CREATE TABLE apps (id INTEGER PRIMARY KEY AUTOINCREMENT, slug TEXT NOT NULL)") catch { ee = true; };
|
||||
if ee { db.close(); return false; }
|
||||
|
||||
sel, pe := db.prepare("SELECT slug FROM apps WHERE id = ?1");
|
||||
if pe { db.close(); return false; }
|
||||
berr := false;
|
||||
sel.bind_int64(1, 7) catch { berr = true; };
|
||||
ok := !berr
|
||||
and sel.readonly()
|
||||
and sel.isexplain() == 0
|
||||
and sel.column_name(0) == "slug"
|
||||
and sel.column_decltype(0) == "TEXT"
|
||||
and sel.column_table_name(0) == "apps"
|
||||
and sel.column_origin_name(0) == "slug"
|
||||
and sel.sql() == "SELECT slug FROM apps WHERE id = ?1"
|
||||
and sel.expanded_sql() == "SELECT slug FROM apps WHERE id = 7";
|
||||
sel.finalize();
|
||||
|
||||
if ok {
|
||||
ins, pe2 := db.prepare("INSERT INTO apps (slug) VALUES ('x')");
|
||||
if pe2 { db.close(); return false; }
|
||||
ok = !ins.readonly();
|
||||
ins.finalize();
|
||||
}
|
||||
|
||||
if ok {
|
||||
meta, me := db.table_column_metadata("apps", "id");
|
||||
if me { db.close(); return false; }
|
||||
ok = meta.primary_key and meta.autoinc and meta.data_type == "INTEGER";
|
||||
if ok {
|
||||
meta2, me2 := db.table_column_metadata("apps", "slug");
|
||||
if me2 { db.close(); return false; }
|
||||
ok = meta2.not_null and !meta2.primary_key and meta2.data_type == "TEXT";
|
||||
}
|
||||
}
|
||||
db.close();
|
||||
return ok;
|
||||
}
|
||||
|
||||
// ── blob I/O ──────────────────────────────────────────────────────────
|
||||
|
||||
check_blob_io :: () -> bool {
|
||||
db, oe := sq.Sqlite.open(".sx-tmp/sqlite_api/blob.db");
|
||||
if oe { return false; }
|
||||
ee := false;
|
||||
db.exec("CREATE TABLE files (id INTEGER PRIMARY KEY, data BLOB)") catch { ee = true; };
|
||||
if ee { db.close(); return false; }
|
||||
|
||||
// reserve 8 zero bytes in row 1, 4 in row 2
|
||||
ins, pe := db.prepare("INSERT INTO files (id, data) VALUES (?1, ?2)");
|
||||
if pe { db.close(); return false; }
|
||||
berr := false;
|
||||
se := false;
|
||||
ins.bind_int64(1, 1) catch { berr = true; };
|
||||
ins.bind_zeroblob(2, 8) catch { berr = true; };
|
||||
ins.step() catch { se = true; 0 };
|
||||
ins.reset();
|
||||
ins.bind_int64(1, 2) catch { berr = true; };
|
||||
ins.bind_zeroblob(2, 4) catch { berr = true; };
|
||||
ins.step() catch { se = true; 0 };
|
||||
ins.finalize();
|
||||
if berr or se { db.close(); return false; }
|
||||
|
||||
bl, be := sq.SqliteBlob.open(@db, "files", "data", 1, true);
|
||||
if be { db.close(); return false; }
|
||||
werr := false;
|
||||
bl.write(2, "HI") catch { werr = true; };
|
||||
rd, re := bl.read(0, 8);
|
||||
sz := bl.bytes();
|
||||
if re { bl.close(); db.close(); return false; }
|
||||
if werr { bl.close(); db.close(); return false; }
|
||||
ok := sz == 8 and rd.len == 8 and rd[0] == 0 and rd[2] == 72 and rd[3] == 73 and rd[4] == 0;
|
||||
|
||||
// reopen onto row 2 — different size, fresh content
|
||||
roerr := false;
|
||||
bl.reopen(2) catch { roerr = true; };
|
||||
if ok { ok = !roerr and bl.bytes() == 4; }
|
||||
bl.close();
|
||||
db.close();
|
||||
return ok;
|
||||
}
|
||||
|
||||
// ── backup + serialization ────────────────────────────────────────────
|
||||
|
||||
count_rows :: (db: *sq.Sqlite, table: string) -> i64 {
|
||||
sel, pe := db.prepare(concat("SELECT COUNT(*) FROM ", table));
|
||||
if pe { return -1; }
|
||||
se := false;
|
||||
rc := sel.step() catch { se = true; 0 };
|
||||
n : i64 = -1;
|
||||
if !se and rc == sq.SQLITE_ROW { n = sel.column_int64(0); }
|
||||
sel.finalize();
|
||||
return n;
|
||||
}
|
||||
|
||||
check_backup :: () -> bool {
|
||||
src, oe := sq.Sqlite.open(".sx-tmp/sqlite_api/bk-src.db");
|
||||
if oe { return false; }
|
||||
ee := false;
|
||||
src.exec("CREATE TABLE t (x); INSERT INTO t VALUES (1),(2),(3),(4)") catch { ee = true; };
|
||||
if ee { src.close(); return false; }
|
||||
|
||||
dst, oe2 := sq.Sqlite.open(".sx-tmp/sqlite_api/bk-dst.db");
|
||||
if oe2 { src.close(); return false; }
|
||||
bke := false;
|
||||
sq.sqlite_backup_run(@dst, @src) catch { bke = true; };
|
||||
ok := !bke and count_rows(@dst, "t") == 4;
|
||||
src.close();
|
||||
dst.close();
|
||||
return ok;
|
||||
}
|
||||
|
||||
check_serialize_round_trip :: () -> bool {
|
||||
db, oe := sq.Sqlite.open(":memory:");
|
||||
if oe { return false; }
|
||||
ee := false;
|
||||
db.exec("CREATE TABLE t (x); INSERT INTO t VALUES (10),(20)") catch { ee = true; };
|
||||
if ee { db.close(); return false; }
|
||||
img, se := db.serialize();
|
||||
db.close();
|
||||
if se { return false; }
|
||||
if img.len < 512 { return false; }
|
||||
|
||||
db2, oe2 := sq.Sqlite.open(":memory:");
|
||||
if oe2 { return false; }
|
||||
de := false;
|
||||
db2.deserialize(img) catch { de = true; };
|
||||
ok := !de and count_rows(@db2, "t") == 2;
|
||||
db2.close();
|
||||
return ok;
|
||||
}
|
||||
|
||||
main :: () -> i32 {
|
||||
process.run(concat("rm -rf ", DBDIR));
|
||||
process.run(concat("mkdir -p ", DBDIR));
|
||||
|
||||
failures : i32 = 0;
|
||||
failures += run_case("library: version/sourceid/threadsafe/options", check_library_info());
|
||||
failures += run_case("library: complete/glob/like/stricmp/errstr", check_text_utils());
|
||||
failures += run_case("library: randomness yields bytes", check_randomness());
|
||||
failures += run_case("conn: open_v2 flags + db_readonly/filename", check_open_v2_flags());
|
||||
failures += run_case("conn: autocommit + txn_state through BEGIN", check_txn_state());
|
||||
failures += run_case("conn: changes64 + set_last_insert_rowid", check_changes_and_rowid());
|
||||
failures += run_case("conn: limit answers the prior value", check_limit());
|
||||
failures += run_case("conn: constraint errcodes + error_offset", check_error_detail());
|
||||
failures += run_case("stmt: double/blob round trip (interior NULs)", check_typed_round_trip());
|
||||
failures += run_case("stmt: named parameter introspection", check_named_parameters());
|
||||
failures += run_case("stmt: clear_bindings + reset reuse", check_rebind_reuse());
|
||||
failures += run_case("stmt: names/decltypes/metadata/expanded_sql", check_statement_introspection());
|
||||
failures += run_case("blob: zeroblob + write/read/reopen", check_blob_io());
|
||||
failures += run_case("backup: full db -> db copy", check_backup());
|
||||
failures += run_case("serialize: image round-trips into a fresh db", check_serialize_round_trip());
|
||||
|
||||
process.run(concat("rm -rf ", DBDIR));
|
||||
print("------------------------------------------------\n");
|
||||
if failures == 0 {
|
||||
print("sqlite_api: ALL CASES PASS\n");
|
||||
return 0;
|
||||
}
|
||||
print("sqlite_api: {} CASE(S) FAILED\n", failures);
|
||||
return 1;
|
||||
}
|
||||
Reference in New Issue
Block a user