diff --git a/wurst/file/SQLite.wurst b/wurst/file/SQLite.wurst new file mode 100644 index 00000000..c853e697 --- /dev/null +++ b/wurst/file/SQLite.wurst @@ -0,0 +1,275 @@ +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_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) + +// 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() + + ondestroy + destroy this.cols + + /** Raw string value at column index. */ + function col(int index) returns string + return this.cols.get(index) + + /** 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.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. */ + private function bindParams(int stmt, ArrayList 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, 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..908e5fd8 --- /dev/null +++ b/wurst/file/SQLiteTests.wurst @@ -0,0 +1,390 @@ +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 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 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 row in rows + destroy row + 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 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 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 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