diff --git a/jdbc-bridge/src/main/java/sqlkit/bridge/ConnectionManager.java b/jdbc-bridge/src/main/java/sqlkit/bridge/ConnectionManager.java index 7c0d4764..da0f0339 100644 --- a/jdbc-bridge/src/main/java/sqlkit/bridge/ConnectionManager.java +++ b/jdbc-bridge/src/main/java/sqlkit/bridge/ConnectionManager.java @@ -15,6 +15,17 @@ public class ConnectionManager { private final Map pools = new HashMap<>(); private final Map loaders = new HashMap<>(); + /** + * Driver classes loaded once per JAR set. + * + * A fresh class loader per connection re-runs the driver's static + * initialisation, which for DuckDB maps a ~107 MB native library (measured + * ~1.4 s). Caching the loader keeps that cost to the first connection of the + * bridge process; entries are released together with the process. + */ + private final Map sharedLoaders = new HashMap<>(); + private final Map sharedDrivers = new HashMap<>(); + /** * Create a new JDBC connection pool. * @@ -49,15 +60,16 @@ public void connect(String connId, String url, String username, } final String jdbcUrl = url; - DriverClassLoader loader = new DriverClassLoader(driverJars); - Class driverCls = Class.forName(driverClass, true, loader); - if (!java.sql.Driver.class.isAssignableFrom(driverCls)) { - throw new ClassifiedException("Class " + driverClass + " does not implement java.sql.Driver", null, ErrorClassifier.ErrorType.UNKNOWN); - } - final java.sql.Driver driver = (java.sql.Driver) driverCls.getDeclaredConstructor().newInstance(); + final java.sql.Driver driver = sharedDriver(driverClass, driverJars); + DriverClassLoader loader = loaderFor(driverJars); loaders.put(connId, loader); HikariConfig config = new HikariConfig(); + // Keep HikariCP's read-only flag aligned with the connection the driver + // already opened: HikariCP calls `connection.setReadOnly(config.isReadOnly())` + // whenever the two disagree, and DuckDB rejects changing the mode at + // connection level ("Can't change read-only status on connection level"). + config.setReadOnly(isReadOnlyUrl(jdbcUrl)); config.setDataSource(new javax.sql.DataSource() { public java.sql.Connection getConnection() throws java.sql.SQLException { java.util.Properties info = new java.util.Properties(); @@ -106,6 +118,51 @@ public void setLoginTimeout(int seconds) {} pools.put(connId, ds); } + /** Reuse one {@link DriverClassLoader} per set of driver JARs. */ + private DriverClassLoader loaderFor(List driverJars) throws Exception { + String key = String.join("|", driverJars); + DriverClassLoader loader = sharedLoaders.get(key); + if (loader == null) { + loader = new DriverClassLoader(driverJars); + sharedLoaders.put(key, loader); + } + return loader; + } + + /** Reuse the instantiated JDBC driver for a driver class + JAR set. */ + private Driver sharedDriver(String driverClass, List driverJars) throws Exception { + String key = driverClass + "|" + String.join("|", driverJars); + Driver driver = sharedDrivers.get(key); + if (driver != null) { + return driver; + } + + DriverClassLoader loader = loaderFor(driverJars); + Class driverCls = Class.forName(driverClass, true, loader); + if (!java.sql.Driver.class.isAssignableFrom(driverCls)) { + throw new ClassifiedException("Class " + driverClass + " does not implement java.sql.Driver", null, ErrorClassifier.ErrorType.UNKNOWN); + } + driver = (java.sql.Driver) driverCls.getDeclaredConstructor().newInstance(); + sharedDrivers.put(key, driver); + return driver; + } + + /** + * Whether a JDBC URL asks for a read-only database. + * + * DuckDB expresses this in the URL (`access_mode=READ_ONLY` from SQLKit, or + * the driver property `duckdb.read_only`), and the driver refuses to change + * the mode through {@link java.sql.Connection#setReadOnly(boolean)}. + */ + static boolean isReadOnlyUrl(String jdbcUrl) { + if (jdbcUrl == null) { + return false; + } + String normalized = jdbcUrl.toLowerCase(Locale.ROOT); + return normalized.contains("access_mode=read_only") + || normalized.contains("duckdb.read_only=true"); + } + /** * Close and remove a connection pool. */ @@ -114,10 +171,9 @@ public void disconnect(String connId) { if (ds != null) { ds.close(); } - DriverClassLoader loader = loaders.remove(connId); - if (loader != null) { - try { loader.close(); } catch (Exception ignored) { } - } + // The loader is shared with other connections (see sharedLoaders), so it is + // only closed when the whole bridge shuts down. + loaders.remove(connId); } /** @@ -164,5 +220,10 @@ public void closeAll() { try { loader.close(); } catch (Exception ignored) { } } loaders.clear(); + for (DriverClassLoader loader : sharedLoaders.values()) { + try { loader.close(); } catch (Exception ignored) { } + } + sharedLoaders.clear(); + sharedDrivers.clear(); } } diff --git a/jdbc-bridge/src/test/java/sqlkit/bridge/ConnectionManagerTest.java b/jdbc-bridge/src/test/java/sqlkit/bridge/ConnectionManagerTest.java new file mode 100644 index 00000000..62486fb2 --- /dev/null +++ b/jdbc-bridge/src/test/java/sqlkit/bridge/ConnectionManagerTest.java @@ -0,0 +1,24 @@ +package sqlkit.bridge; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ConnectionManagerTest { + + @Test + void detectsReadOnlyJdbcUrls() { + assertTrue(ConnectionManager.isReadOnlyUrl("/tmp/app.duckdb;access_mode=READ_ONLY")); + assertTrue(ConnectionManager.isReadOnlyUrl("jdbc:duckdb:/data/app.duckdb;ACCESS_MODE=read_only")); + assertTrue(ConnectionManager.isReadOnlyUrl("jdbc:duckdb:/data/app.duckdb;duckdb.read_only=true")); + } + + @Test + void treatsEverythingElseAsReadWrite() { + assertFalse(ConnectionManager.isReadOnlyUrl("jdbc:duckdb:/data/app.duckdb")); + assertFalse(ConnectionManager.isReadOnlyUrl("jdbc:duckdb:memory:shared")); + assertFalse(ConnectionManager.isReadOnlyUrl("jdbc:duckdb:/data/app.duckdb;duckdb.read_only=false")); + assertFalse(ConnectionManager.isReadOnlyUrl(null)); + } +} diff --git a/scripts/duckdb-jdbc-verify/Verify.java b/scripts/duckdb-jdbc-verify/Verify.java new file mode 100644 index 00000000..f0b25247 --- /dev/null +++ b/scripts/duckdb-jdbc-verify/Verify.java @@ -0,0 +1,164 @@ +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.Properties; + +/** + * Verifies the DuckDB JDBC semantics that SQLKit's connection form and JDBC URL + * building depend on. Run it with the DuckDB JDBC driver on the classpath: + * + * scripts/verify-duckdb-jdbc-urls.sh + * + * Expectations encoded here (DuckDB 1.5.x): + * - a missing database file is created by the engine on connect + * - `;access_mode=READ_ONLY` works as a URL option and blocks writes + * - the same file opened twice with the SAME configuration shares one instance + * - the same file opened twice with DIFFERENT configurations is rejected + * - a NAMED in-memory instance (`memory: