From 795d68879e35c77007de647ff3ce0ee1b5eaed4b Mon Sep 17 00:00:00 2001 From: Reborn Reviewer Date: Wed, 22 Jul 2026 21:09:22 +0200 Subject: [PATCH 1/3] feat: Add compiletime SQLite native bindings and helper API Adds @extern declarations for compiletime SQLite JDBC bindings exposed by the WurstScript compiler (requires wurstscript/WurstScript PR #1180). Native functions: sqlite_open, sqlite_prepare, sqlite_bind_*, sqlite_step, sqlite_column_*, sqlite_reset, sqlite_finalize, sqlite_close, sqlite_exec. High-level API: - SqlResult: row accessor with col, colInt, colReal, colBool helpers - SqliteDb: OOP connection wrapper with select, selectFirst, exists, count - SQL: configurable singleton for single-database workflows Parameterised query support: - execPrepared, selectPrepared, selectFirstPrepared, existsPrepared - Prevents SQL injection by using sqlite_prepare + sqlite_bind_string - Includes injection prevention test Fixes vs original draft: - Moved sqlite_column_count after first sqlite_step (JDBC ResultSet metadata requires an executed statement) - Added parameterised query variants to prevent SQL injection - Added comprehensive doc comments about error behaviour and upstream dep - Unified test annotations to @test (lowercase) - Removed unused ErrorHandling import - Fixed array size to use literal (WurstScript requirement) --- wurst/file/SQLite.wurst | 280 +++++++++++++++++++++++++ wurst/file/SQLiteTests.wurst | 390 +++++++++++++++++++++++++++++++++++ 2 files changed, 670 insertions(+) create mode 100644 wurst/file/SQLite.wurst create mode 100644 wurst/file/SQLiteTests.wurst diff --git a/wurst/file/SQLite.wurst b/wurst/file/SQLite.wurst new file mode 100644 index 00000000..e541b51e --- /dev/null +++ b/wurst/file/SQLite.wurst @@ -0,0 +1,280 @@ +package SQLite +import LinkedList + +// ============================================================================ +// 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_reset(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) + +// SQLite default SQLITE_MAX_COLUMN is 2000; used to cap the column array +// in SqlResult. The compiletime interpreter runs on JVM, so this is not +// constrained by JASS array limits. +constant SQLITE_MAX_COLUMNS = 2000 + +// ============================================================================ +// SqlResult — a single row from a SELECT query +// ============================================================================ + +/** A row returned from a SELECT query. Access columns by index via col(). */ +public class SqlResult + string array[2000] cols + int columnCount = 0 + + /** Raw string value at column index. */ + function col(int index) returns string + return this.cols[index] + + /** Column value parsed as int. */ + function colInt(int index) returns int + return this.cols[index].toInt() + + /** Column value parsed as real. */ + function colReal(int index) returns real + return this.cols[index].toReal() + + /** Column value as boolean ("1" or "true", case-insensitive → true). */ + function colBool(int index) returns boolean + let v = this.cols[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.columnCount + +// ============================================================================ +// 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() + row.columnCount = colCount + for i = 0 to colCount - 1 + row.cols[i] = 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 LinkedList + let list = new LinkedList() + let stmt = sqlite_prepare(this.conn, query) + + if sqlite_step(stmt) + let numCols = sqlite_column_count(stmt) + let limit = numCols > SQLITE_MAX_COLUMNS ? SQLITE_MAX_COLUMNS : numCols + list.add(readRow(stmt, limit)) + + while sqlite_step(stmt) + list.add(readRow(stmt, limit)) + + 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) + let limit = numCols > SQLITE_MAX_COLUMNS ? SQLITE_MAX_COLUMNS : numCols + row = readRow(stmt, limit) + + 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 LinkedList. 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. */ + private function bindParams(int stmt, LinkedList params) + var idx = 1 + for p in params + sqlite_bind_string(stmt, idx, p) + idx++ + + /** Executes a parameterised statement that returns no rows. + Example: db.execPrepared("INSERT INTO t VALUES (?, ?)", asList("foo", "42")) */ + function execPrepared(string query, LinkedList 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, LinkedList params) returns LinkedList + let list = new LinkedList() + let stmt = sqlite_prepare(this.conn, query) + bindParams(stmt, params) + + if sqlite_step(stmt) + let numCols = sqlite_column_count(stmt) + let limit = numCols > SQLITE_MAX_COLUMNS ? SQLITE_MAX_COLUMNS : numCols + list.add(readRow(stmt, limit)) + + while sqlite_step(stmt) + list.add(readRow(stmt, limit)) + + sqlite_finalize(stmt) + return list + + /** Executes a parameterised SELECT and returns only the first row, or null. */ + function selectFirstPrepared(string query, LinkedList 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) + let limit = numCols > SQLITE_MAX_COLUMNS ? SQLITE_MAX_COLUMNS : numCols + row = readRow(stmt, limit) + + sqlite_finalize(stmt) + return row + + /** Returns true if the parameterised query produces at least one row. */ + function existsPrepared(string query, LinkedList 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 LinkedList + 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, LinkedList params) + connection().execPrepared(query, params) + + static function selectPrepared(string query, LinkedList params) returns LinkedList + return connection().selectPrepared(query, params) + + static function selectFirstPrepared(string query, LinkedList params) returns SqlResult + return connection().selectFirstPrepared(query, params) + + static function existsPrepared(string query, LinkedList 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..92fd4f31 --- /dev/null +++ b/wurst/file/SQLiteTests.wurst @@ -0,0 +1,390 @@ +package SQLiteTests +import SQLite +import LinkedList + +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 row in rows + destroy row + 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 LinkedList() + 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 LinkedList() + 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 row in rows + destroy row + destroy rows + destroy db + +@test function test_SqliteDb_selectFirstPrepared_returnsSingleRow() + let db = _createTestDb() + let params = new LinkedList() + 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 LinkedList() + 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 LinkedList() + 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 LinkedList() + 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 LinkedList() + 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 row in rows + destroy row + 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 LinkedList() + 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 row in rows + destroy row + 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 row in rows + destroy row + 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_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 From 59480f1d1e886046b80db2593395317c2060557c Mon Sep 17 00:00:00 2001 From: Reborn Reviewer Date: Wed, 22 Jul 2026 22:02:47 +0200 Subject: [PATCH 2/3] refactor: Use dynamic storage for SqlResult columns Because Wurst implements sized class-member arrays by generating one backing array per element, the previous 2000-slot member array in SqlResult emitted massive code and compiled slowly. This refactors SqlResult to use a dynamic ArrayList to store only the actual returned columns. Additionally updates all LinkedList usages to ArrayList to match. --- wurst/file/SQLite.wurst | 71 +++++++++++++++++------------------- wurst/file/SQLiteTests.wurst | 18 ++++----- 2 files changed, 42 insertions(+), 47 deletions(-) diff --git a/wurst/file/SQLite.wurst b/wurst/file/SQLite.wurst index e541b51e..c853e697 100644 --- a/wurst/file/SQLite.wurst +++ b/wurst/file/SQLite.wurst @@ -1,5 +1,5 @@ package SQLite -import LinkedList +import ArrayList // ============================================================================ // COMPILETIME ONLY — SQLite JDBC bindings exposed by the WurstScript compiler. @@ -39,10 +39,8 @@ import LinkedList @extern public native sqlite_close(int conn) @extern public native sqlite_exec(int conn, string q) -// SQLite default SQLITE_MAX_COLUMN is 2000; used to cap the column array -// in SqlResult. The compiletime interpreter runs on JVM, so this is not -// constrained by JASS array limits. -constant SQLITE_MAX_COLUMNS = 2000 +// 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 @@ -50,29 +48,31 @@ constant SQLITE_MAX_COLUMNS = 2000 /** A row returned from a SELECT query. Access columns by index via col(). */ public class SqlResult - string array[2000] cols - int columnCount = 0 + ArrayList cols = new ArrayList() + + ondestroy + destroy this.cols /** Raw string value at column index. */ function col(int index) returns string - return this.cols[index] + return this.cols.get(index) /** Column value parsed as int. */ function colInt(int index) returns int - return this.cols[index].toInt() + return this.cols.get(index).toInt() /** Column value parsed as real. */ function colReal(int index) returns real - return this.cols[index].toReal() + 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[index] + 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.columnCount + return this.cols.size() // ============================================================================ // SqliteDb — OOP wrapper around a database connection @@ -101,9 +101,8 @@ public class SqliteDb private function readRow(int stmt, int colCount) returns SqlResult let row = new SqlResult() - row.columnCount = colCount for i = 0 to colCount - 1 - row.cols[i] = sqlite_column_string(stmt, i) + row.cols.add(sqlite_column_string(stmt, i)) return row /** Executes a SELECT and returns all result rows. Caller owns the list @@ -111,17 +110,16 @@ public class SqliteDb Column count is determined after the first sqlite_step to ensure the JDBC ResultSet metadata is available. */ - function select(string query) returns LinkedList - let list = new LinkedList() + 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) - let limit = numCols > SQLITE_MAX_COLUMNS ? SQLITE_MAX_COLUMNS : numCols - list.add(readRow(stmt, limit)) + list.add(readRow(stmt, numCols)) while sqlite_step(stmt) - list.add(readRow(stmt, limit)) + list.add(readRow(stmt, numCols)) sqlite_finalize(stmt) return list @@ -134,8 +132,7 @@ public class SqliteDb if sqlite_step(stmt) let numCols = sqlite_column_count(stmt) - let limit = numCols > SQLITE_MAX_COLUMNS ? SQLITE_MAX_COLUMNS : numCols - row = readRow(stmt, limit) + row = readRow(stmt, numCols) sqlite_finalize(stmt) return row @@ -158,12 +155,12 @@ public class SqliteDb // -- Parameterised query methods ------------------------------------------- // These use sqlite_prepare + sqlite_bind_* under the hood. Pass parameter - // values as a LinkedList. Each "?" placeholder in the query is bound + // 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. */ - private function bindParams(int stmt, LinkedList params) + private function bindParams(int stmt, ArrayList params) var idx = 1 for p in params sqlite_bind_string(stmt, idx, p) @@ -171,7 +168,7 @@ public class SqliteDb /** Executes a parameterised statement that returns no rows. Example: db.execPrepared("INSERT INTO t VALUES (?, ?)", asList("foo", "42")) */ - function execPrepared(string query, LinkedList params) + function execPrepared(string query, ArrayList params) let stmt = sqlite_prepare(this.conn, query) bindParams(stmt, params) sqlite_step(stmt) @@ -179,38 +176,36 @@ public class SqliteDb /** 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, LinkedList params) returns LinkedList - let list = new LinkedList() + 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) - let limit = numCols > SQLITE_MAX_COLUMNS ? SQLITE_MAX_COLUMNS : numCols - list.add(readRow(stmt, limit)) + list.add(readRow(stmt, numCols)) while sqlite_step(stmt) - list.add(readRow(stmt, limit)) + 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, LinkedList params) returns SqlResult + 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) - let limit = numCols > SQLITE_MAX_COLUMNS ? SQLITE_MAX_COLUMNS : numCols - row = readRow(stmt, limit) + row = readRow(stmt, numCols) sqlite_finalize(stmt) return row /** Returns true if the parameterised query produces at least one row. */ - function existsPrepared(string query, LinkedList params) returns boolean + function existsPrepared(string query, ArrayList params) returns boolean let stmt = sqlite_prepare(this.conn, query) bindParams(stmt, params) let found = sqlite_step(stmt) @@ -239,7 +234,7 @@ public class SQL static function exec(string query) connection().exec(query) - static function select(string query) returns LinkedList + static function select(string query) returns ArrayList return connection().select(query) static function selectFirst(string query) returns SqlResult @@ -253,16 +248,16 @@ public class SQL // -- Parameterised convenience methods -- - static function execPrepared(string query, LinkedList params) + static function execPrepared(string query, ArrayList params) connection().execPrepared(query, params) - static function selectPrepared(string query, LinkedList params) returns LinkedList + static function selectPrepared(string query, ArrayList params) returns ArrayList return connection().selectPrepared(query, params) - static function selectFirstPrepared(string query, LinkedList params) returns SqlResult + static function selectFirstPrepared(string query, ArrayList params) returns SqlResult return connection().selectFirstPrepared(query, params) - static function existsPrepared(string query, LinkedList params) returns boolean + static function existsPrepared(string query, ArrayList params) returns boolean return connection().existsPrepared(query, params) /** Returns the underlying SqliteDb instance. */ diff --git a/wurst/file/SQLiteTests.wurst b/wurst/file/SQLiteTests.wurst index 92fd4f31..908e5fd8 100644 --- a/wurst/file/SQLiteTests.wurst +++ b/wurst/file/SQLiteTests.wurst @@ -1,6 +1,6 @@ package SQLiteTests import SQLite -import LinkedList +import ArrayList function _createTestDb() returns SqliteDb let db = new SqliteDb(":memory:") @@ -186,7 +186,7 @@ function _createTestDb() returns SqliteDb @test function test_SqliteDb_execPrepared_insertsWithParams() let db = _createTestDb() - let params = new LinkedList() + let params = new ArrayList() params.add("Lancelot") params.add("Knight") params.add("8800") @@ -202,7 +202,7 @@ function _createTestDb() returns SqliteDb @test function test_SqliteDb_selectPrepared_returnsFilteredRows() let db = _createTestDb() - let params = new LinkedList() + let params = new ArrayList() params.add("Paladin") let rows = db.selectPrepared("SELECT name FROM heroes WHERE role = ?", params) destroy params @@ -215,7 +215,7 @@ function _createTestDb() returns SqliteDb @test function test_SqliteDb_selectFirstPrepared_returnsSingleRow() let db = _createTestDb() - let params = new LinkedList() + let params = new ArrayList() params.add("Arthur") let row = db.selectFirstPrepared("SELECT power_level FROM heroes WHERE name = ?", params) destroy params @@ -225,7 +225,7 @@ function _createTestDb() returns SqliteDb @test function test_SqliteDb_selectFirstPrepared_returnsNullWhenNoMatch() let db = _createTestDb() - let params = new LinkedList() + let params = new ArrayList() params.add("Nobody") let row = db.selectFirstPrepared("SELECT name FROM heroes WHERE name = ?", params) destroy params @@ -234,7 +234,7 @@ function _createTestDb() returns SqliteDb @test function test_SqliteDb_existsPrepared_trueWhenPresent() let db = _createTestDb() - let params = new LinkedList() + let params = new ArrayList() params.add("Arthur") db.existsPrepared("SELECT 1 FROM heroes WHERE name = ?", params).assertEquals(true) destroy params @@ -242,7 +242,7 @@ function _createTestDb() returns SqliteDb @test function test_SqliteDb_existsPrepared_falseWhenMissing() let db = _createTestDb() - let params = new LinkedList() + let params = new ArrayList() params.add("Nobody") db.existsPrepared("SELECT 1 FROM heroes WHERE name = ?", params).assertEquals(false) destroy params @@ -251,7 +251,7 @@ function _createTestDb() returns SqliteDb @test function test_SqliteDb_execPrepared_preventsInjection() let db = _createTestDb() // This malicious name would drop the table if interpolated as raw SQL - let params = new LinkedList() + let params = new ArrayList() params.add("'); DROP TABLE heroes; --") params.add("Evil") params.add("0") @@ -322,7 +322,7 @@ function _createTestDb() returns SqliteDb @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 LinkedList() + let params = new ArrayList() params.add("key1") params.add("value1") SQL.execPrepared("INSERT INTO _sgtest6 VALUES (?, ?)", params) From 0c9bca1b302a6f9791bdcf0ced3e02187e4f4ceb Mon Sep 17 00:00:00 2001 From: Reborn Reviewer Date: Fri, 24 Jul 2026 09:10:39 +0200 Subject: [PATCH 3/3] fix(sqlite): make PR compile + complete native surface and NULL handling The API used `for x in ArrayList` in 6 places, but this stdlib's ArrayList has no iterator by design (index loops only), so the PR did not compile. Replaced those with index loops. Also completed the binding surface against WurstScript PR #1180: - expose sqlite_column_is_null and sqlite_clear_bindings natives (were missing) - SqlResult now records per-column NULL flags and exposes colIsNull(), the only reliable way to tell SQL NULL apart from an empty string (col() returns "" for both) Expanded SQLiteTests with a native-surface section proving every native: typed bind/read-back, column_count, column_is_null, step-past-last-row, reset (rewind + preserve bindings), clear_bindings, reset+rebind reuse, multi-statement exec, trigger blocks, and finalize+reprepare. All 354 stdlib tests pass (50 SQLite) with the PR #1180 compiler. Co-Authored-By: Claude Opus 4.8 --- wurst/file/SQLite.wurst | 24 +++-- wurst/file/SQLiteTests.wurst | 197 +++++++++++++++++++++++++++++++++-- 2 files changed, 205 insertions(+), 16 deletions(-) diff --git a/wurst/file/SQLite.wurst b/wurst/file/SQLite.wurst index c853e697..87a11ea4 100644 --- a/wurst/file/SQLite.wurst +++ b/wurst/file/SQLite.wurst @@ -34,7 +34,9 @@ import ArrayList @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) @@ -49,14 +51,24 @@ import ArrayList /** 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. */ + /** 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() @@ -102,6 +114,7 @@ public class SqliteDb 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 @@ -159,12 +172,11 @@ public class SqliteDb // 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. */ + /** 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) - var idx = 1 - for p in params - sqlite_bind_string(stmt, idx, p) - idx++ + 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")) */ diff --git a/wurst/file/SQLiteTests.wurst b/wurst/file/SQLiteTests.wurst index 908e5fd8..6fc25908 100644 --- a/wurst/file/SQLiteTests.wurst +++ b/wurst/file/SQLiteTests.wurst @@ -69,8 +69,8 @@ function _createTestDb() returns SqliteDb rows.get(0).col(0).assertEquals("Arthur") rows.get(1).col(0).assertEquals("Merlin") rows.get(2).col(0).assertEquals("Robin") - for row in rows - destroy row + for i = 0 to rows.size() - 1 + destroy rows.get(i) destroy rows destroy db @@ -208,8 +208,8 @@ function _createTestDb() returns SqliteDb destroy params rows.size().assertEquals(1) rows.get(0).col(0).assertEquals("Arthur") - for row in rows - destroy row + for i = 0 to rows.size() - 1 + destroy rows.get(i) destroy rows destroy db @@ -272,8 +272,8 @@ function _createTestDb() returns SqliteDb let rows = SQL.select("SELECT val FROM _sgtest1 WHERE id = 1") rows.size().assertEquals(1) rows.get(0).col(0).assertEquals("hello") - for row in rows - destroy row + for i = 0 to rows.size() - 1 + destroy rows.get(i) destroy rows SQL.exec("DROP TABLE _sgtest1") SQL.close() @@ -342,8 +342,8 @@ function _createTestDb() returns SqliteDb rows.get(0).colInt(1).assertEquals(9000) rows.get(1).col(0).assertEquals("Merlin") rows.get(2).col(0).assertEquals("Robin") - for row in rows - destroy row + for i = 0 to rows.size() - 1 + destroy rows.get(i) destroy rows destroy db @@ -353,8 +353,8 @@ function _createTestDb() returns SqliteDb rows.size().assertEquals(2) rows.get(0).col(0).assertEquals("Merlin") rows.get(1).col(0).assertEquals("Robin") - for row in rows - destroy row + for i = 0 to rows.size() - 1 + destroy rows.get(i) destroy rows destroy db @@ -368,6 +368,23 @@ function _createTestDb() returns SqliteDb 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)") @@ -388,3 +405,163 @@ function _createTestDb() returns SqliteDb 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