11 changed files with 631 additions and 152 deletions
@ -0,0 +1,45 @@ |
|||||||
|
/* |
||||||
|
* Copyright 2023 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.mongodb.repository.support; |
||||||
|
|
||||||
|
import java.lang.reflect.Method; |
||||||
|
import java.util.Optional; |
||||||
|
|
||||||
|
import com.mongodb.ReadPreference; |
||||||
|
|
||||||
|
/** |
||||||
|
* Interface to abstract {@link CrudMethodMetadata} that provide the {@link ReadPreference} to be used for query |
||||||
|
* execution. |
||||||
|
* |
||||||
|
* @author Mark Paluch |
||||||
|
* @since 4.2 |
||||||
|
*/ |
||||||
|
public interface CrudMethodMetadata { |
||||||
|
|
||||||
|
/** |
||||||
|
* Returns the {@link ReadPreference} to be used. |
||||||
|
* |
||||||
|
* @return the {@link ReadPreference} to be used. |
||||||
|
*/ |
||||||
|
Optional<ReadPreference> getReadPreference(); |
||||||
|
|
||||||
|
/** |
||||||
|
* Returns the {@link Method} that this metadata applies to. |
||||||
|
* |
||||||
|
* @return the {@link Method} that this metadata applies to. |
||||||
|
*/ |
||||||
|
Method getMethod(); |
||||||
|
} |
||||||
@ -0,0 +1,233 @@ |
|||||||
|
/* |
||||||
|
* Copyright 2023 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.mongodb.repository.support; |
||||||
|
|
||||||
|
import java.lang.reflect.Method; |
||||||
|
import java.util.HashSet; |
||||||
|
import java.util.Optional; |
||||||
|
import java.util.Set; |
||||||
|
import java.util.concurrent.ConcurrentHashMap; |
||||||
|
import java.util.concurrent.ConcurrentMap; |
||||||
|
|
||||||
|
import org.aopalliance.intercept.MethodInterceptor; |
||||||
|
import org.aopalliance.intercept.MethodInvocation; |
||||||
|
import org.springframework.aop.TargetSource; |
||||||
|
import org.springframework.aop.framework.ProxyFactory; |
||||||
|
import org.springframework.beans.factory.BeanClassLoaderAware; |
||||||
|
import org.springframework.core.NamedThreadLocal; |
||||||
|
import org.springframework.core.annotation.AnnotatedElementUtils; |
||||||
|
import org.springframework.data.repository.core.RepositoryInformation; |
||||||
|
import org.springframework.data.repository.core.support.RepositoryProxyPostProcessor; |
||||||
|
import org.springframework.lang.Nullable; |
||||||
|
import org.springframework.transaction.support.TransactionSynchronizationManager; |
||||||
|
import org.springframework.util.Assert; |
||||||
|
import org.springframework.util.ClassUtils; |
||||||
|
import org.springframework.util.ReflectionUtils; |
||||||
|
|
||||||
|
import com.mongodb.ReadPreference; |
||||||
|
|
||||||
|
/** |
||||||
|
* {@link RepositoryProxyPostProcessor} that sets up interceptors to read metadata information from the invoked method. |
||||||
|
* This is necessary to allow redeclaration of CRUD methods in repository interfaces and configure read preference |
||||||
|
* information or query hints on them. |
||||||
|
* |
||||||
|
* @author Mark Paluch |
||||||
|
*/ |
||||||
|
class CrudMethodMetadataPostProcessor implements RepositoryProxyPostProcessor, BeanClassLoaderAware { |
||||||
|
|
||||||
|
private @Nullable ClassLoader classLoader = ClassUtils.getDefaultClassLoader(); |
||||||
|
|
||||||
|
@Override |
||||||
|
public void setBeanClassLoader(ClassLoader classLoader) { |
||||||
|
this.classLoader = classLoader; |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public void postProcess(ProxyFactory factory, RepositoryInformation repositoryInformation) { |
||||||
|
factory.addAdvice(new CrudMethodMetadataPopulatingMethodInterceptor(repositoryInformation)); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Returns a {@link CrudMethodMetadata} proxy that will lookup the actual target object by obtaining a thread bound |
||||||
|
* instance from the {@link TransactionSynchronizationManager} later. |
||||||
|
*/ |
||||||
|
CrudMethodMetadata getCrudMethodMetadata() { |
||||||
|
|
||||||
|
ProxyFactory factory = new ProxyFactory(); |
||||||
|
|
||||||
|
factory.addInterface(CrudMethodMetadata.class); |
||||||
|
factory.setTargetSource(new ThreadBoundTargetSource()); |
||||||
|
|
||||||
|
return (CrudMethodMetadata) factory.getProxy(this.classLoader); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* {@link MethodInterceptor} to build and cache {@link DefaultCrudMethodMetadata} instances for the invoked methods. |
||||||
|
* Will bind the found information to a {@link TransactionSynchronizationManager} for later lookup. |
||||||
|
* |
||||||
|
* @see DefaultCrudMethodMetadata |
||||||
|
*/ |
||||||
|
static class CrudMethodMetadataPopulatingMethodInterceptor implements MethodInterceptor { |
||||||
|
|
||||||
|
private static final ThreadLocal<MethodInvocation> currentInvocation = new NamedThreadLocal<>( |
||||||
|
"Current AOP method invocation"); |
||||||
|
|
||||||
|
private final ConcurrentMap<Method, CrudMethodMetadata> metadataCache = new ConcurrentHashMap<>(); |
||||||
|
private final Set<Method> implementations = new HashSet<>(); |
||||||
|
|
||||||
|
CrudMethodMetadataPopulatingMethodInterceptor(RepositoryInformation repositoryInformation) { |
||||||
|
|
||||||
|
ReflectionUtils.doWithMethods(repositoryInformation.getRepositoryInterface(), implementations::add, |
||||||
|
method -> !repositoryInformation.isQueryMethod(method)); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Return the AOP Alliance {@link MethodInvocation} object associated with the current invocation. |
||||||
|
* |
||||||
|
* @return the invocation object associated with the current invocation. |
||||||
|
* @throws IllegalStateException if there is no AOP invocation in progress, or if the |
||||||
|
* {@link CrudMethodMetadataPopulatingMethodInterceptor} was not added to this interceptor chain. |
||||||
|
*/ |
||||||
|
static MethodInvocation currentInvocation() throws IllegalStateException { |
||||||
|
|
||||||
|
MethodInvocation mi = currentInvocation.get(); |
||||||
|
|
||||||
|
if (mi == null) |
||||||
|
throw new IllegalStateException( |
||||||
|
"No MethodInvocation found: Check that an AOP invocation is in progress, and that the " |
||||||
|
+ "CrudMethodMetadataPopulatingMethodInterceptor is upfront in the interceptor chain."); |
||||||
|
return mi; |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public Object invoke(MethodInvocation invocation) throws Throwable { |
||||||
|
|
||||||
|
Method method = invocation.getMethod(); |
||||||
|
|
||||||
|
if (!implementations.contains(method)) { |
||||||
|
return invocation.proceed(); |
||||||
|
} |
||||||
|
|
||||||
|
MethodInvocation oldInvocation = currentInvocation.get(); |
||||||
|
currentInvocation.set(invocation); |
||||||
|
|
||||||
|
try { |
||||||
|
|
||||||
|
CrudMethodMetadata metadata = (CrudMethodMetadata) TransactionSynchronizationManager.getResource(method); |
||||||
|
|
||||||
|
if (metadata != null) { |
||||||
|
return invocation.proceed(); |
||||||
|
} |
||||||
|
|
||||||
|
CrudMethodMetadata methodMetadata = metadataCache.get(method); |
||||||
|
|
||||||
|
if (methodMetadata == null) { |
||||||
|
|
||||||
|
methodMetadata = new DefaultCrudMethodMetadata(method); |
||||||
|
CrudMethodMetadata tmp = metadataCache.putIfAbsent(method, methodMetadata); |
||||||
|
|
||||||
|
if (tmp != null) { |
||||||
|
methodMetadata = tmp; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
TransactionSynchronizationManager.bindResource(method, methodMetadata); |
||||||
|
|
||||||
|
try { |
||||||
|
return invocation.proceed(); |
||||||
|
} finally { |
||||||
|
TransactionSynchronizationManager.unbindResource(method); |
||||||
|
} |
||||||
|
} finally { |
||||||
|
currentInvocation.set(oldInvocation); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Default implementation of {@link CrudMethodMetadata} that will inspect the backing method for annotations. |
||||||
|
*/ |
||||||
|
static class DefaultCrudMethodMetadata implements CrudMethodMetadata { |
||||||
|
|
||||||
|
private final Optional<ReadPreference> readPreference; |
||||||
|
private final Method method; |
||||||
|
|
||||||
|
/** |
||||||
|
* Creates a new {@link DefaultCrudMethodMetadata} for the given {@link Method}. |
||||||
|
* |
||||||
|
* @param method must not be {@literal null}. |
||||||
|
*/ |
||||||
|
DefaultCrudMethodMetadata(Method method) { |
||||||
|
|
||||||
|
Assert.notNull(method, "Method must not be null"); |
||||||
|
|
||||||
|
this.readPreference = findReadPreference(method); |
||||||
|
this.method = method; |
||||||
|
} |
||||||
|
|
||||||
|
private Optional<ReadPreference> findReadPreference(Method method) { |
||||||
|
|
||||||
|
org.springframework.data.mongodb.repository.ReadPreference preference = AnnotatedElementUtils |
||||||
|
.findMergedAnnotation(method, org.springframework.data.mongodb.repository.ReadPreference.class); |
||||||
|
|
||||||
|
if (preference == null) { |
||||||
|
|
||||||
|
preference = AnnotatedElementUtils.findMergedAnnotation(method.getDeclaringClass(), |
||||||
|
org.springframework.data.mongodb.repository.ReadPreference.class); |
||||||
|
} |
||||||
|
|
||||||
|
if (preference == null) { |
||||||
|
return Optional.empty(); |
||||||
|
} |
||||||
|
|
||||||
|
return Optional.of(com.mongodb.ReadPreference.valueOf(preference.value())); |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public Optional<ReadPreference> getReadPreference() { |
||||||
|
return readPreference; |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public Method getMethod() { |
||||||
|
return method; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private static class ThreadBoundTargetSource implements TargetSource { |
||||||
|
|
||||||
|
@Override |
||||||
|
public Class<?> getTargetClass() { |
||||||
|
return CrudMethodMetadata.class; |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public boolean isStatic() { |
||||||
|
return false; |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public Object getTarget() { |
||||||
|
|
||||||
|
MethodInvocation invocation = CrudMethodMetadataPopulatingMethodInterceptor.currentInvocation(); |
||||||
|
return TransactionSynchronizationManager.getResource(invocation.getMethod()); |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public void releaseTarget(Object target) {} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,80 @@ |
|||||||
|
/* |
||||||
|
* Copyright 2023 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.mongodb.repository.support; |
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.*; |
||||||
|
import static org.mockito.Mockito.*; |
||||||
|
|
||||||
|
import reactor.core.publisher.Mono; |
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach; |
||||||
|
import org.junit.jupiter.api.Test; |
||||||
|
import org.junit.jupiter.api.extension.ExtendWith; |
||||||
|
import org.mockito.ArgumentCaptor; |
||||||
|
import org.mockito.Mock; |
||||||
|
import org.mockito.junit.jupiter.MockitoExtension; |
||||||
|
import org.mockito.junit.jupiter.MockitoSettings; |
||||||
|
import org.mockito.quality.Strictness; |
||||||
|
import org.springframework.data.mongodb.core.ReactiveMongoTemplate; |
||||||
|
import org.springframework.data.mongodb.core.convert.MappingMongoConverter; |
||||||
|
import org.springframework.data.mongodb.core.convert.MongoConverter; |
||||||
|
import org.springframework.data.mongodb.core.convert.NoOpDbRefResolver; |
||||||
|
import org.springframework.data.mongodb.core.mapping.MongoMappingContext; |
||||||
|
import org.springframework.data.mongodb.core.query.Query; |
||||||
|
import org.springframework.data.mongodb.repository.Person; |
||||||
|
import org.springframework.data.mongodb.repository.ReadPreference; |
||||||
|
import org.springframework.data.repository.Repository; |
||||||
|
|
||||||
|
/** |
||||||
|
* Unit test for {@link ReactiveMongoRepositoryFactory}. |
||||||
|
* |
||||||
|
* @author Mark Paluch |
||||||
|
*/ |
||||||
|
@ExtendWith(MockitoExtension.class) |
||||||
|
@MockitoSettings(strictness = Strictness.LENIENT) |
||||||
|
public class ReactiveMongoRepositoryFactoryUnitTests { |
||||||
|
|
||||||
|
@Mock ReactiveMongoTemplate template; |
||||||
|
|
||||||
|
MongoConverter converter = new MappingMongoConverter(NoOpDbRefResolver.INSTANCE, new MongoMappingContext()); |
||||||
|
|
||||||
|
@BeforeEach |
||||||
|
public void setUp() { |
||||||
|
when(template.getConverter()).thenReturn(converter); |
||||||
|
} |
||||||
|
|
||||||
|
@Test // GH-2971
|
||||||
|
void considersCrudMethodMetadata() { |
||||||
|
|
||||||
|
when(template.findOne(any(), any(), anyString())).thenReturn(Mono.empty()); |
||||||
|
|
||||||
|
ReactiveMongoRepositoryFactory factory = new ReactiveMongoRepositoryFactory(template); |
||||||
|
MyPersonRepository repository = factory.getRepository(MyPersonRepository.class); |
||||||
|
repository.findById(42L); |
||||||
|
|
||||||
|
ArgumentCaptor<Query> captor = ArgumentCaptor.forClass(Query.class); |
||||||
|
verify(template).findOne(captor.capture(), eq(Person.class), eq("person")); |
||||||
|
|
||||||
|
Query value = captor.getValue(); |
||||||
|
assertThat(value.getReadPreference()).isEqualTo(com.mongodb.ReadPreference.secondary()); |
||||||
|
} |
||||||
|
|
||||||
|
interface MyPersonRepository extends Repository<Person, Long> { |
||||||
|
|
||||||
|
@ReadPreference("secondary") |
||||||
|
Mono<Person> findById(Long id); |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue