diff --git a/wurst/file/SQLite.wurst b/wurst/file/SQLite.wurst new file mode 100644 index 00000000..87a11ea4 --- /dev/null +++ b/wurst/file/SQLite.wurst @@ -0,0 +1,287 @@ +package SQLite +import ArrayList + +// ============================================================================ +// COMPILETIME ONLY — SQLite JDBC bindings exposed by the WurstScript compiler. +// These run during compilation (@compiletime functions) and unit tests (@test). +// They do NOT exist inside Warcraft III at runtime. Do not call any sqlite_* +// native or use SqlResult / SqliteDb / SQL from code that runs in-game. +// +// UPSTREAM DEPENDENCY: Requires wurstscript/WurstScript PR #1180 which adds +// compiletime SQLite native support to the interpreter. These bindings will +// not function without that compiler-side change. +// +// ERROR BEHAVIOUR: The compiler-side natives throw InterpreterExceptions on +// failure (invalid handles, SQL errors, JDBC failures). These exceptions are +// fatal and will abort the compiletime run with an error message. There is no +// recoverable error path — if sqlite_open, sqlite_prepare, or sqlite_exec +// fails, compilation stops. +// +// SQL INJECTION WARNING: The convenience methods (exec, select, selectFirst, +// exists, count) accept raw query strings with NO parameterisation. For any +// query involving dynamic values (user input, computed strings), use the +// parameterised variants (execPrepared, selectPrepared, selectFirstPrepared) +// or call the sqlite_prepare/sqlite_bind_* natives directly. +// ============================================================================ + +@extern public native sqlite_open(string path) returns int +@extern public native sqlite_prepare(int conn, string q) returns int +@extern public native sqlite_bind_int(int stmt, int idx, int val) +@extern public native sqlite_bind_real(int stmt, int idx, real val) +@extern public native sqlite_bind_string(int stmt, int idx, string val) +@extern public native sqlite_step(int stmt) returns boolean +@extern public native sqlite_column_count(int stmt) returns int +@extern public native sqlite_column_int(int stmt, int idx) returns int +@extern public native sqlite_column_real(int stmt, int idx) returns real +@extern public native sqlite_column_string(int stmt, int idx) returns string +@extern public native sqlite_column_is_null(int stmt, int idx) returns boolean +@extern public native sqlite_reset(int stmt) +@extern public native sqlite_clear_bindings(int stmt) +@extern public native sqlite_finalize(int stmt) +@extern public native sqlite_close(int conn) +@extern public native sqlite_exec(int conn, string q) + +// The compiletime interpreter runs on JVM, so columns are dynamically +// allocated in ArrayList without hitting JASS array limits. + +// ============================================================================ +// SqlResult — a single row from a SELECT query +// ============================================================================ + +/** A row returned from a SELECT query. Access columns by index via col(). */ +public class SqlResult + ArrayList cols = new ArrayList() + // Parallel to cols: 1 if the column was SQL NULL, 0 otherwise. Stored as + // int because ArrayList has no precedent/guarantee in this stdlib. + ArrayList nulls = new ArrayList() + + ondestroy + destroy this.cols + destroy this.nulls + + /** Raw string value at column index. SQL NULL is returned as "" — use + colIsNull() to distinguish a genuine NULL from an empty string. */ + function col(int index) returns string + return this.cols.get(index) + + /** True if the column at index was SQL NULL. This is the only reliable way + to tell NULL apart from an empty string, since col() returns "" for both. */ + function colIsNull(int index) returns boolean + return this.nulls.get(index) == 1 + + /** Column value parsed as int. */ + function colInt(int index) returns int + return this.cols.get(index).toInt() + + /** Column value parsed as real. */ + function colReal(int index) returns real + return this.cols.get(index).toReal() + + /** Column value as boolean ("1" or "true", case-insensitive → true). */ + function colBool(int index) returns boolean + let v = this.cols.get(index) + return v == "1" or v == "true" or v == "TRUE" or v == "True" + + /** Number of columns in this row. */ + function size() returns int + return this.cols.size() + +// ============================================================================ +// SqliteDb — OOP wrapper around a database connection +// ============================================================================ + +/** Wraps an SQLite database connection with convenience methods. */ +public class SqliteDb + private int conn + + /** Opens (or creates) the database at the given path. Use ":memory:" for temporary databases. */ + construct(string path) + this.conn = sqlite_open(path) + + /** Returns the raw connection handle for direct native calls. */ + function getHandle() returns int + return this.conn + + // -- Raw query methods (no parameterisation) -------------------------------- + // WARNING: These accept raw SQL strings. Do not interpolate untrusted values + // into the query string. Use the parameterised variants or the native + // sqlite_prepare/sqlite_bind_* functions for dynamic values. + + /** Executes a statement that returns no rows (DDL, INSERT, UPDATE, DELETE). */ + function exec(string query) + sqlite_exec(this.conn, query) + + private function readRow(int stmt, int colCount) returns SqlResult + let row = new SqlResult() + for i = 0 to colCount - 1 + row.nulls.add(sqlite_column_is_null(stmt, i) ? 1 : 0) + row.cols.add(sqlite_column_string(stmt, i)) + return row + + /** Executes a SELECT and returns all result rows. Caller owns the list + and its SqlResult entries — destroy both when done. + + Column count is determined after the first sqlite_step to ensure the + JDBC ResultSet metadata is available. */ + function select(string query) returns ArrayList + let list = new ArrayList() + let stmt = sqlite_prepare(this.conn, query) + + if sqlite_step(stmt) + let numCols = sqlite_column_count(stmt) + list.add(readRow(stmt, numCols)) + + while sqlite_step(stmt) + list.add(readRow(stmt, numCols)) + + sqlite_finalize(stmt) + return list + + /** Executes a SELECT and returns only the first row, or null if no rows match. + Caller owns the returned SqlResult — destroy it when done. */ + function selectFirst(string query) returns SqlResult + let stmt = sqlite_prepare(this.conn, query) + SqlResult row = null + + if sqlite_step(stmt) + let numCols = sqlite_column_count(stmt) + row = readRow(stmt, numCols) + + sqlite_finalize(stmt) + return row + + /** Returns true if the query produces at least one row. */ + function exists(string query) returns boolean + let stmt = sqlite_prepare(this.conn, query) + let found = sqlite_step(stmt) + sqlite_finalize(stmt) + return found + + /** Runs a "SELECT count(…)" query and returns the first column of the first row as int. */ + function count(string query) returns int + let row = this.selectFirst(query) + if row != null + let c = row.colInt(0) + destroy row + return c + return 0 + + // -- Parameterised query methods ------------------------------------------- + // These use sqlite_prepare + sqlite_bind_* under the hood. Pass parameter + // values as a ArrayList. Each "?" placeholder in the query is bound + // to the corresponding list entry (index 1-based for JDBC). All values are + // bound as strings; SQLite's type affinity handles coercion. + + /** Binds a list of string parameters to a prepared statement, 1-indexed. + ArrayList has no iterator by design, so this loops by index. */ + private function bindParams(int stmt, ArrayList params) + for i = 0 to params.size() - 1 + sqlite_bind_string(stmt, i + 1, params.get(i)) + + /** Executes a parameterised statement that returns no rows. + Example: db.execPrepared("INSERT INTO t VALUES (?, ?)", asList("foo", "42")) */ + function execPrepared(string query, ArrayList params) + let stmt = sqlite_prepare(this.conn, query) + bindParams(stmt, params) + sqlite_step(stmt) + sqlite_finalize(stmt) + + /** Executes a parameterised SELECT and returns all result rows. + Caller owns the list and its SqlResult entries — destroy both when done. */ + function selectPrepared(string query, ArrayList params) returns ArrayList + let list = new ArrayList() + let stmt = sqlite_prepare(this.conn, query) + bindParams(stmt, params) + + if sqlite_step(stmt) + let numCols = sqlite_column_count(stmt) + list.add(readRow(stmt, numCols)) + + while sqlite_step(stmt) + list.add(readRow(stmt, numCols)) + + sqlite_finalize(stmt) + return list + + /** Executes a parameterised SELECT and returns only the first row, or null. */ + function selectFirstPrepared(string query, ArrayList params) returns SqlResult + let stmt = sqlite_prepare(this.conn, query) + bindParams(stmt, params) + SqlResult row = null + + if sqlite_step(stmt) + let numCols = sqlite_column_count(stmt) + row = readRow(stmt, numCols) + + sqlite_finalize(stmt) + return row + + /** Returns true if the parameterised query produces at least one row. */ + function existsPrepared(string query, ArrayList params) returns boolean + let stmt = sqlite_prepare(this.conn, query) + bindParams(stmt, params) + let found = sqlite_step(stmt) + sqlite_finalize(stmt) + return found + + ondestroy + sqlite_close(this.conn) + +// ============================================================================ +// SQL — configurable singleton for the common single-database workflow +// ============================================================================ + +/** Override this in your package to point at your project database. */ +@configurable public constant SQL_DATABASE_PATH = ":memory:" + +/** Singleton database access. Configure the path via SQL_DATABASE_PATH. */ +public class SQL + private static SqliteDb db = null + + private static function connection() returns SqliteDb + if db == null + db = new SqliteDb(SQL_DATABASE_PATH) + return db + + static function exec(string query) + connection().exec(query) + + static function select(string query) returns ArrayList + return connection().select(query) + + static function selectFirst(string query) returns SqlResult + return connection().selectFirst(query) + + static function exists(string query) returns boolean + return connection().exists(query) + + static function count(string query) returns int + return connection().count(query) + + // -- Parameterised convenience methods -- + + static function execPrepared(string query, ArrayList params) + connection().execPrepared(query, params) + + static function selectPrepared(string query, ArrayList params) returns ArrayList + return connection().selectPrepared(query, params) + + static function selectFirstPrepared(string query, ArrayList params) returns SqlResult + return connection().selectFirstPrepared(query, params) + + static function existsPrepared(string query, ArrayList params) returns boolean + return connection().existsPrepared(query, params) + + /** Returns the underlying SqliteDb instance. */ + static function getDb() returns SqliteDb + return connection() + + /** Returns the raw connection handle for direct native calls. */ + static function getHandle() returns int + return connection().getHandle() + + /** Closes the singleton connection. Next call reopens it. */ + static function close() + if db != null + destroy db + db = null diff --git a/wurst/file/SQLiteTests.wurst b/wurst/file/SQLiteTests.wurst new file mode 100644 index 00000000..6fc25908 --- /dev/null +++ b/wurst/file/SQLiteTests.wurst @@ -0,0 +1,567 @@ +package SQLiteTests +import SQLite +import ArrayList + +function _createTestDb() returns SqliteDb + let db = new SqliteDb(":memory:") + db.exec("CREATE TABLE heroes (id INTEGER PRIMARY KEY, name TEXT, role TEXT, power_level INTEGER, alive INTEGER)") + db.exec("INSERT INTO heroes (name, role, power_level, alive) VALUES ('Arthur', 'Paladin', 9000, 1)") + db.exec("INSERT INTO heroes (name, role, power_level, alive) VALUES ('Merlin', 'Mage', 8500, 1)") + db.exec("INSERT INTO heroes (name, role, power_level, alive) VALUES ('Robin', 'Archer', 7200, 0)") + return db + +// --- SqlResult tests --- + +@test function test_SqlResult_col_returnsStringValue() + let db = _createTestDb() + let row = db.selectFirst("SELECT name, role FROM heroes WHERE name = 'Arthur'") + row.col(0).assertEquals("Arthur") + row.col(1).assertEquals("Paladin") + destroy row + destroy db + +@test function test_SqlResult_colInt_parsesInteger() + let db = _createTestDb() + let row = db.selectFirst("SELECT power_level FROM heroes WHERE name = 'Arthur'") + row.colInt(0).assertEquals(9000) + destroy row + destroy db + +@test function test_SqlResult_colReal_parsesReal() + let db = _createTestDb() + db.exec("CREATE TABLE stats (val REAL)") + db.exec("INSERT INTO stats VALUES (3.14)") + let row = db.selectFirst("SELECT val FROM stats") + row.colReal(0).assertEquals(3.14, 0.01) + destroy row + destroy db + +@test function test_SqlResult_colBool_parsesOneAsTrue() + let db = _createTestDb() + let row = db.selectFirst("SELECT alive FROM heroes WHERE name = 'Arthur'") + row.colBool(0).assertEquals(true) + destroy row + destroy db + +@test function test_SqlResult_colBool_parsesZeroAsFalse() + let db = _createTestDb() + let row = db.selectFirst("SELECT alive FROM heroes WHERE name = 'Robin'") + row.colBool(0).assertEquals(false) + destroy row + destroy db + +@test function test_SqlResult_size_matchesColumnCount() + let db = _createTestDb() + let row = db.selectFirst("SELECT name, role, power_level FROM heroes LIMIT 1") + row.size().assertEquals(3) + destroy row + let row2 = db.selectFirst("SELECT name FROM heroes LIMIT 1") + row2.size().assertEquals(1) + destroy row2 + destroy db + +// --- SqliteDb.select tests --- + +@test function test_SqliteDb_select_returnsAllRows() + let db = _createTestDb() + let rows = db.select("SELECT name FROM heroes ORDER BY name") + rows.size().assertEquals(3) + rows.get(0).col(0).assertEquals("Arthur") + rows.get(1).col(0).assertEquals("Merlin") + rows.get(2).col(0).assertEquals("Robin") + for i = 0 to rows.size() - 1 + destroy rows.get(i) + destroy rows + destroy db + +@test function test_SqliteDb_select_emptyResultReturnsEmptyList() + let db = _createTestDb() + let rows = db.select("SELECT name FROM heroes WHERE name = 'Nobody'") + rows.size().assertEquals(0) + destroy rows + destroy db + +@test function test_SqliteDb_select_multipleColumns() + let db = _createTestDb() + let rows = db.select("SELECT name, role, power_level FROM heroes WHERE name = 'Merlin'") + rows.size().assertEquals(1) + let row = rows.get(0) + row.col(0).assertEquals("Merlin") + row.col(1).assertEquals("Mage") + row.col(2).assertEquals("8500") + destroy row + destroy rows + destroy db + +// --- SqliteDb.selectFirst tests --- + +@test function test_SqliteDb_selectFirst_returnsSingleRow() + let db = _createTestDb() + let row = db.selectFirst("SELECT name FROM heroes ORDER BY power_level DESC") + row.col(0).assertEquals("Arthur") + destroy row + destroy db + +@test function test_SqliteDb_selectFirst_returnsNullWhenEmpty() + let db = _createTestDb() + let row = db.selectFirst("SELECT name FROM heroes WHERE name = 'Nobody'") + (row == null).assertEquals(true) + destroy db + +// --- SqliteDb.exists tests --- + +@test function test_SqliteDb_exists_trueWhenRowPresent() + let db = _createTestDb() + db.exists("SELECT 1 FROM heroes WHERE name = 'Arthur'").assertEquals(true) + destroy db + +@test function test_SqliteDb_exists_falseWhenNoRows() + let db = _createTestDb() + db.exists("SELECT 1 FROM heroes WHERE name = 'Nobody'").assertEquals(false) + destroy db + +// --- SqliteDb.count tests --- + +@test function test_SqliteDb_count_returnsRowCount() + let db = _createTestDb() + db.count("SELECT count(*) FROM heroes").assertEquals(3) + destroy db + +@test function test_SqliteDb_count_withWhereClause() + let db = _createTestDb() + db.count("SELECT count(*) FROM heroes WHERE alive = 1").assertEquals(2) + destroy db + +@test function test_SqliteDb_count_zeroWhenEmpty() + let db = _createTestDb() + db.count("SELECT count(*) FROM heroes WHERE name = 'Nobody'").assertEquals(0) + destroy db + +// --- SqliteDb.exec tests --- + +@test function test_SqliteDb_exec_insert() + let db = _createTestDb() + db.exec("INSERT INTO heroes (name, role, power_level, alive) VALUES ('Lancelot', 'Knight', 8800, 1)") + db.count("SELECT count(*) FROM heroes").assertEquals(4) + let row = db.selectFirst("SELECT name FROM heroes WHERE name = 'Lancelot'") + row.col(0).assertEquals("Lancelot") + destroy row + destroy db + +@test function test_SqliteDb_exec_update() + let db = _createTestDb() + db.exec("UPDATE heroes SET power_level = 9999 WHERE name = 'Arthur'") + let row = db.selectFirst("SELECT power_level FROM heroes WHERE name = 'Arthur'") + row.colInt(0).assertEquals(9999) + destroy row + destroy db + +@test function test_SqliteDb_exec_delete() + let db = _createTestDb() + db.exec("DELETE FROM heroes WHERE name = 'Robin'") + db.count("SELECT count(*) FROM heroes").assertEquals(2) + db.exists("SELECT 1 FROM heroes WHERE name = 'Robin'").assertEquals(false) + destroy db + +@test function test_SqliteDb_exec_createAndDropTable() + let db = new SqliteDb(":memory:") + db.exec("CREATE TABLE temp (id INTEGER)") + db.exists("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'temp'").assertEquals(true) + db.exec("DROP TABLE temp") + db.exists("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'temp'").assertEquals(false) + destroy db + +// --- SqliteDb.getHandle escape hatch --- + +@test function test_SqliteDb_getHandle_worksWithNativeCalls() + let db = _createTestDb() + let h = db.getHandle() + sqlite_exec(h, "INSERT INTO heroes (name, role, power_level, alive) VALUES ('Gawain', 'Knight', 7500, 1)") + let row = db.selectFirst("SELECT name FROM heroes WHERE name = 'Gawain'") + row.col(0).assertEquals("Gawain") + destroy row + destroy db + +// --- Parameterised query tests --- + +@test function test_SqliteDb_execPrepared_insertsWithParams() + let db = _createTestDb() + let params = new ArrayList() + params.add("Lancelot") + params.add("Knight") + params.add("8800") + params.add("1") + db.execPrepared("INSERT INTO heroes (name, role, power_level, alive) VALUES (?, ?, ?, ?)", params) + destroy params + db.count("SELECT count(*) FROM heroes").assertEquals(4) + let row = db.selectFirst("SELECT name, role FROM heroes WHERE name = 'Lancelot'") + row.col(0).assertEquals("Lancelot") + row.col(1).assertEquals("Knight") + destroy row + destroy db + +@test function test_SqliteDb_selectPrepared_returnsFilteredRows() + let db = _createTestDb() + let params = new ArrayList() + params.add("Paladin") + let rows = db.selectPrepared("SELECT name FROM heroes WHERE role = ?", params) + destroy params + rows.size().assertEquals(1) + rows.get(0).col(0).assertEquals("Arthur") + for i = 0 to rows.size() - 1 + destroy rows.get(i) + destroy rows + destroy db + +@test function test_SqliteDb_selectFirstPrepared_returnsSingleRow() + let db = _createTestDb() + let params = new ArrayList() + params.add("Arthur") + let row = db.selectFirstPrepared("SELECT power_level FROM heroes WHERE name = ?", params) + destroy params + row.colInt(0).assertEquals(9000) + destroy row + destroy db + +@test function test_SqliteDb_selectFirstPrepared_returnsNullWhenNoMatch() + let db = _createTestDb() + let params = new ArrayList() + params.add("Nobody") + let row = db.selectFirstPrepared("SELECT name FROM heroes WHERE name = ?", params) + destroy params + (row == null).assertEquals(true) + destroy db + +@test function test_SqliteDb_existsPrepared_trueWhenPresent() + let db = _createTestDb() + let params = new ArrayList() + params.add("Arthur") + db.existsPrepared("SELECT 1 FROM heroes WHERE name = ?", params).assertEquals(true) + destroy params + destroy db + +@test function test_SqliteDb_existsPrepared_falseWhenMissing() + let db = _createTestDb() + let params = new ArrayList() + params.add("Nobody") + db.existsPrepared("SELECT 1 FROM heroes WHERE name = ?", params).assertEquals(false) + destroy params + destroy db + +@test function test_SqliteDb_execPrepared_preventsInjection() + let db = _createTestDb() + // This malicious name would drop the table if interpolated as raw SQL + let params = new ArrayList() + params.add("'); DROP TABLE heroes; --") + params.add("Evil") + params.add("0") + params.add("0") + db.execPrepared("INSERT INTO heroes (name, role, power_level, alive) VALUES (?, ?, ?, ?)", params) + destroy params + // Table should still exist and have 4 rows (3 original + 1 injected-as-data) + db.count("SELECT count(*) FROM heroes").assertEquals(4) + db.exists("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'heroes'").assertEquals(true) + destroy db + +// --- SQL singleton tests --- + +@test function test_SQL_singleton_execAndSelect() + SQL.exec("DROP TABLE IF EXISTS _sgtest1") + SQL.exec("CREATE TABLE _sgtest1 (id INTEGER PRIMARY KEY, val TEXT)") + SQL.exec("INSERT INTO _sgtest1 VALUES (1, 'hello')") + let rows = SQL.select("SELECT val FROM _sgtest1 WHERE id = 1") + rows.size().assertEquals(1) + rows.get(0).col(0).assertEquals("hello") + for i = 0 to rows.size() - 1 + destroy rows.get(i) + destroy rows + SQL.exec("DROP TABLE _sgtest1") + SQL.close() + +@test function test_SQL_singleton_selectFirst() + SQL.exec("DROP TABLE IF EXISTS _sgtest2") + SQL.exec("CREATE TABLE _sgtest2 (n TEXT)") + SQL.exec("INSERT INTO _sgtest2 VALUES ('alpha')") + SQL.exec("INSERT INTO _sgtest2 VALUES ('beta')") + let row = SQL.selectFirst("SELECT n FROM _sgtest2 ORDER BY n") + row.col(0).assertEquals("alpha") + destroy row + SQL.exec("DROP TABLE _sgtest2") + SQL.close() + +@test function test_SQL_singleton_exists() + SQL.exec("DROP TABLE IF EXISTS _sgtest3") + SQL.exec("CREATE TABLE _sgtest3 (x INTEGER)") + SQL.exec("INSERT INTO _sgtest3 VALUES (42)") + SQL.exists("SELECT 1 FROM _sgtest3 WHERE x = 42").assertEquals(true) + SQL.exists("SELECT 1 FROM _sgtest3 WHERE x = 99").assertEquals(false) + SQL.exec("DROP TABLE _sgtest3") + SQL.close() + +@test function test_SQL_singleton_count() + SQL.exec("DROP TABLE IF EXISTS _sgtest4") + SQL.exec("CREATE TABLE _sgtest4 (x INTEGER)") + SQL.exec("INSERT INTO _sgtest4 VALUES (1)") + SQL.exec("INSERT INTO _sgtest4 VALUES (2)") + SQL.exec("INSERT INTO _sgtest4 VALUES (3)") + SQL.count("SELECT count(*) FROM _sgtest4").assertEquals(3) + SQL.exec("DROP TABLE _sgtest4") + SQL.close() + +@test function test_SQL_singleton_closeAndReopen() + SQL.exec("CREATE TABLE _sgtest5 (v TEXT)") + SQL.exec("INSERT INTO _sgtest5 VALUES ('before')") + SQL.count("SELECT count(*) FROM _sgtest5").assertEquals(1) + SQL.close() + // After close on :memory:, previous data is gone — verify a fresh connection works + SQL.exec("CREATE TABLE _sgtest5 (v TEXT)") + SQL.count("SELECT count(*) FROM _sgtest5").assertEquals(0) + SQL.exec("DROP TABLE _sgtest5") + SQL.close() + +@test function test_SQL_singleton_execPrepared() + SQL.exec("DROP TABLE IF EXISTS _sgtest6") + SQL.exec("CREATE TABLE _sgtest6 (name TEXT, val TEXT)") + let params = new ArrayList() + params.add("key1") + params.add("value1") + SQL.execPrepared("INSERT INTO _sgtest6 VALUES (?, ?)", params) + destroy params + let row = SQL.selectFirst("SELECT val FROM _sgtest6 WHERE name = 'key1'") + row.col(0).assertEquals("value1") + destroy row + SQL.exec("DROP TABLE _sgtest6") + SQL.close() + +// --- Edge cases --- + +@test function test_SqliteDb_select_orderByDescending() + let db = _createTestDb() + let rows = db.select("SELECT name, power_level FROM heroes ORDER BY power_level DESC") + rows.get(0).col(0).assertEquals("Arthur") + rows.get(0).colInt(1).assertEquals(9000) + rows.get(1).col(0).assertEquals("Merlin") + rows.get(2).col(0).assertEquals("Robin") + for i = 0 to rows.size() - 1 + destroy rows.get(i) + destroy rows + destroy db + +@test function test_SqliteDb_select_withLimitAndOffset() + let db = _createTestDb() + let rows = db.select("SELECT name FROM heroes ORDER BY name LIMIT 2 OFFSET 1") + rows.size().assertEquals(2) + rows.get(0).col(0).assertEquals("Merlin") + rows.get(1).col(0).assertEquals("Robin") + for i = 0 to rows.size() - 1 + destroy rows.get(i) + destroy rows + destroy db + +@test function test_SqliteDb_nullValueReturnsEmptyString() + let db = new SqliteDb(":memory:") + db.exec("CREATE TABLE nullable (a TEXT, b TEXT)") + db.exec("INSERT INTO nullable (a) VALUES ('present')") + let row = db.selectFirst("SELECT a, b FROM nullable") + row.col(0).assertEquals("present") + row.col(1).assertEquals("") + destroy row + destroy db + +@test function test_SqlResult_colIsNull_distinguishesNullFromEmptyString() + let db = new SqliteDb(":memory:") + db.exec("CREATE TABLE t (a TEXT, b TEXT, c TEXT)") + // a = 'x' (value), b = '' (empty string, NOT null), c = NULL (omitted) + db.exec("INSERT INTO t (a, b) VALUES ('x', '')") + let row = db.selectFirst("SELECT a, b, c FROM t") + // col() cannot tell '' apart from NULL — both come back as "" + row.col(0).assertEquals("x") + row.col(1).assertEquals("") + row.col(2).assertEquals("") + // colIsNull() is the only reliable discriminator + row.colIsNull(0).assertEquals(false) + row.colIsNull(1).assertEquals(false) + row.colIsNull(2).assertEquals(true) + destroy row + destroy db + +@test function test_SqliteDb_multipleTablesJoin() + let db = new SqliteDb(":memory:") + db.exec("CREATE TABLE authors (id INTEGER PRIMARY KEY, name TEXT)") + db.exec("CREATE TABLE books (id INTEGER PRIMARY KEY, title TEXT, author_id INTEGER)") + db.exec("INSERT INTO authors VALUES (1, 'Tolkien')") + db.exec("INSERT INTO books VALUES (1, 'The Hobbit', 1)") + let row = db.selectFirst("SELECT b.title, a.name FROM books b JOIN authors a ON a.id = b.author_id") + row.col(0).assertEquals("The Hobbit") + row.col(1).assertEquals("Tolkien") + destroy row + destroy db + +@test function test_SqliteDb_aggregateQueries() + let db = _createTestDb() + let row = db.selectFirst("SELECT min(power_level), max(power_level), avg(power_level) FROM heroes") + row.colInt(0).assertEquals(7200) + row.colInt(1).assertEquals(9000) + row.size().assertEquals(3) + destroy row + destroy db + +// ============================================================================ +// Native surface coverage — proves every @extern native behaves as documented. +// These use the raw handle (db.getHandle()) so each native is exercised directly. +// ============================================================================ + +// --- sqlite_bind_int / sqlite_bind_real / sqlite_column_int / sqlite_column_real --- + +@test function test_native_bindTypedAndReadBack() + let db = new SqliteDb(":memory:") + let h = db.getHandle() + sqlite_exec(h, "CREATE TABLE t (i INTEGER, r REAL)") + let ins = sqlite_prepare(h, "INSERT INTO t VALUES (?, ?)") + sqlite_bind_int(ins, 1, 42) + sqlite_bind_real(ins, 2, 2.5) + // stepping an INSERT executes it and yields no row + sqlite_step(ins).assertEquals(false) + sqlite_finalize(ins) + let sel = sqlite_prepare(h, "SELECT i, r FROM t") + sqlite_step(sel).assertEquals(true) + sqlite_column_int(sel, 0).assertEquals(42) + sqlite_column_real(sel, 1).assertEquals(2.5, 0.0001) + sqlite_finalize(sel) + destroy db + +// --- sqlite_column_count --- + +@test function test_native_columnCount_matchesSelectedColumns() + let db = new SqliteDb(":memory:") + let h = db.getHandle() + sqlite_exec(h, "CREATE TABLE t (a INTEGER, b INTEGER, c INTEGER)") + sqlite_exec(h, "INSERT INTO t VALUES (1, 2, 3)") + let stmt = sqlite_prepare(h, "SELECT a, b, c FROM t") + sqlite_step(stmt).assertEquals(true) + sqlite_column_count(stmt).assertEquals(3) + sqlite_finalize(stmt) + destroy db + +// --- sqlite_column_is_null (native) --- + +@test function test_native_columnIsNull() + let db = new SqliteDb(":memory:") + let h = db.getHandle() + sqlite_exec(h, "CREATE TABLE t (a INTEGER, b INTEGER)") + sqlite_exec(h, "INSERT INTO t (a) VALUES (7)") + let stmt = sqlite_prepare(h, "SELECT a, b FROM t") + sqlite_step(stmt).assertEquals(true) + sqlite_column_is_null(stmt, 0).assertEquals(false) + sqlite_column_is_null(stmt, 1).assertEquals(true) + sqlite_finalize(stmt) + destroy db + +// --- sqlite_step past the last row --- + +@test function test_native_stepReturnsFalsePastLastRow() + let db = new SqliteDb(":memory:") + let h = db.getHandle() + sqlite_exec(h, "CREATE TABLE t (v INTEGER)") + sqlite_exec(h, "INSERT INTO t VALUES (1)") + let stmt = sqlite_prepare(h, "SELECT v FROM t") + sqlite_step(stmt).assertEquals(true) + sqlite_step(stmt).assertEquals(false) + sqlite_finalize(stmt) + destroy db + +// --- sqlite_reset: rewinds the cursor AND preserves bound parameters --- + +@test function test_native_reset_rewindsAndPreservesBindings() + let db = new SqliteDb(":memory:") + let h = db.getHandle() + sqlite_exec(h, "CREATE TABLE t (v INTEGER)") + sqlite_exec(h, "INSERT INTO t VALUES (10), (20), (30)") + let stmt = sqlite_prepare(h, "SELECT v FROM t WHERE v >= ? ORDER BY v") + sqlite_bind_int(stmt, 1, 20) + // first pass: 20, 30 + sqlite_step(stmt).assertEquals(true) + sqlite_column_int(stmt, 0).assertEquals(20) + sqlite_step(stmt).assertEquals(true) + sqlite_column_int(stmt, 0).assertEquals(30) + sqlite_step(stmt).assertEquals(false) + // reset rewinds; the binding (20) must still be in effect + sqlite_reset(stmt) + sqlite_step(stmt).assertEquals(true) + sqlite_column_int(stmt, 0).assertEquals(20) + sqlite_finalize(stmt) + destroy db + +// --- sqlite_clear_bindings: unbinds parameters (they become NULL) --- + +@test function test_native_clearBindings_resetsParamsToNull() + let db = new SqliteDb(":memory:") + let h = db.getHandle() + let stmt = sqlite_prepare(h, "SELECT ?") + sqlite_bind_string(stmt, 1, "bound") + sqlite_step(stmt).assertEquals(true) + sqlite_column_is_null(stmt, 0).assertEquals(false) + sqlite_column_string(stmt, 0).assertEquals("bound") + // clear the binding, rewind, step again — the parameter is now NULL + sqlite_clear_bindings(stmt) + sqlite_reset(stmt) + sqlite_step(stmt).assertEquals(true) + sqlite_column_is_null(stmt, 0).assertEquals(true) + sqlite_finalize(stmt) + destroy db + +// --- sqlite_reset then rebind allows reusing one statement with new values --- + +@test function test_native_resetAndRebind_reusesStatement() + let db = new SqliteDb(":memory:") + let h = db.getHandle() + sqlite_exec(h, "CREATE TABLE t (v INTEGER)") + let ins = sqlite_prepare(h, "INSERT INTO t VALUES (?)") + sqlite_bind_int(ins, 1, 100) + sqlite_step(ins).assertEquals(false) + sqlite_reset(ins) + sqlite_bind_int(ins, 1, 200) + sqlite_step(ins).assertEquals(false) + sqlite_finalize(ins) + db.count("SELECT count(*) FROM t").assertEquals(2) + db.count("SELECT count(*) FROM t WHERE v = 100").assertEquals(1) + db.count("SELECT count(*) FROM t WHERE v = 200").assertEquals(1) + destroy db + +// --- sqlite_exec runs a full multi-statement script (native sqlite3_exec) --- + +@test function test_native_exec_runsMultiStatementScript() + let db = new SqliteDb(":memory:") + let h = db.getHandle() + sqlite_exec(h, "CREATE TABLE a (x INTEGER); CREATE TABLE b (y INTEGER); INSERT INTO a VALUES (1); INSERT INTO b VALUES (2);") + db.count("SELECT count(*) FROM a").assertEquals(1) + db.count("SELECT count(*) FROM b").assertEquals(1) + destroy db + +// --- sqlite_exec runs a CREATE TRIGGER ... BEGIN ... END block (compound SQL) --- + +@test function test_native_exec_runsTriggerBlock() + let db = new SqliteDb(":memory:") + let h = db.getHandle() + sqlite_exec(h, "CREATE TABLE src (v INTEGER); CREATE TABLE log (v INTEGER)") + sqlite_exec(h, "CREATE TRIGGER cp AFTER INSERT ON src BEGIN INSERT INTO log VALUES (NEW.v); END;") + sqlite_exec(h, "INSERT INTO src VALUES (5)") + db.count("SELECT count(*) FROM log WHERE v = 5").assertEquals(1) + destroy db + +// --- sqlite_finalize + fresh prepare on the same connection --- + +@test function test_native_finalizeThenReprepare() + let db = new SqliteDb(":memory:") + let h = db.getHandle() + sqlite_exec(h, "CREATE TABLE t (v INTEGER)") + sqlite_exec(h, "INSERT INTO t VALUES (1)") + let s1 = sqlite_prepare(h, "SELECT v FROM t") + sqlite_step(s1).assertEquals(true) + sqlite_finalize(s1) + // a second prepare/step on the same connection still works after finalize + let s2 = sqlite_prepare(h, "SELECT v FROM t") + sqlite_step(s2).assertEquals(true) + sqlite_column_int(s2, 0).assertEquals(1) + sqlite_finalize(s2) + destroy db