diff --git a/docs/modules/databases/index.md b/docs/modules/databases/index.md index d7654cc6a33..6685ade55c8 100644 --- a/docs/modules/databases/index.md +++ b/docs/modules/databases/index.md @@ -10,115 +10,4 @@ You might want to use Testcontainers' database support: !!! note Of course, it's still important to have as few tests that hit the database as possible, and make good use of mocks for components higher up the stack. -You can obtain a temporary database in one of two ways: - - * **JUnit @Rule/@ClassRule**: this mode starts a database inside a container before your tests and tears it down afterwards. - * **Using a specially modified JDBC URL**: after making a very simple modification to your system's JDBC URL string, Testcontainers will provide a disposable stand-in database that can be used without requiring modification to your application code. - -## Database container objects - -Add a @Rule or @ClassRule to your test class, e.g.: - -```java -public class SimpleMySQLTest { - @Rule - public MySQLContainer mysql = new MySQLContainer(); -``` - -Now, in your test code (or a suitable setup method), you can obtain details necessary to connect to this database: - - * `mysql.getJdbcUrl()` provides a JDBC URL your code can connect to - * `mysql.getUsername()` provides the username your code should pass to the driver - * `mysql.getPassword()` provides the password your code should pass to the driver - -Note that if you use `@Rule`, you will be given an isolated container for each test method. If you use `@ClassRule`, you will get on isolated container for all the methods in the test class. - -Examples/Tests: - - * [MySQL](https://github.com/testcontainers/testcontainers-java/blob/master/modules/jdbc-test/src/test/java/org/testcontainers/junit/SimpleMySQLTest.java) - * [PostgreSQL](https://github.com/testcontainers/testcontainers-java/blob/master/modules/jdbc-test/src/test/java/org/testcontainers/junit/SimplePostgreSQLTest.java) - -## Database containers launched via JDBC URL scheme - -As long as you have Testcontainers and the appropriate JDBC driver on your classpath, you can simply modify regular JDBC connection URLs to get a fresh containerized instance of the database each time your application starts up. - -_N.B:_ - -* _TC needs to be on your application's classpath at runtime for this to work_ -* _For Spring Boot (Before version `2.3.0`) you need to specify the driver manually `spring.datasource.driver-class-name=org.testcontainers.jdbc.ContainerDatabaseDriver`_ - -**Original URL**: `jdbc:mysql:5.7.22://localhost:3306/databasename` - -Insert `tc:` after `jdbc:` as follows. Note that the hostname, port and database name will be ignored; you can leave these as-is or set them to any value. - -!!! note - We will use `///` (host-less URIs) from now on to emphasis the unimportance of the `host:port` pair. - From Testcontainers' perspective, `jdbc:mysql:5.7.22://localhost:3306/databasename` and `jdbc:mysql:5.7.22:///databasename` is the same URI. - -### JDBC URL examples - -#### Using Testcontainers with a fixed version - -`jdbc:tc:mysql:5.6.23:///databasename` - -#### Using PostgreSQL - -`jdbc:tc:postgresql:9.6.8:///databasename` - -### Using PostGIS - -`jdbc:tc:postgis:9.6:///databasename` - -### Using Presto - -`jdbc:tc:presto:329://localhost/memory/default` - -## Using a classpath init script - -Testcontainers can run an init script after the database container is started, but before your code is given a connection to it. The script must be on the classpath, and is referenced as follows: - -`jdbc:tc:mysql:5.7.22:///databasename?TC_INITSCRIPT=somepath/init_mysql.sql` - -This is useful if you have a fixed script for setting up database schema, etc. - -## Using an init script from a file - -If the init script path is prefixed `file:`, it will be loaded from a file (relative to the working directory, which will usually be the project root). - -`jdbc:tc:mysql:5.7.22:///databasename?TC_INITSCRIPT=file:src/main/resources/init_mysql.sql` - -#### Using an init function - -Instead of running a fixed script for DB setup, it may be useful to call a Java function that you define. This is intended to allow you to trigger database schema migration tools. To do this, add TC_INITFUNCTION to the URL as follows, passing a full path to the class name and method: - - `jdbc:tc:mysql:5.7.22:///databasename?TC_INITFUNCTION=org.testcontainers.jdbc.JDBCDriverTest::sampleInitFunction` - -The init function must be a public static method which takes a `java.sql.Connection` as its only parameter, e.g. -```java -public class JDBCDriverTest { - public static void sampleInitFunction(Connection connection) throws SQLException { - // e.g. run schema setup or Flyway/liquibase/etc DB migrations here... - } - ... -``` - -#### Running container in daemon mode - -By default database container is being stopped as soon as last connection is closed. There are cases when you might need to start container and keep it running till you stop it explicitly or JVM is shutdown. To do this, add `TC_DAEMON` parameter to the URL as follows: - - `jdbc:tc:mysql:5.7.22:///databasename?TC_DAEMON=true` - -With this parameter database container will keep running even when there're no open connections. - - -#### Running container with tmpfs options - -Container can have `tmpfs` mounts for storing data in host memory. This is useful if you want to speed up your database tests. Be aware that the data will be lost when the container stops. - -To pass this option to the container, add `TC_TMPFS` parameter to the URL as follows: - - `jdbc:tc:postgresql:9.6.8:///databasename?TC_TMPFS=/testtmpfs:rw` - -If you need more than one option, separate them by comma (e.g. `TC_TMPFS=key:value,key1:value1&other_parameters=foo`). - -For more information about `tmpfs` mount, see [the official Docker documentation](https://docs.docker.com/storage/tmpfs/). +See [JDBC](./jdbc.md) and [R2DBC](./r2dbc.md) for information on how to use Testcontainers with SQL-like databases. \ No newline at end of file diff --git a/docs/modules/databases/jdbc.md b/docs/modules/databases/jdbc.md new file mode 100644 index 00000000000..5f83d7fcdeb --- /dev/null +++ b/docs/modules/databases/jdbc.md @@ -0,0 +1,119 @@ +# JDBC support + +You can obtain a temporary database in one of two ways: + + * **Using a specially modified JDBC URL**: after making a very simple modification to your system's JDBC URL string, Testcontainers will provide a disposable stand-in database that can be used without requiring modification to your application code. + * **JUnit @Rule/@ClassRule**: this mode starts a database inside a container before your tests and tears it down afterwards. + +## Database containers launched via JDBC URL scheme + +As long as you have Testcontainers and the appropriate JDBC driver on your classpath, you can simply modify regular JDBC connection URLs to get a fresh containerized instance of the database each time your application starts up. + +_N.B:_ + +* _TC needs to be on your application's classpath at runtime for this to work_ +* _For Spring Boot (Before version `2.3.0`) you need to specify the driver manually `spring.datasource.driver-class-name=org.testcontainers.jdbc.ContainerDatabaseDriver`_ + +**Original URL**: `jdbc:mysql://localhost:3306/databasename` + +Insert `tc:` after `jdbc:` as follows. Note that the hostname, port and database name will be ignored; you can leave these as-is or set them to any value. + +!!! note + We will use `///` (host-less URIs) from now on to emphasis the unimportance of the `host:port` pair. + From Testcontainers' perspective, `jdbc:mysql:5.7.22://localhost:3306/databasename` and `jdbc:mysql:5.7.22:///databasename` is the same URI. + +!!! warning + If you're using the JDBC URL support, there is no need to instantiate an instance of the container - Testcontainers will do it automagically. + +### JDBC URL examples + +#### Using Testcontainers with a fixed version + +`jdbc:tc:mysql:5.6.23:///databasename` + +#### Using PostgreSQL + +`jdbc:tc:postgresql:9.6.8:///databasename` + +#### Using PostGIS + +`jdbc:tc:postgis:9.6:///databasename` + +#### Using Presto + +`jdbc:tc:presto:329://localhost/memory/default` + +### Using a classpath init script + +Testcontainers can run an init script after the database container is started, but before your code is given a connection to it. The script must be on the classpath, and is referenced as follows: + +`jdbc:tc:mysql:5.7.22:///databasename?TC_INITSCRIPT=somepath/init_mysql.sql` + +This is useful if you have a fixed script for setting up database schema, etc. + +### Using an init script from a file + +If the init script path is prefixed `file:`, it will be loaded from a file (relative to the working directory, which will usually be the project root). + +`jdbc:tc:mysql:5.7.22:///databasename?TC_INITSCRIPT=file:src/main/resources/init_mysql.sql` + +### Using an init function + +Instead of running a fixed script for DB setup, it may be useful to call a Java function that you define. This is intended to allow you to trigger database schema migration tools. To do this, add TC_INITFUNCTION to the URL as follows, passing a full path to the class name and method: + + `jdbc:tc:mysql:5.7.22:///databasename?TC_INITFUNCTION=org.testcontainers.jdbc.JDBCDriverTest::sampleInitFunction` + +The init function must be a public static method which takes a `java.sql.Connection` as its only parameter, e.g. +```java +public class JDBCDriverTest { + public static void sampleInitFunction(Connection connection) throws SQLException { + // e.g. run schema setup or Flyway/liquibase/etc DB migrations here... + } + ... +``` + +### Running container in daemon mode + +By default database container is being stopped as soon as last connection is closed. There are cases when you might need to start container and keep it running till you stop it explicitly or JVM is shutdown. To do this, add `TC_DAEMON` parameter to the URL as follows: + + `jdbc:tc:mysql:5.7.22:///databasename?TC_DAEMON=true` + +With this parameter database container will keep running even when there're no open connections. + + +### Running container with tmpfs options + +Container can have `tmpfs` mounts for storing data in host memory. This is useful if you want to speed up your database tests. Be aware that the data will be lost when the container stops. + +To pass this option to the container, add `TC_TMPFS` parameter to the URL as follows: + + `jdbc:tc:postgresql:9.6.8:///databasename?TC_TMPFS=/testtmpfs:rw` + +If you need more than one option, separate them by comma (e.g. `TC_TMPFS=key:value,key1:value1&other_parameters=foo`). + +For more information about `tmpfs` mount, see [the official Docker documentation](https://docs.docker.com/storage/tmpfs/). + +## Database container objects + +In case you can't use the URL support, or need to fine-tune the container, you can instantiate it yourself. + +Add a @Rule or @ClassRule to your test class, e.g.: + +```java +public class SimpleMySQLTest { + @Rule + public MySQLContainer mysql = new MySQLContainer(); +``` + +Now, in your test code (or a suitable setup method), you can obtain details necessary to connect to this database: + + * `mysql.getJdbcUrl()` provides a JDBC URL your code can connect to + * `mysql.getUsername()` provides the username your code should pass to the driver + * `mysql.getPassword()` provides the password your code should pass to the driver + +Note that if you use `@Rule`, you will be given an isolated container for each test method. If you use `@ClassRule`, you will get on isolated container for all the methods in the test class. + +Examples/Tests: + + * [MySQL](https://github.com/testcontainers/testcontainers-java/blob/master/modules/jdbc-test/src/test/java/org/testcontainers/junit/SimpleMySQLTest.java) + * [PostgreSQL](https://github.com/testcontainers/testcontainers-java/blob/master/modules/jdbc-test/src/test/java/org/testcontainers/junit/SimplePostgreSQLTest.java) \ No newline at end of file diff --git a/docs/modules/databases/r2dbc.md b/docs/modules/databases/r2dbc.md new file mode 100644 index 00000000000..6ba3f401a08 --- /dev/null +++ b/docs/modules/databases/r2dbc.md @@ -0,0 +1,57 @@ +# R2DBC support + +You can obtain a temporary database in one of two ways: + + * **Using a specially modified R2DBC URL**: after making a very simple modification to your system's R2DBC URL string, Testcontainers will provide a disposable stand-in database that can be used without requiring modification to your application code. + * **JUnit @Rule/@ClassRule**: this mode starts a database inside a container before your tests and tears it down afterwards. + +## Database containers launched via R2DBC URL scheme + +As long as you have Testcontainers and the appropriate R2DBC driver on your classpath, you can simply modify regular R2DBC connection URLs to get a fresh containerized instance of the database each time your application starts up. + +The started container will be terminated when the `ConnectionFactory` is closed. + +!!! warning + Both the database module (e.g. `org.testcontainers:mysql`) **and** `org.testcontainers:r2dbc` need to be on your application's classpath at runtime. + +**Original URL**: `r2dbc:mysql://localhost:3306/databasename` + +1. Insert `tc:` after `r2dbc:` as follows. Note that the hostname, port and database name will be ignored; you can leave these as-is or set them to any value. +1. Specify the mandatory Docker tag of the database's official image that you want using a `TC_IMAGE_TAG` query parameter. + +**Note that, unlike Testcontainers' JDBC URL support, it is not possible to specify an image tag in the 'scheme' part of the URL, and it is always necessary to specify a tag using `TC_IMAGE_TAG`.** + +So that the URL becomes: +`r2dbc:tc:mysql:///databasename?TC_IMAGE_TAG=5.7.22` + +!!! note + We will use `///` (host-less URIs) from now on to emphasis the unimportance of the `host:port` pair. + From Testcontainers' perspective, `r2dbc:mysql://localhost:3306/databasename` and `r2dbc:mysql:///databasename` is the same URI. + +!!! warning + If you're using the R2DBC URL support, there is no need to instantiate an instance of the container - Testcontainers will do it automagically. + +### R2DBC URL examples + +#### Using MySQL + +`r2dbc:tc:mysql:///databasename?TC_IMAGE_TAG=5.6.23` + +#### Using MariaDB + +`r2dbc:tc:mariadb:///databasename?TC_IMAGE_TAG=10.3.6` + +#### Using PostgreSQL + +`r2dbc:tc:postgresql:///databasename?TC_IMAGE_TAG=9.6.8` + +#### Using MSSQL: + +`r2dbc:tc:sqlserver:///?TC_IMAGE_TAG=2017-CU12` + +## Obtaining `ConnectionFactoryOptions` from database container objects + +If you already have an instance of the database container, you can get an instance of `ConnectionFactoryOptions` from it: + +[Creating `ConnectionFactoryOptions` from an instance)](../../../modules/postgresql/src/test/java/org/testcontainers/containers/PostgreSQLR2DBCDatabaseContainerTest.java) inside_block:get_options + diff --git a/mkdocs.yml b/mkdocs.yml index 49876885dfd..c2d39bc9d2c 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -40,6 +40,8 @@ nav: - Modules: - Databases: - modules/databases/index.md + - modules/databases/jdbc.md + - modules/databases/r2dbc.md - modules/databases/cassandra.md - modules/databases/cockroachdb.md - modules/databases/couchbase.md diff --git a/modules/mariadb/build.gradle b/modules/mariadb/build.gradle index be42121f3d8..93b2de2a716 100644 --- a/modules/mariadb/build.gradle +++ b/modules/mariadb/build.gradle @@ -1,11 +1,20 @@ description = "Testcontainers :: JDBC :: MariaDB" dependencies { + annotationProcessor 'com.google.auto.service:auto-service:1.0-rc6' + compileOnly 'com.google.auto.service:auto-service:1.0-rc6' + compile project(':jdbc') + compileOnly project(':r2dbc') + compileOnly 'org.mariadb:r2dbc-mariadb:0.8.1-alpha1' + testCompile 'org.mariadb.jdbc:mariadb-java-client:2.6.0' testCompile 'com.zaxxer:HikariCP-java6:2.3.13' testCompile 'commons-dbutils:commons-dbutils:1.7' testCompile 'org.apache.tomcat:tomcat-jdbc:9.0.33' testCompile 'org.vibur:vibur-dbcp:25.0' + + testCompile testFixtures(project(':r2dbc')) + testCompile 'org.mariadb:r2dbc-mariadb:0.8.1-alpha1' } diff --git a/modules/mariadb/src/main/java/org/testcontainers/containers/MariaDBContainer.java b/modules/mariadb/src/main/java/org/testcontainers/containers/MariaDBContainer.java index be1b3d3d44d..e402bc24e0e 100644 --- a/modules/mariadb/src/main/java/org/testcontainers/containers/MariaDBContainer.java +++ b/modules/mariadb/src/main/java/org/testcontainers/containers/MariaDBContainer.java @@ -11,7 +11,7 @@ public class MariaDBContainer> extends JdbcD public static final String IMAGE = "mariadb"; public static final String DEFAULT_TAG = "10.3.6"; - private static final Integer MARIADB_PORT = 3306; + static final Integer MARIADB_PORT = 3306; private String databaseName = "test"; private String username = "test"; private String password = "test"; diff --git a/modules/mariadb/src/main/java/org/testcontainers/containers/MariaDBR2DBCDatabaseContainer.java b/modules/mariadb/src/main/java/org/testcontainers/containers/MariaDBR2DBCDatabaseContainer.java new file mode 100644 index 00000000000..4ddbed997d4 --- /dev/null +++ b/modules/mariadb/src/main/java/org/testcontainers/containers/MariaDBR2DBCDatabaseContainer.java @@ -0,0 +1,33 @@ +package org.testcontainers.containers; + +import io.r2dbc.spi.ConnectionFactoryOptions; +import lombok.RequiredArgsConstructor; +import lombok.experimental.Delegate; +import org.testcontainers.lifecycle.Startable; +import org.testcontainers.r2dbc.R2DBCDatabaseContainer; + +@RequiredArgsConstructor +public class MariaDBR2DBCDatabaseContainer implements R2DBCDatabaseContainer { + + @Delegate(types = Startable.class) + private final MariaDBContainer container; + + public static ConnectionFactoryOptions getOptions(MariaDBContainer container) { + ConnectionFactoryOptions options = ConnectionFactoryOptions.builder() + .option(ConnectionFactoryOptions.DRIVER, MariaDBR2DBCDatabaseContainerProvider.DRIVER) + .build(); + + return new MariaDBR2DBCDatabaseContainer(container).configure(options); + } + + @Override + public ConnectionFactoryOptions configure(ConnectionFactoryOptions options) { + return options.mutate() + .option(ConnectionFactoryOptions.HOST, container.getContainerIpAddress()) + .option(ConnectionFactoryOptions.PORT, container.getMappedPort(MariaDBContainer.MARIADB_PORT)) + .option(ConnectionFactoryOptions.DATABASE, container.getDatabaseName()) + .option(ConnectionFactoryOptions.USER, container.getUsername()) + .option(ConnectionFactoryOptions.PASSWORD, container.getPassword()) + .build(); + } +} diff --git a/modules/mariadb/src/main/java/org/testcontainers/containers/MariaDBR2DBCDatabaseContainerProvider.java b/modules/mariadb/src/main/java/org/testcontainers/containers/MariaDBR2DBCDatabaseContainerProvider.java new file mode 100644 index 00000000000..79cb627f8a9 --- /dev/null +++ b/modules/mariadb/src/main/java/org/testcontainers/containers/MariaDBR2DBCDatabaseContainerProvider.java @@ -0,0 +1,30 @@ +package org.testcontainers.containers; + +import com.google.auto.service.AutoService; +import io.r2dbc.spi.ConnectionFactoryOptions; +import org.mariadb.r2dbc.MariadbConnectionFactoryProvider; +import org.testcontainers.r2dbc.R2DBCDatabaseContainer; +import org.testcontainers.r2dbc.R2DBCDatabaseContainerProvider; + +@AutoService(R2DBCDatabaseContainerProvider.class) +public class MariaDBR2DBCDatabaseContainerProvider implements R2DBCDatabaseContainerProvider { + + static final String DRIVER = MariadbConnectionFactoryProvider.MARIADB_DRIVER; + + @Override + public boolean supports(ConnectionFactoryOptions options) { + return DRIVER.equals(options.getRequiredValue(ConnectionFactoryOptions.DRIVER)); + } + + @Override + public R2DBCDatabaseContainer createContainer(ConnectionFactoryOptions options) { + String image = MariaDBContainer.IMAGE + ":" + options.getRequiredValue(IMAGE_TAG_OPTION); + MariaDBContainer container = new MariaDBContainer<>(image) + .withDatabaseName(options.getRequiredValue(ConnectionFactoryOptions.DATABASE)); + + if (Boolean.TRUE.equals(options.getValue(REUSABLE_OPTION))) { + container.withReuse(true); + } + return new MariaDBR2DBCDatabaseContainer(container); + } +} diff --git a/modules/mariadb/src/test/java/org/testcontainers/containers/MariaDBR2DBCDatabaseContainerTest.java b/modules/mariadb/src/test/java/org/testcontainers/containers/MariaDBR2DBCDatabaseContainerTest.java new file mode 100644 index 00000000000..30e8cad9c32 --- /dev/null +++ b/modules/mariadb/src/test/java/org/testcontainers/containers/MariaDBR2DBCDatabaseContainerTest.java @@ -0,0 +1,23 @@ +package org.testcontainers.containers; + +import io.r2dbc.spi.ConnectionFactoryOptions; +import org.testcontainers.r2dbc.AbstractR2DBCDatabaseContainerTest; + +public class MariaDBR2DBCDatabaseContainerTest extends AbstractR2DBCDatabaseContainerTest> { + + @Override + protected ConnectionFactoryOptions getOptions(MariaDBContainer container) { + return MariaDBR2DBCDatabaseContainer.getOptions(container); + } + + @Override + protected String createR2DBCUrl() { + return "r2dbc:tc:mariadb:///db?TC_IMAGE_TAG=10.3.6"; + } + + @Override + protected MariaDBContainer createContainer() { + return new MariaDBContainer<>(); + } + +} diff --git a/modules/mariadb/src/test/resources/logback-test.xml b/modules/mariadb/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..535e406fc13 --- /dev/null +++ b/modules/mariadb/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + diff --git a/modules/mssqlserver/build.gradle b/modules/mssqlserver/build.gradle index 62434722d63..3c44df64eda 100644 --- a/modules/mssqlserver/build.gradle +++ b/modules/mssqlserver/build.gradle @@ -1,5 +1,18 @@ description = "Testcontainers :: MS SQL Server" dependencies { + annotationProcessor 'com.google.auto.service:auto-service:1.0-rc6' + compileOnly 'com.google.auto.service:auto-service:1.0-rc6' + compile project(':jdbc') + + compileOnly project(':r2dbc') + compileOnly 'io.r2dbc:r2dbc-mssql:0.8.1.RELEASE' + + testCompile project(':r2dbc') + testCompile 'io.r2dbc:r2dbc-mssql:0.8.1.RELEASE' + + // MSSQL's wait strategy requires the JDBC driver + testCompile testFixtures(project(':r2dbc')) + testCompile 'com.microsoft.sqlserver:mssql-jdbc:6.1.0.jre8' } diff --git a/modules/mssqlserver/src/main/java/org/testcontainers/containers/MSSQLR2DBCDatabaseContainer.java b/modules/mssqlserver/src/main/java/org/testcontainers/containers/MSSQLR2DBCDatabaseContainer.java new file mode 100644 index 00000000000..27c486c218c --- /dev/null +++ b/modules/mssqlserver/src/main/java/org/testcontainers/containers/MSSQLR2DBCDatabaseContainer.java @@ -0,0 +1,34 @@ +package org.testcontainers.containers; + +import io.r2dbc.spi.ConnectionFactoryOptions; +import lombok.RequiredArgsConstructor; +import lombok.experimental.Delegate; +import org.testcontainers.lifecycle.Startable; +import org.testcontainers.r2dbc.R2DBCDatabaseContainer; + +@RequiredArgsConstructor +public class MSSQLR2DBCDatabaseContainer implements R2DBCDatabaseContainer { + + @Delegate(types = Startable.class) + private final MSSQLServerContainer container; + + public static ConnectionFactoryOptions getOptions(MSSQLServerContainer container) { + ConnectionFactoryOptions options = ConnectionFactoryOptions.builder() + .option(ConnectionFactoryOptions.DRIVER, MSSQLR2DBCDatabaseContainerProvider.DRIVER) + .build(); + + return new MSSQLR2DBCDatabaseContainer(container).configure(options); + } + + @Override + public ConnectionFactoryOptions configure(ConnectionFactoryOptions options) { + return options.mutate() + .option(ConnectionFactoryOptions.HOST, container.getContainerIpAddress()) + .option(ConnectionFactoryOptions.PORT, container.getMappedPort(MSSQLServerContainer.MS_SQL_SERVER_PORT)) + // TODO enable if/when MSSQLServerContainer adds support for customizing the DB name + // .option(ConnectionFactoryOptions.DATABASE, container.getDatabasseName()) + .option(ConnectionFactoryOptions.USER, container.getUsername()) + .option(ConnectionFactoryOptions.PASSWORD, container.getPassword()) + .build(); + } +} diff --git a/modules/mssqlserver/src/main/java/org/testcontainers/containers/MSSQLR2DBCDatabaseContainerProvider.java b/modules/mssqlserver/src/main/java/org/testcontainers/containers/MSSQLR2DBCDatabaseContainerProvider.java new file mode 100644 index 00000000000..1638901226c --- /dev/null +++ b/modules/mssqlserver/src/main/java/org/testcontainers/containers/MSSQLR2DBCDatabaseContainerProvider.java @@ -0,0 +1,29 @@ +package org.testcontainers.containers; + +import com.google.auto.service.AutoService; +import io.r2dbc.mssql.MssqlConnectionFactoryProvider; +import io.r2dbc.spi.ConnectionFactoryOptions; +import org.testcontainers.r2dbc.R2DBCDatabaseContainer; +import org.testcontainers.r2dbc.R2DBCDatabaseContainerProvider; + +@AutoService(R2DBCDatabaseContainerProvider.class) +public class MSSQLR2DBCDatabaseContainerProvider implements R2DBCDatabaseContainerProvider { + + static final String DRIVER = MssqlConnectionFactoryProvider.MSSQL_DRIVER; + + @Override + public boolean supports(ConnectionFactoryOptions options) { + return DRIVER.equals(options.getRequiredValue(ConnectionFactoryOptions.DRIVER)); + } + + @Override + public R2DBCDatabaseContainer createContainer(ConnectionFactoryOptions options) { + String image = MSSQLServerContainer.IMAGE + ":" + options.getRequiredValue(IMAGE_TAG_OPTION); + MSSQLServerContainer container = new MSSQLServerContainer<>(image); + + if (Boolean.TRUE.equals(options.getValue(REUSABLE_OPTION))) { + container.withReuse(true); + } + return new MSSQLR2DBCDatabaseContainer(container); + } +} diff --git a/modules/mssqlserver/src/test/java/org/testcontainers/containers/MSSQLR2DBCDatabaseContainerTest.java b/modules/mssqlserver/src/test/java/org/testcontainers/containers/MSSQLR2DBCDatabaseContainerTest.java new file mode 100644 index 00000000000..1fcf2dd293b --- /dev/null +++ b/modules/mssqlserver/src/test/java/org/testcontainers/containers/MSSQLR2DBCDatabaseContainerTest.java @@ -0,0 +1,22 @@ +package org.testcontainers.containers; + +import io.r2dbc.spi.ConnectionFactoryOptions; +import org.testcontainers.r2dbc.AbstractR2DBCDatabaseContainerTest; + +public class MSSQLR2DBCDatabaseContainerTest extends AbstractR2DBCDatabaseContainerTest> { + + @Override + protected ConnectionFactoryOptions getOptions(MSSQLServerContainer container) { + return MSSQLR2DBCDatabaseContainer.getOptions(container); + } + + @Override + protected String createR2DBCUrl() { + return "r2dbc:tc:sqlserver:///?TC_IMAGE_TAG=2017-CU12"; + } + + @Override + protected MSSQLServerContainer createContainer() { + return new MSSQLServerContainer<>(); + } +} diff --git a/modules/mssqlserver/src/test/resources/container-license-acceptance.txt b/modules/mssqlserver/src/test/resources/container-license-acceptance.txt new file mode 100644 index 00000000000..8a704b9c066 --- /dev/null +++ b/modules/mssqlserver/src/test/resources/container-license-acceptance.txt @@ -0,0 +1 @@ +mcr.microsoft.com/mssql/server:2017-CU12 diff --git a/modules/mssqlserver/src/test/resources/logback-test.xml b/modules/mssqlserver/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..535e406fc13 --- /dev/null +++ b/modules/mssqlserver/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + diff --git a/modules/mysql/build.gradle b/modules/mysql/build.gradle index da609a1b535..10fd90b3c38 100644 --- a/modules/mysql/build.gradle +++ b/modules/mysql/build.gradle @@ -1,11 +1,20 @@ description = "Testcontainers :: JDBC :: MySQL" dependencies { + annotationProcessor 'com.google.auto.service:auto-service:1.0-rc6' + compileOnly 'com.google.auto.service:auto-service:1.0-rc6' + compile project(':jdbc') + compileOnly project(':r2dbc') + compileOnly 'dev.miku:r2dbc-mysql:0.8.1.RELEASE' + testCompile 'mysql:mysql-connector-java:8.0.19' testCompile 'com.zaxxer:HikariCP-java6:2.3.13' testCompile 'org.apache.tomcat:tomcat-jdbc:9.0.33' testCompile 'commons-dbutils:commons-dbutils:1.7' testCompile 'org.vibur:vibur-dbcp:25.0' + + testCompile testFixtures(project(':r2dbc')) + testCompile 'dev.miku:r2dbc-mysql:0.8.1.RELEASE' } diff --git a/modules/mysql/src/main/java/org/testcontainers/containers/MySQLR2DBCDatabaseContainer.java b/modules/mysql/src/main/java/org/testcontainers/containers/MySQLR2DBCDatabaseContainer.java new file mode 100644 index 00000000000..c43ffcca1b2 --- /dev/null +++ b/modules/mysql/src/main/java/org/testcontainers/containers/MySQLR2DBCDatabaseContainer.java @@ -0,0 +1,33 @@ +package org.testcontainers.containers; + +import io.r2dbc.spi.ConnectionFactoryOptions; +import lombok.RequiredArgsConstructor; +import lombok.experimental.Delegate; +import org.testcontainers.lifecycle.Startable; +import org.testcontainers.r2dbc.R2DBCDatabaseContainer; + +@RequiredArgsConstructor +public class MySQLR2DBCDatabaseContainer implements R2DBCDatabaseContainer { + + @Delegate(types = Startable.class) + private final MySQLContainer container; + + public static ConnectionFactoryOptions getOptions(MySQLContainer container) { + ConnectionFactoryOptions options = ConnectionFactoryOptions.builder() + .option(ConnectionFactoryOptions.DRIVER, MySQLR2DBCDatabaseContainerProvider.DRIVER) + .build(); + + return new MySQLR2DBCDatabaseContainer(container).configure(options); + } + + @Override + public ConnectionFactoryOptions configure(ConnectionFactoryOptions options) { + return options.mutate() + .option(ConnectionFactoryOptions.HOST, container.getContainerIpAddress()) + .option(ConnectionFactoryOptions.PORT, container.getMappedPort(MySQLContainer.MYSQL_PORT)) + .option(ConnectionFactoryOptions.DATABASE, container.getDatabaseName()) + .option(ConnectionFactoryOptions.USER, container.getUsername()) + .option(ConnectionFactoryOptions.PASSWORD, container.getPassword()) + .build(); + } +} diff --git a/modules/mysql/src/main/java/org/testcontainers/containers/MySQLR2DBCDatabaseContainerProvider.java b/modules/mysql/src/main/java/org/testcontainers/containers/MySQLR2DBCDatabaseContainerProvider.java new file mode 100644 index 00000000000..3a01616caba --- /dev/null +++ b/modules/mysql/src/main/java/org/testcontainers/containers/MySQLR2DBCDatabaseContainerProvider.java @@ -0,0 +1,30 @@ +package org.testcontainers.containers; + +import com.google.auto.service.AutoService; +import dev.miku.r2dbc.mysql.MySqlConnectionFactoryProvider; +import io.r2dbc.spi.ConnectionFactoryOptions; +import org.testcontainers.r2dbc.R2DBCDatabaseContainer; +import org.testcontainers.r2dbc.R2DBCDatabaseContainerProvider; + +@AutoService(R2DBCDatabaseContainerProvider.class) +public class MySQLR2DBCDatabaseContainerProvider implements R2DBCDatabaseContainerProvider { + + static final String DRIVER = MySqlConnectionFactoryProvider.MYSQL_DRIVER; + + @Override + public boolean supports(ConnectionFactoryOptions options) { + return DRIVER.equals(options.getRequiredValue(ConnectionFactoryOptions.DRIVER)); + } + + @Override + public R2DBCDatabaseContainer createContainer(ConnectionFactoryOptions options) { + String image = MySQLContainer.IMAGE + ":" + options.getRequiredValue(IMAGE_TAG_OPTION); + MySQLContainer container = new MySQLContainer<>(image) + .withDatabaseName(options.getRequiredValue(ConnectionFactoryOptions.DATABASE)); + + if (Boolean.TRUE.equals(options.getValue(REUSABLE_OPTION))) { + container.withReuse(true); + } + return new MySQLR2DBCDatabaseContainer(container); + } +} diff --git a/modules/mysql/src/test/java/org/testcontainers/containers/MySQLR2DBCDatabaseContainerTest.java b/modules/mysql/src/test/java/org/testcontainers/containers/MySQLR2DBCDatabaseContainerTest.java new file mode 100644 index 00000000000..19edc3dbca4 --- /dev/null +++ b/modules/mysql/src/test/java/org/testcontainers/containers/MySQLR2DBCDatabaseContainerTest.java @@ -0,0 +1,23 @@ +package org.testcontainers.containers; + +import io.r2dbc.spi.ConnectionFactoryOptions; +import org.testcontainers.r2dbc.AbstractR2DBCDatabaseContainerTest; + +public class MySQLR2DBCDatabaseContainerTest extends AbstractR2DBCDatabaseContainerTest> { + + @Override + protected ConnectionFactoryOptions getOptions(MySQLContainer container) { + return MySQLR2DBCDatabaseContainer.getOptions(container); + } + + @Override + protected String createR2DBCUrl() { + return "r2dbc:tc:mysql:///db?TC_IMAGE_TAG=5.7.22"; + } + + @Override + protected MySQLContainer createContainer() { + return new MySQLContainer<>(); + } + +} diff --git a/modules/mysql/src/test/resources/logback-test.xml b/modules/mysql/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..535e406fc13 --- /dev/null +++ b/modules/mysql/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + diff --git a/modules/postgresql/build.gradle b/modules/postgresql/build.gradle index 89d40fe6923..c629c1c5a13 100644 --- a/modules/postgresql/build.gradle +++ b/modules/postgresql/build.gradle @@ -1,9 +1,17 @@ description = "Testcontainers :: JDBC :: PostgreSQL" dependencies { + annotationProcessor 'com.google.auto.service:auto-service:1.0-rc6' + compileOnly 'com.google.auto.service:auto-service:1.0-rc6' + compile project(':jdbc') + compileOnly project(':r2dbc') + compileOnly 'io.r2dbc:r2dbc-postgresql:0.8.1.RELEASE' testCompile 'org.postgresql:postgresql:42.2.12' testCompile 'commons-dbutils:commons-dbutils:1.7' testCompile 'com.zaxxer:HikariCP-java6:2.3.13' + + testCompile testFixtures(project(':r2dbc')) + testCompile 'io.r2dbc:r2dbc-postgresql:0.8.1.RELEASE' } diff --git a/modules/postgresql/src/main/java/org/testcontainers/containers/PostgreSQLR2DBCDatabaseContainer.java b/modules/postgresql/src/main/java/org/testcontainers/containers/PostgreSQLR2DBCDatabaseContainer.java new file mode 100644 index 00000000000..9bfb9fdee05 --- /dev/null +++ b/modules/postgresql/src/main/java/org/testcontainers/containers/PostgreSQLR2DBCDatabaseContainer.java @@ -0,0 +1,33 @@ +package org.testcontainers.containers; + +import io.r2dbc.spi.ConnectionFactoryOptions; +import lombok.RequiredArgsConstructor; +import lombok.experimental.Delegate; +import org.testcontainers.lifecycle.Startable; +import org.testcontainers.r2dbc.R2DBCDatabaseContainer; + +@RequiredArgsConstructor +public final class PostgreSQLR2DBCDatabaseContainer implements R2DBCDatabaseContainer { + + @Delegate(types = Startable.class) + private final PostgreSQLContainer container; + + public static ConnectionFactoryOptions getOptions(PostgreSQLContainer container) { + ConnectionFactoryOptions options = ConnectionFactoryOptions.builder() + .option(ConnectionFactoryOptions.DRIVER, PostgreSQLR2DBCDatabaseContainerProvider.DRIVER) + .build(); + + return new PostgreSQLR2DBCDatabaseContainer(container).configure(options); + } + + @Override + public ConnectionFactoryOptions configure(ConnectionFactoryOptions options) { + return options.mutate() + .option(ConnectionFactoryOptions.HOST, container.getContainerIpAddress()) + .option(ConnectionFactoryOptions.PORT, container.getMappedPort(PostgreSQLContainer.POSTGRESQL_PORT)) + .option(ConnectionFactoryOptions.DATABASE, container.getDatabaseName()) + .option(ConnectionFactoryOptions.USER, container.getUsername()) + .option(ConnectionFactoryOptions.PASSWORD, container.getPassword()) + .build(); + } +} diff --git a/modules/postgresql/src/main/java/org/testcontainers/containers/PostgreSQLR2DBCDatabaseContainerProvider.java b/modules/postgresql/src/main/java/org/testcontainers/containers/PostgreSQLR2DBCDatabaseContainerProvider.java new file mode 100644 index 00000000000..5e9da980b8e --- /dev/null +++ b/modules/postgresql/src/main/java/org/testcontainers/containers/PostgreSQLR2DBCDatabaseContainerProvider.java @@ -0,0 +1,30 @@ +package org.testcontainers.containers; + +import com.google.auto.service.AutoService; +import io.r2dbc.postgresql.PostgresqlConnectionFactoryProvider; +import io.r2dbc.spi.ConnectionFactoryOptions; +import org.testcontainers.r2dbc.R2DBCDatabaseContainer; +import org.testcontainers.r2dbc.R2DBCDatabaseContainerProvider; + +@AutoService(R2DBCDatabaseContainerProvider.class) +public final class PostgreSQLR2DBCDatabaseContainerProvider implements R2DBCDatabaseContainerProvider { + + static final String DRIVER = PostgresqlConnectionFactoryProvider.POSTGRESQL_DRIVER; + + @Override + public boolean supports(ConnectionFactoryOptions options) { + return DRIVER.equals(options.getRequiredValue(ConnectionFactoryOptions.DRIVER)); + } + + @Override + public R2DBCDatabaseContainer createContainer(ConnectionFactoryOptions options) { + String image = PostgreSQLContainer.IMAGE + ":" + options.getRequiredValue(IMAGE_TAG_OPTION); + PostgreSQLContainer container = new PostgreSQLContainer<>(image) + .withDatabaseName(options.getRequiredValue(ConnectionFactoryOptions.DATABASE)); + + if (Boolean.TRUE.equals(options.getValue(REUSABLE_OPTION))) { + container.withReuse(true); + } + return new PostgreSQLR2DBCDatabaseContainer(container); + } +} diff --git a/modules/postgresql/src/test/java/org/testcontainers/containers/PostgreSQLR2DBCDatabaseContainerTest.java b/modules/postgresql/src/test/java/org/testcontainers/containers/PostgreSQLR2DBCDatabaseContainerTest.java new file mode 100644 index 00000000000..505b37859ba --- /dev/null +++ b/modules/postgresql/src/test/java/org/testcontainers/containers/PostgreSQLR2DBCDatabaseContainerTest.java @@ -0,0 +1,27 @@ +package org.testcontainers.containers; + +import io.r2dbc.spi.ConnectionFactoryOptions; +import org.testcontainers.r2dbc.AbstractR2DBCDatabaseContainerTest; + +public class PostgreSQLR2DBCDatabaseContainerTest extends AbstractR2DBCDatabaseContainerTest> { + + @Override + protected PostgreSQLContainer createContainer() { + return new PostgreSQLContainer<>(); + } + + @Override + protected ConnectionFactoryOptions getOptions(PostgreSQLContainer container) { + // get_options { + ConnectionFactoryOptions options = PostgreSQLR2DBCDatabaseContainer.getOptions( + container + ); + // } + + return options; + } + + protected String createR2DBCUrl() { + return "r2dbc:tc:postgresql:///db?TC_IMAGE_TAG=10-alpine"; + } +} diff --git a/modules/postgresql/src/test/resources/logback-test.xml b/modules/postgresql/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..535e406fc13 --- /dev/null +++ b/modules/postgresql/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + diff --git a/modules/r2dbc/build.gradle b/modules/r2dbc/build.gradle new file mode 100644 index 00000000000..a7388ebc6d2 --- /dev/null +++ b/modules/r2dbc/build.gradle @@ -0,0 +1,20 @@ +plugins { + id "java-test-fixtures" +} + +description = "Testcontainers :: R2DBC" + +dependencies { + annotationProcessor 'com.google.auto.service:auto-service:1.0-rc6' + compileOnly 'com.google.auto.service:auto-service:1.0-rc6' + + compile project(':testcontainers') + compile 'io.r2dbc:r2dbc-spi:0.8.1.RELEASE' + + testCompile 'org.assertj:assertj-core:3.14.0' + testCompile 'io.r2dbc:r2dbc-postgresql:0.8.1.RELEASE' + testCompile project(':postgresql') + + testFixturesCompile 'io.projectreactor:reactor-core:3.3.4.RELEASE' + testFixturesCompile 'org.assertj:assertj-core:3.14.0' +} diff --git a/modules/r2dbc/src/main/java/org/testcontainers/r2dbc/CancellableSubscription.java b/modules/r2dbc/src/main/java/org/testcontainers/r2dbc/CancellableSubscription.java new file mode 100644 index 00000000000..e64d19a9f49 --- /dev/null +++ b/modules/r2dbc/src/main/java/org/testcontainers/r2dbc/CancellableSubscription.java @@ -0,0 +1,23 @@ +package org.testcontainers.r2dbc; + +import org.reactivestreams.Subscription; + +import java.util.concurrent.atomic.AtomicBoolean; + +class CancellableSubscription implements Subscription { + + private final AtomicBoolean cancelled = new AtomicBoolean(); + + @Override + public void request(long n) { + } + + @Override + public void cancel() { + cancelled.set(true); + } + + public boolean isCancelled() { + return cancelled.get(); + } +} diff --git a/modules/r2dbc/src/main/java/org/testcontainers/r2dbc/ConnectionPublisher.java b/modules/r2dbc/src/main/java/org/testcontainers/r2dbc/ConnectionPublisher.java new file mode 100644 index 00000000000..489d545429d --- /dev/null +++ b/modules/r2dbc/src/main/java/org/testcontainers/r2dbc/ConnectionPublisher.java @@ -0,0 +1,161 @@ +package org.testcontainers.r2dbc; + +import io.r2dbc.spi.Connection; +import io.r2dbc.spi.ConnectionFactory; +import org.reactivestreams.Publisher; +import org.reactivestreams.Subscriber; +import org.reactivestreams.Subscription; + +import java.util.concurrent.CompletableFuture; +import java.util.function.Supplier; + +/** + * Design notes: + * - ConnectionPublisher is Mono-like (0..1), the request amount is ignored + * - given the testing nature, the performance requirements are less strict + * - "synchronized" is used to avoid races + * - Reactive Streams spec violations are not checked (e.g. non-positive request) + */ +class ConnectionPublisher implements Publisher { + + private final Supplier> futureSupplier; + + ConnectionPublisher(Supplier> futureSupplier) { + this.futureSupplier = futureSupplier; + } + + @Override + public void subscribe(Subscriber actual) { + actual.onSubscribe(new StateMachineSubscription(actual)); + } + + private class StateMachineSubscription implements Subscription { + + private final Subscriber actual; + + Subscription subscriptionState; + + StateMachineSubscription(Subscriber actual) { + this.actual = actual; + subscriptionState = new WaitRequestSubscriptionState(); + } + + @Override + public synchronized void request(long n) { + subscriptionState.request(n); + } + + @Override + public synchronized void cancel() { + subscriptionState.cancel(); + } + + synchronized void transitionTo(SubscriptionState newState) { + subscriptionState = newState; + newState.enter(); + } + + abstract class SubscriptionState implements Subscription { + void enter() { + } + } + + class WaitRequestSubscriptionState extends SubscriptionState { + + @Override + public void request(long n) { + transitionTo(new WaitFutureCompletionSubscriptionState()); + } + + @Override + public void cancel() { + } + } + + class WaitFutureCompletionSubscriptionState extends SubscriptionState { + + private CompletableFuture future; + + @Override + void enter() { + this.future = futureSupplier.get(); + + future.whenComplete((connectionFactory, e) -> { + if (e != null) { + actual.onSubscribe(EmptySubscription.INSTANCE); + actual.onError(e); + return; + } + + Publisher publisher = connectionFactory.create(); + transitionTo(new ProxySubscriptionState(publisher)); + }); + } + + @Override + public void request(long n) { + } + + @Override + public void cancel() { + future.cancel(true); + } + } + + class ProxySubscriptionState extends SubscriptionState implements Subscriber { + + private final Publisher publisher; + + private Subscription s; + + private boolean cancelled = false; + + ProxySubscriptionState(Publisher publisher) { + this.publisher = publisher; + } + + @Override + void enter() { + publisher.subscribe(this); + } + + @Override + public void request(long n) { + // Ignore + } + + @Override + public synchronized void cancel() { + cancelled = true; + if (s != null) { + s.cancel(); + } + } + + @Override + public synchronized void onSubscribe(Subscription s) { + this.s = s; + if (!cancelled) { + s.request(1); + } else { + s.cancel(); + } + } + + @Override + public void onNext(Connection connection) { + actual.onNext(connection); + } + + @Override + public void onError(Throwable t) { + actual.onError(t); + } + + @Override + public void onComplete() { + actual.onComplete(); + } + } + } +} diff --git a/modules/r2dbc/src/main/java/org/testcontainers/r2dbc/EmptySubscription.java b/modules/r2dbc/src/main/java/org/testcontainers/r2dbc/EmptySubscription.java new file mode 100644 index 00000000000..7828d121c6f --- /dev/null +++ b/modules/r2dbc/src/main/java/org/testcontainers/r2dbc/EmptySubscription.java @@ -0,0 +1,17 @@ +package org.testcontainers.r2dbc; + +import org.reactivestreams.Subscription; + +enum EmptySubscription implements Subscription { + INSTANCE; + + @Override + public void request(long n) { + + } + + @Override + public void cancel() { + + } +} diff --git a/modules/r2dbc/src/main/java/org/testcontainers/r2dbc/Hidden.java b/modules/r2dbc/src/main/java/org/testcontainers/r2dbc/Hidden.java new file mode 100644 index 00000000000..f642a60d67c --- /dev/null +++ b/modules/r2dbc/src/main/java/org/testcontainers/r2dbc/Hidden.java @@ -0,0 +1,73 @@ +package org.testcontainers.r2dbc; + +import com.google.auto.service.AutoService; +import io.r2dbc.spi.ConnectionFactory; +import io.r2dbc.spi.ConnectionFactoryOptions; +import io.r2dbc.spi.ConnectionFactoryProvider; + +/** + * Hide inner classes that must be public due to the way {@link java.util.ServiceLoader} works + */ +class Hidden { + + @AutoService(ConnectionFactoryProvider.class) + public static final class TestcontainersR2DBCConnectionFactoryProvider implements ConnectionFactoryProvider { + + public static final String DRIVER = "tc"; + + @Override + public ConnectionFactory create(ConnectionFactoryOptions options) { + options = sanitize(options); + options = removeProxying(options); + + return new TestcontainersR2DBCConnectionFactory(options); + } + + private ConnectionFactoryOptions sanitize(ConnectionFactoryOptions options) { + ConnectionFactoryOptions.Builder builder = options.mutate(); + + Object reusable = options.getValue(R2DBCDatabaseContainerProvider.REUSABLE_OPTION); + if (reusable instanceof String) { + builder.option(R2DBCDatabaseContainerProvider.REUSABLE_OPTION, Boolean.valueOf((String) reusable)); + } + return builder.build(); + } + + private ConnectionFactoryOptions removeProxying(ConnectionFactoryOptions options) { + // To delegate to the next factory provider, inspect the PROTOCOL and convert it to the next DRIVER and PROTOCOL values. + // + // example: + // | Property | Input | Output | + // |----------|-----------------|--------------| + // | DRIVER | tc | postgres | + // | PROTOCOL | postgres | | + + String protocol = options.getRequiredValue(ConnectionFactoryOptions.PROTOCOL); + if (protocol.trim().length() == 0) { + throw new IllegalArgumentException("Invalid protocol: " + protocol); + } + String[] protocols = protocol.split(":", 2); + String driverDelegate = protocols[0]; + + // when protocol does NOT contain COLON, the length becomes 1 + String protocolDelegate = protocols.length == 2 ? protocols[1] : ""; + + return ConnectionFactoryOptions.builder() + .from(options) + .option(ConnectionFactoryOptions.DRIVER, driverDelegate) + .option(ConnectionFactoryOptions.PROTOCOL, protocolDelegate) + .build(); + } + + @Override + public boolean supports(ConnectionFactoryOptions options) { + return DRIVER.equals(options.getValue(ConnectionFactoryOptions.DRIVER)); + } + + @Override + public String getDriver() { + return DRIVER; + } + + } +} diff --git a/modules/r2dbc/src/main/java/org/testcontainers/r2dbc/R2DBCDatabaseContainer.java b/modules/r2dbc/src/main/java/org/testcontainers/r2dbc/R2DBCDatabaseContainer.java new file mode 100644 index 00000000000..a303cbafff8 --- /dev/null +++ b/modules/r2dbc/src/main/java/org/testcontainers/r2dbc/R2DBCDatabaseContainer.java @@ -0,0 +1,9 @@ +package org.testcontainers.r2dbc; + +import io.r2dbc.spi.ConnectionFactoryOptions; +import org.testcontainers.lifecycle.Startable; + +public interface R2DBCDatabaseContainer extends Startable { + + ConnectionFactoryOptions configure(ConnectionFactoryOptions options); +} diff --git a/modules/r2dbc/src/main/java/org/testcontainers/r2dbc/R2DBCDatabaseContainerProvider.java b/modules/r2dbc/src/main/java/org/testcontainers/r2dbc/R2DBCDatabaseContainerProvider.java new file mode 100644 index 00000000000..2eabc0d521b --- /dev/null +++ b/modules/r2dbc/src/main/java/org/testcontainers/r2dbc/R2DBCDatabaseContainerProvider.java @@ -0,0 +1,15 @@ +package org.testcontainers.r2dbc; + +import io.r2dbc.spi.ConnectionFactoryOptions; +import io.r2dbc.spi.Option; + +public interface R2DBCDatabaseContainerProvider { + + Option REUSABLE_OPTION = Option.valueOf("TC_REUSABLE"); + + Option IMAGE_TAG_OPTION = Option.valueOf("TC_IMAGE_TAG"); + + boolean supports(ConnectionFactoryOptions options); + + R2DBCDatabaseContainer createContainer(ConnectionFactoryOptions options); +} diff --git a/modules/r2dbc/src/main/java/org/testcontainers/r2dbc/TestcontainersR2DBCConnectionFactory.java b/modules/r2dbc/src/main/java/org/testcontainers/r2dbc/TestcontainersR2DBCConnectionFactory.java new file mode 100644 index 00000000000..1a0fc57ad8c --- /dev/null +++ b/modules/r2dbc/src/main/java/org/testcontainers/r2dbc/TestcontainersR2DBCConnectionFactory.java @@ -0,0 +1,104 @@ +package org.testcontainers.r2dbc; + +import io.r2dbc.spi.Closeable; +import io.r2dbc.spi.Connection; +import io.r2dbc.spi.ConnectionFactories; +import io.r2dbc.spi.ConnectionFactory; +import io.r2dbc.spi.ConnectionFactoryMetadata; +import io.r2dbc.spi.ConnectionFactoryOptions; +import org.reactivestreams.Publisher; +import org.testcontainers.lifecycle.Startable; + +import java.util.ServiceLoader; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.StreamSupport; + +class TestcontainersR2DBCConnectionFactory implements ConnectionFactory, Closeable { + + private static final AtomicLong THREAD_COUNT = new AtomicLong(); + + private static final Executor EXECUTOR = Executors.newCachedThreadPool(r -> { + Thread thread = new Thread(r); + thread.setName("testcontainers-r2dbc-" + THREAD_COUNT.getAndIncrement()); + thread.setDaemon(true); + return thread; + }); + + private final ConnectionFactoryOptions options; + + private final R2DBCDatabaseContainerProvider containerProvider; + + private CompletableFuture future; + + TestcontainersR2DBCConnectionFactory(ConnectionFactoryOptions options) { + this.options = options; + + containerProvider = StreamSupport.stream(ServiceLoader.load(R2DBCDatabaseContainerProvider.class).spliterator(), false) + .filter(it -> it.supports(options)) + .findAny() + .orElseThrow(() -> new IllegalArgumentException("Missing provider for " + options)); + } + + @Override + public Publisher create() { + return new ConnectionPublisher( + () -> { + if (future == null) { + synchronized (this) { + if (future == null) { + future = CompletableFuture.supplyAsync(() -> { + R2DBCDatabaseContainer container = containerProvider.createContainer(options); + container.start(); + return container; + }, EXECUTOR); + } + } + } + return future.thenApply(it -> { + return ConnectionFactories.find( + it.configure(options) + ); + }); + } + ); + } + + @Override + public ConnectionFactoryMetadata getMetadata() { + // TODO check whether it is fine to call it without setting the host/port and other values + return ConnectionFactories.find(options).getMetadata(); + } + + @Override + public Publisher close() { + return s -> { + CompletableFuture futureRef; + synchronized (this) { + futureRef = this.future; + this.future = null; + } + + CancellableSubscription subscription = new CancellableSubscription(); + s.onSubscribe(subscription); + + if (futureRef == null) { + if (!subscription.isCancelled()) { + s.onComplete(); + } + } else { + futureRef.thenAcceptAsync(Startable::stop, EXECUTOR); + + EXECUTOR.execute(() -> { + futureRef.cancel(true); + if (!subscription.isCancelled()) { + s.onComplete(); + } + }); + } + }; + } + +} diff --git a/modules/r2dbc/src/test/java/org/testcontainers/r2dbc/TestcontainersR2DBCConnectionFactoryTest.java b/modules/r2dbc/src/test/java/org/testcontainers/r2dbc/TestcontainersR2DBCConnectionFactoryTest.java new file mode 100644 index 00000000000..6fad93f0e2a --- /dev/null +++ b/modules/r2dbc/src/test/java/org/testcontainers/r2dbc/TestcontainersR2DBCConnectionFactoryTest.java @@ -0,0 +1,79 @@ +package org.testcontainers.r2dbc; + +import io.r2dbc.postgresql.api.PostgresqlException; +import io.r2dbc.spi.Closeable; +import io.r2dbc.spi.Connection; +import io.r2dbc.spi.ConnectionFactories; +import io.r2dbc.spi.ConnectionFactory; +import io.r2dbc.spi.Result; +import org.assertj.core.api.Assertions; +import org.junit.Test; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +public class TestcontainersR2DBCConnectionFactoryTest { + + @Test + public void failsOnUnknownProvider() { + String nonExistingProvider = UUID.randomUUID().toString(); + Assertions + .assertThatThrownBy(() -> { + ConnectionFactories.get(String.format("r2dbc:tc:%s:///db", nonExistingProvider)); + }) + .hasMessageContaining("Missing provider") + .hasMessageContaining(nonExistingProvider); + } + + @Test + public void reusesUntilConnectionFactoryIsClosed() { + String url = "r2dbc:tc:postgresql:///db?TC_IMAGE_TAG=10-alpine"; + ConnectionFactory connectionFactory = ConnectionFactories.get(url); + + Integer updated = Flux + .usingWhen( + connectionFactory.create(), + connection -> { + return Mono + .from(connection.createStatement("CREATE TABLE test(id integer PRIMARY KEY)").execute()) + .thenMany(connection.createStatement("INSERT INTO test(id) VALUES(123)").execute()) + .flatMap(Result::getRowsUpdated); + }, + Connection::close + ) + .blockFirst(); + + assertThat(updated).isEqualTo(1); + + Flux select = Flux + .usingWhen( + Flux.defer(connectionFactory::create), + connection -> { + return Flux + .from(connection.createStatement("SELECT COUNT(*) FROM test").execute()) + .flatMap(it -> it.map((row, meta) -> (Long) row.get(0))); + }, + Connection::close + ); + + Long rows = select.blockFirst(); + + assertThat(rows).isEqualTo(1); + + close(connectionFactory); + + Assertions + .assertThatThrownBy(select::blockFirst) + .isInstanceOf(PostgresqlException.class) + // relation "X" does not exists + // https://github.com/postgres/postgres/blob/REL_10_0/src/backend/utils/errcodes.txt#L349 + .returns("42P01", e -> ((PostgresqlException) e).getErrorDetails().getCode()); + } + + private static void close(ConnectionFactory connectionFactory) { + Mono.from(((Closeable) connectionFactory).close()).block(); + } +} diff --git a/modules/r2dbc/src/test/resources/logback-test.xml b/modules/r2dbc/src/test/resources/logback-test.xml new file mode 100644 index 00000000000..535e406fc13 --- /dev/null +++ b/modules/r2dbc/src/test/resources/logback-test.xml @@ -0,0 +1,16 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger - %msg%n + + + + + + + + + diff --git a/modules/r2dbc/src/testFixtures/java/org/testcontainers/r2dbc/AbstractR2DBCDatabaseContainerTest.java b/modules/r2dbc/src/testFixtures/java/org/testcontainers/r2dbc/AbstractR2DBCDatabaseContainerTest.java new file mode 100644 index 00000000000..7f14ecca2a7 --- /dev/null +++ b/modules/r2dbc/src/testFixtures/java/org/testcontainers/r2dbc/AbstractR2DBCDatabaseContainerTest.java @@ -0,0 +1,64 @@ +package org.testcontainers.r2dbc; + +import io.r2dbc.spi.Closeable; +import io.r2dbc.spi.Connection; +import io.r2dbc.spi.ConnectionFactories; +import io.r2dbc.spi.ConnectionFactory; +import io.r2dbc.spi.ConnectionFactoryOptions; +import org.junit.Test; +import org.testcontainers.containers.GenericContainer; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThat; + +public abstract class AbstractR2DBCDatabaseContainerTest> { + + protected abstract ConnectionFactoryOptions getOptions(T container); + + protected abstract String createR2DBCUrl(); + + protected String createTestQuery(int result) { + return String.format("SELECT %d", result); + } + + @Test + public final void testGetOptions() { + try (T container = createContainer()) { + container.start(); + + ConnectionFactory connectionFactory = ConnectionFactories.get(getOptions(container)); + runTestQuery(connectionFactory); + } + } + + @Test + public final void testUrlSupport() { + ConnectionFactory connectionFactory = ConnectionFactories.get(createR2DBCUrl()); + runTestQuery(connectionFactory); + } + + protected abstract T createContainer(); + + protected void runTestQuery(ConnectionFactory connectionFactory) { + try { + int expected = 42; + Number result = Flux + .usingWhen( + connectionFactory.create(), + connection -> connection.createStatement(createTestQuery(expected)).execute(), + Connection::close + ) + .flatMap(it -> it.map((row, meta) -> (Number) row.get(0))) + .blockFirst(); + + assertThat(result) + .isNotNull() + .returns(expected, Number::intValue); + } finally { + if (connectionFactory instanceof Closeable) { + Mono.from(((Closeable) connectionFactory).close()).block(); + } + } + } +}