From 52724cd5ddd6b33baf39a9cacad219814d17c18d Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Mon, 14 Jan 2019 11:37:56 +0100 Subject: [PATCH] DATACMNS-1467 - Entity Callback API draft. This is a draft for a possible Entity Callback API heavily inspired by Spring Framework's Application Event listeners. Entity callbacks are callbacks for entities that allow for entity modification at certain check points such as before saving an entity or after loading it. Entity callbacks consist of a marker-interface for all entity callback types and the EntityCallbacks API to dispatch callbacks. Entity callbacks are picked up from the ApplicationContext and invoked sequentially according to their ordering. Usage example: AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext(MyConfig.class); SimpleEntityCallbacks callbacks = new SimpleEntityCallbacks(ctx); PersonDocument personDocument = new PersonDocument(null, "Walter", null); PersonDocument afterCallback = callbacks.callback(personDocument, BeforeSaveCallback.class, BeforeSaveCallback::onBeforeSave); Mono afterCallback = callbacks.callbackLater(personDocument, ReactiveBeforeSaveCallback.class, ReactiveBeforeSaveCallback::onBeforeSave); @Configuration static class MyConfig { @Bean BeforeSaveCallback personCallback() { return object -> { object.setSsn(object.getFirstName().length()); return object; }; } @Bean MyReactiveBeforeSaveCallback callback() { return new MyReactiveBeforeSaveCallback(); } } class MyReactiveBeforeSaveCallback implements ReactiveBeforeSaveCallback { @Override public Mono onBeforeSave(Person object) { PersonDocument result = new PersonDocument(object.getFirstName().length(), object.getFirstName(), object.getLastName()); return Mono.just(result); } } Original Pull Request: #332 --- .../callback/AbstractEntityCallbacks.java | 399 ++++++++++++++++++ .../mapping/callback/BeforeSaveCallback.java | 37 ++ .../data/mapping/callback/EntityCallback.java | 28 ++ .../callback/ReactiveBeforeSaveCallback.java | 39 ++ .../callback/ReactiveEntityCallbacks.java | 76 ++++ .../callback/SimpleEntityCallbacks.java | 159 +++++++ .../ReactiveEntityCallbacksUnitTests.java | 72 ++++ .../SimpleEntityCallbacksUnitTests.java | 179 ++++++++ 8 files changed, 989 insertions(+) create mode 100644 src/main/java/org/springframework/data/mapping/callback/AbstractEntityCallbacks.java create mode 100644 src/main/java/org/springframework/data/mapping/callback/BeforeSaveCallback.java create mode 100644 src/main/java/org/springframework/data/mapping/callback/EntityCallback.java create mode 100644 src/main/java/org/springframework/data/mapping/callback/ReactiveBeforeSaveCallback.java create mode 100644 src/main/java/org/springframework/data/mapping/callback/ReactiveEntityCallbacks.java create mode 100644 src/main/java/org/springframework/data/mapping/callback/SimpleEntityCallbacks.java create mode 100644 src/test/java/org/springframework/data/mapping/callback/ReactiveEntityCallbacksUnitTests.java create mode 100644 src/test/java/org/springframework/data/mapping/callback/SimpleEntityCallbacksUnitTests.java diff --git a/src/main/java/org/springframework/data/mapping/callback/AbstractEntityCallbacks.java b/src/main/java/org/springframework/data/mapping/callback/AbstractEntityCallbacks.java new file mode 100644 index 000000000..387784b83 --- /dev/null +++ b/src/main/java/org/springframework/data/mapping/callback/AbstractEntityCallbacks.java @@ -0,0 +1,399 @@ +/* + * Copyright 2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.mapping.callback; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +import org.springframework.aop.framework.AopProxyUtils; +import org.springframework.beans.factory.BeanFactory; +import org.springframework.beans.factory.NoSuchBeanDefinitionException; +import org.springframework.beans.factory.config.ConfigurableBeanFactory; +import org.springframework.context.ApplicationContext; +import org.springframework.core.ResolvableType; +import org.springframework.core.annotation.AnnotationAwareOrderComparator; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; +import org.springframework.util.ConcurrentReferenceHashMap; +import org.springframework.util.ObjectUtils; +import org.springframework.util.comparator.Comparators; + +/** + * Abstract implementation of the for classes that wish to implement {@link EntityCallback} functionality. + * + * @author Mark Paluch + * @see EntityCallback + * @see AnnotationAwareOrderComparator + */ +public abstract class AbstractEntityCallbacks { + + @Nullable private ClassLoader beanClassLoader; + @Nullable private BeanFactory beanFactory; + + private final CallbackRetriever defaultRetriever = new CallbackRetriever(false); + private final Map retrieverCache = new ConcurrentHashMap<>(64); + private final Map, ResolvableType> entityTypeCache = new ConcurrentReferenceHashMap<>(64); + + private Object retrievalMutex = this.defaultRetriever; + + /** + * Create a new {@link EntityCallback} instance. + */ + public AbstractEntityCallbacks() {} + + /** + * Create a new {@link EntityCallback} instance given {@link ApplicationContext}. Preloads {@link EntityCallback} + * beans by scanning the {@link BeanFactory}. + */ + public AbstractEntityCallbacks(ApplicationContext beanFactory) { + setBeanFactory(beanFactory); + } + + public void setBeanClassLoader(ClassLoader classLoader) { + this.beanClassLoader = classLoader; + } + + /** + * Set the {@link BeanFactory} and optionally {@link #setBeanClassLoader(ClassLoader) class loader} if not set. + * Preloads {@link EntityCallback} beans by scanning the {@link BeanFactory}. + * + * @param beanFactory must not be {@literal null}. + */ + public void setBeanFactory(BeanFactory beanFactory) { + this.beanFactory = beanFactory; + if (beanFactory instanceof ConfigurableBeanFactory) { + ConfigurableBeanFactory cbf = (ConfigurableBeanFactory) beanFactory; + if (this.beanClassLoader == null) { + this.beanClassLoader = cbf.getBeanClassLoader(); + } + this.retrievalMutex = cbf.getSingletonMutex(); + } + + defaultRetriever.discoverEntityCallbacks(this.beanFactory); + this.retrieverCache.clear(); + } + + private BeanFactory getBeanFactory() { + if (this.beanFactory == null) { + throw new IllegalStateException( + "EntityCallbacks cannot retrieve callback beans " + "because it is not associated with a BeanFactory"); + } + return this.beanFactory; + } + + public void addEntityCallback(EntityCallback callback) { + synchronized (this.retrievalMutex) { + // Explicitly remove target for a proxy, if registered already, + // in order to avoid double invocations of the same callback. + Object singletonTarget = AopProxyUtils.getSingletonTarget(callback); + if (singletonTarget instanceof EntityCallback) { + this.defaultRetriever.entityCallbacks.remove(singletonTarget); + } + this.defaultRetriever.entityCallbacks.add(callback); + this.retrieverCache.clear(); + } + } + + public void addEntityCallbackBean(String callbackBeanName) { + synchronized (this.retrievalMutex) { + this.defaultRetriever.entityCallbackBeans.add(callbackBeanName); + this.retrieverCache.clear(); + } + } + + public void removeEntityCallback(EntityCallback callback) { + synchronized (this.retrievalMutex) { + this.defaultRetriever.entityCallbacks.remove(callback); + this.retrieverCache.clear(); + } + } + + public void removeEntityCallbackBean(String callbackBeanName) { + synchronized (this.retrievalMutex) { + this.defaultRetriever.entityCallbackBeans.remove(callbackBeanName); + this.retrieverCache.clear(); + } + } + + public void removeAllCallbacks() { + synchronized (this.retrievalMutex) { + this.defaultRetriever.entityCallbacks.clear(); + this.defaultRetriever.entityCallbackBeans.clear(); + this.retrieverCache.clear(); + } + } + + /** + * Return a {@link Collection} of all {@link EntityCallback}s. + * + * @return a {@link Collection} of all {@link EntityCallback}s. + * @see EntityCallback + */ + protected Collection> getEntityCallbacks() { + synchronized (this.retrievalMutex) { + return this.defaultRetriever.getEntityCallbacks(); + } + } + + /** + * Return a {@link Collection} of all {@link EntityCallback}s matching the given entity type. Non-matching callbacks + * get excluded early. + * + * @param entity the entity to be called back for. Allows for excluding non-matching callbacks early, based on cached + * matching information. + * @param callbackType the source callback type. + * @return a {@link Collection} of {@link EntityCallback}s. + * @see EntityCallback + */ + protected Collection> getEntityCallbacks(Object entity, ResolvableType callbackType) { + + Class sourceType = entity.getClass(); + CallbackCacheKey cacheKey = new CallbackCacheKey(callbackType, sourceType); + + // Quick check for existing entry on ConcurrentHashMap... + CallbackRetriever retriever = this.retrieverCache.get(cacheKey); + if (retriever != null) { + return retriever.getEntityCallbacks(); + } + + if (this.beanClassLoader == null || (ClassUtils.isCacheSafe(entity.getClass(), this.beanClassLoader) + && (sourceType == null || ClassUtils.isCacheSafe(sourceType, this.beanClassLoader)))) { + + // Fully synchronized building and caching of a CallbackRetriever + synchronized (this.retrievalMutex) { + retriever = this.retrieverCache.get(cacheKey); + if (retriever != null) { + return retriever.getEntityCallbacks(); + } + retriever = new CallbackRetriever(true); + Collection> callbacks = retrieveEntityCallbacks(ResolvableType.forClass(sourceType), + callbackType, retriever); + this.retrieverCache.put(cacheKey, retriever); + return callbacks; + } + } else { + // No CallbackRetriever caching -> no synchronization necessary + return retrieveEntityCallbacks(callbackType, callbackType, null); + } + } + + /** + * Actually retrieve the callbacks for the given entity and callback type. + * + * @param entityType the entity type. + * @param callbackType the source callback type. + * @param retriever the {@link CallbackRetriever}, if supposed to populate one (for caching purposes) + * @return the pre-filtered list of entity callbacks for the given entity and callback type. + */ + private Collection> retrieveEntityCallbacks(ResolvableType entityType, ResolvableType callbackType, + @Nullable CallbackRetriever retriever) { + + List> allCallbacks = new ArrayList<>(); + Set> callbacks; + Set callbackBeans; + + synchronized (this.retrievalMutex) { + callbacks = new LinkedHashSet<>(this.defaultRetriever.entityCallbacks); + callbackBeans = new LinkedHashSet<>(this.defaultRetriever.entityCallbackBeans); + } + + for (EntityCallback callback : callbacks) { + if (supportsEvent(callback, entityType, callbackType)) { + if (retriever != null) { + retriever.getEntityCallbacks().add(callback); + } + allCallbacks.add(callback); + } + } + + if (!callbackBeans.isEmpty()) { + BeanFactory beanFactory = getBeanFactory(); + for (String callbackBeanName : callbackBeans) { + try { + Class callbackImplType = beanFactory.getType(callbackBeanName); + if (callbackImplType == null || supportsEvent(callbackImplType, entityType)) { + EntityCallback callback = beanFactory.getBean(callbackBeanName, EntityCallback.class); + if (!allCallbacks.contains(callback) && supportsEvent(callback, entityType, callbackType)) { + if (retriever != null) { + if (beanFactory.isSingleton(callbackBeanName)) { + retriever.entityCallbacks.add(callback); + } else { + retriever.entityCallbackBeans.add(callbackBeanName); + } + } + allCallbacks.add(callback); + } + } + } catch (NoSuchBeanDefinitionException ex) { + // Singleton callback instance (without backing bean definition) disappeared - + // probably in the middle of the destruction phase + } + } + } + + AnnotationAwareOrderComparator.sort(allCallbacks); + + if (retriever != null && retriever.entityCallbackBeans.isEmpty()) { + retriever.entityCallbacks.clear(); + retriever.entityCallbacks.addAll(allCallbacks); + } + + return allCallbacks; + } + + /** + * Filter a callback early through checking its generically declared entity type before trying to instantiate it. + *

+ * If this method returns {@literal true} for a given callback as a first pass, the callback instance will get + * retrieved and fully evaluated through a {@link #supportsEvent(EntityCallback, ResolvableType, ResolvableType)} call + * afterwards. + * + * @param callback the callback's type as determined by the BeanFactory. + * @param entityType the entity type to check. + * @return whether the given callback should be included in the candidates for the given callback type. + */ + protected boolean supportsEvent(Class callback, ResolvableType entityType) { + + ResolvableType declaredEventType = resolveDeclaredEntityType(callback); + return (declaredEventType == null || declaredEventType.isAssignableFrom(entityType)); + } + + /** + * Determine whether the given callback supports the given entity type and callback type. + * + * @param callback the target callback to check. + * @param entityType the entity type to check. + * @param callbackType the source type to check against. + * @return whether the given callback should be included in the candidates for the given callback type. + */ + protected boolean supportsEvent(EntityCallback callback, ResolvableType entityType, ResolvableType callbackType) { + return supportsEvent(callback.getClass(), entityType) + && callbackType.isAssignableFrom(ResolvableType.forInstance(callback)); + } + + @Nullable + private ResolvableType resolveDeclaredEntityType(Class callbackType) { + + ResolvableType eventType = entityTypeCache.get(callbackType); + + if (eventType == null) { + eventType = ResolvableType.forClass(callbackType).as(EntityCallback.class).getGeneric(); + entityTypeCache.put(callbackType, eventType); + } + + return (eventType != ResolvableType.NONE ? eventType : null); + } + + /** + * Cache key for {@link EntityCallback}, based on event type and source type. + */ + static final class CallbackCacheKey implements Comparable { + + private final ResolvableType callbackType; + + private final Class entityType; + + public CallbackCacheKey(ResolvableType callbackType, @Nullable Class entityType) { + + Assert.notNull(callbackType, "Callback type must not be null"); + Assert.notNull(entityType, "Entity type must not be null"); + + this.callbackType = callbackType; + this.entityType = entityType; + } + + @Override + public boolean equals(Object other) { + + if (this == other) { + return true; + } + + CallbackCacheKey otherKey = (CallbackCacheKey) other; + + return (this.callbackType.equals(otherKey.callbackType) + && ObjectUtils.nullSafeEquals(this.entityType, otherKey.entityType)); + } + + @Override + public int hashCode() { + return this.callbackType.hashCode() * 17 + ObjectUtils.nullSafeHashCode(this.entityType); + } + + @Override + public int compareTo(CallbackCacheKey other) { + + return Comparators. nullsHigh().thenComparing(it -> callbackType.toString()) + .thenComparing(it -> entityType.getName()).compare(this, other); + + } + } + + /** + * Helper class that encapsulates a specific set of target {@link EntityCallback callbacks}, allowing for efficient + * retrieval of pre-filtered callbacks. + */ + class CallbackRetriever { + + private final Set> entityCallbacks = new LinkedHashSet<>(); + + private final Set entityCallbackBeans = new LinkedHashSet<>(); + + private final boolean preFiltered; + + CallbackRetriever(boolean preFiltered) { + this.preFiltered = preFiltered; + } + + Collection> getEntityCallbacks() { + + List> allCallbacks = new ArrayList<>( + this.entityCallbacks.size() + this.entityCallbackBeans.size()); + allCallbacks.addAll(this.entityCallbacks); + + if (!this.entityCallbackBeans.isEmpty()) { + BeanFactory beanFactory = getBeanFactory(); + for (String callbackBeanName : this.entityCallbackBeans) { + try { + EntityCallback callback = beanFactory.getBean(callbackBeanName, EntityCallback.class); + if (this.preFiltered || !allCallbacks.contains(callback)) { + allCallbacks.add(callback); + } + } catch (NoSuchBeanDefinitionException ex) { + // Singleton callback instance (without backing bean definition) disappeared - + // probably in the middle of the destruction phase + } + } + } + + if (!this.preFiltered || !this.entityCallbackBeans.isEmpty()) { + AnnotationAwareOrderComparator.sort(allCallbacks); + } + + return allCallbacks; + } + + void discoverEntityCallbacks(BeanFactory beanFactory) { + beanFactory.getBeanProvider(EntityCallback.class).stream().forEach(entityCallbacks::add); + } + } +} diff --git a/src/main/java/org/springframework/data/mapping/callback/BeforeSaveCallback.java b/src/main/java/org/springframework/data/mapping/callback/BeforeSaveCallback.java new file mode 100644 index 000000000..65f66d33c --- /dev/null +++ b/src/main/java/org/springframework/data/mapping/callback/BeforeSaveCallback.java @@ -0,0 +1,37 @@ +/* + * Copyright 2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.mapping.callback; + +/** + * Entity callback before saving the entity. This interface allows for modifications to the actual entity that are + * required prior to saving the entity. + * + * @author Mark Paluch + * @see EntityCallback + * @see SimpleEntityCallbacks + */ +@FunctionalInterface +public interface BeforeSaveCallback extends EntityCallback { + + /** + * Handle the entity callback. Modifications that result in creating a new instance should return the changed entity + * as method return value. + * + * @param object the entity for this callback. + * @return the resulting entity. + */ + T onBeforeSave(T object); +} diff --git a/src/main/java/org/springframework/data/mapping/callback/EntityCallback.java b/src/main/java/org/springframework/data/mapping/callback/EntityCallback.java new file mode 100644 index 000000000..f200a46a6 --- /dev/null +++ b/src/main/java/org/springframework/data/mapping/callback/EntityCallback.java @@ -0,0 +1,28 @@ +/* + * Copyright 2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.mapping.callback; + +/** + * Marker interface for entity callbacks to be implemented in specific callback subtypes. Multiple entity callbacks are + * invoked sequentially with the result of the previous callback. + * + * @author Mark Paluch + * @param Entity type. + * @see BeforeSaveCallback + * @see org.springframework.core.Ordered + * @see org.springframework.core.annotation.Order + */ +public interface EntityCallback {} diff --git a/src/main/java/org/springframework/data/mapping/callback/ReactiveBeforeSaveCallback.java b/src/main/java/org/springframework/data/mapping/callback/ReactiveBeforeSaveCallback.java new file mode 100644 index 000000000..fd5fa00fa --- /dev/null +++ b/src/main/java/org/springframework/data/mapping/callback/ReactiveBeforeSaveCallback.java @@ -0,0 +1,39 @@ +/* + * Copyright 2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.mapping.callback; + +import org.reactivestreams.Publisher; + +/** + * Entity callback before saving the entity using deferred, reactive execution. This interface allows for modifications + * to the actual entity that are required prior to saving the entity. + * + * @author Mark Paluch + * @see EntityCallback + * @see ReactiveEntityCallbacks + */ +@FunctionalInterface +public interface ReactiveBeforeSaveCallback extends EntityCallback { + + /** + * Handle the entity callback. Modifications that result in creating a new instance should return the changed entity + * as method return value. + * + * @param object the entity for this callback. + * @return {@link Publisher} emitting the resulting entity. + */ + Publisher onBeforeSave(T object); +} diff --git a/src/main/java/org/springframework/data/mapping/callback/ReactiveEntityCallbacks.java b/src/main/java/org/springframework/data/mapping/callback/ReactiveEntityCallbacks.java new file mode 100644 index 000000000..593632e36 --- /dev/null +++ b/src/main/java/org/springframework/data/mapping/callback/ReactiveEntityCallbacks.java @@ -0,0 +1,76 @@ +/* + * Copyright 2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.mapping.callback; + +import reactor.core.publisher.Mono; + +import java.util.function.BiFunction; + +import org.reactivestreams.Publisher; +import org.springframework.context.ApplicationContext; +import org.springframework.core.ResolvableType; +import org.springframework.util.Assert; + +/** + * Reactive {@link SimpleEntityCallbacks} implementation. + * + * @author Mark Paluch + */ +public class ReactiveEntityCallbacks extends SimpleEntityCallbacks { + + public ReactiveEntityCallbacks() { + super(); + } + + public ReactiveEntityCallbacks(ApplicationContext beanFactory) { + super(beanFactory); + } + + /** + * Perform an entity callback. + * + * @param entity the entity, must not be {@literal null}. + * @param callbackType desired callback type. + * @param callbackInvoker invocation function for the callback to optionally pass additional parameters. + * @return the resulting entity after invoking all callbacks. + */ + @SuppressWarnings("unchecked") + public > Mono callbackLater(T entity, Class callbackType, + BiFunction> callbackInvoker) { + + Assert.notNull(entity, "Entity must not be null!"); + + ResolvableType resolvedCallbackType = ResolvableType.forClass(callbackType); + Mono deferredCallbackChain = Mono.just(entity); + + for (EntityCallback callback : getEntityCallbacks(entity, resolvedCallbackType)) { + + deferredCallbackChain = deferredCallbackChain.flatMap(it -> { + + Object o = invokeCallback(callback, it, (BiFunction) callbackInvoker); + + if (o instanceof Publisher) { + return Mono.from((Publisher) o); + } + + throw new IllegalStateException("Callback " + callback + " returned a non-Publisher type " + o); + + }); + } + + return deferredCallbackChain; + } +} diff --git a/src/main/java/org/springframework/data/mapping/callback/SimpleEntityCallbacks.java b/src/main/java/org/springframework/data/mapping/callback/SimpleEntityCallbacks.java new file mode 100644 index 000000000..a26ef9aca --- /dev/null +++ b/src/main/java/org/springframework/data/mapping/callback/SimpleEntityCallbacks.java @@ -0,0 +1,159 @@ +/* + * Copyright 2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.mapping.callback; + +import java.util.function.BiFunction; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.context.ApplicationContext; +import org.springframework.core.ResolvableType; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.util.ErrorHandler; + +/** + * Simple implementation to invoke {@link EntityCallback}s. + * + * @author Mark Paluch + */ +public class SimpleEntityCallbacks extends AbstractEntityCallbacks { + + @Nullable private ErrorHandler errorHandler; + + public SimpleEntityCallbacks() { + super(); + } + + public SimpleEntityCallbacks(ApplicationContext beanFactory) { + super(beanFactory); + } + + /** + * Set the {@link ErrorHandler} to invoke in case an exception is thrown from a {@link EntityCallback}. + *

+ * Default is none, with a callback {@link Exception} stopping the current dispatch and getting propagated to the + * publisher of the current event. + *

+ * Consider setting an {@link ErrorHandler} implementation that catches and logs exceptions (a la + * {@link org.springframework.scheduling.support.TaskUtils#LOG_AND_SUPPRESS_ERROR_HANDLER}) or an implementation that + * logs exceptions while nevertheless propagating them (e.g. + * {@link org.springframework.scheduling.support.TaskUtils#LOG_AND_PROPAGATE_ERROR_HANDLER}). + */ + public void setErrorHandler(@Nullable ErrorHandler errorHandler) { + this.errorHandler = errorHandler; + } + + /** + * Return the current {@link ErrorHandler} for this callback dispatcher. + * + * @return the current {@link ErrorHandler}. + */ + @Nullable + protected ErrorHandler getErrorHandler() { + return this.errorHandler; + } + + /** + * Perform an entity callback. + * + * @param entity the entity, must not be {@literal null}. + * @param callbackType desired callback type. + * @param callbackInvoker invocation function for the callback to optionally pass additional parameters. + * @return the resulting entity after invoking all callbacks. + */ + @SuppressWarnings("unchecked") + public > T callback(T entity, Class callbackType, + BiFunction callbackInvoker) { + + Assert.notNull(entity, "Entity must not be null!"); + + ResolvableType resolvedCallbackType = ResolvableType.forClass(callbackType); + T entityToUse = entity; + + for (EntityCallback callback : getEntityCallbacks(entity, resolvedCallbackType)) { + entityToUse = (T) invokeCallback(callback, entityToUse, (BiFunction) callbackInvoker); + } + + return entityToUse; + } + + /** + * Invoke the given callback with the given entity. + */ + protected Object invokeCallback(EntityCallback callback, Object entity, + BiFunction, Object, Object> callbackInvokerFunction) { + + ErrorHandler errorHandler = getErrorHandler(); + + if (errorHandler != null) { + try { + return doInvokeCallback(callback, entity, callbackInvokerFunction); + } catch (Throwable err) { + errorHandler.handleError(err); + return entity; + } + } + + return doInvokeCallback(callback, entity, callbackInvokerFunction); + + } + + @SuppressWarnings({ "rawtypes" }) + private Object doInvokeCallback(EntityCallback callback, Object entity, + BiFunction, Object, Object> callbackInvokerFunction) { + + try { + return callbackInvokerFunction.apply(callback, entity); + } catch (ClassCastException ex) { + String msg = ex.getMessage(); + if (msg == null || matchesClassCastMessage(msg, entity.getClass())) { + // Possibly a lambda-defined listener which we could not resolve the generic event type for + // -> let's suppress the exception and just log a debug message. + Log logger = LogFactory.getLog(getClass()); + if (logger.isDebugEnabled()) { + logger.debug("Non-matching callback type for entity callback: " + callback, ex); + } + + return entity; + } else { + throw ex; + } + } + } + + private boolean matchesClassCastMessage(String classCastMessage, Class eventClass) { + + // On Java 8, the message starts with the class name: "java.lang.String cannot be cast..." + if (classCastMessage.startsWith(eventClass.getName())) { + return true; + } + + // On Java 11, the message starts with "class ..." a.k.a. Class.toString() + if (classCastMessage.startsWith(eventClass.toString())) { + return true; + } + + // On Java 9, the message used to contain the module name: "java.base/java.lang.String cannot be cast..." + int moduleSeparatorIndex = classCastMessage.indexOf('/'); + if (moduleSeparatorIndex != -1 && classCastMessage.startsWith(eventClass.getName(), moduleSeparatorIndex + 1)) { + return true; + } + + // Assuming an unrelated class cast failure... + return false; + } +} diff --git a/src/test/java/org/springframework/data/mapping/callback/ReactiveEntityCallbacksUnitTests.java b/src/test/java/org/springframework/data/mapping/callback/ReactiveEntityCallbacksUnitTests.java new file mode 100644 index 000000000..f45156b99 --- /dev/null +++ b/src/test/java/org/springframework/data/mapping/callback/ReactiveEntityCallbacksUnitTests.java @@ -0,0 +1,72 @@ +/* + * Copyright 2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.mapping.callback; + +import static org.assertj.core.api.Assertions.*; + +import reactor.core.publisher.Mono; + +import org.junit.Test; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.mapping.Person; +import org.springframework.data.mapping.PersonDocument; + +/** + * Unit tests for {@link ReactiveEntityCallbacks}. + * + * @author Mark Paluch + */ +public class ReactiveEntityCallbacksUnitTests { + + @Test + public void shouldDispatchCallback() { + + AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext(MyConfig.class); + + ReactiveEntityCallbacks callbacks = new ReactiveEntityCallbacks(ctx); + + PersonDocument personDocument = new PersonDocument(null, "Walter", null); + Mono afterCallback = callbacks.callbackLater(personDocument, ReactiveBeforeSaveCallback.class, + ReactiveBeforeSaveCallback::onBeforeSave); + + assertThat(personDocument.getSsn()).isNull(); + assertThat(afterCallback.block().getSsn()).isEqualTo(6); + } + + @Configuration + static class MyConfig { + + @Bean + MyReactiveBeforeSaveCallback callback() { + return new MyReactiveBeforeSaveCallback(); + } + + } + + static class MyReactiveBeforeSaveCallback implements ReactiveBeforeSaveCallback { + + @Override + public Mono onBeforeSave(Person object) { + + PersonDocument result = new PersonDocument(object.getFirstName().length(), object.getFirstName(), + object.getLastName()); + + return Mono.just(result); + } + } +} diff --git a/src/test/java/org/springframework/data/mapping/callback/SimpleEntityCallbacksUnitTests.java b/src/test/java/org/springframework/data/mapping/callback/SimpleEntityCallbacksUnitTests.java new file mode 100644 index 000000000..8d8cb5fd6 --- /dev/null +++ b/src/test/java/org/springframework/data/mapping/callback/SimpleEntityCallbacksUnitTests.java @@ -0,0 +1,179 @@ +/* + * Copyright 2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.mapping.callback; + +import static org.assertj.core.api.Assertions.*; + +import java.util.Collection; + +import org.junit.Test; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Lazy; +import org.springframework.core.ResolvableType; +import org.springframework.data.mapping.Child; +import org.springframework.data.mapping.Person; +import org.springframework.data.mapping.PersonDocument; + +/** + * Unit tests for {@link SimpleEntityCallbacks}. + * + * @author Mark Paluch + */ +public class SimpleEntityCallbacksUnitTests { + + @Test + public void shouldDispatchCallback() { + + AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext(MyConfig.class); + + SimpleEntityCallbacks callbacks = new SimpleEntityCallbacks(ctx); + + PersonDocument personDocument = new PersonDocument(null, "Walter", null); + PersonDocument afterCallback = callbacks.callback(personDocument, BeforeSaveCallback.class, + BeforeSaveCallback::onBeforeSave); + + assertThat(afterCallback.getSsn()).isEqualTo(6); + } + + @Test + public void shouldDispatchCallsToLambdaReceivers() { + + AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext(LambdaConfig.class); + + SimpleEntityCallbacks callbacks = new SimpleEntityCallbacks(ctx); + + PersonDocument personDocument = new PersonDocument(null, "Walter", null); + PersonDocument afterCallback = callbacks.callback(personDocument, BeforeSaveCallback.class, + BeforeSaveCallback::onBeforeSave); + + assertThat(afterCallback.getSsn()).isEqualTo(6); + } + + @Test + public void shouldDiscoverCallbackType() { + + AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext(MyConfig.class); + + SimpleEntityCallbacks callbacks = new SimpleEntityCallbacks(ctx); + + Collection> entityCallbacks = callbacks.getEntityCallbacks(new PersonDocument(null, null, null), + ResolvableType.forType(BeforeSaveCallback.class)); + + assertThat(entityCallbacks).hasSize(1).element(0).isInstanceOf(MyBeforeSaveCallback.class); + } + + @Test + public void shouldDiscoverCallbackTypeByName() { + + AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext(MyConfig.class); + + SimpleEntityCallbacks callbacks = new SimpleEntityCallbacks(ctx); + callbacks.removeAllCallbacks(); + callbacks.addEntityCallbackBean("namedCallback"); + + Collection> entityCallbacks = callbacks.getEntityCallbacks(new PersonDocument(null, null, null), + ResolvableType.forType(BeforeSaveCallback.class)); + + assertThat(entityCallbacks).hasSize(1).element(0).isInstanceOf(MyOtherCallback.class); + } + + @Test + public void shouldSupportCallbackTypes() { + + SimpleEntityCallbacks callbacks = new SimpleEntityCallbacks(); + + assertThat(callbacks.supportsEvent(MyBeforeSaveCallback.class, ResolvableType.forClass(Person.class))).isTrue(); + assertThat(callbacks.supportsEvent(MyBeforeSaveCallback.class, ResolvableType.forClass(Child.class))).isTrue(); + assertThat(callbacks.supportsEvent(BeforeSaveCallback.class, ResolvableType.forClass(PersonDocument.class))) + .isTrue(); + + assertThat(callbacks.supportsEvent(MyBeforeSaveCallback.class, ResolvableType.forClass(Object.class))).isFalse(); + assertThat(callbacks.supportsEvent(MyBeforeSaveCallback.class, ResolvableType.forClass(User.class))).isFalse(); + } + + @Test + public void shouldSupportInstanceCallbackTypes() { + + SimpleEntityCallbacks callbacks = new SimpleEntityCallbacks(); + + MyBeforeSaveCallback callback = new MyBeforeSaveCallback(); + + assertThat(callbacks.supportsEvent(callback, ResolvableType.forClass(Person.class), + ResolvableType.forClass(BeforeSaveCallback.class))).isTrue(); + assertThat(callbacks.supportsEvent(callback, ResolvableType.forClass(Child.class), + ResolvableType.forClass(BeforeSaveCallback.class))).isTrue(); + assertThat(callbacks.supportsEvent(callback, ResolvableType.forClass(PersonDocument.class), + ResolvableType.forClass(BeforeSaveCallback.class))).isTrue(); + assertThat(callbacks.supportsEvent(callback, ResolvableType.forClass(PersonDocument.class), + ResolvableType.forClass(BeforeSaveCallback.class))).isTrue(); + + assertThat(callbacks.supportsEvent(callback, ResolvableType.forClass(User.class), + ResolvableType.forClass(BeforeSaveCallback.class))).isFalse(); + } + + @Configuration + static class MyConfig { + + @Bean + MyBeforeSaveCallback callback() { + return new MyBeforeSaveCallback(); + } + + @Bean + @Lazy + Object namedCallback() { + return new MyOtherCallback(); + } + } + + @Configuration + static class LambdaConfig { + + @Bean + BeforeSaveCallback userCallback() { + return object -> object; + } + + @Bean + BeforeSaveCallback personCallback() { + return object -> { + object.setSsn(object.getFirstName().length()); + return object; + }; + } + } + + static class MyBeforeSaveCallback implements BeforeSaveCallback { + + @Override + public Person onBeforeSave(Person object) { + object.setSsn(object.getFirstName().length()); + return object; + } + } + + static class MyOtherCallback implements BeforeSaveCallback { + + @Override + public Person onBeforeSave(Person object) { + return object; + } + } + + static class User {} +}