diff --git a/WURST_SQLITE_GUIDE.md b/WURST_SQLITE_GUIDE.md new file mode 100644 index 000000000..0a8f67156 --- /dev/null +++ b/WURST_SQLITE_GUIDE.md @@ -0,0 +1,110 @@ +# Wurst SQLite Compiletime Support Guide + +This guide covers setting up an SQLite database locally alongside Wurst scripts, allowing you to load and persist structured data during Wurst compilation using the new SQLite JDBC bindings. + +### 1. Database Setup / Initialization +You don’t strictly *need* an initial physical file if you use `:memory:` or execute table creation directly through Wurst. However, best practice for map development is to use a dedicated standalone file inside your Wurst project (for example, `wurst_data.db`). + +If you'd rather not manually create the `.db` file using `sqlite3`, you can rely on Wurst to generate the target `.db` file and its tables during `@compiletime`! SQLite JDBC will automatically create the file if it does not exist when using `sqlite_open()`. + +*** + +### 2. Full Test Snippet: Wurst Script +Save this code in your project (e.g. `SQLiteIntegrationTest.wurst`). This snippet demonstrates creating the database, loading dummy values into it using pure `INSERT` statements, updating it, and executing `sqlite_select` (with assertions) to ensure everything behaves identically during compiletime and testing. + +> **Note on Persisted Databases:** +> When testing with physical `.db` files across multiple builds, use `DROP TABLE IF EXISTS` before `CREATE TABLE` to ensure your build scripts remain idempotent. + +```wurst +package SQLiteIntegrationTest +import LinkedList +import ErrorHandling + +// Natively exposed bindings +@extern native sqlite_open(string path) returns int +@extern native sqlite_prepare(int conn, string q) returns int +@extern native sqlite_step(int stmt) returns boolean +@extern native sqlite_column_string(int stmt, int idx) returns string +@extern native sqlite_column_count(int stmt) returns int +@extern native sqlite_exec(int conn, string q) +@extern native sqlite_finalize(int stmt) +@extern native sqlite_close(int conn) + +// The generalized result class with variable binding +public class SqlResult + string array cols + +// A full SQL-Select helper +public function sqlite_select(int db, string query) returns LinkedList + let list = new LinkedList() + let stmt = sqlite_prepare(db, query) + let cols = sqlite_column_count(stmt) + + while sqlite_step(stmt) + let row = new SqlResult() + // SQLite permits up to 2000 columns per result set. + let limit = cols > 2000 ? 2000 : cols + for i = 0 to limit - 1 + row.cols[i] = sqlite_column_string(stmt, i) + + list.add(row) + + sqlite_finalize(stmt) + return list + +// ============================================ +// Database Tests & Population Functions +// ============================================ + +function buildAndVerifyDatabase() + // Open a database connection (Use path like "heroes.db" for persistent storage) + // We use :memory: here for rapid temporary testing + let db = sqlite_open(":memory:") + + // 1. Create table structured schema + sqlite_exec(db, "DROP TABLE IF EXISTS Heroes") + sqlite_exec(db, "CREATE TABLE Heroes (id INTEGER PRIMARY KEY, name TEXT, role TEXT, power_level INTEGER)") + + // 2. Insert dummy data + sqlite_exec(db, "INSERT INTO Heroes (name, role, power_level) VALUES ('Arthur', 'Paladin', 9000)") + sqlite_exec(db, "INSERT INTO Heroes (name, role, power_level) VALUES ('Merlin', 'Mage', 8500)") + sqlite_exec(db, "INSERT INTO Heroes (name, role, power_level) VALUES ('Robin', 'Archer', 7200)") + + // 3. Execute select to read all fields + let results = sqlite_select(db, "SELECT name, role, power_level FROM Heroes ORDER BY power_level DESC") + + // 4. Verify length matches insertions + if results.size() != 3 + error("Expected 3 database entries, found " + results.size().toString()) + + // 5. Verify the highest power is returned first correctly (Arthur is 9000 -> cols[2]) + let topHero = results.get(0) + if topHero.cols[0] != "Arthur" or topHero.cols[2] != "9000" + error("Expected Arthur as highest power, but got " + topHero.cols[0] + " with " + topHero.cols[2]) + + // 5b. Verify standard fetch + let mageHero = results.get(1) + if mageHero.cols[0] != "Merlin" or mageHero.cols[1] != "Mage" + error("Validation Failed for Merlin row!") + + // 6. Output to the developer console log + print("Database built and successfully validated all rows!") + + // Close the connection + sqlite_close(db) + +// Executes inside Wurst Unit Test run +@test function databaseSystemTest() + buildAndVerifyDatabase() + +// Executes during Build or IDE evaluation +@compiletime function compilerDatabaseLoad() + buildAndVerifyDatabase() +``` + +### 3. Running & Verifying +If you drop the file above into your repo, you can immediately test it utilizing VSCode: +1. Open the file in the editor. +2. Click the `Run Test` CodeLens helper right above `@test function databaseSystemTest()` +3. In VSCode's Output/Wurst terminal you should see: *"Database built and successfully validated all rows!"* with a green checkmark indicating successful unit execution. +4. If you intentionally sabotage an assertion (e.g., checking if Arthur's power level was 5000), you'll see a red underline directly in VSCode exactly where the query assertion or extraction fails. diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/intermediateLang/interpreter/CompiletimeNatives.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/intermediateLang/interpreter/CompiletimeNatives.java index c52418ec8..da920c9fc 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/intermediateLang/interpreter/CompiletimeNatives.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/intermediateLang/interpreter/CompiletimeNatives.java @@ -19,6 +19,14 @@ import java.time.LocalDateTime; import java.time.temporal.ChronoUnit; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.Map; +import java.util.HashMap; + @SuppressWarnings("ucd") // ignore unused code detector warnings, because this class uses reflection public class CompiletimeNatives extends ReflectionBasedNativeProvider implements NativesProvider { private final boolean isProd; @@ -180,4 +188,179 @@ public ILconstString getBuildDate() { public ILconstBool isProductionBuild() { return isProd ? ILconstBool.TRUE : ILconstBool.FALSE; } + + private int sqliteHandleCounter = 0; + private final Map sqliteConnections = new HashMap<>(); + private final Map sqliteStatements = new HashMap<>(); + private final Map sqliteResultSets = new HashMap<>(); + + public ILconstInt sqlite_open(ILconstString path) { + try { + Connection conn = DriverManager.getConnection("jdbc:sqlite:" + path.getVal()); + int handle = ++sqliteHandleCounter; + sqliteConnections.put(handle, conn); + return new ILconstInt(handle); + } catch (SQLException e) { + throw new InterpreterException("Failed to open SQLite database " + path.getVal() + ": " + e.getMessage()); + } + } + + public ILconstInt sqlite_prepare(ILconstInt connection, ILconstString query) { + Connection conn = sqliteConnections.get(connection.getVal()); + if (conn == null) throw new InterpreterException("Invalid SQLite connection handle: " + connection.getVal()); + try { + PreparedStatement stmt = conn.prepareStatement(query.getVal()); + int handle = ++sqliteHandleCounter; + sqliteStatements.put(handle, stmt); + return new ILconstInt(handle); + } catch (SQLException e) { + throw new InterpreterException("Failed to prepare SQLite statement: " + e.getMessage()); + } + } + + public void sqlite_bind_int(ILconstInt statement, ILconstInt index, ILconstInt value) { + PreparedStatement stmt = sqliteStatements.get(statement.getVal()); + if (stmt == null) throw new InterpreterException("Invalid SQLite statement handle: " + statement.getVal()); + try { + stmt.setInt(index.getVal(), value.getVal()); + } catch (SQLException e) { + throw new InterpreterException("Failed to bind int: " + e.getMessage()); + } + } + + public void sqlite_bind_real(ILconstInt statement, ILconstInt index, ILconstReal value) { + PreparedStatement stmt = sqliteStatements.get(statement.getVal()); + if (stmt == null) throw new InterpreterException("Invalid SQLite statement handle: " + statement.getVal()); + try { + stmt.setDouble(index.getVal(), (double) value.getVal()); + } catch (SQLException e) { + throw new InterpreterException("Failed to bind real: " + e.getMessage()); + } + } + + public void sqlite_bind_string(ILconstInt statement, ILconstInt index, ILconstString value) { + PreparedStatement stmt = sqliteStatements.get(statement.getVal()); + if (stmt == null) throw new InterpreterException("Invalid SQLite statement handle: " + statement.getVal()); + try { + stmt.setString(index.getVal(), value.getVal()); + } catch (SQLException e) { + throw new InterpreterException("Failed to bind string: " + e.getMessage()); + } + } + + public ILconstBool sqlite_step(ILconstInt statement) { + PreparedStatement stmt = sqliteStatements.get(statement.getVal()); + if (stmt == null) throw new InterpreterException("Invalid SQLite statement handle: " + statement.getVal()); + try { + ResultSet rs = sqliteResultSets.get(statement.getVal()); + if (rs == null) { + boolean hasResultSet = stmt.execute(); + if (hasResultSet) { + rs = stmt.getResultSet(); + sqliteResultSets.put(statement.getVal(), rs); + boolean hasRow = rs.next(); + return hasRow ? ILconstBool.TRUE : ILconstBool.FALSE; + } else { + return ILconstBool.FALSE; + } + } else { + boolean hasRow = rs.next(); + return hasRow ? ILconstBool.TRUE : ILconstBool.FALSE; + } + } catch (SQLException e) { + throw new InterpreterException("Failed to step SQLite statement: " + e.getMessage()); + } + } + + public ILconstInt sqlite_column_count(ILconstInt statement) { + try { + ResultSet rs = sqliteResultSets.get(statement.getVal()); + if (rs != null) { + return new ILconstInt(rs.getMetaData().getColumnCount()); + } + PreparedStatement stmt = sqliteStatements.get(statement.getVal()); + if (stmt == null) throw new InterpreterException("Invalid SQLite statement handle: " + statement.getVal()); + java.sql.ResultSetMetaData meta = stmt.getMetaData(); + return new ILconstInt(meta == null ? 0 : meta.getColumnCount()); + } catch (SQLException e) { + throw new InterpreterException("Failed to get column count: " + e.getMessage()); + } + } + + public ILconstInt sqlite_column_int(ILconstInt statement, ILconstInt index) { + ResultSet rs = sqliteResultSets.get(statement.getVal()); + if (rs == null) throw new InterpreterException("No result set for statement handle: " + statement.getVal()); + try { + return new ILconstInt(rs.getInt(index.getVal() + 1)); + } catch (SQLException e) { + throw new InterpreterException("Failed to get column int: " + e.getMessage()); + } + } + + public ILconstReal sqlite_column_real(ILconstInt statement, ILconstInt index) { + ResultSet rs = sqliteResultSets.get(statement.getVal()); + if (rs == null) throw new InterpreterException("No result set for statement handle: " + statement.getVal()); + try { + return new ILconstReal((float) rs.getDouble(index.getVal() + 1)); + } catch (SQLException e) { + throw new InterpreterException("Failed to get column real: " + e.getMessage()); + } + } + + public ILconstString sqlite_column_string(ILconstInt statement, ILconstInt index) { + ResultSet rs = sqliteResultSets.get(statement.getVal()); + if (rs == null) throw new InterpreterException("No result set for statement handle: " + statement.getVal()); + try { + String val = rs.getString(index.getVal() + 1); + return new ILconstString(val == null ? "" : val); + } catch (SQLException e) { + throw new InterpreterException("Failed to get column string: " + e.getMessage()); + } + } + + public void sqlite_reset(ILconstInt statement) { + PreparedStatement stmt = sqliteStatements.get(statement.getVal()); + if (stmt != null) { + try { + ResultSet rs = sqliteResultSets.remove(statement.getVal()); + if (rs != null) rs.close(); + } catch (SQLException e) { + // Ignore + } + } + } + + public void sqlite_finalize(ILconstInt statement) { + PreparedStatement stmt = sqliteStatements.remove(statement.getVal()); + if (stmt != null) { + try { + ResultSet rs = sqliteResultSets.remove(statement.getVal()); + if (rs != null) rs.close(); + stmt.close(); + } catch (SQLException e) { + // Ignore + } + } + } + + public void sqlite_close(ILconstInt connection) { + Connection conn = sqliteConnections.remove(connection.getVal()); + if (conn != null) { + try { + conn.close(); + } catch (SQLException e) { + // Ignore + } + } + } + + public void sqlite_exec(ILconstInt connection, ILconstString query) { + Connection conn = sqliteConnections.get(connection.getVal()); + if (conn == null) throw new InterpreterException("Invalid SQLite connection handle: " + connection.getVal()); + try (java.sql.Statement stmt = conn.createStatement()) { + stmt.execute(query.getVal()); + } catch (SQLException e) { + throw new InterpreterException("Failed to exec SQLite query: " + e.getMessage()); + } + } } diff --git a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/CompiletimeTests.java b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/CompiletimeTests.java index 5f41d6a11..8668d8e1c 100644 --- a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/CompiletimeTests.java +++ b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/CompiletimeTests.java @@ -422,4 +422,66 @@ public void nullBug() { } + @Test + public void testCompiletimeSQLite() { + test().withStdLib() + .executeProg(true) + .runCompiletimeFunctions(true) + .executeProgOnlyAfterTransforms() + .lines("package Test", + "import LinkedList", + "@extern native sqlite_open(string path) returns int", + "@extern native sqlite_prepare(int conn, string q) returns int", + "@extern native sqlite_step(int stmt) returns boolean", + "@extern native sqlite_column_string(int stmt, int idx) returns string", + "@extern native sqlite_column_count(int stmt) returns int", + "@extern native sqlite_exec(int conn, string q)", + "@extern native sqlite_finalize(int stmt)", + "", + "class SqlResult", + " string v1 = \"\"", + " string v2 = \"\"", + " string v3 = \"\"", + " string v4 = \"\"", + "", + "function sqlite_select(int db, string query) returns LinkedList", + " let list = new LinkedList()", + " let stmt = sqlite_prepare(db, query)", + " let cols = sqlite_column_count(stmt)", + " while sqlite_step(stmt)", + " let row = new SqlResult()", + " if cols > 0", + " row.v1 = sqlite_column_string(stmt, 0)", + " if cols > 1", + " row.v2 = sqlite_column_string(stmt, 1)", + " if cols > 2", + " row.v3 = sqlite_column_string(stmt, 2)", + " if cols > 3", + " row.v4 = sqlite_column_string(stmt, 3)", + " list.add(row)", + " sqlite_finalize(stmt)", + " return list", + "", + "function testSelect() returns int", + " let db = sqlite_open(\":memory:\")", + " sqlite_exec(db, \"CREATE TABLE Jobs (id INTEGER, name TEXT, desc TEXT, val TEXT)\")", + " sqlite_exec(db, \"INSERT INTO Jobs VALUES (1, 'Warrior', 'Melee C', 'A')\")", + " sqlite_exec(db, \"INSERT INTO Jobs VALUES (2, 'Mage', 'Ranged C', 'B')\")", + " let res = sqlite_select(db, \"SELECT * FROM Jobs ORDER BY id ASC\")", + " int count = 0", + " if res.size() == 2", + " let first = res.get(0)", + " if first.v1 == \"1\" and first.v2 == \"Warrior\"", + " count++", + " let second = res.get(1)", + " if second.v1 == \"2\" and second.v2 == \"Mage\"", + " count++", + " return count", + "", + "let c = compiletime(testSelect())", + "init", + " if c == 2", + " testSuccess()"); + } + } diff --git a/de.peeeq.wurstscript/testarray.wurst b/de.peeeq.wurstscript/testarray.wurst new file mode 100644 index 000000000..df3a83ec1 --- /dev/null +++ b/de.peeeq.wurstscript/testarray.wurst @@ -0,0 +1,6 @@ +package TestArray +class SqlResult + string array cols + + function setCol(int i, string val) + cols[i] = val