diff --git a/docs/modules/databases/mongodb.md b/docs/modules/databases/mongodb.md index 9e81e28c39f..441025a8d92 100644 --- a/docs/modules/databases/mongodb.md +++ b/docs/modules/databases/mongodb.md @@ -3,7 +3,14 @@ !!! note This module is INCUBATING. While it is ready for use and operational in the current version of Testcontainers, it is possible that it may receive breaking changes in the future. See [our contributing guidelines](/contributing/#incubating-modules) for more information on our incubating modules policy. -## Usage example +The MongoDB module provides two Testcontainers for MongoDB unit testing: + +* [MongoDBContainer](#mongodbcontainer) - the core MongoDB database +* [MongoDBAtlasLocalContainer](#mongodbatlaslocalcontainer) - the core MongoDB database combined with MongoDB Atlas Search + Atlas Vector Search + +## MongoDBContainer + +### Usage example The following example shows how to create a MongoDBContainer: @@ -36,6 +43,37 @@ For instance, to initialize a single node replica set on fixed ports via Docker, As we can see, there is a lot of operations to execute and we even haven't touched a non-fixed port approach. That's where the MongoDBContainer might come in handy. +## MongoDBAtlasLocalContainer + +### Usage example + +The following example shows how to create a MongoDBAtlasLocalContainer: + + +[Creating a MongoDB Atlas Local Container](../../../modules/mongodb/src/test/java/org/testcontainers/mongodb/MongoDBAtlasLocalContainerTest.java) inside_block:creatingAtlasLocalContainer + + +And how to start it: + + +[Start the Container](../../../modules/mongodb/src/test/java/org/testcontainers/mongodb/MongoDBAtlasLocalContainerTest.java) inside_block:startingAtlasLocalContainer + + +The connection string provided by the MongoDBAtlasLocalContainer's getConnectionString() method includes the dynamically allocated port: + + +[Get the Connection String](../../../modules/mongodb/src/test/java/org/testcontainers/mongodb/MongoDBAtlasLocalContainerTest.java) inside_block:getConnectionStringAtlasLocalContainer + + +e.g. `mongodb://localhost:12345/?directConnection=true` + +### References +MongoDB Atlas Local combines the MongoDB database engine with MongoT, a sidecar process for advanced searching capabilities built by MongoDB and powered by [Apache Lucene](https://lucene.apache.org/). + +The container (mongodb/mongodb-atlas-local) documentation can be found [here](https://www.mongodb.com/docs/atlas/cli/current/atlas-cli-deploy-docker/). + +General information about Atlas Search can be found [here](https://www.mongodb.com/docs/atlas/atlas-search/). + ## Adding this module to your project dependencies Add the following dependency to your `pom.xml`/`build.gradle` file: @@ -55,7 +93,7 @@ Add the following dependency to your `pom.xml`/`build.gradle` file: ``` !!! hint -Adding this Testcontainers library JAR will not automatically add a database driver JAR to your project. You should ensure that your project also has a suitable database driver as a dependency + Adding this Testcontainers library JAR will not automatically add a database driver JAR to your project. You should ensure that your project also has a suitable database driver as a dependency #### Copyright Copyright (c) 2019 Konstantin Silaev diff --git a/modules/mongodb/src/main/java/org/testcontainers/mongodb/MongoDBAtlasLocalContainer.java b/modules/mongodb/src/main/java/org/testcontainers/mongodb/MongoDBAtlasLocalContainer.java new file mode 100644 index 00000000000..52067c01ff2 --- /dev/null +++ b/modules/mongodb/src/main/java/org/testcontainers/mongodb/MongoDBAtlasLocalContainer.java @@ -0,0 +1,38 @@ +package org.testcontainers.mongodb; + +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; + +/** + * Testcontainers implementation for MongoDB Atlas. + *

+ * Supported images: {@code mongodb/mongodb-atlas-local} + *

+ * Exposed ports: 27017 + */ +public class MongoDBAtlasLocalContainer extends GenericContainer { + + private static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("mongodb/mongodb-atlas-local"); + + private static final int MONGODB_INTERNAL_PORT = 27017; + + public MongoDBAtlasLocalContainer(final String dockerImageName) { + this(DockerImageName.parse(dockerImageName)); + } + + public MongoDBAtlasLocalContainer(final DockerImageName dockerImageName) { + super(dockerImageName); + dockerImageName.assertCompatibleWith(DEFAULT_IMAGE_NAME); + + withExposedPorts(MONGODB_INTERNAL_PORT); + waitingFor(Wait.forSuccessfulCommand("runner healthcheck")); + } + + /** + * Get the connection string to MongoDB. + */ + public String getConnectionString() { + return String.format("mongodb://%s:%d/?directConnection=true", getHost(), getMappedPort(MONGODB_INTERNAL_PORT)); + } +} diff --git a/modules/mongodb/src/test/java/org/testcontainers/mongodb/AtlasLocalDataAccess.java b/modules/mongodb/src/test/java/org/testcontainers/mongodb/AtlasLocalDataAccess.java new file mode 100644 index 00000000000..c3e6aff41f9 --- /dev/null +++ b/modules/mongodb/src/test/java/org/testcontainers/mongodb/AtlasLocalDataAccess.java @@ -0,0 +1,177 @@ +package org.testcontainers.mongodb; + +import com.mongodb.ConnectionString; +import com.mongodb.MongoClientSettings; +import com.mongodb.client.ListSearchIndexesIterable; +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoClients; +import com.mongodb.client.MongoCollection; +import com.mongodb.client.MongoDatabase; +import com.mongodb.client.model.Aggregates; +import com.mongodb.client.model.search.SearchOperator; +import com.mongodb.client.model.search.SearchOptions; +import com.mongodb.client.model.search.SearchPath; +import org.bson.BsonDocument; +import org.bson.Document; +import org.bson.codecs.configuration.CodecRegistries; +import org.bson.codecs.configuration.CodecRegistry; +import org.bson.codecs.pojo.PojoCodecProvider; +import org.bson.conversions.Bson; +import org.bson.json.JsonWriterSettings; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Collections; +import java.util.concurrent.TimeUnit; + +import static org.awaitility.Awaitility.await; + +public class AtlasLocalDataAccess implements AutoCloseable { + + private static final Logger log = LoggerFactory.getLogger(AtlasLocalDataAccess.class); + + private final MongoClient mongoClient; + + private final MongoDatabase testDB; + + private final MongoCollection testCollection; + + private final String collectionName; + + public AtlasLocalDataAccess(String connectionString, String databaseName, String collectionName) { + this.collectionName = collectionName; + log.info("DataAccess connecting to {}", connectionString); + + CodecRegistry pojoCodecRegistry = CodecRegistries.fromProviders( + PojoCodecProvider.builder().automatic(true).build() + ); + CodecRegistry codecRegistry = CodecRegistries.fromRegistries( + MongoClientSettings.getDefaultCodecRegistry(), + pojoCodecRegistry + ); + MongoClientSettings clientSettings = MongoClientSettings + .builder() + .applyConnectionString(new ConnectionString(connectionString)) + .codecRegistry(codecRegistry) + .build(); + mongoClient = MongoClients.create(clientSettings); + testDB = mongoClient.getDatabase(databaseName); + testCollection = testDB.getCollection(collectionName, TestData.class); + } + + @Override + public void close() { + mongoClient.close(); + } + + public void initAtlasSearchIndex() throws URISyntaxException, IOException, InterruptedException { + //Create the collection (if it doesn't exist). Required because unlike other database operations, createSearchIndex will fail if the collection doesn't exist yet + testDB.createCollection(collectionName); + + //Read the atlas search index JSON from a resource file + String atlasSearchIndexJson = new String( + Files.readAllBytes(Paths.get(getClass().getResource("/atlas-local-index.json").toURI())), + StandardCharsets.UTF_8 + ); + log.info( + "Creating Atlas Search index AtlasSearchIndex on collection {}:\n{}", + collectionName, + atlasSearchIndexJson + ); + testCollection.createSearchIndex("AtlasSearchIndex", BsonDocument.parse(atlasSearchIndexJson)); + + //wait for the atlas search index to be ready + Instant start = Instant.now(); + await() + .atMost(5, TimeUnit.SECONDS) + .pollInterval(10, TimeUnit.MILLISECONDS) + .pollInSameThread() + .until(this::getIndexStatus, "READY"::equalsIgnoreCase); + + log.info( + "Atlas Search index AtlasSearchIndex on collection {} is ready (took {} milliseconds) to create.", + collectionName, + start.until(Instant.now(), ChronoUnit.MILLIS) + ); + } + + private String getIndexStatus() { + ListSearchIndexesIterable searchIndexes = testCollection.listSearchIndexes(); + for (Document searchIndex : searchIndexes) { + if (searchIndex.get("name").equals("AtlasSearchIndex")) { + return searchIndex.getString("status"); + } + } + return null; + } + + public void insertData(TestData data) { + log.info("Inserting document {}", data); + testCollection.insertOne(data); + } + + public TestData findAtlasSearch(String test) { + Bson searchClause = Aggregates.search( + SearchOperator.of(SearchOperator.text(SearchPath.fieldPath("test"), test).fuzzy()), + SearchOptions.searchOptions().index("AtlasSearchIndex") + ); + log.trace( + "Searching for document using Atlas Search:\n{}", + searchClause.toBsonDocument().toJson(JsonWriterSettings.builder().indent(true).build()) + ); + return testCollection.aggregate(Collections.singletonList(searchClause)).first(); + } + + public static class TestData { + + String test; + + int test2; + + boolean test3; + + public TestData() {} + + public TestData(String test, int test2, boolean test3) { + this.test = test; + this.test2 = test2; + this.test3 = test3; + } + + public String getTest() { + return test; + } + + public void setTest(String test) { + this.test = test; + } + + public int getTest2() { + return test2; + } + + public void setTest2(int test2) { + this.test2 = test2; + } + + public boolean isTest3() { + return test3; + } + + public void setTest3(boolean test3) { + this.test3 = test3; + } + + @Override + public String toString() { + return "TestData{" + "test='" + test + '\'' + ", test2=" + test2 + ", test3=" + test3 + '}'; + } + } +} diff --git a/modules/mongodb/src/test/java/org/testcontainers/mongodb/MongoDBAtlasLocalContainerTest.java b/modules/mongodb/src/test/java/org/testcontainers/mongodb/MongoDBAtlasLocalContainerTest.java new file mode 100644 index 00000000000..2d7664d51fa --- /dev/null +++ b/modules/mongodb/src/test/java/org/testcontainers/mongodb/MongoDBAtlasLocalContainerTest.java @@ -0,0 +1,79 @@ +package org.testcontainers.mongodb; + +import org.junit.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Objects; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +public class MongoDBAtlasLocalContainerTest { + + private static final Logger log = LoggerFactory.getLogger(MongoDBAtlasLocalContainerTest.class); + + @Test + public void getConnectionString() { + try ( + MongoDBAtlasLocalContainer container = new MongoDBAtlasLocalContainer("mongodb/mongodb-atlas-local:7.0.9") + ) { + container.start(); + String connectionString = container.getConnectionString(); + assertThat(connectionString).isNotNull(); + assertThat(connectionString).startsWith("mongodb://"); + assertThat(connectionString) + .isEqualTo( + String.format( + "mongodb://%s:%d/?directConnection=true", + container.getHost(), + container.getFirstMappedPort() + ) + ); + } + } + + @Test + public void createAtlasIndexAndSearchIt() throws Exception { + try ( + // creatingAtlasLocalContainer { + MongoDBAtlasLocalContainer atlasLocalContainer = new MongoDBAtlasLocalContainer( + "mongodb/mongodb-atlas-local:7.0.9" + ); + // } + ) { + // startingAtlasLocalContainer { + atlasLocalContainer.start(); + // } + + // getConnectionStringAtlasLocalContainer { + String connectionString = atlasLocalContainer.getConnectionString(); + // } + + try ( + AtlasLocalDataAccess atlasLocalDataAccess = new AtlasLocalDataAccess(connectionString, "test", "test") + ) { + atlasLocalDataAccess.initAtlasSearchIndex(); + + atlasLocalDataAccess.insertData(new AtlasLocalDataAccess.TestData("tests", 123, true)); + + Instant start = Instant.now(); + log.info( + "Waiting for Atlas Search to index the data by polling atlas search query (Atlas Search is eventually consistent)" + ); + await() + .atMost(5, TimeUnit.SECONDS) + .pollInterval(10, TimeUnit.MILLISECONDS) + .pollInSameThread() + .until(() -> atlasLocalDataAccess.findAtlasSearch("test"), Objects::nonNull); + log.info( + "Atlas Search indexed the new data and was searchable after {}ms.", + start.until(Instant.now(), ChronoUnit.MILLIS) + ); + } + } + } +} diff --git a/modules/mongodb/src/test/resources/atlas-local-index.json b/modules/mongodb/src/test/resources/atlas-local-index.json new file mode 100644 index 00000000000..1bca016025f --- /dev/null +++ b/modules/mongodb/src/test/resources/atlas-local-index.json @@ -0,0 +1,18 @@ +{ + "mappings": { + "dynamic": false, + "fields": { + "test": { + "type": "string" + }, + "test2": { + "type": "number", + "representation": "int64", + "indexDoubles": false + }, + "test3": { + "type": "boolean" + } + } + } +}