From 795d68879e35c77007de647ff3ce0ee1b5eaed4b Mon Sep 17 00:00:00 2001 From: Reborn Reviewer Date: Wed, 22 Jul 2026 21:09:22 +0200 Subject: [PATCH 1/2] 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/2] 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)