diff --git a/pom.xml b/pom.xml index 01303f0..ed7d0ff 100644 --- a/pom.xml +++ b/pom.xml @@ -78,6 +78,7 @@ https://sonarcloud.io 2.78 + 0.10.2 @@ -146,6 +147,21 @@ org.eclipse.jetty jetty-util + + org.reflections + reflections + ${org.reflections.version} + + + com.google.guava + guava + + + + + org.glassfish.jersey.core + jersey-server + org.junit.jupiter @@ -202,7 +218,7 @@ prepare-agent - prepare-agent + prepare-agent diff --git a/src/main/java/io/dropwizard/jdbi/unitofwork/JdbiUnitOfWork.java b/src/main/java/io/dropwizard/jdbi/unitofwork/JdbiUnitOfWork.java new file mode 100644 index 0000000..e66cc38 --- /dev/null +++ b/src/main/java/io/dropwizard/jdbi/unitofwork/JdbiUnitOfWork.java @@ -0,0 +1,22 @@ +package io.dropwizard.jdbi.unitofwork; + +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.METHOD; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +/** + * When annotating a Jersey resource method, wraps the method in a Jdbi transaction context + * associated with a valid handle. + *

+ * A transaction will automatically {@code begin} before the resource method is invoked, + * {@code commit} if the method returned without throwing any exception and {@code rollback} + * if an exception was thrown. + */ +@Target(METHOD) +@Retention(RUNTIME) +@Documented +public @interface JdbiUnitOfWork { +} diff --git a/src/main/java/io/dropwizard/jdbi/unitofwork/README.md b/src/main/java/io/dropwizard/jdbi/unitofwork/README.md new file mode 100644 index 0000000..85799d9 --- /dev/null +++ b/src/main/java/io/dropwizard/jdbi/unitofwork/README.md @@ -0,0 +1,85 @@ +## @JdbiUnitOfWork - Unit of Work Support + +Provides a `Unit of Work` annotation for a Jdbi backed Dropwizard service for wrapping resource methods in a transaction +context + +- [`Dropwizard`](https://github.com/dropwizard/dropwizard) provides a very + slick [`@UnitOfWork`](https://www.dropwizard.io/en/latest/manual/hibernate.html) annotation that wraps a transaction + context around resource methods annotated with this annotation. This is very useful for wrapping multiple calls in a + single database transaction all of which will succeed or roll back atomically. + + +- However this support is only available for `Hibernate`. This module provides support for a `Jdbi`backend + +## Features + +- `transactionality` across multiple datasources when called from a request thread +- `transactionality` across multiple datasources across `multiple threads` +- `excluding` selectively, certain set of URI's from transaction contexts, such as `ELB`, `Health Checks` etc +- `Http GET` methods are excluded from transaction by default. +- `Http POST` methods are wrapped around in a transaction only when annotated with `@JdbiUnitOfWork` + +## Usage + +- Add the dependency to your `pom.xml` + +- Construct a `JdbiUnitOfWorkProvider` from the DBI instance. + + ```java + JdbiUnitOfWorkProvider provider = JdbiUnitOfWorkProvider.withDefault(dbi); // most common + or + JdbiUnitOfWorkProvider provider = JdbiUnitOfWorkProvider.withLinked(dbi); // most common + ``` + + If you are using Guice, you can bind the instance + ``` + bind(JdbiUnitOfWorkProvider.class).toInstance(provider); + ``` + +
+ +- Provide the list of package where the SQL Objects / DAO (to be attached) are located. Classes with Jdbi + annotations `@SqlQuery` or `@SqlUpdate` or `@SqlBatch` or `@SqlCall` will be picked automatically. + +
+ + Use `JdbiUnitOfWorkProvider` to generate the proxies. You can also register the classes one by one. + + ```java + + // class level + SampleDao dao = (SampleDao) provider.getWrappedInstanceForDaoClass(SampleDao.class); + // use the proxies and pass it as they were normal instances + resource = new SampleResource(dao); + + // package level + List daoPackages = Lists.newArrayList("", "fq-package-name-2", ...); + Map proxies = unitOfWorkProvider.getWrappedInstanceForDaoPackage(daoPackages); + // use the proxies and pass it as they were normal instances + resource = ...new SampleResource((SampleDao)proxies.get(SampleDao.class)) + ``` + +
+ +- Finally, we need to register the event listener with the Jersey Environment using the constructed provider + ``` + environment.jersey().register(new JdbiUnitOfWorkApplicationEventListener(provider, new HashSet<>()));; + ``` + In case you'd like to exclude certain URI paths from being monitored, you can pass them into exclude paths; + ``` + Set excludePaths = new HashSet<>(); + environment.jersey().register(new JdbiUnitOfWorkApplicationEventListener(handleManager, excludePaths)); + ``` + +
+ +- Start annotating resource methods with `@JdbiUnitOfWork` and you're good to go. + ```java + @POST + @Path("/") + @JdbiUnitOfWork + public RequestResponse createRequest() { + ..do stateful work (across multiple Dao's) + return response + } + ``` diff --git a/src/main/java/io/dropwizard/jdbi/unitofwork/core/DefaultJdbiHandleManager.java b/src/main/java/io/dropwizard/jdbi/unitofwork/core/DefaultJdbiHandleManager.java new file mode 100644 index 0000000..4299b2e --- /dev/null +++ b/src/main/java/io/dropwizard/jdbi/unitofwork/core/DefaultJdbiHandleManager.java @@ -0,0 +1,39 @@ +package io.dropwizard.jdbi.unitofwork.core; + +import org.skife.jdbi.v2.DBI; +import org.skife.jdbi.v2.Handle; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * This implementation gets a new handle each time it is invoked. It simulates the default + * behaviour of creating new handles each time the dao method is invoked. + *

+ * It can be used to service requests which interact with only a single method in a single handle. + * This is a lightweight implementation suitable for testing, such as with embedded databases. + * Any serious application should not be using this as it may quickly leak / run out of handles + * + * @apiNote Not suitable for requests spanning multiple Dbi as the handle returned is different + * This implementation, therefore, does not support thread factory creation. + */ +public class DefaultJdbiHandleManager implements JdbiHandleManager { + + private final Logger log = LoggerFactory.getLogger(DefaultJdbiHandleManager.class); + private final DBI dbi; + + public DefaultJdbiHandleManager(DBI dbi) { + this.dbi = dbi; + } + + @Override + public Handle get() { + Handle handle = dbi.open(); + log.debug("handle [{}] : Thread Id [{}]", handle.hashCode(), Thread.currentThread().getId()); + return handle; + } + + @Override + public void clear() { + log.debug("No Op"); + } +} diff --git a/src/main/java/io/dropwizard/jdbi/unitofwork/core/JdbiHandleManager.java b/src/main/java/io/dropwizard/jdbi/unitofwork/core/JdbiHandleManager.java new file mode 100644 index 0000000..ec39fc0 --- /dev/null +++ b/src/main/java/io/dropwizard/jdbi/unitofwork/core/JdbiHandleManager.java @@ -0,0 +1,55 @@ +package io.dropwizard.jdbi.unitofwork.core; + +import org.skife.jdbi.v2.Handle; + +import java.util.concurrent.ThreadFactory; + +/** + * A {@link JdbiHandleManager} is used to provide the lifecycle of a {@link Handle} with respect + * to a given scope. A scope may be session based, request based or may be invoked on every run. + */ +public interface JdbiHandleManager { + + /** + * Provide a way to get a Jdbi handle, a wrapped connection to the underlying database + * + * @return a valid handle tied with a specific scope + */ + Handle get(); + + /** + * Provide a way to clear the handle rendering it useless for the other methods + */ + void clear(); + + /** + * Provide a thread factory for the caller with some identity represented by the + * {@link #getConversationId()}. This can be used by the caller to create multiple threads, + * say, using {@link java.util.concurrent.ExecutorService}. The {@link JdbiHandleManager} can + * then use the thread factory to identify and manage handle use across multiple threads. + * + * @return a thread factory used to safely create multiple threads + * @throws UnsupportedOperationException by default. Implementations overriding this method + * must ensure that the conversation id is unique + */ + default ThreadFactory createThreadFactory() { + throw new UnsupportedOperationException("Thread factory creation is not supported"); + } + + /** + * Provide a unique identifier for the conversation with a handle. No two identifiers + * should co exist at once during the application lifecycle or else handle corruption + * or misuse might occur. + *

+ * This can be relied upon by the {@link #createThreadFactory()} to reuse handles across + * multiple threads spawned off a request thread. + * + * @return a unique identifier applicable to a scope + * @implNote hashcode can not be relied upon for providing a unique identifier due to the + * possibility of collision. Instead opt for a monotonically increasing counter, such as + * the thread id. + */ + default String getConversationId() { + return String.valueOf(Thread.currentThread().getId()); + } +} diff --git a/src/main/java/io/dropwizard/jdbi/unitofwork/core/JdbiUnitOfWorkProvider.java b/src/main/java/io/dropwizard/jdbi/unitofwork/core/JdbiUnitOfWorkProvider.java new file mode 100644 index 0000000..13e077c --- /dev/null +++ b/src/main/java/io/dropwizard/jdbi/unitofwork/core/JdbiUnitOfWorkProvider.java @@ -0,0 +1,129 @@ +package io.dropwizard.jdbi.unitofwork.core; + +import com.google.common.collect.Sets; +import com.google.common.reflect.Reflection; +import org.reflections.Reflections; +import org.reflections.scanners.Scanners; +import org.skife.jdbi.v2.DBI; +import org.skife.jdbi.v2.sqlobject.SqlBatch; +import org.skife.jdbi.v2.sqlobject.SqlCall; +import org.skife.jdbi.v2.sqlobject.SqlQuery; +import org.skife.jdbi.v2.sqlobject.SqlUpdate; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.lang.reflect.Method; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +@SuppressWarnings({"UnstableApiUsage", "rawtypes", "unchecked"}) +public class JdbiUnitOfWorkProvider { + + private final Logger log = LoggerFactory.getLogger(JdbiUnitOfWorkProvider.class); + private final JdbiHandleManager handleManager; + + private JdbiUnitOfWorkProvider(JdbiHandleManager handleManager) { + this.handleManager = handleManager; + } + + public static JdbiUnitOfWorkProvider withDefault(DBI dbi) { + JdbiHandleManager handleManager = new RequestScopedJdbiHandleManager(dbi); + return new JdbiUnitOfWorkProvider(handleManager); + } + + public static JdbiUnitOfWorkProvider withLinked(DBI dbi) { + JdbiHandleManager handleManager = new LinkedRequestScopedJdbiHandleManager(dbi); + return new JdbiUnitOfWorkProvider(handleManager); + } + + public JdbiHandleManager getHandleManager() { + return handleManager; + } + + /** + * getWrappedInstanceForDaoClass generates a proxy instance of the dao class for which + * the jdbi unit of work aspect would be wrapped around with. + *

+ * Note: It is recommended to use {@link JdbiUnitOfWorkProvider#getWrappedInstanceForDaoPackage(List)} instead + * as passing a list of packages is easier than passing each instance individually. + *

+ * This method however may be used in case the classpath scanning is disabled. + * If the original class is null or contains no relevant JDBI annotations, this method throws an + * exception + * + * @param daoClass the DAO class for which a proxy needs to be created fo + * @return the wrapped instance ready to be passed around + */ + public Object getWrappedInstanceForDaoClass(Class daoClass) { + if (daoClass == null) { + throw new IllegalArgumentException("DAO Class cannot be null"); + } + boolean atLeastOneJdbiMethod = false; + for (Method method : daoClass.getDeclaredMethods()) { + if (method.getDeclaringClass() == daoClass) { + atLeastOneJdbiMethod = method.getAnnotation(SqlQuery.class) != null; + atLeastOneJdbiMethod = atLeastOneJdbiMethod || method.getAnnotation(SqlUpdate.class) != null; + atLeastOneJdbiMethod = atLeastOneJdbiMethod || method.getAnnotation(SqlUpdate.class) != null; + atLeastOneJdbiMethod = atLeastOneJdbiMethod || method.getAnnotation(SqlBatch.class) != null; + atLeastOneJdbiMethod = atLeastOneJdbiMethod || method.getAnnotation(SqlCall.class) != null; + } + } + if (!atLeastOneJdbiMethod) { + throw new IllegalArgumentException(String.format("Class [%s] has no method annotated with a Jdbi SQL Object", daoClass.getSimpleName())); + } + + log.info("Binding class [{}] with proxy handler [{}] ", daoClass.getSimpleName(), handleManager.getClass().getSimpleName()); + ManagedHandleInvocationHandler handler = new ManagedHandleInvocationHandler<>(handleManager, daoClass); + Object proxiedInstance = Reflection.newProxy(daoClass, handler); + return daoClass.cast(proxiedInstance); + } + + /** + * getWrappedInstanceForDaoPackage generates a map where every DAO class identified + * through the given list of packages is mapped to its initialised proxy instance + * the jdbi unit of work aspect would be wrapped around with. + *

+ * In case classpath scanning is disabled, use {@link JdbiUnitOfWorkProvider#getWrappedInstanceForDaoClass(Class)} + *

+ * If the original package list is null, this method throws an exception + * + * @param daoPackages the list of packages that contain the DAO classes + * @return the map mapping dao classes to its initialised proxies + */ + public Map getWrappedInstanceForDaoPackage(List daoPackages) { + if (daoPackages == null) { + throw new IllegalArgumentException("DAO Class package list cannot be null"); + } + + Set> allDaoClasses = daoPackages.stream() + .map(this::getDaoClassesForPackage) + .flatMap(Collection::stream) + .collect(Collectors.toSet()); + + Map classInstanceMap = new HashMap<>(); + for (Class klass : allDaoClasses) { + log.info("Binding class [{}] with proxy handler [{}] ", klass.getSimpleName(), handleManager.getClass().getSimpleName()); + Object instance = getWrappedInstanceForDaoClass(klass); + classInstanceMap.put(klass, instance); + } + return classInstanceMap; + } + + private Set> getDaoClassesForPackage(String pkg) { + Set daoClasses = new HashSet<>(); + + Sets.SetView union = Sets.union(daoClasses, new Reflections(pkg, Scanners.MethodsAnnotated).getMethodsAnnotatedWith(SqlQuery.class)); + union = Sets.union(union, new Reflections(pkg, Scanners.MethodsAnnotated).getMethodsAnnotatedWith(SqlUpdate.class)); + union = Sets.union(union, new Reflections(pkg, Scanners.MethodsAnnotated).getMethodsAnnotatedWith(SqlBatch.class)); + union = Sets.union(union, new Reflections(pkg, Scanners.MethodsAnnotated).getMethodsAnnotatedWith(SqlCall.class)); + + return union.stream() + .map(Method::getDeclaringClass) + .collect(Collectors.toSet()); + } +} diff --git a/src/main/java/io/dropwizard/jdbi/unitofwork/core/LinkedRequestScopedJdbiHandleManager.java b/src/main/java/io/dropwizard/jdbi/unitofwork/core/LinkedRequestScopedJdbiHandleManager.java new file mode 100644 index 0000000..53371d1 --- /dev/null +++ b/src/main/java/io/dropwizard/jdbi/unitofwork/core/LinkedRequestScopedJdbiHandleManager.java @@ -0,0 +1,99 @@ +package io.dropwizard.jdbi.unitofwork.core; + +import com.google.common.util.concurrent.ThreadFactoryBuilder; +import org.skife.jdbi.v2.DBI; +import org.skife.jdbi.v2.Handle; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.annotation.Nullable; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ThreadFactory; + +/** + * This implementation provides a handle scoped to a thread and all other threads Y spawned from X + * All Y threads must follow a particular name format extracted from the conversation id + * This is one of the ways the manager can know of the grouping and establish re-usability of + * handles across such grouped threads. + *

+ * It can be used to service requests where only a single handle instance has to be used by multiple + * threads that are spawned with the specified name format from an initial thread. Use this only + * when you have complete control over the threads you create. The threads must not run once the + * parent thread is returned to the pool or else the handles will be invalid or in other words + * parent thread must block on the results of children. + * + *
+ * It relies on the fact that the {@code Jdbi.Handle} is inherently thread safe and can be used to service + * dao requests between multiple threads. + * Note: Not suitable when you can not set the name format for the newly spawned threads. + **/ +class LinkedRequestScopedJdbiHandleManager implements JdbiHandleManager { + + private final Logger log = LoggerFactory.getLogger(LinkedRequestScopedJdbiHandleManager.class); + private final Map parentThreadHandleMap = new ConcurrentHashMap<>(); + private final DBI dbi; + + public LinkedRequestScopedJdbiHandleManager(DBI dbi) { + this.dbi = dbi; + } + + @Override + public Handle get() { + String parent = substringBetween(Thread.currentThread().getName()); + Handle handle; + if (parent == null) { + handle = getHandle(); + log.debug("Owner of handle [{}] : Parent Thread Id [{}]", handle.hashCode(), Thread.currentThread().getId()); + + } else { + handle = parentThreadHandleMap.get(parent); + if (handle == null) { + throw new IllegalStateException(String.format("Handle to be reused in child thread [%s] is null for parent thread [%s]", Thread.currentThread().getId(), parent)); + } + log.debug("Reusing parent thread handle [{}] for [{}]", handle.hashCode(), Thread.currentThread().getId()); + } + return handle; + } + + @Override + public void clear() { + String parent = getConversationId(); + Handle handle = parentThreadHandleMap.get(parent); + if (handle != null) { + handle.close(); + log.debug("Closed handle Thread Id [{}] has handle id [{}]", Thread.currentThread().getId(), handle.hashCode()); + + parentThreadHandleMap.remove(parent); + log.debug("Clearing handle member for parent thread [{}] ", Thread.currentThread().getId()); + } + } + + @Override + public ThreadFactory createThreadFactory() { + String threadName = String.format("[%s]-%%d", getConversationId()); + return new ThreadFactoryBuilder().setNameFormat(threadName).build(); + } + + private Handle getHandle() { + String threadIdentity = getConversationId(); + if (parentThreadHandleMap.containsKey(threadIdentity)) { + return parentThreadHandleMap.get(threadIdentity); + } + Handle handle = dbi.open(); + parentThreadHandleMap.putIfAbsent(threadIdentity, handle); + return handle; + } + + @Nullable + private String substringBetween(String threadName) { + final int start = threadName.indexOf("["); + if (start != -1) { + final int end = threadName.indexOf("]", start + "[".length()); + if (end != -1) { + return threadName.substring(start + "[".length(), end); + } + } + return null; + } +} diff --git a/src/main/java/io/dropwizard/jdbi/unitofwork/core/ManagedHandleInvocationHandler.java b/src/main/java/io/dropwizard/jdbi/unitofwork/core/ManagedHandleInvocationHandler.java new file mode 100644 index 0000000..85d2a84 --- /dev/null +++ b/src/main/java/io/dropwizard/jdbi/unitofwork/core/ManagedHandleInvocationHandler.java @@ -0,0 +1,63 @@ +package io.dropwizard.jdbi.unitofwork.core; + +import org.skife.jdbi.v2.Handle; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; + +/** + * Implementation of {@link InvocationHandler} that attaches the underlying class to a handle + * obtained through {@link JdbiHandleManager} on every invocation. + *

+ * Note: Attaching a handle to a class is an idempotent operation. If a handle {@literal H} + * is attached to a class, attaching {@literal H} to the same class again serves no purpose. + *

+ * Also delegates {@link Object#toString} to the real object instead of the proxy which is + * helpful for debugging + */ +public class ManagedHandleInvocationHandler implements InvocationHandler { + + private final Logger log = LoggerFactory.getLogger(ManagedHandleInvocationHandler.class); + private static final Object[] NO_ARGS = {}; + private final JdbiHandleManager handleManager; + private final Class underlying; + + public ManagedHandleInvocationHandler(JdbiHandleManager handleManager, Class underlying) { + this.handleManager = handleManager; + this.underlying = underlying; + } + + /** + * {@inheritDoc} + *

    + *
  • {@code proxy.toString()} delegates to {@link ManagedHandleInvocationHandler#toString} + *
  • other method calls are dispatched to {@link ManagedHandleInvocationHandler#handleInvocation}. + *
+ */ + @Override + public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { + if (args == null) { + args = NO_ARGS; + } + if (args.length == 0 && method.getName().equals("toString")) { + return toString(); + } + return handleInvocation(method, args); + } + + private Object handleInvocation(Method method, Object[] args) throws IllegalAccessException, InvocationTargetException { + Handle handle = handleManager.get(); + log.debug("{}.{} [{}] Thread Id [{}] with handle id [{}]", method.getDeclaringClass().getSimpleName(), method.getName(), underlying.getSimpleName(), Thread.currentThread().getId(), handle.hashCode()); + + Object dao = handle.attach(underlying); + return method.invoke(dao, args); + } + + @Override + public String toString() { + return "Proxy[" + underlying.getSimpleName() + "]"; + } +} diff --git a/src/main/java/io/dropwizard/jdbi/unitofwork/core/RequestScopedJdbiHandleManager.java b/src/main/java/io/dropwizard/jdbi/unitofwork/core/RequestScopedJdbiHandleManager.java new file mode 100644 index 0000000..cf51580 --- /dev/null +++ b/src/main/java/io/dropwizard/jdbi/unitofwork/core/RequestScopedJdbiHandleManager.java @@ -0,0 +1,50 @@ +package io.dropwizard.jdbi.unitofwork.core; + +import org.skife.jdbi.v2.DBI; +import org.skife.jdbi.v2.Handle; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * This implementation gets a new handle which is scoped to the thread requesting the handle. + *

+ * It can be used to service requests which interact with multiple SQL objects as part of a common + * transaction. All such SQL objects will be attached to the common handle. + * + * @apiNote Not suitable for requests which spawn new threads from the requesting thread as the scoped + * handle is not preserved. This implementation, therefore, does not support thread factory creation + */ +class RequestScopedJdbiHandleManager implements JdbiHandleManager { + + private final Logger log = LoggerFactory.getLogger(RequestScopedJdbiHandleManager.class); + private final DBI dbi; + + @SuppressWarnings("ThreadLocalUsage") + private final ThreadLocal threadLocal = new ThreadLocal<>(); + + public RequestScopedJdbiHandleManager(DBI dbi) { + this.dbi = dbi; + } + + @Override + public Handle get() { + if (threadLocal.get() == null) { + threadLocal.set(dbi.open()); + } + Handle handle = threadLocal.get(); + log.debug("handle [{}] : Thread Id [{}]", handle.hashCode(), Thread.currentThread().getId()); + return handle; + } + + @Override + public void clear() { + Handle handle = threadLocal.get(); + if (handle != null) { + handle.close(); + log.debug("Closed handle Thread Id [{}] has handle id [{}]", Thread.currentThread().getId(), handle.hashCode()); + + threadLocal.remove(); + log.debug("Clearing handle member for thread [{}] ", Thread.currentThread().getId()); + } + } +} diff --git a/src/main/java/io/dropwizard/jdbi/unitofwork/listener/HttpGetRequestJdbiUnitOfWorkEventListener.java b/src/main/java/io/dropwizard/jdbi/unitofwork/listener/HttpGetRequestJdbiUnitOfWorkEventListener.java new file mode 100644 index 0000000..2ddc52f --- /dev/null +++ b/src/main/java/io/dropwizard/jdbi/unitofwork/listener/HttpGetRequestJdbiUnitOfWorkEventListener.java @@ -0,0 +1,35 @@ +package io.dropwizard.jdbi.unitofwork.listener; + +import io.dropwizard.jdbi.unitofwork.core.JdbiHandleManager; +import org.glassfish.jersey.server.monitoring.RequestEvent; +import org.glassfish.jersey.server.monitoring.RequestEventListener; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * This listener binds a transaction aspect to the currently serving GET request without creating + * any transaction context and is simply responsible for initialising and terminating handles + * upon successful start and end of request marked by Jersey request monitoring events + * {@code RESOURCE_METHOD_START} and {@code FINISHED} respectively + *

+ * For creating a transaction context, see {@link NonHttpGetRequestJdbiUnitOfWorkEventListener} + */ +class HttpGetRequestJdbiUnitOfWorkEventListener implements RequestEventListener { + + private final Logger log = LoggerFactory.getLogger(HttpGetRequestJdbiUnitOfWorkEventListener.class); + private final JdbiTransactionAspect transactionAspect; + + HttpGetRequestJdbiUnitOfWorkEventListener(JdbiHandleManager handleManager) { + this.transactionAspect = new JdbiTransactionAspect(handleManager); + } + + @Override + public void onEvent(RequestEvent event) { + RequestEvent.Type type = event.getType(); + log.debug("Handling GET Request Event {} {}", type, Thread.currentThread().getId()); + + if (type == RequestEvent.Type.FINISHED) { + transactionAspect.terminateHandle(); + } + } +} diff --git a/src/main/java/io/dropwizard/jdbi/unitofwork/listener/JdbiTransactionAspect.java b/src/main/java/io/dropwizard/jdbi/unitofwork/listener/JdbiTransactionAspect.java new file mode 100644 index 0000000..bc557bb --- /dev/null +++ b/src/main/java/io/dropwizard/jdbi/unitofwork/listener/JdbiTransactionAspect.java @@ -0,0 +1,69 @@ +package io.dropwizard.jdbi.unitofwork.listener; + +import io.dropwizard.jdbi.unitofwork.core.JdbiHandleManager; +import org.skife.jdbi.v2.Handle; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * An aspect providing low level operations around a {@link Handle} + * This is inspired from Dropwizard's Unit of work aspect used to manage handles for hibernate. + * + * @see + * Unit Of Work Aspect + */ +public class JdbiTransactionAspect { + + private final Logger log = LoggerFactory.getLogger(JdbiTransactionAspect.class); + private final JdbiHandleManager handleManager; + + public JdbiTransactionAspect(JdbiHandleManager handleManager) { + this.handleManager = handleManager; + } + + public void begin() { + try { + Handle handle = handleManager.get(); + handle.begin(); + log.debug("Begin Transaction Thread Id [{}] has handle id [{}] Transaction {} Level {}", Thread.currentThread().getId(), handle.hashCode(), handle.isInTransaction(), handle.getTransactionIsolationLevel()); + + } catch (Exception ex) { + handleManager.clear(); + throw ex; + } + } + + public void commit() { + Handle handle = handleManager.get(); + if (handle == null) { + log.debug("Handle was found to be null during commit for Thread Id [{}]. It might have already been closed", Thread.currentThread().getId()); + return; + } + try { + handle.commit(); + log.debug("Performing commit Thread Id [{}] has handle id [{}] Transaction {} Level {}", Thread.currentThread().getId(), handle.hashCode(), handle.isInTransaction(), handle.getTransactionIsolationLevel()); + + } catch (Exception ex) { + handle.rollback(); + throw ex; + } + } + + public void rollback() { + Handle handle = handleManager.get(); + if (handle == null) { + log.debug("Handle was found to be null during rollback for [{}]", Thread.currentThread().getId()); + return; + } + try { + handle.rollback(); + log.debug("Performed rollback on Thread Id [{}] has handle id [{}] Transaction {} Level {}", Thread.currentThread().getId(), handle.hashCode(), handle.isInTransaction(), handle.getTransactionIsolationLevel()); + } finally { + terminateHandle(); + } + } + + public void terminateHandle() { + handleManager.clear(); + } +} diff --git a/src/main/java/io/dropwizard/jdbi/unitofwork/listener/JdbiUnitOfWorkApplicationEventListener.java b/src/main/java/io/dropwizard/jdbi/unitofwork/listener/JdbiUnitOfWorkApplicationEventListener.java new file mode 100644 index 0000000..d2737ae --- /dev/null +++ b/src/main/java/io/dropwizard/jdbi/unitofwork/listener/JdbiUnitOfWorkApplicationEventListener.java @@ -0,0 +1,58 @@ +package io.dropwizard.jdbi.unitofwork.listener; + +import io.dropwizard.jdbi.unitofwork.core.JdbiUnitOfWorkProvider; +import org.glassfish.jersey.server.monitoring.ApplicationEvent; +import org.glassfish.jersey.server.monitoring.ApplicationEventListener; +import org.glassfish.jersey.server.monitoring.RequestEvent; +import org.glassfish.jersey.server.monitoring.RequestEventListener; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.annotation.Nullable; +import javax.ws.rs.HttpMethod; +import java.util.Set; + +/** + * This application event listener establishes a new request event listener for every request. + * The request listener triggers calls appropriate methods on a transaction aspect based on the + * request lifecycle methods returned from Jersey + *

+ * {@code HttpMethod.GET} requests are assumed to be in non transaction boundary and are routed + * to {@link HttpGetRequestJdbiUnitOfWorkEventListener} + *

+ * Non {@code HttpMethod.GET} requests are assumed to be in a transaction boundary and are routed + * to {@link NonHttpGetRequestJdbiUnitOfWorkEventListener} + * + * @implNote For requests that never not require a connection with the database, such as ELB health + * checks or computate only use cases, opening and closing a handle is redundant and wasteful + * Such request URIs should be added in the set of {@link #excludedPaths} + */ +public class JdbiUnitOfWorkApplicationEventListener implements ApplicationEventListener { + + private final Logger log = LoggerFactory.getLogger(JdbiUnitOfWorkApplicationEventListener.class); + private final JdbiUnitOfWorkProvider unitOfWorkProvider; + private final Set excludedPaths; + + public JdbiUnitOfWorkApplicationEventListener(JdbiUnitOfWorkProvider unitOfWorkProvider, Set excludedPaths) { + this.unitOfWorkProvider = unitOfWorkProvider; + this.excludedPaths = excludedPaths; + } + + @Override + public void onEvent(ApplicationEvent event) { + log.debug("Received Application event {}", event.getType()); + } + + @Override + @Nullable + public RequestEventListener onRequest(RequestEvent event) { + String path = event.getUriInfo().getPath(); + if (excludedPaths.stream().anyMatch(path::contains)) { + return null; + } + if (event.getContainerRequest().getMethod().equals(HttpMethod.GET)) { + return new HttpGetRequestJdbiUnitOfWorkEventListener(unitOfWorkProvider.getHandleManager()); + } + return new NonHttpGetRequestJdbiUnitOfWorkEventListener(unitOfWorkProvider.getHandleManager()); + } +} diff --git a/src/main/java/io/dropwizard/jdbi/unitofwork/listener/NonHttpGetRequestJdbiUnitOfWorkEventListener.java b/src/main/java/io/dropwizard/jdbi/unitofwork/listener/NonHttpGetRequestJdbiUnitOfWorkEventListener.java new file mode 100644 index 0000000..14f5431 --- /dev/null +++ b/src/main/java/io/dropwizard/jdbi/unitofwork/listener/NonHttpGetRequestJdbiUnitOfWorkEventListener.java @@ -0,0 +1,78 @@ +package io.dropwizard.jdbi.unitofwork.listener; + +import io.dropwizard.jdbi.unitofwork.JdbiUnitOfWork; +import io.dropwizard.jdbi.unitofwork.core.JdbiHandleManager; +import org.glassfish.jersey.server.model.ResourceMethod; +import org.glassfish.jersey.server.monitoring.RequestEvent; +import org.glassfish.jersey.server.monitoring.RequestEventListener; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * This listener binds a transaction aspect to the currently serving GET request by creating + * a transaction context if and only if the resource method is annotated with {@link JdbiUnitOfWork} + *

+ * It is responsible for initialising and terminating handles as well as calling appropriate + * transaction methods based on theJersey request monitoring events + * {@code RESOURCE_METHOD_START}, {@code RESP_FILTERS_START}, {@code ON_EXCEPTION} and {@code FINISHED} + *

+ * For creating a access context without transactions, see {@link HttpGetRequestJdbiUnitOfWorkEventListener} + */ +class NonHttpGetRequestJdbiUnitOfWorkEventListener implements RequestEventListener { + + private final Logger log = LoggerFactory.getLogger(NonHttpGetRequestJdbiUnitOfWorkEventListener.class); + private final JdbiTransactionAspect transactionAspect; + + NonHttpGetRequestJdbiUnitOfWorkEventListener(JdbiHandleManager handleManager) { + this.transactionAspect = new JdbiTransactionAspect(handleManager); + } + + @Override + public void onEvent(RequestEvent event) { + RequestEvent.Type type = event.getType(); + String httpMethod = event.getContainerRequest().getMethod(); + + log.debug("Handling {} Request Event {} {}", httpMethod, type, Thread.currentThread().getId()); + boolean isTransactional = isTransactional(event); + + if (type == RequestEvent.Type.RESOURCE_METHOD_START) { + initialise(isTransactional); + + } else if (type == RequestEvent.Type.RESP_FILTERS_START) { + commit(isTransactional); + + } else if (type == RequestEvent.Type.ON_EXCEPTION) { + rollback(isTransactional); + + } else if (type == RequestEvent.Type.FINISHED) { + transactionAspect.terminateHandle(); + } + } + + private void commit(boolean isTransactional) { + if (isTransactional) { + transactionAspect.commit(); + } + } + + private void rollback(boolean isTransactional) { + if (isTransactional) { + transactionAspect.rollback(); + } + } + + private void initialise(boolean isTransactional) { + if (isTransactional) { + transactionAspect.begin(); + } + } + + private boolean isTransactional(RequestEvent event) { + ResourceMethod method = event.getUriInfo().getMatchedResourceMethod(); + if (method != null) { + JdbiUnitOfWork annotation = method.getInvocable().getDefinitionMethod().getAnnotation(JdbiUnitOfWork.class); + return annotation != null; + } + return false; + } +} diff --git a/src/test/java/io/dropwizard/jdbi/unitofwork/core/DefaultJdbiHandleManagerTest.java b/src/test/java/io/dropwizard/jdbi/unitofwork/core/DefaultJdbiHandleManagerTest.java new file mode 100644 index 0000000..5f76404 --- /dev/null +++ b/src/test/java/io/dropwizard/jdbi/unitofwork/core/DefaultJdbiHandleManagerTest.java @@ -0,0 +1,52 @@ +package io.dropwizard.jdbi.unitofwork.core; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.stubbing.Answer; +import org.skife.jdbi.v2.DBI; +import org.skife.jdbi.v2.Handle; + +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class DefaultJdbiHandleManagerTest { + + private DBI dbi; + + private DefaultJdbiHandleManager manager; + + @BeforeEach + public void setUp() { + dbi = mock(DBI.class); + this.manager = new DefaultJdbiHandleManager(dbi); + } + + @Test + public void testGetSetsTheHandlePerInvocation() throws InterruptedException { + when(dbi.open()).thenAnswer((Answer) invocation -> mock(Handle.class)); + Handle firstHandle = manager.get(); + Handle secondHandle = manager.get(); + assertNotEquals(firstHandle, secondHandle); + + Thread newHandleInvokerThread = new Thread(() -> assertNotEquals(firstHandle, manager.get())); + newHandleInvokerThread.start(); + newHandleInvokerThread.join(); + verify(dbi, times(3)).open(); + } + + @Test + public void testClear() { + manager.clear(); + verify(dbi, never()).open(); + } + + @Test + public void testCreateThreadFactoryIsNotSupported() { + assertThrows(UnsupportedOperationException.class, () -> manager.createThreadFactory()); + } +} diff --git a/src/test/java/io/dropwizard/jdbi/unitofwork/core/JdbiUnitOfWorkProviderTest.java b/src/test/java/io/dropwizard/jdbi/unitofwork/core/JdbiUnitOfWorkProviderTest.java new file mode 100644 index 0000000..2aeab8c --- /dev/null +++ b/src/test/java/io/dropwizard/jdbi/unitofwork/core/JdbiUnitOfWorkProviderTest.java @@ -0,0 +1,65 @@ +package io.dropwizard.jdbi.unitofwork.core; + +import com.google.common.collect.Lists; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.skife.jdbi.v2.DBI; +import org.skife.jdbi.v2.sqlobject.SqlQuery; +import org.skife.jdbi.v2.sqlobject.SqlUpdate; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class JdbiUnitOfWorkProviderTest { + + @Mock + private DBI dbi; + + private JdbiUnitOfWorkProvider provider; + + @BeforeEach + public void setUp() { + MockitoAnnotations.openMocks(this); + this.provider = JdbiUnitOfWorkProvider.withDefault(dbi); + } + + @Test + public void testGetWrappedInstanceForDaoClass() { + assertNotNull(provider.getWrappedInstanceForDaoClass(DaoA.class)); + assertNotNull(provider.getWrappedInstanceForDaoClass(DaoB.class)); + assertThrows(IllegalArgumentException.class, () -> provider.getWrappedInstanceForDaoClass(DaoC.class)); + } + + @Test + @SuppressWarnings("rawtypes") + public void testGetWrappedInstanceForDaoPackage() { + Map instanceObjectMap = provider.getWrappedInstanceForDaoPackage(Lists.newArrayList( + "io.dropwizard.jdbi.unitofwork.core" + )); + assertEquals(2, instanceObjectMap.size()); + assertNotNull(instanceObjectMap.get(DaoA.class)); + assertNotNull(instanceObjectMap.get(DaoB.class)); + assertNull(instanceObjectMap.get(DaoC.class)); + } + + interface DaoA { + + @SqlUpdate + void update(); + } + + interface DaoB { + + @SqlQuery + void select(); + } + + interface DaoC { + } +} diff --git a/src/test/java/io/dropwizard/jdbi/unitofwork/core/LinkedRequestScopedJdbiHandleManagerTest.java b/src/test/java/io/dropwizard/jdbi/unitofwork/core/LinkedRequestScopedJdbiHandleManagerTest.java new file mode 100644 index 0000000..a560014 --- /dev/null +++ b/src/test/java/io/dropwizard/jdbi/unitofwork/core/LinkedRequestScopedJdbiHandleManagerTest.java @@ -0,0 +1,114 @@ +package io.dropwizard.jdbi.unitofwork.core; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.stubbing.Answer; +import org.skife.jdbi.v2.DBI; +import org.skife.jdbi.v2.Handle; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadFactory; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class LinkedRequestScopedJdbiHandleManagerTest { + + private DBI dbi; + + private LinkedRequestScopedJdbiHandleManager manager; + + @BeforeEach + public void setUp() { + dbi = mock(DBI.class); + this.manager = new LinkedRequestScopedJdbiHandleManager(dbi); + } + + @Test + public void testGetSetsSameHandleForMultipleInvocationsInSameThread() { + when(dbi.open()).thenAnswer((Answer) invocation -> mock(Handle.class)); + Handle firstHandle = manager.get(); + Handle secondHandle = manager.get(); + assertEquals(firstHandle, secondHandle); + + verify(dbi, times(1)).open(); + } + + @Test + public void testGetSetsNewHandleForEachThread() throws InterruptedException { + when(dbi.open()).thenAnswer((Answer) invocation -> mock(Handle.class)); + Handle handleThreadA = manager.get(); + + Thread newHandleInvokerThread = new Thread(() -> assertNotEquals(handleThreadA, manager.get())); + newHandleInvokerThread.start(); + newHandleInvokerThread.join(); + verify(dbi, times(2)).open(); + } + + @Test + public void testGetSetsSameHandleForChildThreadsIfTheThreadFactoryIsPlaced() throws InterruptedException { + when(dbi.open()).thenAnswer((Answer) invocation -> mock(Handle.class)); + Handle parentHandle = manager.get(); + ThreadFactory threadFactory = manager.createThreadFactory(); + + final int NUM_THREADS = 6; + CountDownLatch endGate = new CountDownLatch(NUM_THREADS); + ExecutorService service = Executors.newFixedThreadPool(NUM_THREADS, threadFactory); + + for (int i = 0; i < NUM_THREADS; i++) { + service.submit(() -> { + Handle childHandle = manager.get(); + assertEquals(parentHandle, childHandle); + endGate.countDown(); + }); + } + service.shutdown(); + endGate.await(); + verify(dbi, times(1)).open(); + } + + @Test + public void testGetSetsNewHandleForChildThreadsIfTheThreadFactoryIsNotPlaced() throws InterruptedException { + when(dbi.open()).thenAnswer((Answer) invocation -> mock(Handle.class)); + Handle parentHandle = manager.get(); + + final int NUM_THREADS = 5; + CountDownLatch endGate = new CountDownLatch(NUM_THREADS); + ExecutorService service = Executors.newFixedThreadPool(NUM_THREADS); + + for (int i = 0; i < NUM_THREADS; i++) { + service.submit(() -> { + Handle childHandle = manager.get(); + assertNotEquals(parentHandle, childHandle); + endGate.countDown(); + }); + } + service.shutdown(); + endGate.await(); + verify(dbi, times(NUM_THREADS + 1)).open(); + } + + @Test + public void testClearClosesHandleAndClearsHandle() { + Handle mockHandle = mock(Handle.class); + when(dbi.open()).thenReturn(mockHandle); + + manager.get(); + manager.clear(); + verify(dbi, times(1)).open(); + verify(mockHandle, times(1)).close(); + } + + @Test + public void testClearDoesNothingWhenHandleIsNull() { + manager.clear(); + verify(dbi, never()).open(); + } +} diff --git a/src/test/java/io/dropwizard/jdbi/unitofwork/core/ManagedHandleInvocationHandlerTest.java b/src/test/java/io/dropwizard/jdbi/unitofwork/core/ManagedHandleInvocationHandlerTest.java new file mode 100644 index 0000000..0fbcf3b --- /dev/null +++ b/src/test/java/io/dropwizard/jdbi/unitofwork/core/ManagedHandleInvocationHandlerTest.java @@ -0,0 +1,69 @@ +package io.dropwizard.jdbi.unitofwork.core; + +import com.google.common.reflect.Reflection; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.skife.jdbi.v2.Handle; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@SuppressWarnings({"UnstableApiUsage"}) +public class ManagedHandleInvocationHandlerTest { + + private JdbiHandleManager handleManager; + + private Handle mockHandle; + + DummyDao proxiedDao; + + @BeforeEach + public void setUp() { + handleManager = mock(JdbiHandleManager.class); + mockHandle = mock(Handle.class); + when(handleManager.get()).thenReturn(mockHandle); + Class declaringClass = DummyDao.class; + ManagedHandleInvocationHandler proxy = new ManagedHandleInvocationHandler<>(handleManager, declaringClass); + Object proxiedInstance = Reflection.newProxy(declaringClass, proxy); + when(mockHandle.attach(declaringClass)).thenReturn(new DummyDaoImpl(mockHandle)); + proxiedDao = declaringClass.cast(proxiedInstance); + } + + @Test + public void testHandleIsAttachedInTheProxyClassAndIsCalled() { + proxiedDao.query(); + verify(mockHandle, times(1)).select(any()); + verify(mockHandle, times(1)).attach(any()); + } + + @Test + public void testToStringCallsTheInstanceMethodAndNotTheProxyMethod() { + String str = proxiedDao.toString(); + assertEquals("Proxy[DummyDao]", str); + verify(handleManager, never()).get(); + } + + interface DummyDao { + void query(); + } + + class DummyDaoImpl implements DummyDao { + + private final Handle handle; + + DummyDaoImpl(Handle handle) { + this.handle = handle; + } + + @Override + public void query() { + handle.select("select * from some_table"); + assertEquals(handle, mockHandle); + } + } +} diff --git a/src/test/java/io/dropwizard/jdbi/unitofwork/core/RequestScopedJdbiHandleManagerTest.java b/src/test/java/io/dropwizard/jdbi/unitofwork/core/RequestScopedJdbiHandleManagerTest.java new file mode 100644 index 0000000..0e0b90a --- /dev/null +++ b/src/test/java/io/dropwizard/jdbi/unitofwork/core/RequestScopedJdbiHandleManagerTest.java @@ -0,0 +1,72 @@ +package io.dropwizard.jdbi.unitofwork.core; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.stubbing.Answer; +import org.skife.jdbi.v2.DBI; +import org.skife.jdbi.v2.Handle; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class RequestScopedJdbiHandleManagerTest { + + private DBI dbi; + + private RequestScopedJdbiHandleManager manager; + + @BeforeEach + public void setUp() { + dbi = mock(DBI.class); + this.manager = new RequestScopedJdbiHandleManager(dbi); + } + + @Test + public void testGetSetsSameHandleForMultipleInvocationsInSameThread() { + when(dbi.open()).thenAnswer((Answer) invocation -> mock(Handle.class)); + Handle firstHandle = manager.get(); + Handle secondHandle = manager.get(); + assertEquals(firstHandle, secondHandle); + + verify(dbi, times(1)).open(); + } + + @Test + public void testGetSetsNewHandleForEachThread() throws InterruptedException { + when(dbi.open()).thenAnswer((Answer) invocation -> mock(Handle.class)); + Handle handleThreadA = manager.get(); + + Thread newHandleInvokerThread = new Thread(() -> assertNotEquals(handleThreadA, manager.get())); + newHandleInvokerThread.start(); + newHandleInvokerThread.join(); + verify(dbi, times(2)).open(); + } + + @Test + public void testClearClosesHandleAndClearsThreadLocal() { + Handle mockHandle = mock(Handle.class); + when(dbi.open()).thenReturn(mockHandle); + + manager.get(); + manager.clear(); + verify(dbi, times(1)).open(); + verify(mockHandle, times(1)).close(); + } + + @Test + public void testClearDoesNothingWhenHandleIsNull() { + manager.clear(); + verify(dbi, never()).open(); + } + + @Test + public void testCreateThreadFactoryIsNotSupported() { + assertThrows(UnsupportedOperationException.class, () -> manager.createThreadFactory()); + } +} diff --git a/src/test/java/io/dropwizard/jdbi/unitofwork/listener/HttpGetRequestJdbiUnitOfWorkEventListenerTest.java b/src/test/java/io/dropwizard/jdbi/unitofwork/listener/HttpGetRequestJdbiUnitOfWorkEventListenerTest.java new file mode 100644 index 0000000..fb78aae --- /dev/null +++ b/src/test/java/io/dropwizard/jdbi/unitofwork/listener/HttpGetRequestJdbiUnitOfWorkEventListenerTest.java @@ -0,0 +1,38 @@ +package io.dropwizard.jdbi.unitofwork.listener; + +import io.dropwizard.jdbi.unitofwork.core.JdbiHandleManager; +import org.glassfish.jersey.server.monitoring.RequestEvent; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.skife.jdbi.v2.Handle; + +import static org.glassfish.jersey.server.monitoring.RequestEvent.Type.FINISHED; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class HttpGetRequestJdbiUnitOfWorkEventListenerTest { + + private JdbiHandleManager handleManager; + + private RequestEvent requestEvent; + + private HttpGetRequestJdbiUnitOfWorkEventListener listener; + + @BeforeEach + public void setUp() { + handleManager = mock(JdbiHandleManager.class); + when(handleManager.get()).thenReturn(mock(Handle.class)); + requestEvent = mock(RequestEvent.class); + this.listener = new HttpGetRequestJdbiUnitOfWorkEventListener(handleManager); + } + + @Test + public void testHandleIsClosedWhenEventTypeIsFinished() { + when(requestEvent.getType()).thenReturn(FINISHED); + + listener.onEvent(requestEvent); + verify(handleManager, times(1)).clear(); + } +} diff --git a/src/test/java/io/dropwizard/jdbi/unitofwork/listener/JdbiTransactionAspectTest.java b/src/test/java/io/dropwizard/jdbi/unitofwork/listener/JdbiTransactionAspectTest.java new file mode 100644 index 0000000..a712379 --- /dev/null +++ b/src/test/java/io/dropwizard/jdbi/unitofwork/listener/JdbiTransactionAspectTest.java @@ -0,0 +1,104 @@ +package io.dropwizard.jdbi.unitofwork.listener; + +import io.dropwizard.jdbi.unitofwork.core.JdbiHandleManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.skife.jdbi.v2.Handle; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class JdbiTransactionAspectTest { + + private JdbiHandleManager handleManager; + + private Handle mockHandle; + + private JdbiTransactionAspect aspect; + + @BeforeEach + public void setUp() { + handleManager = mock(JdbiHandleManager.class); + mockHandle = mock(Handle.class); + when(handleManager.get()).thenReturn(mockHandle); + this.aspect = new JdbiTransactionAspect(handleManager); + } + + @Test + public void testBeginWhenHandleBeginThrowsException() { + when(mockHandle.begin()).thenThrow(IllegalArgumentException.class); + assertThrows(IllegalArgumentException.class, () -> aspect.begin()); + verify(handleManager, times(1)).clear(); + verify(mockHandle, never()).commit(); + } + + @Test + public void testBeginWorksAsExpected() { + doReturn(mockHandle).when(mockHandle).begin(); + aspect.begin(); + + verify(mockHandle, times(1)).begin(); + verify(mockHandle, never()).close(); + verify(handleManager, never()).clear(); + verify(mockHandle, never()).commit(); + } + + @Test + public void testCommitDoesNothingWhenHandleIsNull() { + assertDoesNotThrow(() -> aspect.commit()); + } + + @Test + public void testCommitWhenHandleCommitThrowsException() { + when(mockHandle.commit()).thenThrow(IllegalArgumentException.class); + assertThrows(IllegalArgumentException.class, () -> aspect.commit()); + verify(mockHandle, times(1)).rollback(); + } + + @Test + public void testCommitWorksAsExpected() { + doReturn(mockHandle).when(mockHandle).commit(); + aspect.commit(); + + verify(mockHandle, times(1)).commit(); + verify(mockHandle, never()).rollback(); + } + + @Test + public void testRollbackDoesNothingWhenHandleIsNull() { + assertDoesNotThrow(() -> aspect.rollback()); + } + + @Test + public void testRollbackWhenHandleRollbackThrowsException() { + when(mockHandle.rollback()).thenThrow(IllegalArgumentException.class); + assertThrows(IllegalArgumentException.class, () -> aspect.rollback()); + verify(mockHandle, times(1)).rollback(); + } + + @Test + public void testRollbackWorksAsExpected() { + doReturn(mockHandle).when(mockHandle).rollback(); + aspect.rollback(); + + verify(mockHandle, times(1)).rollback(); + verify(handleManager, times(1)).clear(); + } + + @Test + public void testTerminateHandleDoesNothingWhenHandleIsNull() { + assertDoesNotThrow(() -> aspect.terminateHandle()); + } + + @Test + public void testTerminateHandleWorksAsExpected() { + aspect.terminateHandle(); + verify(handleManager, times(1)).clear(); + } +} diff --git a/src/test/java/io/dropwizard/jdbi/unitofwork/listener/JdbiUnitOfWorkApplicationEventListenerTest.java b/src/test/java/io/dropwizard/jdbi/unitofwork/listener/JdbiUnitOfWorkApplicationEventListenerTest.java new file mode 100644 index 0000000..c266e22 --- /dev/null +++ b/src/test/java/io/dropwizard/jdbi/unitofwork/listener/JdbiUnitOfWorkApplicationEventListenerTest.java @@ -0,0 +1,75 @@ +package io.dropwizard.jdbi.unitofwork.listener; + +import com.google.common.collect.Sets; +import io.dropwizard.jdbi.unitofwork.core.JdbiHandleManager; +import io.dropwizard.jdbi.unitofwork.core.JdbiUnitOfWorkProvider; +import org.glassfish.jersey.server.monitoring.ApplicationEvent; +import org.glassfish.jersey.server.monitoring.RequestEvent; +import org.glassfish.jersey.server.monitoring.RequestEventListener; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.skife.jdbi.v2.Handle; + +import javax.ws.rs.HttpMethod; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class JdbiUnitOfWorkApplicationEventListenerTest { + + private RequestEvent requestEvent; + + private JdbiUnitOfWorkApplicationEventListener applicationListener; + + @BeforeEach + public void setUp() { + JdbiHandleManager handleManager = mock(JdbiHandleManager.class); + when(handleManager.get()).thenReturn(mock(Handle.class)); + JdbiUnitOfWorkProvider provider = mock(JdbiUnitOfWorkProvider.class); + when(provider.getHandleManager()).thenReturn(handleManager); + + requestEvent = mock(RequestEvent.class, Mockito.RETURNS_DEEP_STUBS); + Set excludedPaths = Sets.newHashSet("excluded"); + this.applicationListener = new JdbiUnitOfWorkApplicationEventListener(provider, excludedPaths); + } + + @Test + public void testOnEventDoesNothing() { + ApplicationEvent applicationEvent = mock(ApplicationEvent.class); + applicationListener.onEvent(applicationEvent); + verify(applicationEvent, times(1)).getType(); + } + + @Test + public void testOnRequestDoesNothingWhenRequestEventPathIsExcluded() { + when(requestEvent.getUriInfo().getPath()).thenReturn("excluded"); + assertNull(applicationListener.onRequest(requestEvent)); + } + + @Test + public void testOnRequestReturnsCorrectEventListenerWhenMethodTypeIsGet() { + when(requestEvent.getUriInfo().getPath()).thenReturn("exclude-me-not"); + when(requestEvent.getContainerRequest().getMethod()).thenReturn(HttpMethod.GET); + + RequestEventListener eventListener = applicationListener.onRequest(requestEvent); + assertNotNull(eventListener); + assertEquals(HttpGetRequestJdbiUnitOfWorkEventListener.class, eventListener.getClass()); + } + + @Test + public void testOnRequestReturnsCorrectEventListenerWhenMethodTypeIsNotGet() { + when(requestEvent.getUriInfo().getPath()).thenReturn("exclude-me-not"); + when(requestEvent.getContainerRequest().getMethod()).thenReturn(HttpMethod.PUT); + + RequestEventListener eventListener = applicationListener.onRequest(requestEvent); + assertNotNull(eventListener); + assertEquals(NonHttpGetRequestJdbiUnitOfWorkEventListener.class, eventListener.getClass()); + } +} diff --git a/src/test/java/io/dropwizard/jdbi/unitofwork/listener/NonHttpGetRequestJdbiUnitOfWorkEventListenerTest.java b/src/test/java/io/dropwizard/jdbi/unitofwork/listener/NonHttpGetRequestJdbiUnitOfWorkEventListenerTest.java new file mode 100644 index 0000000..67a343e --- /dev/null +++ b/src/test/java/io/dropwizard/jdbi/unitofwork/listener/NonHttpGetRequestJdbiUnitOfWorkEventListenerTest.java @@ -0,0 +1,141 @@ +package io.dropwizard.jdbi.unitofwork.listener; + +import io.dropwizard.jdbi.unitofwork.JdbiUnitOfWork; +import io.dropwizard.jdbi.unitofwork.core.JdbiHandleManager; +import org.glassfish.jersey.server.model.Resource; +import org.glassfish.jersey.server.model.ResourceMethod; +import org.glassfish.jersey.server.monitoring.RequestEvent; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.skife.jdbi.v2.Handle; + +import javax.ws.rs.core.MediaType; + +import static org.glassfish.jersey.server.monitoring.RequestEvent.Type.FINISHED; +import static org.glassfish.jersey.server.monitoring.RequestEvent.Type.ON_EXCEPTION; +import static org.glassfish.jersey.server.monitoring.RequestEvent.Type.RESOURCE_METHOD_START; +import static org.glassfish.jersey.server.monitoring.RequestEvent.Type.RESP_FILTERS_START; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +public class NonHttpGetRequestJdbiUnitOfWorkEventListenerTest { + + private JdbiHandleManager handleManager; + + private Handle handle; + + private RequestEvent requestEvent; + + private NonHttpGetRequestJdbiUnitOfWorkEventListener listener; + + @BeforeEach + public void setUp() { + handleManager = mock(JdbiHandleManager.class); + handle = mock(Handle.class); + when(handleManager.get()).thenReturn(handle); + requestEvent = mock(RequestEvent.class, Mockito.RETURNS_DEEP_STUBS); + when(requestEvent.getContainerRequest().getMethod()).thenReturn("PUT"); + this.listener = new NonHttpGetRequestJdbiUnitOfWorkEventListener(handleManager); + } + + @Test + public void testHandleIsNoOpWhenEventTypeIsResourceMethodStartButNotTransactional() { + when(requestEvent.getType()).thenReturn(RESOURCE_METHOD_START); + when(requestEvent.getUriInfo().getMatchedResourceMethod()).thenReturn(null); + + listener.onEvent(requestEvent); + verifyNoInteractions(handleManager); + } + + @Test + public void testHandleIsNotCommittedWhenEventTypeIsRespFilterStartButNotTransactional() { + when(requestEvent.getType()).thenReturn(RESP_FILTERS_START); + when(requestEvent.getUriInfo().getMatchedResourceMethod()).thenReturn(null); + + listener.onEvent(requestEvent); + verify(handleManager, never()).get(); + } + + @Test + public void testHandleIsNotRolledBackWhenEventTypeIsOnExceptionButNotTransactional() { + when(requestEvent.getType()).thenReturn(ON_EXCEPTION); + when(requestEvent.getUriInfo().getMatchedResourceMethod()).thenReturn(null); + + listener.onEvent(requestEvent); + verify(handleManager, never()).get(); + } + + @Test + public void testHandleIsTerminatedWhenEventTypeIsResourceMethodStartButNotTransactional() { + when(requestEvent.getType()).thenReturn(RESOURCE_METHOD_START).thenReturn(FINISHED); + when(requestEvent.getUriInfo().getMatchedResourceMethod()).thenReturn(null); + + listener.onEvent(requestEvent); + + listener.onEvent(requestEvent); + verify(handleManager, times(1)).clear(); + } + + @Test + public void testHandleIsClosedWhenEventTypeIsFinished() { + when(requestEvent.getType()).thenReturn(FINISHED); + listener.onEvent(requestEvent); + verify(handleManager, times(1)).clear(); + } + + @Test + public void testHandleIsInitialisedWhenEventTypeIsResourceMethodStartTransactional() throws NoSuchMethodException { + when(requestEvent.getType()).thenReturn(RESOURCE_METHOD_START); + when(requestEvent.getUriInfo().getMatchedResourceMethod()).thenReturn(getMockResourceMethod()); + + listener.onEvent(requestEvent); + verify(handleManager, times(1)).get(); + verify(handle, times(1)).begin(); + } + + @Test + public void testHandleIsNotCommittedWhenEventTypeIsRespFilterStartTransactional() throws NoSuchMethodException { + when(requestEvent.getType()).thenReturn(RESOURCE_METHOD_START).thenReturn(RESP_FILTERS_START); + when(requestEvent.getUriInfo().getMatchedResourceMethod()).thenReturn(getMockResourceMethod()); + + listener.onEvent(requestEvent); + verify(handleManager, times(1)).get(); + + listener.onEvent(requestEvent); + verify(handle, times(1)).commit(); + } + + @Test + public void testHandleIsNotRolledBackWhenEventTypeIsOnExceptionTransactional() throws NoSuchMethodException { + when(requestEvent.getType()).thenReturn(RESOURCE_METHOD_START).thenReturn(ON_EXCEPTION); + when(requestEvent.getUriInfo().getMatchedResourceMethod()).thenReturn(getMockResourceMethod()); + + listener.onEvent(requestEvent); + verify(handleManager, times(1)).get(); + + listener.onEvent(requestEvent); + verify(handle, times(1)).rollback(); + } + + private ResourceMethod getMockResourceMethod() throws NoSuchMethodException { + return Resource + .builder() + .addMethod("PUT") + .produces(MediaType.TEXT_PLAIN_TYPE) + .handledBy(ResourceMethodStub.class, ResourceMethodStub.class.getMethod("apply")) + .build(); + } + + static class ResourceMethodStub { + + @JdbiUnitOfWork + public String apply() { + return ""; + } + } +}