+ * The global instance will use {@link Connection}s from the primary {@link DataSource} + * of the given {@link QueryManager}'s {@link PersistenceManagerFactory}. + *
+ * Usage of the global instance should be preferred to make the best possible use of JDBI's + * internal caching mechanisms. However, this instance can't participate in transactions + * initiated by JDO (via {@link QueryManager} or {@link PersistenceManager}). + *
+ * If {@link Jdbi} usage in an active JDO {@link javax.jdo.Transaction} is desired, + * use {@link #localJdbi(QueryManager)} instead, which will use the same {@link Connection} + * as the provided {@link QueryManager}. + * + * @param qm The {@link QueryManager} to determine the {@link DataSource} from + * @return The global {@link Jdbi} instance + */ + public static Jdbi jdbi(final QueryManager qm) { + return jdbi(qm.getPersistenceManager()); + } + + private static Jdbi jdbi(final PersistenceManager pm) { + return GLOBAL_INSTANCE_HOLDER + .updateAndGet(previous -> { + if (previous == null || previous.pmf != pm.getPersistenceManagerFactory()) { + // The PMF reference does not usually change, unless it has been recreated, + // or multiple PMFs exist in the same application. The latter is not the case + // for Dependency-Track, and the former only happens during test execution, + // where each test (re-)creates the PMF. + final Jdbi jdbi = createFromPmf(pm.getPersistenceManagerFactory()); + return new GlobalInstanceHolder(jdbi, pm.getPersistenceManagerFactory()); + } + + return previous; + }) + .jdbi(); + } + + /** + * Create a new local {@link Jdbi} instance. + *
+ * The instance will use the same {@link Connection} used by the given {@link QueryManager}, + * allowing it to participate in {@link javax.jdo.Transaction}s initiated by {@code qm}. + *
+ * Because using local {@link Jdbi} instances has a high performance impact (e.g. due to ineffective caching), + * this method will throw if {@code qm} is not participating in an active {@link javax.jdo.Transaction} + * already. + *
+ * Just like {@link QueryManager} itself, {@link Jdbi} instances created by this method are not
+ * thread safe!
+ *
+ * @param qm The {@link QueryManager} to use the underlying {@link Connection} of
+ * @return A new {@link Jdbi} instance
+ * @throws IllegalStateException When the given {@link QueryManager} is not participating
+ * in an active {@link javax.jdo.Transaction}
+ */
+ public static Jdbi localJdbi(final QueryManager qm) {
+ return localJdbi(qm.getPersistenceManager());
+ }
+
+ private static Jdbi localJdbi(final PersistenceManager pm) {
+ if (!pm.currentTransaction().isActive()) {
+ throw new IllegalStateException("""
+ Local JDBI instances must not be used outside of an active JDO transaction. \
+ Use the global instance instead if combining JDBI with JDO transactions is not needed.""");
+ }
+
+ return Jdbi.create(new JdoConnectionFactory(pm));
+ }
+
+ private record GlobalInstanceHolder(Jdbi jdbi, PersistenceManagerFactory pmf) {
+ }
+
+ private static Jdbi createFromPmf(final PersistenceManagerFactory pmf) {
+ try {
+ if (pmf instanceof final JDOPersistenceManagerFactory jdoPmf
+ && jdoPmf.getNucleusContext().getStoreManager() instanceof final RDBMSStoreManager storeManager
+ && storeManager.getConnectionManager() instanceof final ConnectionManagerImpl connectionManager
+ && readField(connectionManager, "primaryConnectionFactory", true) instanceof ConnectionFactoryImpl connectionFactory
+ && readField(connectionFactory, "dataSource", true) instanceof final DataSource dataSource) {
+ return Jdbi
+ .create(dataSource)
+ .installPlugin(new SqlObjectPlugin());
+ }
+ } catch (IllegalAccessException e) {
+ throw new IllegalStateException("Failed to access datasource of PMF via reflection", e);
+ }
+
+ throw new IllegalStateException("Failed to access primary datasource of PMF");
+ }
+
+}
diff --git a/src/main/java/org/dependencytrack/persistence/jdbi/JdoConnectionFactory.java b/src/main/java/org/dependencytrack/persistence/jdbi/JdoConnectionFactory.java
new file mode 100644
index 000000000..fc42ba2d0
--- /dev/null
+++ b/src/main/java/org/dependencytrack/persistence/jdbi/JdoConnectionFactory.java
@@ -0,0 +1,36 @@
+package org.dependencytrack.persistence.jdbi;
+
+import net.jcip.annotations.NotThreadSafe;
+import org.jdbi.v3.core.ConnectionFactory;
+
+import javax.jdo.PersistenceManager;
+import javax.jdo.datastore.JDOConnection;
+import java.sql.Connection;
+
+@NotThreadSafe
+class JdoConnectionFactory implements ConnectionFactory {
+
+ private final PersistenceManager pm;
+ private JDOConnection jdoConnection;
+
+ JdoConnectionFactory(final PersistenceManager pm) {
+ this.pm = pm;
+ }
+
+ @Override
+ public Connection openConnection() {
+ if (jdoConnection != null) {
+ throw new IllegalStateException("A JDO connection is already open");
+ }
+
+ jdoConnection = pm.getDataStoreConnection();
+ return (Connection) jdoConnection.getNativeConnection();
+ }
+
+ @Override
+ public void closeConnection(final Connection conn) {
+ jdoConnection.close();
+ jdoConnection = null;
+ }
+
+}
diff --git a/src/test/java/org/dependencytrack/persistence/jdbi/JdbiFactoryTest.java b/src/test/java/org/dependencytrack/persistence/jdbi/JdbiFactoryTest.java
new file mode 100644
index 000000000..5e469b02a
--- /dev/null
+++ b/src/test/java/org/dependencytrack/persistence/jdbi/JdbiFactoryTest.java
@@ -0,0 +1,111 @@
+package org.dependencytrack.persistence.jdbi;
+
+import alpine.server.persistence.PersistenceManagerFactory;
+import org.dependencytrack.PersistenceCapableTest;
+import org.dependencytrack.model.Component;
+import org.dependencytrack.model.Project;
+import org.dependencytrack.persistence.QueryManager;
+import org.jdbi.v3.core.Jdbi;
+import org.junit.Test;
+
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+
+public class JdbiFactoryTest extends PersistenceCapableTest {
+
+ @Test
+ public void testGlobalInstance() {
+ final Jdbi jdbi = JdbiFactory.jdbi(qm);
+
+ // Issue a test query to ensure the JDBI instance is functional.
+ final Integer queryResult = jdbi.withHandle(handle ->
+ handle.createQuery("SELECT 666").mapTo(Integer.class).one());
+ assertThat(queryResult).isEqualTo(666);
+
+ // Ensure that the same JDBI instance is returned even when passing
+ // a different QueryManager instance. Because the underlying PMF
+ // did not change, the global JDBI instance must remain untouched.
+ try (final var otherQm = new QueryManager()) {
+ assertThat(JdbiFactory.jdbi(otherQm)).isEqualTo(jdbi);
+ }
+ }
+
+ @Test
+ public void testGlobalInstanceWithJdoTransaction() {
+ qm.runInTransaction(() -> {
+ // Create a new project.
+ final var project = new Project();
+ project.setName("acme-app");
+ project.setVersion("1.0.0");
+ qm.getPersistenceManager().makePersistent(project);
+
+ // Query for the created project, despite its creation not having been committed yet.
+ // Because the global JDBI instance uses a different connection than the QueryManager,
+ // it won't be able to see the yet-uncommitted change.
+ final Optional