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 {} +}