From d7d557e41ceeca49f46d866807467a5abb66c7ec Mon Sep 17 00:00:00 2001
From: Scott Leberknight <174812+sleberknight@users.noreply.github.com>
Date: Thu, 12 Nov 2020 16:47:07 -0500
Subject: [PATCH] Add MongoRepositoryContext
* Add MongoRepositoryContext
* Extract some common Mongo testing code into MongoTestHelpers
* Refactor KiwiMongoConvertersTest to use MongoTestHelpers
Fixes #408
---
.../context/MongoRepositoryContext.java | 135 ++++++++++
.../context/MongoRepositoryContextTest.java | 234 ++++++++++++++++++
.../spring/data/KiwiMongoConvertersTest.java | 20 +-
.../spring/util/MongoTestHelpers.java | 30 +++
4 files changed, 403 insertions(+), 16 deletions(-)
create mode 100644 src/main/java/org/kiwiproject/spring/context/MongoRepositoryContext.java
create mode 100644 src/test/java/org/kiwiproject/spring/context/MongoRepositoryContextTest.java
create mode 100644 src/test/java/org/kiwiproject/spring/util/MongoTestHelpers.java
diff --git a/src/main/java/org/kiwiproject/spring/context/MongoRepositoryContext.java b/src/main/java/org/kiwiproject/spring/context/MongoRepositoryContext.java
new file mode 100644
index 00000000..e088b24f
--- /dev/null
+++ b/src/main/java/org/kiwiproject/spring/context/MongoRepositoryContext.java
@@ -0,0 +1,135 @@
+package org.kiwiproject.spring.context;
+
+import static java.util.stream.Collectors.toSet;
+import static org.kiwiproject.spring.data.KiwiMongoConverters.addCustomConverters;
+import static org.kiwiproject.spring.data.KiwiMongoConverters.newBsonUndefinedToNullObjectConverter;
+
+import com.mongodb.WriteConcern;
+import lombok.Getter;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.ApplicationListener;
+import org.springframework.context.support.GenericApplicationContext;
+import org.springframework.data.mongodb.core.MongoTemplate;
+import org.springframework.data.mongodb.core.SimpleMongoClientDbFactory;
+import org.springframework.data.mongodb.repository.MongoRepository;
+import org.springframework.data.mongodb.repository.support.MongoRepositoryFactory;
+
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import java.util.stream.Stream;
+
+/**
+ * This class generates the context and factory necessary to programmatically initialize Spring Data
+ * {@link org.springframework.data.mongodb.repository.MongoRepository} interfaces.
+ *
+ * This also provides a default map of repositories. For simple basic usages, simply call {@link #getRepository} with
+ * the appropriate type and it will return (or create-and-return) the repository instance.
+ */
+@Slf4j
+public class MongoRepositoryContext {
+
+ /**
+ * The {@link MongoTemplate} initialized by this context.
+ */
+ @Getter
+ private final MongoTemplate mongoTemplate;
+
+ /**
+ * Direct access to the {@link MongoRepositoryFactory} in case it is needed for whatever reason.
+ */
+ @Getter
+ private final MongoRepositoryFactory factory;
+
+ /**
+ * The Spring application context used by this instance, in case it is needed for whatever reason.
+ */
+ @Getter
+ private final GenericApplicationContext springContext;
+
+ private final ConcurrentMap, MongoRepository, ?>> repoMap = new ConcurrentHashMap<>();
+
+ /**
+ * Create a new instance using the given MongoDB URI.
+ *
+ * @param mongoUri the MongoDB connection string, e.g. {@code mongodb://my-mongo-server.test:27017}
+ */
+ public MongoRepositoryContext(String mongoUri) {
+ this(initializeMongoTemplate(mongoUri));
+ }
+
+ /**
+ * Create a new instance that will use the given {@link MongoTemplate}.
+ *
+ * @param mongoTemplate the MongoTemplate to use
+ */
+ public MongoRepositoryContext(MongoTemplate mongoTemplate) {
+ this.mongoTemplate = mongoTemplate;
+ this.factory = new MongoRepositoryFactory(mongoTemplate);
+ this.springContext = new GenericApplicationContext();
+ this.springContext.refresh();
+ this.mongoTemplate.setApplicationContext(springContext);
+ }
+
+ /**
+ * Get a MongoRepository having the given class.
+ *
+ * @param repositoryInterfaceClass the repository class
+ * @param the repository class
+ * @return the singleton repository instance
+ */
+ @SuppressWarnings("unchecked")
+ public final > T getRepository(Class repositoryInterfaceClass) {
+ return (T) repoMap.computeIfAbsent(
+ repositoryInterfaceClass,
+ missingClass -> (T) factory.getRepository(missingClass));
+ }
+
+ /**
+ * Attach one or more listeners to the context.
+ *
+ * @param listeners the listeners to attach
+ * @see org.springframework.context.support.AbstractApplicationContext#addApplicationListener(ApplicationListener)
+ */
+ public void attachListeners(ApplicationListener>... listeners) {
+ Set registeredListenerClassNames = springContext.getApplicationListeners()
+ .stream()
+ .map(listener -> listener.getClass().getName())
+ .collect(toSet());
+
+ Stream.of(listeners).forEach(listener -> {
+ var listenerClassName = listener.getClass().getName();
+ LOG.debug("Adding application listener: {}", listenerClassName);
+
+ if (registeredListenerClassNames.contains(listenerClassName)) {
+ LOG.warn("There is already listener of type of {}; adding another one may cause unintended consequences",
+ listenerClassName);
+ }
+
+ springContext.addApplicationListener(listener);
+ });
+ }
+
+ /**
+ * Convenience method to initialize a new {@link MongoTemplate} from the given MongoDB connection string.
+ *
+ * The returned instance is configured with write concern set to {@link WriteConcern#ACKNOWLEDGED}.
+ *
+ * This method also registers
+ * {@link org.kiwiproject.spring.data.KiwiMongoConverters.BsonUndefinedToNullStringConverter BsonUndefinedToNullStringConverter}
+ * as a custom converter.
+ *
+ * @param mongoUri the MongoDB connection string, e.g. {@code mongodb://my-mongo-server.test:27017}
+ * @return a new MongoTemplate instance
+ */
+ public static MongoTemplate initializeMongoTemplate(String mongoUri) {
+ var mongoDbFactory = new SimpleMongoClientDbFactory(mongoUri);
+
+ var mongoTemplate = new MongoTemplate(mongoDbFactory);
+ mongoTemplate.setWriteConcern(WriteConcern.ACKNOWLEDGED);
+
+ addCustomConverters(mongoTemplate, newBsonUndefinedToNullObjectConverter());
+
+ return mongoTemplate;
+ }
+}
diff --git a/src/test/java/org/kiwiproject/spring/context/MongoRepositoryContextTest.java b/src/test/java/org/kiwiproject/spring/context/MongoRepositoryContextTest.java
new file mode 100644
index 00000000..4a485b26
--- /dev/null
+++ b/src/test/java/org/kiwiproject/spring/context/MongoRepositoryContextTest.java
@@ -0,0 +1,234 @@
+package org.kiwiproject.spring.context;
+
+import static java.util.stream.Collectors.toSet;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.kiwiproject.spring.util.MongoTestHelpers.mongoConnectionString;
+import static org.kiwiproject.spring.util.MongoTestHelpers.startInMemoryMongoServer;
+
+import de.bwaldvogel.mongo.MongoServer;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import lombok.NonNull;
+import lombok.RequiredArgsConstructor;
+import org.bson.Document;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.springframework.data.annotation.Id;
+import org.springframework.data.mongodb.core.mapping.event.AbstractMongoEventListener;
+import org.springframework.data.mongodb.core.mapping.event.AfterLoadEvent;
+import org.springframework.data.mongodb.core.mapping.event.AfterSaveEvent;
+import org.springframework.data.mongodb.core.mapping.event.LoggingEventListener;
+import org.springframework.data.mongodb.core.mapping.event.MongoMappingEvent;
+import org.springframework.data.mongodb.repository.MongoRepository;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+
+@DisplayName("MongoRepositoryContext")
+class MongoRepositoryContextTest {
+
+ private static MongoServer mongoServer;
+
+ private MongoRepositoryContext mongoRepositoryContext;
+
+ @BeforeAll
+ static void beforeAll() {
+ mongoServer = startInMemoryMongoServer();
+ }
+
+ @AfterAll
+ static void afterAll() {
+ mongoServer.shutdownNow();
+ }
+
+ @BeforeEach
+ void setUp() {
+ String mongoUri = mongoConnectionString(mongoServer);
+ mongoRepositoryContext = new MongoRepositoryContext(mongoUri);
+ }
+
+ @Nested
+ class Construction {
+
+ @Test
+ void shouldInitializeMongoTemplate() {
+ assertThat(mongoRepositoryContext.getMongoTemplate()).isNotNull();
+ }
+
+ @Test
+ void shouldInitializeMongoRepositoryFactory() {
+ assertThat(mongoRepositoryContext.getFactory()).isNotNull();
+ }
+
+ @Test
+ void shouldInitializeSpringContext() {
+ assertThat(mongoRepositoryContext.getSpringContext()).isNotNull();
+ }
+ }
+
+ @Nested
+ class GetRepository {
+
+ @ParameterizedTest
+ @ValueSource(classes = {SampleQuoteDocRepository.class, SamplePersonDocRepository.class})
+ void shouldGetRepositories(Class> repositoryClass) {
+ var repository = mongoRepositoryContext.getRepository(repositoryClass);
+
+ assertThat(repository).isNotNull();
+
+ assertThatCode(repository::findAll).doesNotThrowAnyException();
+ }
+
+ @Test
+ void shouldReturnSameInstanceOnceInitialized() {
+ var repository1 = mongoRepositoryContext.getRepository(SampleQuoteDocRepository.class);
+ var repository2 = mongoRepositoryContext.getRepository(SampleQuoteDocRepository.class);
+
+ assertThat(repository1).isSameAs(repository2);
+ }
+
+ @Test
+ void shouldBeAbleToUseRepository() {
+ var quoteDocRepository = mongoRepositoryContext.getRepository(SampleQuoteDocRepository.class);
+
+ quoteDocRepository.insert(new SampleQuoteDoc(
+ "Blaise Pascal",
+ "Justice without force is powerless; force without justice is tyrannical"));
+
+ quoteDocRepository.insert(new SampleQuoteDoc(
+ "Adam Smith",
+ "Science is the great antidote to the poison of enthusiasm and superstition."));
+
+ var people = quoteDocRepository.findAll()
+ .stream()
+ .map(SampleQuoteDoc::getName)
+ .collect(toSet());
+
+ assertThat(people).containsExactlyInAnyOrder("Adam Smith", "Blaise Pascal");
+ }
+ }
+
+ @Nested
+ class AttachListeners {
+
+ @Test
+ void shouldAttachListeners() {
+ var afterSaveListener = new MyAfterSaveEventListener();
+ var afterLoadListener = new MyAfterLoadEventListener();
+
+ mongoRepositoryContext.attachListeners(new LoggingEventListener(), afterSaveListener, afterLoadListener);
+
+ var personDocRepository = mongoRepositoryContext.getRepository(SamplePersonDocRepository.class);
+
+ var alice = personDocRepository.insert(new SamplePersonDoc("Alice", 42));
+ var bob = personDocRepository.insert(new SamplePersonDoc("Bob", 49));
+
+ var aliceId = alice.getId();
+ var bobId = bob.getId();
+
+ personDocRepository.findById(bobId).orElseThrow();
+ personDocRepository.findById(aliceId).orElseThrow();
+ personDocRepository.findById(bobId).orElseThrow();
+
+ assertThat(afterSaveListener.savedIds).containsExactly(aliceId, bobId);
+ assertThat(afterLoadListener.loadedIds).containsExactly(bobId, aliceId, bobId);
+ }
+
+ @Test
+ void shouldAllowMultipleListenersOfSameType() {
+ var afterLoadListener1 = new MyAfterLoadEventListener();
+ var afterLoadListener2 = new MyAfterLoadEventListener();
+
+ mongoRepositoryContext.attachListeners(afterLoadListener1);
+
+ // This second call triggers the warning (must inspect logs to see the WARN message)
+ mongoRepositoryContext.attachListeners(afterLoadListener2);
+
+ var personDocRepository = mongoRepositoryContext.getRepository(SamplePersonDocRepository.class);
+
+ var alice = personDocRepository.insert(new SamplePersonDoc("Carlos", 45));
+
+ var aliceId = alice.getId();
+ personDocRepository.findById(aliceId).orElseThrow();
+
+ // Both listeners should be triggered
+ assertThat(afterLoadListener1.loadedIds).containsExactly(aliceId);
+ assertThat(afterLoadListener2.loadedIds).containsExactly(aliceId);
+ }
+ }
+
+ @SuppressWarnings("NullableProblems")
+ private static class MyAfterLoadEventListener extends AbstractMongoEventListener