diff --git a/.gitignore b/.gitignore index c7170052b0a..4c3f24f5bb4 100644 --- a/.gitignore +++ b/.gitignore @@ -47,8 +47,9 @@ node_modules/ .gradle/ build/ -# Eclipse IDE -.settings/ -.classpath -.project -bin/ +# Eclipse IDE files +**/.project +**/.classpath +**/.settings +**/bin/ +**/out/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 40d21d6016d..a43956e9b03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ All notable changes to this project will be documented in this file. ## UNRELEASED ### Fixed +- Fixed JDBC URL Regex Pattern to ensure all supported Database URL's are accepted ([\#596](https://github.com/testcontainers/testcontainers-java/issues/596)) +- Filtered out TestContainer parameters (TC_*) from query string before passing to database ([\#345](https://github.com/testcontainers/testcontainers-java/issues/345)) - Use `latest` tag as default image tag ([\#676](https://github.com/testcontainers/testcontainers-java/issues/676)) ### Changed @@ -12,6 +14,10 @@ All notable changes to this project will be documented in this file. - Add support for defining container labels ([\#725](https://github.com/testcontainers/testcontainers-java/pull/725)) - Use `quay.io/testcontainers/ryuk` instead of `bsideup/ryuk` ([\#721](https://github.com/testcontainers/testcontainers-java/pull/721)) - Added Couchbase module ([\#688](https://github.com/testcontainers/testcontainers-java/pull/688)) +- Enhancements and Fixes for JDBC URL usage to create Containers ([\#594](https://github.com/testcontainers/testcontainers-java/pull/594)) + - Extracted JDBC URL manipulations to a separate class - `ConnectionUrl`. + - Added an overloaded method `JdbcDatabaseContainerProvider.newInstance(ConnectionUrl)`, with default implementation delegating to the existing `newInstance(tag)` method. (Relates to [\#566](https://github.com/testcontainers/testcontainers-java/issues/566)) + - Added an implementation of `MySQLContainerProvider.newInstance(ConnectionUrl)` that uses Database Name, User, and Password from JDBC URL while creating new MySQL Container. ([\#566](https://github.com/testcontainers/testcontainers-java/issues/566) for MySQL Container) - Changed **internal** port of KafkaContainer back to 9092 ([\#733](https://github.com/testcontainers/testcontainers-java/pull/733)) - Add support for Dockerfile based images to OracleContainer ([\#734](https://github.com/testcontainers/testcontainers-java/pull/734)) - Read from both `/proc/net/tcp` and `/proc/net/tcp6` in `InternalCommandPortListeningCheck` ([\#750](https://github.com/testcontainers/testcontainers-java/pull/750)) diff --git a/modules/jdbc-test/src/test/java/org/testcontainers/jdbc/JDBCDriverTest.java b/modules/jdbc-test/src/test/java/org/testcontainers/jdbc/JDBCDriverTest.java index 4ef75c2c516..49c889fcb5d 100644 --- a/modules/jdbc-test/src/test/java/org/testcontainers/jdbc/JDBCDriverTest.java +++ b/modules/jdbc-test/src/test/java/org/testcontainers/jdbc/JDBCDriverTest.java @@ -32,26 +32,29 @@ public class JDBCDriverTest { public boolean performTestForCharacterSet; @Parameter(3) public boolean performTestForCustomIniFile; + @Parameter(4) + public boolean performTestForJDBCParams; @Parameterized.Parameters(name = "{index} - {0}") public static Iterable data() { return asList( new Object[][]{ - {"jdbc:tc:mysql://hostname/databasename", false, false, false}, - {"jdbc:tc:mysql:5.5.43://hostname/databasename?TC_INITSCRIPT=somepath/init_mysql.sql", true, false, false}, - {"jdbc:tc:mysql:5.5.43://hostname/databasename?TC_INITFUNCTION=org.testcontainers.jdbc.JDBCDriverTest::sampleInitFunction", true, false, false}, - {"jdbc:tc:mysql:5.5.43://hostname/databasename?useUnicode=yes&characterEncoding=utf8", false, true, false}, - {"jdbc:tc:mysql:5.5.43://hostname/databasename", false, false, false}, - {"jdbc:tc:mysql:5.5.43://hostname/databasename?useSSL=false", false, false, false}, - {"jdbc:tc:postgresql:9.6.8://hostname/databasename", false, false, false}, - {"jdbc:tc:mysql:5.6://hostname/databasename?TC_MY_CNF=somepath/mysql_conf_override", false, false, true}, - {"jdbc:tc:mariadb://hostname/databasename", false, false, false}, - {"jdbc:tc:mariadb:10.2.14://hostname/databasename", false, false, false}, - {"jdbc:tc:mariadb:10.2.14://hostname/databasename?useUnicode=yes&characterEncoding=utf8", false, true, false}, - {"jdbc:tc:mariadb:10.2.14://hostname/databasename?TC_INITSCRIPT=somepath/init_mariadb.sql", true, false, false}, - {"jdbc:tc:mariadb:10.2.14://hostname/databasename?TC_INITFUNCTION=org.testcontainers.jdbc.JDBCDriverTest::sampleInitFunction", true, false, false}, - {"jdbc:tc:mariadb:10.2.14://hostname/databasename?TC_MY_CNF=somepath/mariadb_conf_override", false, false, true} - }); + {"jdbc:tc:mysql://hostname/databasename", false, false, false, false}, + {"jdbc:tc:mysql://hostname/databasename?user=someuser&TC_INITSCRIPT=somepath/init_mysql.sql", true, false, false, true}, + {"jdbc:tc:mysql:5.5.43://hostname/databasename?user=someuser&TC_INITFUNCTION=org.testcontainers.jdbc.JDBCDriverTest::sampleInitFunction", true, false, false, true}, + {"jdbc:tc:mysql:5.5.43://hostname/databasename?user=someuser&password=somepwd&TC_INITSCRIPT=somepath/init_mysql.sql", true, false, false, true}, + {"jdbc:tc:mysql:5.5.43://hostname/databasename?user=someuser&password=somepwd&TC_INITFUNCTION=org.testcontainers.jdbc.JDBCDriverTest::sampleInitFunction", true, false, false, true}, + {"jdbc:tc:mysql:5.5.43://hostname/databasename?useUnicode=yes&characterEncoding=utf8", false, true, false, false}, + {"jdbc:tc:mysql:5.5.43://hostname/databasename", false, false, false, false}, + {"jdbc:tc:mysql:5.5.43://hostname/databasename?useSSL=false", false, false, false, false}, + {"jdbc:tc:postgresql:9.6.8://hostname/databasename", false, false, false, false}, + {"jdbc:tc:mysql:5.6://hostname/databasename?TC_MY_CNF=somepath/mysql_conf_override", false, false, true, false}, + {"jdbc:tc:mariadb://hostname/databasename", false, false, false, false}, + {"jdbc:tc:mariadb:10.2.14://hostname/databasename", false, false, false, false}, + {"jdbc:tc:mariadb:10.2.14://hostname/databasename?useUnicode=yes&characterEncoding=utf8", false, true, false, false}, + {"jdbc:tc:mariadb:10.2.14://hostname/databasename?TC_INITSCRIPT=somepath/init_mariadb.sql", true, false, false, false}, + {"jdbc:tc:mariadb:10.2.14://hostname/databasename?TC_INITFUNCTION=org.testcontainers.jdbc.JDBCDriverTest::sampleInitFunction", true, false, false, false}, + {"jdbc:tc:mariadb:10.2.14://hostname/databasename?TC_MY_CNF=somepath/mariadb_conf_override", false, false, true, false}}); } public static void sampleInitFunction(Connection connection) throws SQLException { @@ -77,7 +80,14 @@ public void test() throws SQLException { performTestForScriptedSchema(jdbcUrl); } + if (performTestForJDBCParams) { + performTestForJDBCParamUsage(jdbcUrl); + } + if (performTestForCharacterSet) { + //Called twice to ensure that the query string parameters are used when + //connections are created from cached containers. + performSimpleTestWithCharacterSet(jdbcUrl); performSimpleTestWithCharacterSet(jdbcUrl); } @@ -107,25 +117,57 @@ private void performTestForScriptedSchema(String jdbcUrl) throws SQLException { assertEquals("A basic SELECT query succeeds where the schema has been applied from a script", "hello world", resultSetString); return true; }); - - assertTrue("The database returned a record as expected", result); - } } - private void performSimpleTestWithCharacterSet(String jdbcUrl) throws SQLException { + private void performTestForJDBCParamUsage(String jdbcUrl) throws SQLException { try (HikariDataSource dataSource = getDataSource(jdbcUrl, 1)) { - boolean result = new QueryRunner(dataSource).query("SHOW VARIABLES LIKE 'character\\_set\\_connection'", rs -> { + boolean result = new QueryRunner(dataSource).query("select CURRENT_USER()", rs -> { + rs.next(); + String resultUser = rs.getString(1); + assertEquals("User from query param is created.", "someuser@%", resultUser); + return true; + }); + + result = new QueryRunner(dataSource).query("SELECT DATABASE()", rs -> { rs.next(); - String resultSetInt = rs.getString(2); - assertEquals("Passing query parameters to set DB connection encoding is successful", "utf8", resultSetInt); + String resultDB = rs.getString(1); + assertEquals("Database name from URL String is used.", "databasename", resultDB); return true; }); assertTrue("The database returned a record as expected", result); + } } + /** + * This method intentionally verifies encoding twice to ensure that the query string parameters are used when + * Connections are created from cached containers. + * + * @param jdbcUrl + * @throws SQLException + */ + private void performSimpleTestWithCharacterSet(String jdbcUrl) throws SQLException { + HikariDataSource datasource1 = verifyCharacterSet(jdbcUrl); + HikariDataSource datasource2 = verifyCharacterSet(jdbcUrl); + datasource1.close(); + datasource2.close(); + } + + private HikariDataSource verifyCharacterSet(String jdbcUrl) throws SQLException { + HikariDataSource dataSource = getDataSource(jdbcUrl, 1); + boolean result = new QueryRunner(dataSource).query("SHOW VARIABLES LIKE 'character\\_set\\_connection'", rs -> { + rs.next(); + String resultSetInt = rs.getString(2); + assertEquals("Passing query parameters to set DB connection encoding is successful", "utf8", resultSetInt); + return true; + }); + + assertTrue("The database returned a record as expected", result); + return dataSource; + } + private void performTestForCustomIniFile(final String jdbcUrl) throws SQLException { assumeFalse(SystemUtils.IS_OS_WINDOWS); try (HikariDataSource ds = getDataSource(jdbcUrl, 1)) { diff --git a/modules/jdbc/src/main/java/org/testcontainers/containers/JdbcDatabaseContainerProvider.java b/modules/jdbc/src/main/java/org/testcontainers/containers/JdbcDatabaseContainerProvider.java index aadc266f6f9..2a73879a572 100644 --- a/modules/jdbc/src/main/java/org/testcontainers/containers/JdbcDatabaseContainerProvider.java +++ b/modules/jdbc/src/main/java/org/testcontainers/containers/JdbcDatabaseContainerProvider.java @@ -1,5 +1,7 @@ package org.testcontainers.containers; +import org.testcontainers.jdbc.ConnectionUrl; + import lombok.extern.slf4j.Slf4j; /** @@ -8,8 +10,19 @@ @Slf4j public abstract class JdbcDatabaseContainerProvider { + /** + * Tests if the specified database type is supported by this Container Provider. It should match to the base image name. + * @param databaseType {@link String} + * @return true when provider can handle this database type, else false. + */ public abstract boolean supports(String databaseType); + /** + * Instantiate a new {@link JdbcDatabaseContainer} without any specified image tag. Subclasses should + * override this method if possible, to provide a default tag that is more stable than latest`. + * + * @return Instance of {@link JdbcDatabaseContainer} + */ public JdbcDatabaseContainer newInstance() { log.warn("No explicit version tag was provided in JDBC URL and this class ({}) does not " + "override newInstance() to set a default tag. `latest` will be used but results may " + @@ -17,5 +30,23 @@ public JdbcDatabaseContainer newInstance() { return this.newInstance("latest"); } + /** + * Instantiate a new {@link JdbcDatabaseContainer} with specified image tag. + * @param tag + * @return Instance of {@link JdbcDatabaseContainer} + */ public abstract JdbcDatabaseContainer newInstance(String tag); + + /** + * Instantiate a new {@link JdbcDatabaseContainer} using information provided with {@link ConnectionUrl}. + * @param url {@link ConnectionUrl} + * @return Instance of {@link JdbcDatabaseContainer} + */ + public JdbcDatabaseContainer newInstance(ConnectionUrl url) { + if (url.getImageTag().isPresent()) { + return newInstance(url.getImageTag().get()); + } else { + return newInstance(); + } + } } diff --git a/modules/jdbc/src/main/java/org/testcontainers/jdbc/ConnectionUrl.java b/modules/jdbc/src/main/java/org/testcontainers/jdbc/ConnectionUrl.java new file mode 100644 index 00000000000..7c5a53ede65 --- /dev/null +++ b/modules/jdbc/src/main/java/org/testcontainers/jdbc/ConnectionUrl.java @@ -0,0 +1,204 @@ +package org.testcontainers.jdbc; + +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; + +import java.util.*; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +/** + * This is an Immutable class holding JDBC Connection Url and its parsed components, used by {@link ContainerDatabaseDriver}. + *

