diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/CompiletimeFunctionRunner.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/CompiletimeFunctionRunner.java index 4767bc3b9..8defd87c2 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/CompiletimeFunctionRunner.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/CompiletimeFunctionRunner.java @@ -40,7 +40,7 @@ import java.util.*; import java.util.stream.Collectors; -public class CompiletimeFunctionRunner { +public class CompiletimeFunctionRunner implements AutoCloseable { private final ImProg imProg; private final ILInterpreter interpreter; @@ -659,4 +659,17 @@ public void setOutputStream(PrintStream printStream) { interpreter.getGlobalState().setOutStream(printStream); } + /** + * Releases any resources held by the interpreter or global state (such as open SQLite connections and statements) + * created during compiletime function execution. + */ + @Override + public void close() { + if (interpreter != null) { + interpreter.close(); + } else if (globalState != null) { + globalState.close(); + } + } + } diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/WurstCompilerJassImpl.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/WurstCompilerJassImpl.java index 281f76e27..b4b503730 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/WurstCompilerJassImpl.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/WurstCompilerJassImpl.java @@ -111,11 +111,13 @@ public void runCompiletime(WurstProjectConfigData projectConfigData, boolean isP // compile & inject object-editor data // TODO run optimizations later? gui.sendProgress("Running compiletime functions"); - CompiletimeFunctionRunner ctr = new CompiletimeFunctionRunner(imTranslator, getImProg(), getMapFile(), getMapfileMpqEditor(), gui, - CompiletimeFunctions, projectConfigData, isProd, cache); - ctr.setInjectObjects(runArgs.isInjectObjects()); - ctr.setOutputStream(new PrintStream(System.err)); - ctr.run(); + // Use try-with-resources to release open native resources (e.g., SQLite DB handles) after compiletime execution finishes. + try (CompiletimeFunctionRunner ctr = new CompiletimeFunctionRunner(imTranslator, getImProg(), getMapFile(), getMapfileMpqEditor(), gui, + CompiletimeFunctions, projectConfigData, isProd, cache)) { + ctr.setInjectObjects(runArgs.isInjectObjects()); + ctr.setOutputStream(new PrintStream(System.err)); + ctr.run(); + } } if (gui.getErrorCount() > 0) { 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 11d188e46..c0160fa23 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 @@ -16,8 +16,22 @@ import net.moonlightflower.wc3libs.misc.MetaFieldId; import net.moonlightflower.wc3libs.misc.ObjId; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import org.sqlite.SQLiteConfig; +import org.sqlite.SQLiteConnection; + import java.time.LocalDateTime; import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; @SuppressWarnings("ucd") // ignore unused code detector warnings, because this class uses reflection public class CompiletimeNatives extends ReflectionBasedNativeProvider implements NativesProvider { @@ -185,4 +199,310 @@ 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<>(); + private final Map sqliteStatementConnections = new HashMap<>(); + private final Set sqliteExecutedStatements = new HashSet<>(); + + private Connection sqliteConnection(int handle) { + Connection connection = sqliteConnections.get(handle); + if (connection == null) { + throw new InterpreterException("Invalid SQLite connection handle: " + handle); + } + return connection; + } + + private PreparedStatement sqliteStatement(int handle) { + PreparedStatement statement = sqliteStatements.get(handle); + if (statement == null) { + throw new InterpreterException("Invalid SQLite statement handle: " + handle); + } + return statement; + } + + public ILconstInt sqlite_open(ILconstString path) { + String dbPath = path.getVal(); + // SQLite "file:" URI paths can carry query parameters such as + // "?enable_load_extension=true" that would turn on extension loading and let + // load_extension() dlopen arbitrary native code on the build machine at compiletime. + // Reject query parameters on the URI form. Plain file paths may legitimately contain + // '?' (e.g. on Linux), so only the URI form is restricted; the explicit + // enableLoadExtension(false) below is the defense-in-depth backstop for every path. + if (dbPath.startsWith("file:") && dbPath.indexOf('?') >= 0) { + throw new InterpreterException("Invalid SQLite database URI (query parameters are not allowed): " + dbPath); + } + try { + // Defense-in-depth: explicitly disable extension loading regardless of the path. + SQLiteConfig config = new SQLiteConfig(); + config.enableLoadExtension(false); + Connection conn = DriverManager.getConnection("jdbc:sqlite:" + dbPath, config.toProperties()); + int handle = ++sqliteHandleCounter; + sqliteConnections.put(handle, conn); + return new ILconstInt(handle); + } catch (SQLException e) { + throw new InterpreterException("Failed to open SQLite database " + dbPath + ": " + e.getMessage()); + } + } + + public ILconstInt sqlite_prepare(ILconstInt connection, ILconstString query) { + Connection conn = sqliteConnection(connection.getVal()); + try { + PreparedStatement stmt = conn.prepareStatement(query.getVal()); + int handle = ++sqliteHandleCounter; + sqliteStatements.put(handle, stmt); + sqliteStatementConnections.put(handle, connection.getVal()); + return new ILconstInt(handle); + } catch (SQLException e) { + throw new InterpreterException("Failed to prepare SQLite statement: " + e.getMessage()); + } + } + + /** + * Invalidates any prior execution of the statement so that the next sqlite_step + * re-executes it. Called after a parameter is (re)bound: without this, binding new + * values after a step (but before a sqlite_reset) would be silently ignored and the + * new values never queried/written. Note this also closes any open result set, so a + * column must be read before the next bind — the contract is bind, step, read, then + * (re)bind and step again. + */ + private void markStatementForReexecution(int handle) { + sqliteExecutedStatements.remove(handle); + ResultSet rs = sqliteResultSets.remove(handle); + if (rs != null) { + try { rs.close(); } catch (SQLException ignored) {} + } + } + + // Parameter indices are 1-based, mirroring the SQLite C API (sqlite3_bind_*). + public void sqlite_bind_int(ILconstInt statement, ILconstInt index, ILconstInt value) { + PreparedStatement stmt = sqliteStatement(statement.getVal()); + try { + stmt.setInt(index.getVal(), value.getVal()); + markStatementForReexecution(statement.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 = sqliteStatement(statement.getVal()); + try { + stmt.setDouble(index.getVal(), (double) value.getVal()); + markStatementForReexecution(statement.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 = sqliteStatement(statement.getVal()); + try { + stmt.setString(index.getVal(), value.getVal()); + markStatementForReexecution(statement.getVal()); + } catch (SQLException e) { + throw new InterpreterException("Failed to bind string: " + e.getMessage()); + } + } + + public ILconstBool sqlite_step(ILconstInt statement) { + PreparedStatement stmt = sqliteStatement(statement.getVal()); + try { + ResultSet rs = sqliteResultSets.get(statement.getVal()); + if (rs == null) { + if (sqliteExecutedStatements.contains(statement.getVal())) { + return ILconstBool.FALSE; + } + boolean hasResultSet = stmt.execute(); + sqliteExecutedStatements.add(statement.getVal()); + 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 = sqliteStatement(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()); + } + } + + // Column indices are 0-based, mirroring the SQLite C API (sqlite3_column_*); the +1 + // converts to JDBC's 1-based ResultSet indexing. Note this asymmetry with the 1-based + // bind indices above — both match the C API. + 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 { + // WurstScript int is 32-bit; a 64-bit SQLite INTEGER is truncated by getInt. + 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 { + // A SQL NULL maps to "" here; use sqlite_column_is_null to distinguish NULL + // from an empty string / zero value. + 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 ILconstBool sqlite_column_is_null(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 { + rs.getObject(index.getVal() + 1); + return rs.wasNull() ? ILconstBool.TRUE : ILconstBool.FALSE; + } catch (SQLException e) { + throw new InterpreterException("Failed to check column null: " + e.getMessage()); + } + } + + public void sqlite_reset(ILconstInt statement) { + // Mirror SQLite's sqlite3_reset(): reset execution state so the statement can be + // stepped again, but leave bound parameters in place. Clearing bindings here would + // break the common reuse pattern of binding once and re-stepping. Use + // sqlite_clear_bindings to explicitly clear parameters (sqlite3_clear_bindings()). + sqliteStatement(statement.getVal()); + try { + ResultSet rs = sqliteResultSets.remove(statement.getVal()); + if (rs != null) rs.close(); + sqliteExecutedStatements.remove(statement.getVal()); + } catch (SQLException e) { + throw new InterpreterException("Failed to reset SQLite statement: " + e.getMessage()); + } + } + + public void sqlite_clear_bindings(ILconstInt statement) { + PreparedStatement stmt = sqliteStatement(statement.getVal()); + try { + stmt.clearParameters(); + } catch (SQLException e) { + throw new InterpreterException("Failed to clear SQLite statement bindings: " + e.getMessage()); + } + } + + public void sqlite_finalize(ILconstInt statement) { + PreparedStatement stmt = sqliteStatements.remove(statement.getVal()); + if (stmt == null) throw new InterpreterException("Invalid SQLite statement handle: " + statement.getVal()); + sqliteStatementConnections.remove(statement.getVal()); + sqliteExecutedStatements.remove(statement.getVal()); + try { + ResultSet rs = sqliteResultSets.remove(statement.getVal()); + if (rs != null) rs.close(); + stmt.close(); + } catch (SQLException e) { + throw new InterpreterException("Failed to finalize SQLite statement: " + e.getMessage()); + } + } + + public void sqlite_close(ILconstInt connection) { + Connection conn = sqliteConnections.remove(connection.getVal()); + if (conn == null) throw new InterpreterException("Invalid SQLite connection handle: " + connection.getVal()); + // Finalize every statement belonging to this connection, then always close the + // connection itself. A failure finalizing one statement must not abort the loop or + // skip conn.close() — otherwise the connection (already removed from the map above) + // would leak permanently, unreachable even by closeAllSqliteResources(). + List errors = new ArrayList<>(); + for (int statement : sqliteStatementConnections.entrySet().stream() + .filter(entry -> entry.getValue().intValue() == connection.getVal()) + .map(Map.Entry::getKey) + .toList()) { + try { + sqlite_finalize(new ILconstInt(statement)); + } catch (InterpreterException e) { + errors.add(e.getMessage()); + } + } + try { + conn.close(); + } catch (SQLException e) { + errors.add("Failed to close SQLite connection: " + e.getMessage()); + } + if (!errors.isEmpty()) { + throw new InterpreterException("Errors while closing SQLite connection: " + String.join("; ", errors)); + } + } + + public void sqlite_exec(ILconstInt connection, ILconstString query) { + Connection conn = sqliteConnection(connection.getVal()); + try { + // Delegate to SQLite's native sqlite3_exec (via the xerial driver's low-level + // DB handle) so SQLite's own parser handles statement boundaries. JDBC's + // Statement.execute runs only the FIRST statement of a ';'-separated script, + // and a hand-rolled splitter cannot correctly handle trigger BEGIN...END + // bodies, CASE...END, or every identifier-quoting form ([id], `id`, "id"). + SQLiteConnection sqliteConn = conn.unwrap(SQLiteConnection.class); + sqliteConn.getDatabase()._exec(query.getVal()); + } catch (SQLException e) { + throw new InterpreterException("Failed to exec SQLite query: " + e.getMessage()); + } + } + + @Override + public void close() { + closeAllSqliteResources(); + } + + /** + * Closes all open SQLite resources (result sets, statements, connections). + * Called by the interpreter on shutdown to prevent resource leaks. + */ + public void closeAllSqliteResources() { + for (ResultSet rs : sqliteResultSets.values()) { + try { rs.close(); } catch (SQLException ignored) {} + } + sqliteResultSets.clear(); + for (PreparedStatement stmt : sqliteStatements.values()) { + try { stmt.close(); } catch (SQLException ignored) {} + } + sqliteStatements.clear(); + sqliteStatementConnections.clear(); + sqliteExecutedStatements.clear(); + for (Connection conn : sqliteConnections.values()) { + try { conn.close(); } catch (SQLException ignored) {} + } + sqliteConnections.clear(); + // Deliberately do NOT reset sqliteHandleCounter: keeping it monotonic means a stale + // handle from a previous run can never silently alias a freshly-allocated resource + // if this provider instance is ever reused after close(). + } } diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/requests/RunTests.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/requests/RunTests.java index 475ff0a72..61a62d37f 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/requests/RunTests.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/requests/RunTests.java @@ -5,7 +5,6 @@ import org.wurstscript.projectconfig.WurstProjectConfigData; import de.peeeq.wurstio.CompiletimeFunctionRunner; import de.peeeq.wurstio.jassinterpreter.InterpreterException; -import de.peeeq.wurstio.jassinterpreter.ReflectionNativeProvider; import de.peeeq.wurstio.languageserver.ModelManager; import de.peeeq.wurstio.languageserver.WFile; import de.peeeq.wurstscript.RunArgs; @@ -162,17 +161,15 @@ public int getTotalTests() { public TestResult runTests(ImTranslator translator, ImProg imProg, Optional funcToTest, Optional cu) { WurstGui gui = new TestGui(); - CompiletimeFunctionRunner cfr = new CompiletimeFunctionRunner(translator, imProg, Optional.empty(), null, gui, - CompiletimeFunctions, WurstProjectConfigData.empty(), false, false); - ILInterpreter interpreter = cfr.getInterpreter(); - ProgramState globalState = cfr.getGlobalState(); - if (globalState == null) { - globalState = new ProgramState(gui, imProg, true); - } - if (interpreter == null) { - interpreter = new ILInterpreter(imProg, gui, Optional.empty(), globalState); - interpreter.addNativeProvider(new ReflectionNativeProvider(interpreter)); - } + // Use try-with-resources to ensure NativeProvider resources (e.g. SQLite database connections and file handles) + // created during test execution are automatically closed when the language server finishes processing the request. + try (CompiletimeFunctionRunner cfr = new CompiletimeFunctionRunner(translator, imProg, Optional.empty(), null, gui, + CompiletimeFunctions, WurstProjectConfigData.empty(), false, false)) { + // CompiletimeFunctionRunner always constructs both, and cfr.close() (the + // try-with-resources above) owns their lifecycle. Building local replacements + // here would be dead code that also escaped that cleanup, so use the runner's. + ILInterpreter interpreter = cfr.getInterpreter(); + ProgramState globalState = cfr.getGlobalState(); redirectInterpreterOutput(globalState); @@ -355,6 +352,7 @@ public TestResult runTests(ImTranslator translator, ImProg imProg, Optional getNativeProviders() { return nativeProviders; } + /** + * Closes all registered NativesProvider instances to release external native resources (such as SQLite connections). + */ + @Override + public void close() { + for (NativesProvider provider : nativeProviders) { + try { + provider.close(); + } catch (Exception e) { + WLogger.severe(e); + } + } + } + public @Nullable NativesProvider getCachedNativeProvider(String funcName) { return nativeProviderByFunc.get(funcName); } diff --git a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/CompiletimeNativesTest.java b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/CompiletimeNativesTest.java index c8db3168f..d817c7870 100644 --- a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/CompiletimeNativesTest.java +++ b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/CompiletimeNativesTest.java @@ -204,6 +204,73 @@ public void sameIdObjectDefinitionsMergeModsWithoutDuplicateError() throws Excep assertTrue(obj.getMods().stream().anyMatch(m -> m.getId().getVal().equals("utip"))); } + @Test + public void testCloseSqliteResourcesOnProviderClose() { + CompiletimeNatives natives = new CompiletimeNatives(null, null, false); + ILconstInt connHandle = natives.sqlite_open(new ILconstString(":memory:")); + natives.sqlite_exec(connHandle, new ILconstString("CREATE TABLE Test (id INT);")); + ILconstInt stmtHandle = natives.sqlite_prepare(connHandle, new ILconstString("INSERT INTO Test VALUES (1);")); + natives.sqlite_step(stmtHandle); + + natives.close(); + + try { + natives.sqlite_prepare(connHandle, new ILconstString("SELECT * FROM Test;")); + org.testng.Assert.fail("Expected InterpreterException for invalid connection handle after close"); + } catch (de.peeeq.wurstio.jassinterpreter.InterpreterException e) { + assertTrue(e.getMessage().contains("Invalid SQLite connection handle")); + } + } + + @Test + public void testCloseSqliteResourcesFromProgramStateClose() throws Exception { + WurstGuiLogger gui = new WurstGuiLogger(); + ProgramStateIO state = new ProgramStateIO(Optional.empty(), null, gui, emptyProg(), true); + CompiletimeNatives natives = new CompiletimeNatives(state, null, false); + state.addNativeProvider(natives); + + ILconstInt connHandle = natives.sqlite_open(new ILconstString(":memory:")); + natives.sqlite_exec(connHandle, new ILconstString("CREATE TABLE Test (id INT);")); + + state.close(); + + try { + natives.sqlite_prepare(connHandle, new ILconstString("SELECT * FROM Test;")); + org.testng.Assert.fail("Expected InterpreterException for invalid connection handle after ProgramState close"); + } catch (de.peeeq.wurstio.jassinterpreter.InterpreterException e) { + assertTrue(e.getMessage().contains("Invalid SQLite connection handle")); + } + } + + @Test + public void sqliteExecRunsMultiStatementScriptWithTrigger() { + WurstGuiLogger gui = new WurstGuiLogger(); + ProgramStateIO state = new ProgramStateIO(Optional.empty(), null, gui, emptyProg(), true); + CompiletimeNatives natives = new CompiletimeNatives(state, null, false); + state.addNativeProvider(natives); + + ILconstInt db = natives.sqlite_open(new ILconstString(":memory:")); + // Multi-statement script including a trigger BEGIN...END body (whose inner ';' + // terminators would break a naive splitter) and a bracket-quoted identifier + // containing ';'. sqlite3_exec delegates to SQLite's own parser, so both work. + natives.sqlite_exec(db, new ILconstString( + "CREATE TABLE src (id INTEGER);" + + "CREATE TABLE dst (id INTEGER);" + + "CREATE TRIGGER mirror AFTER INSERT ON src BEGIN INSERT INTO dst VALUES (NEW.id); END;" + + "CREATE TABLE [we;ird] (x INTEGER);" + + "INSERT INTO src VALUES (1); INSERT INTO src VALUES (2);")); + + ILconstInt q = natives.sqlite_prepare(db, new ILconstString( + "SELECT (SELECT COUNT(*) FROM dst), " + + "(SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='we;ird')")); + assertTrue(natives.sqlite_step(q).getVal()); + assertEquals(natives.sqlite_column_int(q, new ILconstInt(0)).getVal(), 2); // trigger mirrored both inserts + assertEquals(natives.sqlite_column_int(q, new ILconstInt(1)).getVal(), 1); // weird-named table created + natives.sqlite_finalize(q); + natives.sqlite_close(db); + state.close(); + } + private ImProg emptyProg() { Element trace = Ast.NoExpr(); return JassIm.ImProg(trace, JassIm.ImVars(), JassIm.ImFunctions(), JassIm.ImMethods(), JassIm.ImClasses(), JassIm.ImTypeClassFuncs(), new HashMap<>()); 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..02af4d00a 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,183 @@ 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_int(int stmt, int idx) returns int", + "@extern native sqlite_column_real(int stmt, int idx) returns real", + "@extern native sqlite_column_count(int stmt) returns int", + "@extern native sqlite_exec(int conn, string q)", + "@extern native sqlite_bind_int(int stmt, int idx, int value)", + "@extern native sqlite_bind_real(int stmt, int idx, real value)", + "@extern native sqlite_bind_string(int stmt, int idx, string value)", + "@extern native sqlite_reset(int stmt)", + "@extern native sqlite_finalize(int stmt)", + "@extern native sqlite_close(int conn)", + "", + "function testFullSQLiteApi() returns int", + " let db = sqlite_open(\":memory:\")", + " sqlite_exec(db, \"CREATE TABLE Items (id INTEGER, name TEXT, price REAL)\")", + " let insert = sqlite_prepare(db, \"INSERT INTO Items VALUES (?, ?, ?)\")", + " sqlite_bind_int(insert, 1, 101)", + " sqlite_bind_string(insert, 2, \"Sword\")", + " sqlite_bind_real(insert, 3, 15.5)", + " let s1 = sqlite_step(insert)", + " let s2 = sqlite_step(insert)", + " sqlite_reset(insert)", + " sqlite_bind_int(insert, 1, 102)", + " sqlite_bind_string(insert, 2, \"Shield\")", + " sqlite_bind_real(insert, 3, 25.0)", + " let s3 = sqlite_step(insert)", + " sqlite_finalize(insert)", + " let query = sqlite_prepare(db, \"SELECT id, name, price FROM Items ORDER BY id ASC\")", + " let cols = sqlite_column_count(query)", + " int count = 0", + " if not s1 and not s2 and not s3 and cols == 3 and sqlite_step(query)", + " if sqlite_column_int(query, 0) == 101 and sqlite_column_string(query, 1) == \"Sword\" and sqlite_column_real(query, 2) == 15.5", + " count++", + " if sqlite_step(query)", + " if sqlite_column_int(query, 0) == 102 and sqlite_column_string(query, 1) == \"Shield\" and sqlite_column_real(query, 2) == 25.0", + " count++", + " sqlite_finalize(query)", + " sqlite_close(db)", + " return count", + "", + "let c = compiletime(testFullSQLiteApi())", + "init", + " if c == 2", + " testSuccess()"); + } + + @Test + public void testCompiletimeSQLiteResetPreservesBindings() { + test().withStdLib() + .executeProg(true) + .runCompiletimeFunctions(true) + .executeProgOnlyAfterTransforms() + .lines("package Test", + "@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_int(int stmt, int idx) returns int", + "@extern native sqlite_exec(int conn, string q)", + "@extern native sqlite_bind_int(int stmt, int idx, int value)", + "@extern native sqlite_bind_string(int stmt, int idx, string value)", + "@extern native sqlite_reset(int stmt)", + "@extern native sqlite_clear_bindings(int stmt)", + "@extern native sqlite_finalize(int stmt)", + "@extern native sqlite_close(int conn)", + "", + "function testResetPreservesBindings() returns int", + " let db = sqlite_open(\":memory:\")", + " sqlite_exec(db, \"CREATE TABLE Items (id INTEGER, name TEXT)\")", + " let insert = sqlite_prepare(db, \"INSERT INTO Items VALUES (?, ?)\")", + " sqlite_bind_int(insert, 1, 101)", + " sqlite_bind_string(insert, 2, \"Sword\")", + " let s1 = sqlite_step(insert)", + // reset then step again WITHOUT rebinding: bindings must be preserved, + // so the same row (101, Sword) is inserted a second time. + " sqlite_reset(insert)", + " let s2 = sqlite_step(insert)", + " sqlite_finalize(insert)", + " let count = sqlite_prepare(db, \"SELECT COUNT(*) FROM Items WHERE id = 101 AND name = 'Sword'\")", + " int preserved = 0", + " if not s1 and not s2 and sqlite_step(count)", + " preserved = sqlite_column_int(count, 0)", + " sqlite_finalize(count)", + // sqlite_clear_bindings clears parameters back to NULL, so the next insert + // writes a NULL id that will not match the id = 999 filter. + " let insert2 = sqlite_prepare(db, \"INSERT INTO Items VALUES (?, ?)\")", + " sqlite_bind_int(insert2, 1, 999)", + " sqlite_bind_string(insert2, 2, \"Cleared\")", + " sqlite_clear_bindings(insert2)", + " let s3 = sqlite_step(insert2)", + " sqlite_finalize(insert2)", + " let cleared = sqlite_prepare(db, \"SELECT COUNT(*) FROM Items WHERE id = 999\")", + " int clearedCount = 1", + " if not s3 and sqlite_step(cleared)", + " clearedCount = sqlite_column_int(cleared, 0)", + " sqlite_finalize(cleared)", + " sqlite_close(db)", + // expect 2 rows preserved by reset and 0 rows for id 999 after clear_bindings + " if preserved == 2 and clearedCount == 0", + " return 1", + " return 0", + "", + "let c = compiletime(testResetPreservesBindings())", + "init", + " if c == 1", + " testSuccess()"); + } + + @Test + public void testCompiletimeSQLiteExecMultiStatementAndNulls() { + test().withStdLib() + .executeProg(true) + .runCompiletimeFunctions(true) + .executeProgOnlyAfterTransforms() + .lines("package Test", + "@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_int(int stmt, int idx) returns int", + "@extern native sqlite_column_is_null(int stmt, int idx) returns boolean", + "@extern native sqlite_exec(int conn, string q)", + "@extern native sqlite_bind_int(int stmt, int idx, int value)", + "@extern native sqlite_finalize(int stmt)", + "@extern native sqlite_close(int conn)", + "", + "function testExecMultiAndNulls() returns int", + " let db = sqlite_open(\":memory:\")", + // multi-statement exec must create BOTH tables (only the first runs + // under a naive Statement.execute) + " sqlite_exec(db, \"CREATE TABLE A (id INTEGER); CREATE TABLE B (id INTEGER, name TEXT)\")", + // multi-statement DML: both inserts must run + " sqlite_exec(db, \"INSERT INTO A VALUES (1); INSERT INTO A VALUES (2)\")", + " let ca = sqlite_prepare(db, \"SELECT COUNT(*) FROM A\")", + " int countA = 0", + " if sqlite_step(ca)", + " countA = sqlite_column_int(ca, 0)", + " sqlite_finalize(ca)", + // rebind WITHOUT an intervening sqlite_reset must force re-execution, + // so both id 3 and id 4 get inserted (4 rows total in A) + " let ins = sqlite_prepare(db, \"INSERT INTO A VALUES (?)\")", + " sqlite_bind_int(ins, 1, 3)", + " let b1 = sqlite_step(ins)", + " sqlite_bind_int(ins, 1, 4)", + " let b2 = sqlite_step(ins)", + " sqlite_finalize(ins)", + " let ca2 = sqlite_prepare(db, \"SELECT COUNT(*) FROM A\")", + " int countA2 = 0", + " if sqlite_step(ca2)", + " countA2 = sqlite_column_int(ca2, 0)", + " sqlite_finalize(ca2)", + // NULL detection: a genuine SQL NULL must be distinguishable + " sqlite_exec(db, \"INSERT INTO B VALUES (7, NULL)\")", + " let q = sqlite_prepare(db, \"SELECT id, name FROM B WHERE id = 7\")", + " boolean idNotNull = false", + " boolean nameIsNull = false", + " if not b1 and not b2 and sqlite_step(q)", + " idNotNull = not sqlite_column_is_null(q, 0)", + " nameIsNull = sqlite_column_is_null(q, 1)", + " sqlite_finalize(q)", + " sqlite_close(db)", + " if countA == 2 and countA2 == 4 and idNotNull and nameIsNull", + " return 1", + " return 0", + "", + "let c = compiletime(testExecMultiAndNulls())", + "init", + " if c == 1", + " testSuccess()"); + } }