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 { + + List loadedIds = new ArrayList<>(); + + @Override + public void onAfterLoad(AfterLoadEvent event) { + getId(event).ifPresent(loadedIds::add); + } + } + + @SuppressWarnings("NullableProblems") + private static class MyAfterSaveEventListener extends AbstractMongoEventListener { + + List savedIds = new ArrayList<>(); + + @Override + public void onAfterSave(AfterSaveEvent event) { + getId(event).ifPresent(savedIds::add); + } + } + + private static Optional getId(MongoMappingEvent event) { + return getId(event.getDocument()); + } + + private static Optional getId(Document document) { + return Optional.ofNullable(document) + .map(doc -> doc.get("_id")) + .map(Object::toString); + } + + @Data + @NoArgsConstructor + @RequiredArgsConstructor + private static class SampleQuoteDoc { + + @Id + private String id; + + @NonNull + private String name; + + @NonNull + private String text; + } + + private interface SampleQuoteDocRepository extends MongoRepository { + } + + @Data + @NoArgsConstructor + @RequiredArgsConstructor + private static class SamplePersonDoc { + @Id + private String id; + + @NonNull + private String name; + + @NonNull + private int age; + } + + private interface SamplePersonDocRepository extends MongoRepository { + } +} \ No newline at end of file diff --git a/src/test/java/org/kiwiproject/spring/data/KiwiMongoConvertersTest.java b/src/test/java/org/kiwiproject/spring/data/KiwiMongoConvertersTest.java index 9242ce85..0b2290d8 100644 --- a/src/test/java/org/kiwiproject/spring/data/KiwiMongoConvertersTest.java +++ b/src/test/java/org/kiwiproject/spring/data/KiwiMongoConvertersTest.java @@ -1,10 +1,10 @@ package org.kiwiproject.spring.data; import static org.assertj.core.api.Assertions.assertThat; -import static org.kiwiproject.base.KiwiStrings.f; +import static org.kiwiproject.spring.util.MongoTestHelpers.newMongoTemplate; +import static org.kiwiproject.spring.util.MongoTestHelpers.startInMemoryMongoServer; import de.bwaldvogel.mongo.MongoServer; -import de.bwaldvogel.mongo.backend.memory.MemoryBackend; import org.bson.BsonUndefined; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; @@ -13,7 +13,6 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.data.mongodb.core.MongoTemplate; -import org.springframework.data.mongodb.core.SimpleMongoClientDbFactory; @DisplayName("KiwiMongoConverters") class KiwiMongoConvertersTest { @@ -34,10 +33,7 @@ static void afterAll() { @BeforeEach void setUp() { - var addr = mongoServer.getLocalAddress(); - var connectionString = f("mongodb://{}:{}/kiwi_mongo_converters_test", addr.getHostName(), addr.getPort()); - var factory = new SimpleMongoClientDbFactory(connectionString); - mongoTemplate = new MongoTemplate(factory); + mongoTemplate = newMongoTemplate(mongoServer); } @Nested @@ -63,12 +59,4 @@ void shouldConvertToNull() { assertThat(converter.convert(new BsonUndefined())).isNull(); } } - - - private static MongoServer startInMemoryMongoServer() { - var mongoServer = new MongoServer(new MemoryBackend()); - mongoServer.bind(); - return mongoServer; - } - -} \ No newline at end of file +} diff --git a/src/test/java/org/kiwiproject/spring/util/MongoTestHelpers.java b/src/test/java/org/kiwiproject/spring/util/MongoTestHelpers.java new file mode 100644 index 00000000..56b699c3 --- /dev/null +++ b/src/test/java/org/kiwiproject/spring/util/MongoTestHelpers.java @@ -0,0 +1,30 @@ +package org.kiwiproject.spring.util; + +import static org.kiwiproject.base.KiwiStrings.f; + +import de.bwaldvogel.mongo.MongoServer; +import de.bwaldvogel.mongo.backend.memory.MemoryBackend; +import lombok.experimental.UtilityClass; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.SimpleMongoClientDbFactory; + +@UtilityClass +public class MongoTestHelpers { + + public static MongoServer startInMemoryMongoServer() { + var mongoServer = new MongoServer(new MemoryBackend()); + mongoServer.bind(); + return mongoServer; + } + + public static String mongoConnectionString(MongoServer mongoServer) { + var addr = mongoServer.getLocalAddress(); + return f("mongodb://{}:{}/test_db_{}", addr.getHostName(), addr.getPort(), System.currentTimeMillis()); + } + + public static MongoTemplate newMongoTemplate(MongoServer mongoServer) { + var connectionString = MongoTestHelpers.mongoConnectionString(mongoServer); + var factory = new SimpleMongoClientDbFactory(connectionString); + return new MongoTemplate(factory); + } +}