Subplan 02 Slice 2 foundation. vendor/sqlite/ holds the amalgamation (provenance + upgrade notes in its README); make build compiles it into build/vendor/libsqlite3.a (statically linked into dist via -L) and build/vendor/jit/libsqlite3.dylib (dlopen'd by sx run via tests/run.sh's -L flag) — separate directories because the macOS linker prefers a dylib over an archive in one search dir. The sx JIT resolves #foreign symbols via dlsym(RTLD_DEFAULT), where the already-loaded OS libsqlite3 wins by load order — so the vendored build renames its API to dist_sqlite3_* (vendor/sqlite/rename.h, -include'd), making resolution unambiguous in both modes: those symbols exist only in the vendored products. src/db/sqlite.sx binds the renamed surface behind Sqlite/SqliteStmt (open/exec/prepare/bind/step/column/finalize, errmsg, last_insert_rowid, changes, libversion); opaque handles cross the FFI as usize, strings read from sqlite are copied before its buffers die. make test 20/20 (new: sqlite_smoke.sx — pins the loaded version to the vendored 3.53.2, round trip, reopen persistence, BEGIN/ROLLBACK, errmsg; also verified as an AOT binary with no libsqlite3 in otool -L).
174 lines
6.1 KiB
Plaintext
174 lines
6.1 KiB
Plaintext
// Pinned acceptance for P5.1 — the VENDORED SQLite is linked and usable
|
|
// through src/db/sqlite.sx.
|
|
//
|
|
// * version: sqlite3_libversion() equals the vendored amalgamation's
|
|
// version (vendor/sqlite/README.md) — a silent fallback to the OS
|
|
// libsqlite3 fails here, in both `sx run` (dlopen via -L
|
|
// build/vendor/jit) and `sx build` (static link via -L build/vendor).
|
|
// * round trip: create table, INSERT via bound parameters (text +
|
|
// int64 + null), SELECT back with WHERE binding, typed columns.
|
|
// * persistence: rows survive close + reopen of the same file.
|
|
// * transactions: BEGIN/ROLLBACK leaves no row behind.
|
|
// * errors: bad SQL raises, and errmsg names the problem.
|
|
#import "modules/std.sx";
|
|
#import "modules/std/fs.sx";
|
|
process :: #import "modules/std/process.sx";
|
|
sq :: #import "../src/db/sqlite.sx";
|
|
|
|
VENDORED_VERSION :: "3.53.2";
|
|
DBDIR :: ".sx-tmp/sqlite_smoke";
|
|
DBPATH :: ".sx-tmp/sqlite_smoke/t.db";
|
|
|
|
run_case :: (label: string, ok: bool) -> i32 {
|
|
if ok { print(" PASS {}\n", label); return 0; }
|
|
print(" FAIL {}\n", label);
|
|
return 1;
|
|
}
|
|
|
|
check_version :: () -> bool {
|
|
v := sq.sqlite_version();
|
|
if v != VENDORED_VERSION {
|
|
print(" loaded sqlite {} but vendor/sqlite is {}\n", v, VENDORED_VERSION);
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
check_roundtrip :: () -> bool {
|
|
db, oe := sq.Sqlite.open(DBPATH);
|
|
if oe { return false; }
|
|
|
|
ee := false;
|
|
db.exec("CREATE TABLE apps (id INTEGER PRIMARY KEY, slug TEXT NOT NULL, owner TEXT, created_at INTEGER NOT NULL)") catch { ee = true; };
|
|
if ee { db.close(); return false; }
|
|
|
|
ins, pe := db.prepare("INSERT INTO apps (slug, owner, created_at) VALUES (?1, ?2, ?3)");
|
|
if pe { db.close(); return false; }
|
|
berr := false;
|
|
ins.bind_text(1, "acme-app") catch { berr = true; };
|
|
ins.bind_text(2, "ci") catch { berr = true; };
|
|
ins.bind_int64(3, 1781250000) catch { berr = true; };
|
|
se := false;
|
|
rc := ins.step() catch { se = true; 0 };
|
|
ins.finalize();
|
|
if berr or se or rc != sq.SQLITE_DONE { db.close(); return false; }
|
|
rowid := db.last_insert_rowid();
|
|
if rowid != 1 { db.close(); return false; }
|
|
|
|
// second row with a NULL owner
|
|
ins2, pe2 := db.prepare("INSERT INTO apps (slug, owner, created_at) VALUES (?1, ?2, ?3)");
|
|
if pe2 { db.close(); return false; }
|
|
ins2.bind_text(1, "other-app") catch { berr = true; };
|
|
ins2.bind_null(2) catch { berr = true; };
|
|
ins2.bind_int64(3, 1781250001) catch { berr = true; };
|
|
ins2.step() catch { se = true; 0 };
|
|
ins2.finalize();
|
|
if berr or se { db.close(); return false; }
|
|
|
|
// select the first row back through a WHERE binding
|
|
sel, pe3 := db.prepare("SELECT id, slug, owner, created_at FROM apps WHERE slug = ?1");
|
|
if pe3 { db.close(); return false; }
|
|
sel.bind_text(1, "acme-app") catch { berr = true; };
|
|
src := sel.step() catch { se = true; 0 };
|
|
ok := !berr and !se and src == sq.SQLITE_ROW;
|
|
if ok {
|
|
ok = sel.column_count() == 4
|
|
and sel.column_int64(0) == 1
|
|
and sel.column_text(1) == "acme-app"
|
|
and sel.column_text(2) == "ci"
|
|
and sel.column_int64(3) == 1781250000
|
|
and sel.column_type(1) == sq.SQLITE_TEXT;
|
|
}
|
|
if ok {
|
|
drc := sel.step() catch { se = true; 0 };
|
|
ok = !se and drc == sq.SQLITE_DONE; // exactly one matching row
|
|
}
|
|
sel.finalize();
|
|
|
|
// NULL column reads as "" with column_type NULL
|
|
if ok {
|
|
sel2, pe4 := db.prepare("SELECT owner FROM apps WHERE slug = 'other-app'");
|
|
if pe4 { db.close(); return false; }
|
|
nrc := sel2.step() catch { se = true; 0 };
|
|
ok = !se and nrc == sq.SQLITE_ROW
|
|
and sel2.column_type(0) == sq.SQLITE_NULL
|
|
and sel2.column_text(0) == "";
|
|
sel2.finalize();
|
|
}
|
|
|
|
db.close();
|
|
return ok;
|
|
}
|
|
|
|
check_persistence :: () -> bool {
|
|
db, oe := sq.Sqlite.open(DBPATH);
|
|
if oe { return false; }
|
|
sel, pe := db.prepare("SELECT COUNT(*) FROM apps");
|
|
if pe { db.close(); return false; }
|
|
se := false;
|
|
rc := sel.step() catch { se = true; 0 };
|
|
ok := !se and rc == sq.SQLITE_ROW and sel.column_int64(0) == 2;
|
|
sel.finalize();
|
|
db.close();
|
|
return ok;
|
|
}
|
|
|
|
check_transaction_rollback :: () -> bool {
|
|
db, oe := sq.Sqlite.open(DBPATH);
|
|
if oe { return false; }
|
|
ee := false;
|
|
db.exec("BEGIN") catch { ee = true; };
|
|
db.exec("INSERT INTO apps (slug, owner, created_at) VALUES ('doomed', 'x', 0)") catch { ee = true; };
|
|
db.exec("ROLLBACK") catch { ee = true; };
|
|
if ee { db.close(); return false; }
|
|
|
|
sel, pe := db.prepare("SELECT COUNT(*) FROM apps WHERE slug = 'doomed'");
|
|
if pe { db.close(); return false; }
|
|
se := false;
|
|
rc := sel.step() catch { se = true; 0 };
|
|
ok := !se and rc == sq.SQLITE_ROW and sel.column_int64(0) == 0;
|
|
sel.finalize();
|
|
db.close();
|
|
return ok;
|
|
}
|
|
|
|
check_error_path :: () -> bool {
|
|
db, oe := sq.Sqlite.open(DBPATH);
|
|
if oe { return false; }
|
|
failed := false;
|
|
db.exec("FROBNICATE THE DATABASE") catch { failed = true; };
|
|
msg := db.errmsg();
|
|
db.close();
|
|
if !failed { return false; }
|
|
// errmsg names the offending token
|
|
i := 0;
|
|
found := false;
|
|
while i + 10 <= msg.len {
|
|
v := string.{ ptr = @msg[i], len = 10 };
|
|
if v == "FROBNICATE" { found = true; break; }
|
|
i += 1;
|
|
}
|
|
return found;
|
|
}
|
|
|
|
main :: () -> i32 {
|
|
process.run(concat("rm -rf ", DBDIR));
|
|
process.run(concat("mkdir -p ", DBDIR));
|
|
|
|
failures : i32 = 0;
|
|
failures += run_case("vendored version is loaded", check_version());
|
|
failures += run_case("create/insert/select round trip", check_roundtrip());
|
|
failures += run_case("rows persist across reopen", check_persistence());
|
|
failures += run_case("BEGIN/ROLLBACK leaves no row", check_transaction_rollback());
|
|
failures += run_case("bad SQL raises with errmsg detail", check_error_path());
|
|
|
|
process.run(concat("rm -rf ", DBDIR));
|
|
print("------------------------------------------------\n");
|
|
if failures == 0 {
|
|
print("sqlite_smoke: ALL CASES PASS\n");
|
|
return 0;
|
|
}
|
|
print("sqlite_smoke: {} CASE(S) FAILED\n", failures);
|
|
return 1;
|
|
}
|