+ * {@link ConnectionUrl#parseUrl()} method must be called after instantiating this class. + * + * @author manikmagar + */ +@EqualsAndHashCode(of = "url") @Getter +public class ConnectionUrl { + + private String url; + + private String databaseType; + + private Optional imageTag; + + /** + * This is a part of the connection string that may specify host:port/databasename. + * It may vary for different clients and so clients can parse it as needed. + */ + private String dbHostString; + + private boolean inDaemonMode = false; + + private Optional databaseHost = Optional.empty(); + + private Optional databasePort = Optional.empty(); + + private Optional databaseName = Optional.empty(); + + private Optional initScriptPath = Optional.empty(); + + private Optional initFunction = Optional.empty(); + + private Optional queryString; + + private Map containerParameters; + + private Map queryParameters; + + public static ConnectionUrl newInstance(final String url){ + ConnectionUrl connectionUrl = new ConnectionUrl(url); + connectionUrl.parseUrl(); + return connectionUrl; + } + + private ConnectionUrl(final String url) { + this.url = Objects.requireNonNull(url, "Connection URL cannot be null"); + } + + public static boolean accepts(final String url) { + return url.startsWith("jdbc:tc:"); + } + + /** + * This method applies various REGEX Patterns to parse the URL associated with this instance. + * This is called from a @{@link ConnectionUrl#newInstance(String)} static factory method to create immutable instance of {@link ConnectionUrl}. + * To avoid mutation after class is instantiated, this method should not be publicly accessible. + */ + private void parseUrl() { + /* + Extract from the JDBC connection URL: + * The database type (e.g. mysql, postgresql, ...) + * The docker tag, if provided. + * The URL query string, if provided + */ + Matcher urlMatcher = Patterns.URL_MATCHING_PATTERN.matcher(this.getUrl()); + if (!urlMatcher.matches()) { + //Try for Oracle pattern + urlMatcher = Patterns.ORACLE_URL_MATCHING_PATTERN.matcher(this.getUrl()); + if (!urlMatcher.matches()) { + throw new IllegalArgumentException("JDBC URL matches jdbc:tc: prefix but the database or tag name could not be identified"); + } + } + databaseType = urlMatcher.group(1); + + imageTag = Optional.ofNullable(urlMatcher.group(3)); + + //String like hostname:port/database name, which may vary based on target database. + //Clients can further parse it as needed. + dbHostString = urlMatcher.group(4); + + //In case it matches to the default pattern + Matcher dbInstanceMatcher = Patterns.DB_INSTANCE_MATCHING_PATTERN.matcher(dbHostString); + if (dbInstanceMatcher.matches()) { + databaseHost = Optional.of(dbInstanceMatcher.group(1)); + databasePort = Optional.ofNullable(dbInstanceMatcher.group(3)).map(value -> Integer.valueOf(value)); + databaseName = Optional.of(dbInstanceMatcher.group(4)); + } + + queryParameters = Collections.unmodifiableMap( + parseQueryParameters( + Optional.ofNullable(urlMatcher.group(5)).orElse(""))); + + String query = queryParameters + .entrySet() + .stream() + .map(e -> e.getKey() + "=" + e.getValue()) + .collect(Collectors.joining("&")); + + if (query == null || query.trim().length() == 0) { + queryString = Optional.empty(); + } else { + queryString = Optional.of("?" + query); + } + + containerParameters = Collections.unmodifiableMap(parseContainerParameters()); + + initScriptPath = Optional.ofNullable(containerParameters.get("TC_INITSCRIPT")); + + Matcher funcMatcher = Patterns.INITFUNCTION_MATCHING_PATTERN.matcher(this.getUrl()); + if (funcMatcher.matches()) { + initFunction = Optional.of(new InitFunctionDef(funcMatcher.group(2), funcMatcher.group(4))); + } + + Matcher daemonMatcher = Patterns.DAEMON_MATCHING_PATTERN.matcher(this.getUrl()); + inDaemonMode = daemonMatcher.matches() && Boolean.parseBoolean(daemonMatcher.group(2)); + + } + + /** + * Get the TestContainers Parameters such as Init Function, Init Script path etc. + * + * @return {@link Map} + */ + private Map parseContainerParameters() { + + Map results = new HashMap<>(); + + Matcher matcher = Patterns.TC_PARAM_MATCHING_PATTERN.matcher(this.getUrl()); + while (matcher.find()) { + String key = matcher.group(1); + String value = matcher.group(2); + results.put(key, value); + } + + return results; + } + + /** + * Get all Query parameters specified in the Connection URL after ?. This DOES NOT include TestContainers (TC_*) parameters. + * + * @return {@link Map} + */ + private Map parseQueryParameters(final String queryString) { + + Map results = new HashMap<>(); + Matcher matcher = Patterns.QUERY_PARAM_MATCHING_PATTERN.matcher(queryString); + while (matcher.find()) { + String key = matcher.group(1); + String value = matcher.group(2); + if(!key.matches(Patterns.TC_PARAM_NAME_PATTERN)) results.put(key, value); + } + + return results; + } + + /** + * This interface defines the Regex Patterns used by {@link ConnectionUrl}. + * + * @author manikmagar + */ + public interface Patterns { + Pattern URL_MATCHING_PATTERN = Pattern.compile("jdbc:tc:([a-z]+)(:([^:]+))?://([^\\?]+)(\\?.*)?"); + + Pattern ORACLE_URL_MATCHING_PATTERN = Pattern.compile("jdbc:tc:([a-z]+)(:([^(thin:)]+))?:thin:@([^\\?]+)(\\?.*)?"); + + //Matches to part of string - hostname:port/databasename + Pattern DB_INSTANCE_MATCHING_PATTERN = Pattern.compile("([^:]+)(:([0-9]+))?/([^\\\\?]+)"); + + Pattern DAEMON_MATCHING_PATTERN = Pattern.compile(".*([\\?&]?)TC_DAEMON=([^\\?&]+).*"); + Pattern INITSCRIPT_MATCHING_PATTERN = Pattern.compile(".*([\\?&]?)TC_INITSCRIPT=([^\\?&]+).*"); + Pattern INITFUNCTION_MATCHING_PATTERN = Pattern.compile(".*([\\?&]?)TC_INITFUNCTION=" + + "((\\p{javaJavaIdentifierStart}\\p{javaJavaIdentifierPart}*\\.)*\\p{javaJavaIdentifierStart}\\p{javaJavaIdentifierPart}*)" + + "::" + + "(\\p{javaJavaIdentifierStart}\\p{javaJavaIdentifierPart}*)" + + ".*"); + + String TC_PARAM_NAME_PATTERN = "(TC_[A-Z_]+)"; + + Pattern TC_PARAM_MATCHING_PATTERN = Pattern.compile(TC_PARAM_NAME_PATTERN + "=([^\\?&]+)"); + + Pattern QUERY_PARAM_MATCHING_PATTERN = Pattern.compile("([^\\?&=]+)=([^\\?&]+)"); + + } + + @Getter + @AllArgsConstructor + public class InitFunctionDef { + private String className; + private String methodName; + } +} diff --git a/modules/jdbc/src/main/java/org/testcontainers/jdbc/ContainerDatabaseDriver.java b/modules/jdbc/src/main/java/org/testcontainers/jdbc/ContainerDatabaseDriver.java index 569dc794213..f5a6dc57731 100644 --- a/modules/jdbc/src/main/java/org/testcontainers/jdbc/ContainerDatabaseDriver.java +++ b/modules/jdbc/src/main/java/org/testcontainers/jdbc/ContainerDatabaseDriver.java @@ -16,8 +16,6 @@ import java.sql.*; import java.util.*; import java.util.logging.Logger; -import java.util.regex.Matcher; -import java.util.regex.Pattern; /** * Test Containers JDBC proxy driver. This driver will handle JDBC URLs of the form: @@ -37,16 +35,6 @@ */ public class ContainerDatabaseDriver implements Driver { - private static final Pattern URL_MATCHING_PATTERN = Pattern.compile("jdbc:tc:([a-z]+)(:([^:]+))?://[^\\?]+(\\?.*)?"); - private static final Pattern DAEMON_MATCHING_PATTERN = Pattern.compile(".*([\\?&]?)TC_DAEMON=([^\\?&]+).*"); - private static final Pattern INITSCRIPT_MATCHING_PATTERN = Pattern.compile(".*([\\?&]?)TC_INITSCRIPT=([^\\?&]+).*"); - private static final Pattern INITFUNCTION_MATCHING_PATTERN = Pattern.compile(".*([\\?&]?)TC_INITFUNCTION=" + - "((\\p{javaJavaIdentifierStart}\\p{javaJavaIdentifierPart}*\\.)*\\p{javaJavaIdentifierStart}\\p{javaJavaIdentifierPart}*)" + - "::" + - "(\\p{javaJavaIdentifierStart}\\p{javaJavaIdentifierPart}*)" + - ".*"); - - private static final Pattern TC_PARAM_MATCHING_PATTERN = Pattern.compile("([A-Z_]+)=([^\\?&]+)"); private static final org.slf4j.Logger LOGGER = LoggerFactory.getLogger(ContainerDatabaseDriver.class); private Driver delegate; @@ -81,52 +69,34 @@ public synchronized Connection connect(String url, final Properties info) throws return null; } + ConnectionUrl connectionUrl = ConnectionUrl.newInstance(url); + synchronized (jdbcUrlContainerCache) { - String queryString = ""; + String queryString = connectionUrl.getQueryString().orElse(""); /* If we already have a running container for this exact connection string, we want to connect to that rather than create a new container */ - JdbcDatabaseContainer container = jdbcUrlContainerCache.get(url); + JdbcDatabaseContainer container = jdbcUrlContainerCache.get(connectionUrl.getUrl()); if (container == null) { - /* - Extract from the JDBC connection URL: - * The database type (e.g. mysql, postgresql, ...) - * The docker tag, if provided. - * The URL query string, if provided - */ - Matcher urlMatcher = URL_MATCHING_PATTERN.matcher(url); - if (!urlMatcher.matches()) { - throw new IllegalArgumentException("JDBC URL matches jdbc:tc: prefix but the database or tag name could not be identified"); - } - String databaseType = urlMatcher.group(1); - String tag = urlMatcher.group(3); - queryString = urlMatcher.group(4); - if (queryString == null) { - queryString = ""; - } + LOGGER.debug("Container not found in cache, creating new instance"); - Map parameters = getContainerParameters(url); + Map parameters = connectionUrl.getContainerParameters(); /* Find a matching container type using ServiceLoader. */ ServiceLoader databaseContainers = ServiceLoader.load(JdbcDatabaseContainerProvider.class); for (JdbcDatabaseContainerProvider candidateContainerType : databaseContainers) { - if (candidateContainerType.supports(databaseType)) { - - if (tag != null) { - container = candidateContainerType.newInstance(tag); - } else { - container = candidateContainerType.newInstance(); - } + if (candidateContainerType.supports(connectionUrl.getDatabaseType())) { + container = candidateContainerType.newInstance(connectionUrl); delegate = container.getJdbcDriverInstance(); } } if (container == null) { - throw new UnsupportedOperationException("Database name " + databaseType + " not supported"); + throw new UnsupportedOperationException("Database name " + connectionUrl.getDatabaseType() + " not supported"); } /* @@ -157,42 +127,29 @@ public synchronized Connection connect(String url, final Properties info) throws */ if (!initializedContainers.contains(container.getContainerId())) { DatabaseDelegate databaseDelegate = new JdbcDatabaseDelegate(container); - runInitScriptIfRequired(url, databaseDelegate); - runInitFunctionIfRequired(url, connection); + runInitScriptIfRequired(connectionUrl, databaseDelegate); + runInitFunctionIfRequired(connectionUrl, connection); initializedContainers.add(container.getContainerId()); } - return wrapConnection(connection, container, url); + return wrapConnection(connection, container, connectionUrl); } } - private Map getContainerParameters(String url) { - - Map results = new HashMap<>(); - - Matcher matcher = TC_PARAM_MATCHING_PATTERN.matcher(url); - while (matcher.find()) { - String key = matcher.group(1); - String value = matcher.group(2); - results.put(key, value); - } - - return results; - } /** * Wrap the connection, setting up a callback to be called when the connection is closed. *

* When there are no more open connections, the container itself will be stopped. * - * @param connection the new connection to be wrapped - * @param container the container which the connection is associated with - * @param url the testcontainers JDBC URL for this connection + * @param connection the new connection to be wrapped + * @param container the container which the connection is associated with + * @param connectionUrl {@link ConnectionUrl} instance representing JDBC Url for this connection * @return the connection, wrapped */ - private Connection wrapConnection(final Connection connection, final JdbcDatabaseContainer container, final String url) { - final Matcher matcher = DAEMON_MATCHING_PATTERN.matcher(url); - final boolean isDaemon = matcher.matches() ? Boolean.parseBoolean(matcher.group(2)) : false; + private Connection wrapConnection(final Connection connection, final JdbcDatabaseContainer container, final ConnectionUrl connectionUrl) { + + final boolean isDaemon = connectionUrl.isInDaemonMode(); Set connections = containerConnections.get(container.getContainerId()); @@ -209,7 +166,7 @@ private Connection wrapConnection(final Connection connection, final JdbcDatabas finalConnections.remove(connection); if (!isDaemon && finalConnections.isEmpty()) { container.stop(); - jdbcUrlContainerCache.remove(url); + jdbcUrlContainerCache.remove(connectionUrl.getUrl()); } }); } @@ -217,14 +174,13 @@ private Connection wrapConnection(final Connection connection, final JdbcDatabas /** * Run an init script from the classpath. * - * @param url the JDBC URL to check for init script declarations. + * @param connectionUrl {@link ConnectionUrl} instance representing JDBC Url with init script. * @param databaseDelegate database delegate to apply init scripts to the database * @throws SQLException on script or DB error */ - private void runInitScriptIfRequired(String url, DatabaseDelegate databaseDelegate) throws SQLException { - Matcher matcher = INITSCRIPT_MATCHING_PATTERN.matcher(url); - if (matcher.matches()) { - String initScriptPath = matcher.group(2); + private void runInitScriptIfRequired(final ConnectionUrl connectionUrl, DatabaseDelegate databaseDelegate) throws SQLException { + if (connectionUrl.getInitScriptPath().isPresent()) { + String initScriptPath = connectionUrl.getInitScriptPath().get(); try { URL resource = Thread.currentThread().getContextClassLoader().getResource(initScriptPath); @@ -248,15 +204,14 @@ private void runInitScriptIfRequired(String url, DatabaseDelegate databaseDelega /** * Run an init function (must be a public static method on an accessible class). * - * @param url the JDBC URL to check for init function declarations. - * @param connection JDBC connection to apply init functions to. + * @param connectionUrl {@link ConnectionUrl} instance representing JDBC Url with r init function declarations. + * @param connection JDBC connection to apply init functions to. * @throws SQLException on script or DB error */ - private void runInitFunctionIfRequired(String url, Connection connection) throws SQLException { - Matcher matcher = INITFUNCTION_MATCHING_PATTERN.matcher(url); - if (matcher.matches()) { - String className = matcher.group(2); - String methodName = matcher.group(4); + private void runInitFunctionIfRequired(final ConnectionUrl connectionUrl, Connection connection) throws SQLException { + if (connectionUrl.getInitFunction().isPresent()) { + String className = connectionUrl.getInitFunction().get().getClassName(); + String methodName = connectionUrl.getInitFunction().get().getMethodName(); try { Class initFunctionClazz = Class.forName(className); @@ -314,6 +269,7 @@ public static void killContainers() { * Utility method to kill a database container directly from test support code. It shouldn't be necessary to use this, * but it is provided for convenience - e.g. for situations where many different database containers are being * tested and cleanup is needed to limit resource usage. + * * @param jdbcUrl the JDBC URL of the container which should be killed */ public static void killContainer(String jdbcUrl) { @@ -330,6 +286,7 @@ public static void killContainer(String jdbcUrl) { /** * Utility method to get an instance of a database container given its JDBC URL. + * * @param jdbcUrl the JDBC URL of the container instance to get * @return an instance of database container or null if no container associated with JDBC URL */ diff --git a/modules/jdbc/src/test/java/org/testcontainers/jdbc/ConnectionUrlDriversTests.java b/modules/jdbc/src/test/java/org/testcontainers/jdbc/ConnectionUrlDriversTests.java new file mode 100644 index 00000000000..3f0673d834f --- /dev/null +++ b/modules/jdbc/src/test/java/org/testcontainers/jdbc/ConnectionUrlDriversTests.java @@ -0,0 +1,57 @@ +package org.testcontainers.jdbc; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameter; + +import java.util.Optional; + +import static java.util.Arrays.asList; +import static org.rnorth.visibleassertions.VisibleAssertions.assertEquals; + +/** + * This Test class validates that all supported JDBC URL's can be parsed by ConnectionUrl class. + * + * @author ManikMagar + */ +@RunWith(Parameterized.class) +public class ConnectionUrlDriversTests { + + @Parameter + public String jdbcUrl; + @Parameter(1) + public String databaseType; + @Parameter(2) + public Optional tag; + @Parameter(3) + public String dbHostString; + @Parameter(4) + public String databaseName; + + @Parameterized.Parameters(name = "{index} - {0}") + public static Iterable data() { + return asList( + new Object[][]{ + {"jdbc:tc:mysql:5.5.43://hostname/test", "mysql", Optional.of("5.5.43"), "hostname/test", "test"}, + {"jdbc:tc:mysql://hostname/test", "mysql", Optional.empty(), "hostname/test", "test"}, + {"jdbc:tc:postgresql:1.2.3://hostname/test", "postgresql", Optional.of("1.2.3"), "hostname/test", "test"}, + {"jdbc:tc:postgresql://hostname/test", "postgresql", Optional.empty(), "hostname/test", "test"}, + {"jdbc:tc:sqlserver:1.2.3://localhost;instance=SQLEXPRESS:1433;databaseName=test", "sqlserver", Optional.of("1.2.3"), "localhost;instance=SQLEXPRESS:1433;databaseName=test", ""}, + {"jdbc:tc:sqlserver://localhost;instance=SQLEXPRESS:1433;databaseName=test", "sqlserver", Optional.empty(), "localhost;instance=SQLEXPRESS:1433;databaseName=test", ""}, + {"jdbc:tc:mariadb:1.2.3://localhost:3306/test", "mariadb", Optional.of("1.2.3"), "localhost:3306/test", "test"}, + {"jdbc:tc:mariadb://localhost:3306/test", "mariadb", Optional.empty(), "localhost:3306/test", "test"}, + {"jdbc:tc:oracle:1.2.3:thin:@localhost:1521/test", "oracle", Optional.of("1.2.3"), "localhost:1521/test", "test"}, + {"jdbc:tc:oracle:thin:@localhost:1521/test", "oracle", Optional.empty(), "localhost:1521/test", "test"} + }); + } + + @Test + public void test() { + ConnectionUrl url = ConnectionUrl.newInstance(jdbcUrl); + assertEquals("Database Type is as expected", databaseType, url.getDatabaseType()); + assertEquals("Image tag is as expected", tag, url.getImageTag()); + assertEquals("Database Host String is as expected", dbHostString, url.getDbHostString()); + assertEquals("Database Name is as expected", databaseName, url.getDatabaseName().orElse("")); + } +} diff --git a/modules/jdbc/src/test/java/org/testcontainers/jdbc/ConnectionUrlTest.java b/modules/jdbc/src/test/java/org/testcontainers/jdbc/ConnectionUrlTest.java new file mode 100644 index 00000000000..a5c36d8f065 --- /dev/null +++ b/modules/jdbc/src/test/java/org/testcontainers/jdbc/ConnectionUrlTest.java @@ -0,0 +1,88 @@ +package org.testcontainers.jdbc; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; + +import java.util.Optional; + +import static org.rnorth.visibleassertions.VisibleAssertions.*; + +public class ConnectionUrlTest { + + @Rule + public ExpectedException thrown = ExpectedException.none(); + + + @Test + public void testConnectionUrl1() { + String urlString = "jdbc:tc:mysql:5.6.23://somehostname:3306/databasename?a=b&c=d"; + ConnectionUrl url = ConnectionUrl.newInstance(urlString); + + assertEquals("Database Type value is as expected", "mysql", url.getDatabaseType()); + assertEquals("Database Image tag value is as expected", "5.6.23", url.getImageTag().get()); + assertEquals("Database Host String is as expected", "somehostname:3306/databasename", url.getDbHostString()); + assertEquals("Query String value is as expected", "?a=b&c=d", url.getQueryString().get()); + assertEquals("Database Host value is as expected", "somehostname", url.getDatabaseHost().get()); + assertEquals("Database Port value is as expected", 3306, url.getDatabasePort().get()); + assertEquals("Database Name value is as expected", "databasename", url.getDatabaseName().get()); + + assertEquals("Parameter a is captured", "b", url.getQueryParameters().get("a")); + assertEquals("Parameter c is captured", "d", url.getQueryParameters().get("c")); + } + + + @Test + public void testConnectionUrl2() { + String urlString = "jdbc:tc:mysql://somehostname/databasename"; + ConnectionUrl url = ConnectionUrl.newInstance(urlString); + + assertEquals("Database Type value is as expected", "mysql", url.getDatabaseType()); + assertFalse("Database Image tag value is as expected", url.getImageTag().isPresent()); + assertEquals("Database Host String is as expected", "somehostname/databasename", url.getDbHostString()); + assertEquals("Query String value is as expected", Optional.empty(), url.getQueryString()); + assertEquals("Database Host value is as expected", "somehostname", url.getDatabaseHost().get()); + assertFalse("Database Port is null as expected", url.getDatabasePort().isPresent()); + assertEquals("Database Name value is as expected", "databasename", url.getDatabaseName().get()); + + assertTrue("Connection Parameters set is empty", url.getQueryParameters().isEmpty()); + + } + + @Test + public void testInitScriptPathCapture() { + String urlString = "jdbc:tc:mysql:5.6.23://somehostname:3306/databasename?a=b&c=d&TC_INITSCRIPT=somepath/init_mysql.sql"; + ConnectionUrl url = ConnectionUrl.newInstance(urlString); + + assertEquals("Database Type value is as expected", "somepath/init_mysql.sql", url.getInitScriptPath().get()); + assertEquals("Query String value is as expected", "?a=b&c=d", url.getQueryString().get()); + assertEquals("INIT SCRIPT Path exists in Container Parameters", "somepath/init_mysql.sql", url.getContainerParameters().get("TC_INITSCRIPT")); + + //Parameter sets are unmodifiable + thrown.expect(UnsupportedOperationException.class); + url.getContainerParameters().remove("TC_INITSCRIPT"); + url.getQueryParameters().remove("a"); + + } + + @Test + public void testInitFunctionCapture() { + String urlString = "jdbc:tc:mysql:5.6.23://somehostname:3306/databasename?a=b&c=d&TC_INITFUNCTION=org.testcontainers.jdbc.JDBCDriverTest::sampleInitFunction"; + ConnectionUrl url = ConnectionUrl.newInstance(urlString); + + assertTrue("Init Function parameter exists", url.getInitFunction().isPresent()); + + assertEquals("Init function class is as expected", "org.testcontainers.jdbc.JDBCDriverTest", url.getInitFunction().get().getClassName()); + assertEquals("Init function class is as expected", "sampleInitFunction", url.getInitFunction().get().getMethodName()); + + } + + @Test + public void testDaemonCapture() { + String urlString = "jdbc:tc:mysql:5.6.23://somehostname:3306/databasename?a=b&c=d&TC_DAEMON=true"; + ConnectionUrl url = ConnectionUrl.newInstance(urlString); + + assertTrue("Daemon flag is set to true.", url.isInDaemonMode()); + + } +} diff --git a/modules/mysql/src/main/java/org/testcontainers/containers/MySQLContainerProvider.java b/modules/mysql/src/main/java/org/testcontainers/containers/MySQLContainerProvider.java index f503330d0e5..cfc072707da 100644 --- a/modules/mysql/src/main/java/org/testcontainers/containers/MySQLContainerProvider.java +++ b/modules/mysql/src/main/java/org/testcontainers/containers/MySQLContainerProvider.java @@ -1,9 +1,18 @@ package org.testcontainers.containers; +import org.testcontainers.jdbc.ConnectionUrl; + +import java.util.Objects; + /** * Factory for MySQL containers. */ public class MySQLContainerProvider extends JdbcDatabaseContainerProvider { + + private static final String USER_PARAM = "user"; + + private static final String PASSWORD_PARAM = "password"; + @Override public boolean supports(String databaseType) { return databaseType.equals(MySQLContainer.NAME); @@ -16,6 +25,32 @@ public JdbcDatabaseContainer newInstance() { @Override public JdbcDatabaseContainer newInstance(String tag) { - return new MySQLContainer(MySQLContainer.IMAGE + ":" + tag); + if (tag != null) { + return new MySQLContainer(MySQLContainer.IMAGE + ":" + tag); + } else { + return newInstance(); + } + } + + @Override + public JdbcDatabaseContainer newInstance(ConnectionUrl connectionUrl) { + Objects.requireNonNull(connectionUrl, "Connection URL cannot be null"); + + final String databaseName = connectionUrl.getDatabaseName().orElse("test"); + final String user = connectionUrl.getQueryParameters().getOrDefault(USER_PARAM, "test"); + final String password = connectionUrl.getQueryParameters().getOrDefault(PASSWORD_PARAM, "test"); + + final JdbcDatabaseContainer instance; + if (connectionUrl.getImageTag().isPresent()) { + instance = newInstance(connectionUrl.getImageTag().get()); + } else { + instance = newInstance(); + } + + return instance + .withDatabaseName(databaseName) + .withUsername(user) + .withPassword(password); } + }