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..479a8228e 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,315 @@ 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(); + // Clearing bindings mutates the parameters (all become NULL), so it must + // invalidate any prior execution just like sqlite_bind_* — otherwise a + // step after clearing on an already-executed statement would return false + // and silently skip re-running with the now-NULL parameters. + markStatementForReexecution(statement.getVal()); + } 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..9f984c0bf 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 @@ -7,6 +7,7 @@ import de.peeeq.wurstscript.ast.Element; import de.peeeq.wurstscript.gui.WurstGuiLogger; import de.peeeq.wurstscript.intermediatelang.ILconstInt; +import de.peeeq.wurstscript.intermediatelang.ILconstReal; import de.peeeq.wurstscript.intermediatelang.ILconstString; import de.peeeq.wurstscript.jassIm.ImProg; import de.peeeq.wurstscript.jassIm.JassIm; @@ -204,6 +205,324 @@ 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(); + } + + // ---- SQLite API coverage: every native's intended behavior + error contract ---- + + private static CompiletimeNatives newSqliteNatives() { + // globalState is unused by the sqlite_* natives, so null is fine here. + return new CompiletimeNatives(null, null, false); + } + + private static ILconstInt i(int v) { + return new ILconstInt(v); + } + + private static void assertInterpreterError(String expectedSubstring, Runnable action) { + try { + action.run(); + org.testng.Assert.fail("Expected InterpreterException containing '" + expectedSubstring + "'"); + } catch (de.peeeq.wurstio.jassinterpreter.InterpreterException e) { + assertTrue(e.getMessage().contains(expectedSubstring), + "Expected message to contain '" + expectedSubstring + "' but was: " + e.getMessage()); + } + } + + @Test + public void sqliteReadsBackEveryColumnTypeIncludingNull() { + CompiletimeNatives n = newSqliteNatives(); + ILconstInt db = n.sqlite_open(new ILconstString(":memory:")); + n.sqlite_exec(db, new ILconstString( + "CREATE TABLE T (i INTEGER, r REAL, s TEXT, n TEXT);" + + "INSERT INTO T VALUES (42, 2.5, 'hello', NULL);")); + ILconstInt q = n.sqlite_prepare(db, new ILconstString("SELECT i, r, s, n FROM T")); + // column_count works before any step (metadata branch) ... + assertEquals(n.sqlite_column_count(q).getVal(), 4); + assertTrue(n.sqlite_step(q).getVal()); + // ... and after a step (result-set branch) + assertEquals(n.sqlite_column_count(q).getVal(), 4); + assertEquals(n.sqlite_column_int(q, i(0)).getVal(), 42); + assertEquals((double) n.sqlite_column_real(q, i(1)).getVal(), 2.5, 0.0); + assertEquals(n.sqlite_column_string(q, i(2)).getVal(), "hello"); + // a genuine SQL NULL reads back as "" but is flagged by sqlite_column_is_null + assertEquals(n.sqlite_column_string(q, i(3)).getVal(), ""); + assertTrue(n.sqlite_column_is_null(q, i(3)).getVal()); + assertFalse(n.sqlite_column_is_null(q, i(0)).getVal()); + // stepping past the last row returns false + assertFalse(n.sqlite_step(q).getVal()); + n.sqlite_finalize(q); + n.sqlite_close(db); + } + + @Test + public void sqliteResetRewindsSelectResultSet() { + CompiletimeNatives n = newSqliteNatives(); + ILconstInt db = n.sqlite_open(new ILconstString(":memory:")); + n.sqlite_exec(db, new ILconstString( + "CREATE TABLE T (id INTEGER); INSERT INTO T VALUES (10); INSERT INTO T VALUES (20);")); + ILconstInt q = n.sqlite_prepare(db, new ILconstString("SELECT id FROM T ORDER BY id")); + assertTrue(n.sqlite_step(q).getVal()); + assertEquals(n.sqlite_column_int(q, i(0)).getVal(), 10); + assertTrue(n.sqlite_step(q).getVal()); + assertEquals(n.sqlite_column_int(q, i(0)).getVal(), 20); + // reset rewinds: the next step reads the first row again + n.sqlite_reset(q); + assertTrue(n.sqlite_step(q).getVal()); + assertEquals(n.sqlite_column_int(q, i(0)).getVal(), 10); + n.sqlite_finalize(q); + n.sqlite_close(db); + } + + @Test + public void sqliteColumnIntTruncates64BitValueToInt() { + CompiletimeNatives n = newSqliteNatives(); + ILconstInt db = n.sqlite_open(new ILconstString(":memory:")); + n.sqlite_exec(db, new ILconstString("CREATE TABLE T (v INTEGER); INSERT INTO T VALUES (5000000000);")); + ILconstInt q = n.sqlite_prepare(db, new ILconstString("SELECT v FROM T")); + assertTrue(n.sqlite_step(q).getVal()); + // WurstScript int is 32-bit: a 64-bit INTEGER wraps to its low 32 bits (documented). + assertEquals(n.sqlite_column_int(q, i(0)).getVal(), (int) 5000000000L); + n.sqlite_finalize(q); + n.sqlite_close(db); + } + + @Test + public void sqliteBindRoundTripAndRebindAfterStepReexecutes() { + CompiletimeNatives n = newSqliteNatives(); + ILconstInt db = n.sqlite_open(new ILconstString(":memory:")); + n.sqlite_exec(db, new ILconstString("CREATE TABLE T (i INTEGER, r REAL, s TEXT)")); + ILconstInt ins = n.sqlite_prepare(db, new ILconstString("INSERT INTO T VALUES (?, ?, ?)")); + n.sqlite_bind_int(ins, i(1), i(1)); + n.sqlite_bind_real(ins, i(2), new ILconstReal(1.5f)); + n.sqlite_bind_string(ins, i(3), new ILconstString("a")); + assertFalse(n.sqlite_step(ins).getVal()); + // rebind i and s WITHOUT a reset: must re-execute; r keeps its previous binding + n.sqlite_bind_int(ins, i(1), i(2)); + n.sqlite_bind_string(ins, i(3), new ILconstString("b")); + assertFalse(n.sqlite_step(ins).getVal()); + n.sqlite_finalize(ins); + + ILconstInt q = n.sqlite_prepare(db, new ILconstString("SELECT i, r, s FROM T ORDER BY i")); + assertTrue(n.sqlite_step(q).getVal()); + assertEquals(n.sqlite_column_int(q, i(0)).getVal(), 1); + assertEquals((double) n.sqlite_column_real(q, i(1)).getVal(), 1.5, 0.0); + assertEquals(n.sqlite_column_string(q, i(2)).getVal(), "a"); + assertTrue(n.sqlite_step(q).getVal()); + assertEquals(n.sqlite_column_int(q, i(0)).getVal(), 2); + assertEquals((double) n.sqlite_column_real(q, i(1)).getVal(), 1.5, 0.0); // preserved binding + assertEquals(n.sqlite_column_string(q, i(2)).getVal(), "b"); + assertFalse(n.sqlite_step(q).getVal()); + n.sqlite_finalize(q); + n.sqlite_close(db); + } + + @Test + public void sqliteClearBindingsResetsParametersToNull() { + CompiletimeNatives n = newSqliteNatives(); + ILconstInt db = n.sqlite_open(new ILconstString(":memory:")); + n.sqlite_exec(db, new ILconstString("CREATE TABLE T (a INTEGER, b TEXT)")); + ILconstInt ins = n.sqlite_prepare(db, new ILconstString("INSERT INTO T VALUES (?, ?)")); + n.sqlite_bind_int(ins, i(1), i(7)); + n.sqlite_bind_string(ins, i(2), new ILconstString("x")); + n.sqlite_clear_bindings(ins); + assertFalse(n.sqlite_step(ins).getVal()); + n.sqlite_finalize(ins); + + ILconstInt q = n.sqlite_prepare(db, new ILconstString("SELECT a, b FROM T")); + assertTrue(n.sqlite_step(q).getVal()); + assertTrue(n.sqlite_column_is_null(q, i(0)).getVal()); + assertTrue(n.sqlite_column_is_null(q, i(1)).getVal()); + n.sqlite_finalize(q); + n.sqlite_close(db); + } + + @Test + public void sqliteClearBindingsAfterStepReexecutesWithoutReset() { + // Regression: sqlite_clear_bindings must invalidate a prior execution just like + // sqlite_bind_*, so a step after clearing re-runs the statement with NULL params + // WITHOUT an explicit sqlite_reset. Otherwise the second step silently no-ops. + CompiletimeNatives n = newSqliteNatives(); + ILconstInt db = n.sqlite_open(new ILconstString(":memory:")); + n.sqlite_exec(db, new ILconstString("CREATE TABLE T (v INTEGER)")); + ILconstInt ins = n.sqlite_prepare(db, new ILconstString("INSERT INTO T VALUES (?)")); + n.sqlite_bind_int(ins, i(1), i(100)); + assertFalse(n.sqlite_step(ins).getVal()); // inserts 100 + n.sqlite_clear_bindings(ins); // no explicit reset + assertFalse(n.sqlite_step(ins).getVal()); // must re-execute → inserts NULL + n.sqlite_finalize(ins); + + ILconstInt q = n.sqlite_prepare(db, new ILconstString("SELECT count(*), count(v) FROM T")); + assertTrue(n.sqlite_step(q).getVal()); + assertEquals(n.sqlite_column_int(q, i(0)).getVal(), 2); // two rows total + assertEquals(n.sqlite_column_int(q, i(1)).getVal(), 1); // one non-NULL (the 100) + n.sqlite_finalize(q); + n.sqlite_close(db); + } + + @Test + public void sqliteClearBindingsAfterStepDiscardsOldResultSet() { + // Companion to the above for the SELECT path: after a step opens a result set, + // clear_bindings must discard it so the next step RE-RUNS the query with the now + // NULL parameter, rather than continuing to walk the stale result set. + CompiletimeNatives n = newSqliteNatives(); + ILconstInt db = n.sqlite_open(new ILconstString(":memory:")); + n.sqlite_exec(db, new ILconstString("CREATE TABLE T (v INTEGER)")); + n.sqlite_exec(db, new ILconstString("INSERT INTO T VALUES (1), (2), (3)")); + ILconstInt q = n.sqlite_prepare(db, new ILconstString("SELECT v FROM T WHERE v <> ? ORDER BY v")); + n.sqlite_bind_int(q, i(1), i(2)); // excludes 2 → rows {1, 3} + assertTrue(n.sqlite_step(q).getVal()); + assertEquals(n.sqlite_column_int(q, i(0)).getVal(), 1); + // Without the fix, the next step continues the old result set and returns row 3. + // With the fix, it re-executes: "v <> NULL" matches nothing → no rows. + n.sqlite_clear_bindings(q); + assertFalse(n.sqlite_step(q).getVal()); + n.sqlite_finalize(q); + n.sqlite_close(db); + } + + @Test + public void sqliteFinalizeInvalidatesStatementHandle() { + CompiletimeNatives n = newSqliteNatives(); + ILconstInt db = n.sqlite_open(new ILconstString(":memory:")); + n.sqlite_exec(db, new ILconstString("CREATE TABLE T (id INTEGER)")); + ILconstInt stmt = n.sqlite_prepare(db, new ILconstString("INSERT INTO T VALUES (1)")); + n.sqlite_finalize(stmt); + assertInterpreterError("Invalid SQLite statement handle", () -> n.sqlite_step(stmt)); + assertInterpreterError("Invalid SQLite statement handle", () -> n.sqlite_finalize(stmt)); + n.sqlite_close(db); + } + + @Test + public void sqliteCloseInvalidatesConnectionAndItsStatements() { + CompiletimeNatives n = newSqliteNatives(); + ILconstInt db = n.sqlite_open(new ILconstString(":memory:")); + n.sqlite_exec(db, new ILconstString("CREATE TABLE T (id INTEGER)")); + ILconstInt stmt = n.sqlite_prepare(db, new ILconstString("SELECT id FROM T")); + n.sqlite_close(db); + assertInterpreterError("Invalid SQLite connection handle", () -> n.sqlite_prepare(db, new ILconstString("SELECT 1"))); + assertInterpreterError("Invalid SQLite connection handle", () -> n.sqlite_exec(db, new ILconstString("SELECT 1"))); + assertInterpreterError("Invalid SQLite connection handle", () -> n.sqlite_close(db)); + // statements belonging to the closed connection were finalized/invalidated too + assertInterpreterError("Invalid SQLite statement handle", () -> n.sqlite_step(stmt)); + } + + @Test + public void sqliteInvalidHandlesAndBadSqlThrow() { + CompiletimeNatives n = newSqliteNatives(); + ILconstInt db = n.sqlite_open(new ILconstString(":memory:")); + assertInterpreterError("Invalid SQLite statement handle", () -> n.sqlite_bind_int(i(999), i(1), i(1))); + assertInterpreterError("Invalid SQLite statement handle", () -> n.sqlite_bind_real(i(999), i(1), new ILconstReal(1f))); + assertInterpreterError("Invalid SQLite statement handle", () -> n.sqlite_bind_string(i(999), i(1), new ILconstString("x"))); + assertInterpreterError("Invalid SQLite statement handle", () -> n.sqlite_step(i(999))); + assertInterpreterError("Invalid SQLite statement handle", () -> n.sqlite_reset(i(999))); + assertInterpreterError("Invalid SQLite statement handle", () -> n.sqlite_clear_bindings(i(999))); + assertInterpreterError("Invalid SQLite statement handle", () -> n.sqlite_finalize(i(999))); + assertInterpreterError("No result set", () -> n.sqlite_column_int(i(999), i(0))); + assertInterpreterError("No result set", () -> n.sqlite_column_is_null(i(999), i(0))); + assertInterpreterError("Invalid SQLite connection handle", () -> n.sqlite_prepare(i(999), new ILconstString("SELECT 1"))); + assertInterpreterError("Invalid SQLite connection handle", () -> n.sqlite_exec(i(999), new ILconstString("SELECT 1"))); + assertInterpreterError("Invalid SQLite connection handle", () -> n.sqlite_close(i(999))); + assertInterpreterError("Failed to prepare SQLite statement", () -> n.sqlite_prepare(db, new ILconstString("NOT VALID SQL"))); + assertInterpreterError("Failed to exec SQLite query", () -> n.sqlite_exec(db, new ILconstString("NOT VALID SQL"))); + n.sqlite_close(db); + } + + @Test + public void sqliteColumnAccessWithoutResultSetThrows() { + CompiletimeNatives n = newSqliteNatives(); + ILconstInt db = n.sqlite_open(new ILconstString(":memory:")); + n.sqlite_exec(db, new ILconstString("CREATE TABLE T (id INTEGER); INSERT INTO T VALUES (1)")); + ILconstInt q = n.sqlite_prepare(db, new ILconstString("SELECT id FROM T")); + // reading a column before any step -> no result set yet + assertInterpreterError("No result set", () -> n.sqlite_column_int(q, i(0))); + assertInterpreterError("No result set", () -> n.sqlite_column_is_null(q, i(0))); + n.sqlite_finalize(q); + n.sqlite_close(db); + } + + @Test + public void sqliteOpenRejectsFileUriWithQueryParameters() { + CompiletimeNatives n = newSqliteNatives(); + assertInterpreterError("query parameters are not allowed", + () -> n.sqlite_open(new ILconstString("file::memory:?enable_load_extension=true"))); + } + + @Test + public void sqliteLoadExtensionIsBlocked() { + CompiletimeNatives n = newSqliteNatives(); + ILconstInt db = n.sqlite_open(new ILconstString(":memory:")); + // extension loading is disabled on the connection, so load_extension is not authorized + // (this is what closes the dlopen-arbitrary-native-code vector at compiletime). + assertInterpreterError("not authorized", + () -> n.sqlite_exec(db, new ILconstString("SELECT load_extension('nonexistent_extension')"))); + n.sqlite_close(db); + } + 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()"); + } }