From 6e51eb378658b7ab256dc0b9370569ef3f2f8f92 Mon Sep 17 00:00:00 2001 From: Reborn Reviewer Date: Thu, 23 Jul 2026 00:20:25 +0200 Subject: [PATCH 1/4] 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()"); + } } From 4bceaef9279efae46e9b361c54d8db67aa5450fe Mon Sep 17 00:00:00 2001 From: Reborn Reviewer Date: Thu, 23 Jul 2026 23:23:20 +0200 Subject: [PATCH 2/4] fix(interpreter): preserve SQLite bindings across sqlite_reset sqlite_reset previously called PreparedStatement.clearParameters(), wiping all bound parameters. This diverges from SQLite's sqlite3_reset(), which resets execution state but leaves bindings in place, and broke the common reuse pattern of binding a parameter once, stepping, resetting, and stepping again without rebinding. sqlite_reset now only clears the execution state (open result set and the executed marker). A separate sqlite_clear_bindings native maps to sqlite3_clear_bindings() for callers that explicitly want to drop bindings. Co-Authored-By: Claude Opus 4.8 --- .../interpreter/CompiletimeNatives.java | 16 ++++- .../wurstscript/tests/CompiletimeTests.java | 61 +++++++++++++++++++ 2 files changed, 75 insertions(+), 2 deletions(-) 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 24c591afa..99661a816 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 @@ -342,17 +342,29 @@ public ILconstString sqlite_column_string(ILconstInt statement, ILconstInt index } public void sqlite_reset(ILconstInt statement) { - PreparedStatement stmt = sqliteStatement(statement.getVal()); + // 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()); - stmt.clearParameters(); } 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()); 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 31a7b0c57..01d81f87b 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 @@ -478,4 +478,65 @@ public void testCompiletimeSQLite() { " 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()"); + } } From 4ba78432ec7c8d271d08772b7ab01aa75224ca20 Mon Sep 17 00:00:00 2001 From: Reborn Reviewer Date: Thu, 23 Jul 2026 23:42:41 +0200 Subject: [PATCH 3/4] fix(interpreter): harden compiletime SQLite natives Address adversarial review findings on the SQLite compiletime natives: - sqlite_exec now runs every statement of a ';'-separated script instead of silently dropping all but the first (JDBC/xerial only executes the first). Splitting respects string literals, quoted identifiers and comments. - sqlite_open rejects smuggled URI query parameters and explicitly disables extension loading, closing a load_extension() -> dlopen native-code vector reachable from a malicious compiletime dependency. - sqlite_close now always closes the connection even if finalizing a statement throws, aggregating errors, so the connection can no longer leak. - (re)binding a parameter invalidates prior execution state, so binding new values after a step (without an explicit sqlite_reset) re-executes instead of being silently ignored. - Added sqlite_column_is_null so callers can distinguish SQL NULL from an empty string / zero value. - closeAllSqliteResources no longer resets the handle counter, keeping handles monotonic so a stale handle can never alias a new resource. - Documented the 1-based bind / 0-based column index convention and the 32-bit truncation of sqlite_column_int. - Removed dead, cleanup-escaping fallback branches in RunTests. Tests: multi-statement exec, rebind-without-reset re-execution and NULL detection compiletime tests, plus unit tests for the SQL statement splitter. Co-Authored-By: Claude Opus 4.8 --- .../interpreter/CompiletimeNatives.java | 133 +++++++++++++++++- .../languageserver/requests/RunTests.java | 11 +- .../tests/CompiletimeNativesTest.java | 36 +++++ .../wurstscript/tests/CompiletimeTests.java | 62 ++++++++ 4 files changed, 228 insertions(+), 14 deletions(-) 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 99661a816..f08db843f 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 @@ -21,10 +21,14 @@ import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; +import org.sqlite.SQLiteConfig; + 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; @@ -219,13 +223,24 @@ private PreparedStatement sqliteStatement(int handle) { } public ILconstInt sqlite_open(ILconstString path) { + String dbPath = path.getVal(); + // Reject smuggled JDBC/URI query parameters (e.g. "?enable_load_extension=true"). + // Without this a caller could turn on SQLite extension loading and then dlopen an + // arbitrary shared object via load_extension(), i.e. run native code on the build + // machine at compiletime. A legitimate file path or ":memory:" never needs a "?". + if (dbPath.indexOf('?') >= 0) { + throw new InterpreterException("Invalid SQLite database path (query parameters are not allowed): " + dbPath); + } try { - Connection conn = DriverManager.getConnection("jdbc:sqlite:" + path.getVal()); + // 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 " + path.getVal() + ": " + e.getMessage()); + throw new InterpreterException("Failed to open SQLite database " + dbPath + ": " + e.getMessage()); } } @@ -242,10 +257,26 @@ public ILconstInt sqlite_prepare(ILconstInt connection, ILconstString query) { } } + /** + * 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. + */ + 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()); } @@ -255,6 +286,7 @@ public void sqlite_bind_real(ILconstInt statement, ILconstInt index, ILconstReal 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()); } @@ -264,6 +296,7 @@ public void sqlite_bind_string(ILconstInt statement, ILconstInt index, ILconstSt 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()); } @@ -310,10 +343,14 @@ public ILconstInt sqlite_column_count(ILconstInt statement) { } } + // 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()); @@ -334,6 +371,8 @@ 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) { @@ -341,6 +380,17 @@ public ILconstString sqlite_column_string(ILconstInt statement, ILconstInt index } } + 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 @@ -382,28 +432,97 @@ public void sqlite_finalize(ILconstInt statement) { 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()) { - sqlite_finalize(new ILconstInt(statement)); + try { + sqlite_finalize(new ILconstInt(statement)); + } catch (InterpreterException e) { + errors.add(e.getMessage()); + } } try { conn.close(); } catch (SQLException e) { - throw new InterpreterException("Failed to close SQLite connection: " + e.getMessage()); + 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 (java.sql.Statement stmt = conn.createStatement()) { - stmt.execute(query.getVal()); + // JDBC's Statement.execute runs only the FIRST statement of a ';'-separated + // script (the xerial driver silently discards the rest), whereas sqlite3_exec + // runs them all. Split into individual statements and execute each so + // multi-statement scripts behave as callers expect. + for (String single : splitSqlStatements(query.getVal())) { + stmt.execute(single); + } } catch (SQLException e) { throw new InterpreterException("Failed to exec SQLite query: " + e.getMessage()); } } + /** + * Splits a SQL script into its individual ';'-separated statements, respecting single- + * quoted string literals, double-quoted identifiers (with doubled-quote escapes), and + * line ({@code --}) / block ({@code / * ... * /}) comments so that semicolons inside + * those are not treated as separators. Comments are dropped; empty statements are skipped. + */ + public static List splitSqlStatements(String sql) { + List statements = new ArrayList<>(); + StringBuilder current = new StringBuilder(); + int i = 0; + int n = sql.length(); + while (i < n) { + char c = sql.charAt(i); + if (c == '\'' || c == '"') { + char quote = c; + current.append(c); + i++; + while (i < n) { + char d = sql.charAt(i); + current.append(d); + i++; + if (d == quote) { + if (i < n && sql.charAt(i) == quote) { + current.append(quote); // doubled quote = escaped quote, not a terminator + i++; + } else { + break; + } + } + } + } else if (c == '-' && i + 1 < n && sql.charAt(i + 1) == '-') { + while (i < n && sql.charAt(i) != '\n') i++; // line comment: drop to end of line + } else if (c == '/' && i + 1 < n && sql.charAt(i + 1) == '*') { + i += 2; + while (i + 1 < n && !(sql.charAt(i) == '*' && sql.charAt(i + 1) == '/')) i++; + i = Math.min(i + 2, n); // consume the closing */ + } else if (c == ';') { + String trimmed = current.toString().trim(); + if (!trimmed.isEmpty()) statements.add(trimmed); + current.setLength(0); + i++; + } else { + current.append(c); + i++; + } + } + String trailing = current.toString().trim(); + if (!trailing.isEmpty()) statements.add(trailing); + return statements; + } + @Override public void close() { closeAllSqliteResources(); @@ -428,6 +547,8 @@ public void closeAllSqliteResources() { try { conn.close(); } catch (SQLException ignored) {} } sqliteConnections.clear(); - sqliteHandleCounter = 0; + // 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 7de167100..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; @@ -166,15 +165,11 @@ public TestResult runTests(ImTranslator translator, ImProg imProg, Optional()); 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 01d81f87b..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 @@ -539,4 +539,66 @@ public void testCompiletimeSQLiteResetPreservesBindings() { " 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()"); + } } From deffe836569ce7ba2562da8042b536a379ba1377 Mon Sep 17 00:00:00 2001 From: Reborn Reviewer Date: Thu, 23 Jul 2026 23:52:49 +0200 Subject: [PATCH 4/4] fix(interpreter): run sqlite_exec via native sqlite3_exec Follow-up to the second adversarial review. Replace the hand-rolled SQL statement splitter in sqlite_exec with a direct call to SQLite's native sqlite3_exec (via the xerial driver's low-level DB handle). The splitter could not parse trigger BEGIN...END bodies, CASE...END, or the [id] / `id` identifier-quoting forms, so those scripts were shredded into invalid fragments; delegating to SQLite's own parser handles all of them. The splitter and its unit tests are removed; a native test now exercises a multi-statement script containing a trigger and a ';'-bearing quoted identifier. Also from the review: - sqlite_open now only rejects query parameters on the "file:" URI form, so a plain path legitimately containing '?' (e.g. on Linux) is accepted; enableLoadExtension(false) remains the defense-in-depth backstop for all paths (the extension-loading vector was verified closed either way). - Documented that (re)binding a parameter closes any open result set. Co-Authored-By: Claude Opus 4.8 --- .../interpreter/CompiletimeNatives.java | 86 +++++-------------- .../tests/CompiletimeNativesTest.java | 57 ++++++------ 2 files changed, 45 insertions(+), 98 deletions(-) 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 f08db843f..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 @@ -22,6 +22,7 @@ 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; @@ -224,12 +225,14 @@ private PreparedStatement sqliteStatement(int handle) { public ILconstInt sqlite_open(ILconstString path) { String dbPath = path.getVal(); - // Reject smuggled JDBC/URI query parameters (e.g. "?enable_load_extension=true"). - // Without this a caller could turn on SQLite extension loading and then dlopen an - // arbitrary shared object via load_extension(), i.e. run native code on the build - // machine at compiletime. A legitimate file path or ":memory:" never needs a "?". - if (dbPath.indexOf('?') >= 0) { - throw new InterpreterException("Invalid SQLite database path (query parameters are not allowed): " + dbPath); + // 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. @@ -261,7 +264,9 @@ public ILconstInt sqlite_prepare(ILconstInt connection, ILconstString query) { * 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. + * 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); @@ -459,70 +464,19 @@ public void sqlite_close(ILconstInt connection) { public void sqlite_exec(ILconstInt connection, ILconstString query) { Connection conn = sqliteConnection(connection.getVal()); - try (java.sql.Statement stmt = conn.createStatement()) { - // JDBC's Statement.execute runs only the FIRST statement of a ';'-separated - // script (the xerial driver silently discards the rest), whereas sqlite3_exec - // runs them all. Split into individual statements and execute each so - // multi-statement scripts behave as callers expect. - for (String single : splitSqlStatements(query.getVal())) { - stmt.execute(single); - } + 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()); } } - /** - * Splits a SQL script into its individual ';'-separated statements, respecting single- - * quoted string literals, double-quoted identifiers (with doubled-quote escapes), and - * line ({@code --}) / block ({@code / * ... * /}) comments so that semicolons inside - * those are not treated as separators. Comments are dropped; empty statements are skipped. - */ - public static List splitSqlStatements(String sql) { - List statements = new ArrayList<>(); - StringBuilder current = new StringBuilder(); - int i = 0; - int n = sql.length(); - while (i < n) { - char c = sql.charAt(i); - if (c == '\'' || c == '"') { - char quote = c; - current.append(c); - i++; - while (i < n) { - char d = sql.charAt(i); - current.append(d); - i++; - if (d == quote) { - if (i < n && sql.charAt(i) == quote) { - current.append(quote); // doubled quote = escaped quote, not a terminator - i++; - } else { - break; - } - } - } - } else if (c == '-' && i + 1 < n && sql.charAt(i + 1) == '-') { - while (i < n && sql.charAt(i) != '\n') i++; // line comment: drop to end of line - } else if (c == '/' && i + 1 < n && sql.charAt(i + 1) == '*') { - i += 2; - while (i + 1 < n && !(sql.charAt(i) == '*' && sql.charAt(i + 1) == '/')) i++; - i = Math.min(i + 2, n); // consume the closing */ - } else if (c == ';') { - String trimmed = current.toString().trim(); - if (!trimmed.isEmpty()) statements.add(trimmed); - current.setLength(0); - i++; - } else { - current.append(c); - i++; - } - } - String trailing = current.toString().trim(); - if (!trailing.isEmpty()) statements.add(trailing); - return statements; - } - @Override public void close() { closeAllSqliteResources(); 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 bd46a8909..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 @@ -243,39 +243,32 @@ public void testCloseSqliteResourcesFromProgramStateClose() throws Exception { } @Test - public void splitSqlStatementsSplitsTopLevelSemicolons() { - assertEquals( - CompiletimeNatives.splitSqlStatements("CREATE TABLE A (id INT); CREATE TABLE B (id INT)"), - List.of("CREATE TABLE A (id INT)", "CREATE TABLE B (id INT)")); - } - - @Test - public void splitSqlStatementsHandlesSingleStatementWithoutTrailingSemicolon() { - assertEquals( - CompiletimeNatives.splitSqlStatements("SELECT 1"), - List.of("SELECT 1")); - } - - @Test - public void splitSqlStatementsIgnoresSemicolonsInsideStringLiterals() { - // The ';' and the doubled '' escape inside the literal must not split or terminate. - assertEquals( - CompiletimeNatives.splitSqlStatements("INSERT INTO T VALUES ('a;b''c'); SELECT 1"), - List.of("INSERT INTO T VALUES ('a;b''c')", "SELECT 1")); - } - - @Test - public void splitSqlStatementsIgnoresSemicolonsInsideQuotedIdentifiers() { - assertEquals( - CompiletimeNatives.splitSqlStatements("SELECT \"weird;col\" FROM T; SELECT 2"), - List.of("SELECT \"weird;col\" FROM T", "SELECT 2")); - } + 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); - @Test - public void splitSqlStatementsDropsCommentsAndSkipsEmptyStatements() { - assertEquals( - CompiletimeNatives.splitSqlStatements("SELECT 1; -- a comment; still comment\n;; /* block; comment */ SELECT 2;"), - List.of("SELECT 1", "SELECT 2")); + 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() {