diff --git a/pom.xml b/pom.xml index d54f70f43..cf7c73e2d 100644 --- a/pom.xml +++ b/pom.xml @@ -92,6 +92,7 @@ 2.15.2 2.15.2 2.3.6 + 3.42.0 3.2.2 us.springett @@ -544,6 +555,24 @@ false + + + + org.apache.maven.plugins + maven-compiler-plugin + + 17 + 17 + + -Xlint:all + -Xlint:-processing + -Xlint:-serial + -parameters + + + + + org.apache.maven.plugins diff --git a/src/main/java/org/dependencytrack/persistence/jdbi/JdbiFactory.java b/src/main/java/org/dependencytrack/persistence/jdbi/JdbiFactory.java new file mode 100644 index 000000000..478547ac6 --- /dev/null +++ b/src/main/java/org/dependencytrack/persistence/jdbi/JdbiFactory.java @@ -0,0 +1,114 @@ +package org.dependencytrack.persistence.jdbi; + +import org.datanucleus.api.jdo.JDOPersistenceManagerFactory; +import org.datanucleus.store.connection.ConnectionManagerImpl; +import org.datanucleus.store.rdbms.ConnectionFactoryImpl; +import org.datanucleus.store.rdbms.RDBMSStoreManager; +import org.dependencytrack.persistence.QueryManager; +import org.jdbi.v3.core.Jdbi; +import org.jdbi.v3.sqlobject.SqlObjectPlugin; + +import javax.jdo.PersistenceManager; +import javax.jdo.PersistenceManagerFactory; +import javax.sql.DataSource; +import java.sql.Connection; +import java.util.concurrent.atomic.AtomicReference; + +import static org.apache.commons.lang3.reflect.FieldUtils.readField; + +public class JdbiFactory { + + private static final AtomicReference GLOBAL_INSTANCE_HOLDER = new AtomicReference<>(); + + /** + * Get a global {@link Jdbi} instance, initializing it if it hasn't been initialized before. + *

+ * 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 projectName = JdbiFactory.jdbi(qm).withHandle(handle -> + handle.createQuery("SELECT \"NAME\" FROM \"PROJECT\"").mapTo(String.class).findFirst()); + assertThat(projectName).isNotPresent(); + }); + } + + @Test + public void testGlobalInstanceWhenPmfChanges() { + final Jdbi jdbi = JdbiFactory.jdbi(qm); + + // Close the PMF and ensure that the JDBI instance is no longer usable. + PersistenceManagerFactory.tearDown(); + assertThatExceptionOfType(IllegalStateException.class) + .isThrownBy(() -> jdbi.withHandle(handle -> + handle.createQuery("SELECT 666").mapTo(Integer.class).one())) + .withMessage("Pool not open"); + + // Create a new QueryManager (which will initialize a new PMF behind the scenes). + try (final var otherQm = new QueryManager()) { + // Request the global JDBI instance again and verify it differs from the original one. + // Because the PMF changed, a new instance must have been created. + final Jdbi otherJdbi = JdbiFactory.jdbi(otherQm); + assertThat(otherJdbi).isNotEqualTo(jdbi); + + // Issue a test query to ensure the new JDBI instance is functional. + final Integer queryResult = otherJdbi.withHandle(handle -> + handle.createQuery("SELECT 666").mapTo(Integer.class).one()); + assertThat(queryResult).isEqualTo(666); + } + } + + @Test + public void testLocalInstanceOutsideOfJdoTransaction() { + assertThatExceptionOfType(IllegalStateException.class) + .isThrownBy(() -> JdbiFactory.localJdbi(qm)) + .withMessageContaining("Local JDBI instances must not be used outside of an active JDO transaction"); + } + + @Test + public void testLocalInstanceWithJdoTransaction() { + 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 local JDBI instance uses the same connection as the QueryManager, + // it must be able to see the yet-uncommitted change. + final Optional projectName = JdbiFactory.localJdbi(qm).withHandle(handle -> + handle.createQuery("SELECT \"NAME\" FROM \"PROJECT\"").mapTo(String.class).findFirst()); + assertThat(projectName).contains("acme-app"); + + // Ensure the connection is still usable after being returned from JDBI, + // by creating another record using the QueryManager. + final var component = new Component(); + component.setProject(project); + component.setName("acme-lib"); + component.setVersion("2.0.0"); + qm.getPersistenceManager().makePersistent(component); + }); + } + +} \ No newline at end of file