From 6e51eb378658b7ab256dc0b9370569ef3f2f8f92 Mon Sep 17 00:00:00 2001 From: Reborn Reviewer Date: Thu, 23 Jul 2026 00:20:25 +0200 Subject: [PATCH] feat: Add compiletime SQLite support with runner resource lifecycle management --- .../wurstio/CompiletimeFunctionRunner.java | 15 +- .../peeeq/wurstio/WurstCompilerJassImpl.java | 12 +- .../interpreter/CompiletimeNatives.java | 233 ++++++++++++++++++ .../languageserver/requests/RunTests.java | 25 +- .../interpreter/ILInterpreter.java | 12 +- .../interpreter/NativesProvider.java | 11 +- .../interpreter/ProgramState.java | 16 +- .../tests/CompiletimeNativesTest.java | 38 +++ .../wurstscript/tests/CompiletimeTests.java | 56 +++++ 9 files changed, 398 insertions(+), 20 deletions(-) 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..24c591afa 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,17 @@ 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 java.time.LocalDateTime; import java.time.temporal.ChronoUnit; +import java.util.HashMap; +import java.util.HashSet; +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 +194,228 @@ 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) { + 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 = 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()); + } + } + + public void sqlite_bind_int(ILconstInt statement, ILconstInt index, ILconstInt value) { + PreparedStatement stmt = sqliteStatement(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 = sqliteStatement(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 = sqliteStatement(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 = 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()); + } + } + + 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 = sqliteStatement(statement.getVal()); + try { + ResultSet rs = sqliteResultSets.remove(statement.getVal()); + if (rs != null) rs.close(); + sqliteExecutedStatements.remove(statement.getVal()); + stmt.clearParameters(); + } catch (SQLException e) { + throw new InterpreterException("Failed to reset SQLite statement: " + 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()); + for (int statement : sqliteStatementConnections.entrySet().stream() + .filter(entry -> entry.getValue().intValue() == connection.getVal()) + .map(Map.Entry::getKey) + .toList()) { + sqlite_finalize(new ILconstInt(statement)); + } + try { + conn.close(); + } catch (SQLException e) { + throw new InterpreterException("Failed to close SQLite connection: " + e.getMessage()); + } + } + + public void sqlite_exec(ILconstInt connection, ILconstString query) { + Connection conn = sqliteConnection(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()); + } + } + + @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(); + sqliteHandleCounter = 0; + } } 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..7de167100 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 @@ -162,17 +162,19 @@ 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)) { + 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)); + } redirectInterpreterOutput(globalState); @@ -355,6 +357,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..49d60cc4b 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,44 @@ 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")); + } + } + 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..31a7b0c57 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,60 @@ 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()"); + } }