Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Only force embedded H2 databases to be NOT_SHARED #309

Merged
merged 2 commits into from
Oct 30, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
package org.kiwiproject.dropwizard.error;

import static java.util.Objects.isNull;
import static java.util.Objects.nonNull;
import static org.kiwiproject.base.KiwiStrings.f;
import static org.kiwiproject.dropwizard.error.ErrorContextUtilities.checkCommonArguments;
import static org.kiwiproject.dropwizard.error.dao.ApplicationErrorJdbc.createInMemoryH2Database;
import static org.kiwiproject.dropwizard.error.dao.ApplicationErrorJdbc.isH2DataStore;
import static org.kiwiproject.dropwizard.error.dao.ApplicationErrorJdbc.isH2EmbeddedDataStore;

import io.dropwizard.core.setup.Environment;
import io.dropwizard.db.DataSourceFactory;
import lombok.extern.slf4j.Slf4j;
import org.checkerframework.checker.nullness.qual.Nullable;
import org.jdbi.v3.core.Jdbi;
import org.kiwiproject.dropwizard.error.config.CleanupConfig;
import org.kiwiproject.dropwizard.error.dao.ApplicationErrorDao;
Expand Down Expand Up @@ -271,7 +274,7 @@ private static void logTimeWindowAlreadySetWarning(String fieldName, Object fiel
*/
public ErrorContext buildInMemoryH2() {
if (dataStoreTypeAlreadySet && dataStoreType == DataStoreType.SHARED) {
forceH2DatabaseToBeNotSharedWithWarning();
forceH2EmbeddedDatabaseToBeNotSharedWithWarning(null);
} else {
dataStoreType = DataStoreType.NOT_SHARED;
}
Expand All @@ -296,8 +299,8 @@ public ErrorContext buildInMemoryH2() {
* {@link ApplicationErrorJdbc#dataStoreTypeOf(DataSourceFactory)}.
*/
public ErrorContext buildWithDataStoreFactory(DataSourceFactory dataSourceFactory) {
if (dataStoreTypeAlreadySet && isH2DataStore(dataSourceFactory)) {
forceH2DatabaseToBeNotSharedWithWarning();
if (dataStoreTypeAlreadySet && isH2EmbeddedDataStore(dataSourceFactory) && dataStoreType == DataStoreType.SHARED) {
forceH2EmbeddedDatabaseToBeNotSharedWithWarning(dataSourceFactory.getUrl());
} else if (!dataStoreTypeAlreadySet) {
dataStoreType = ApplicationErrorJdbc.dataStoreTypeOf(dataSourceFactory);
}
Expand All @@ -312,9 +315,10 @@ public ErrorContext buildWithDataStoreFactory(DataSourceFactory dataSourceFactor
return newJdbi3ErrorContext(jdbi);
}

private void forceH2DatabaseToBeNotSharedWithWarning() {
LOG.warn("An in-memory H2 database was requested with a SHARED data store type." +
" This will be converted to a NOT_SHARED data store type.");
private void forceH2EmbeddedDatabaseToBeNotSharedWithWarning(@Nullable String url) {
var urlMessage = isNull(url) ? "" : f(" (url: %s)", url);
LOG.warn("An embedded H2 database was requested with a SHARED data store type." +
" This will be converted to a NOT_SHARED data store type.{}", urlMessage);
this.dataStoreType = DataStoreType.NOT_SHARED;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package org.kiwiproject.dropwizard.error.dao;

import static java.util.Objects.isNull;
import static java.util.Objects.requireNonNull;
import static org.apache.commons.lang3.StringUtils.isNotBlank;
import static org.apache.commons.lang3.StringUtils.startsWithAny;
import static org.kiwiproject.base.KiwiPreconditions.checkArgumentNotNull;
import static org.kiwiproject.base.KiwiStrings.format;

Expand All @@ -16,11 +19,13 @@
import lombok.experimental.UtilityClass;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.RandomStringUtils;
import org.checkerframework.checker.nullness.qual.Nullable;
import org.kiwiproject.dropwizard.error.model.DataStoreType;

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.util.Locale;

/**
* Helper utilities when using JDBC for application error persistence.
Expand All @@ -34,10 +39,23 @@ public class ApplicationErrorJdbc {
private static final String MIGRATIONS_FILENAME = "dropwizard-app-errors-migrations.xml";
private static final String H2_DRIVER = "org.h2.Driver";
private static final String H2_IN_MEMORY_DB_URL = "jdbc:h2:mem:dw-app-errors;DB_CLOSE_DELAY=-1";

private static final String H2_IN_MEMORY_DB_USERNAME = "appErrorUser";
private static final String H2_IN_MEMORY_DB_PASSWORD = RandomStringUtils.randomAlphanumeric(20);

private static final String H2_EMBEDDED_IN_MEMORY_URL_PREFIX = "jdbc:h2:mem:";
private static final String H2_EMBEDDED_FILE_EXPLICIT_URL_PREFIX = "jdbc:h2:file:";
private static final String H2_EMBEDDED_FILE_RELATIVE_URL_PREFIX = "jdbc:h2:~/";
private static final String H2_EMBEDDED_FILE_POSIX_ABSOLUTE_URL_PREFIX = "jdbc:h2:/";
private static final String H2_EMBEDDED_FILE_WINDOWS_ABSOLUTE_URL_PREFIX = "jdbc:h2:C:";
private static final String[] H2_EMBEDDED_URL_PREFIXES = {
H2_EMBEDDED_IN_MEMORY_URL_PREFIX,
H2_EMBEDDED_FILE_EXPLICIT_URL_PREFIX,
H2_EMBEDDED_FILE_RELATIVE_URL_PREFIX,
H2_EMBEDDED_FILE_POSIX_ABSOLUTE_URL_PREFIX,
H2_EMBEDDED_FILE_WINDOWS_ABSOLUTE_URL_PREFIX
};
private static final String H2_AUTOMATIC_MIXED_MODE = "AUTO_SERVER=TRUE";

/**
* Creates an in-memory H2 database that will stay alive as long as the JVM is alive and will use the same database
* for all connections (it uses {@code DB_CLOSE_DELAY=-1} in the database URL to accomplish this).
Expand Down Expand Up @@ -113,14 +131,12 @@ static String getDatabaseProductNameOrUnknown(Connection conn) {
*
* @param dataSourceFactory the DataSourceFactory to check
* @return the resolved DataStoreType
* @implNote Currently this uses ONLY the driver class to make this determination and always assumes H2 databases
* are NOT shared. This simplistic implementation could change in the future.
* @see #isH2DataStore(DataSourceFactory)
* @see #isH2EmbeddedDataStore(DataSourceFactory)
*/
public static DataStoreType dataStoreTypeOf(DataSourceFactory dataSourceFactory) {
checkArgumentNotNull(dataSourceFactory);

if (isH2DataStore(dataSourceFactory)) {
if (isH2EmbeddedDataStore(dataSourceFactory)) {
return DataStoreType.NOT_SHARED;
}

Expand All @@ -131,16 +147,49 @@ public static DataStoreType dataStoreTypeOf(DataSourceFactory dataSourceFactory)
* Is the given {@link DataSourceFactory} configured for an H2 database?
*
* @param dataSourceFactory the DataSourceFactory to check
* @return true if the driver class is the H2 driver, false otherwise (including null argument)
* @implNote Currently this uses ONLY the driver class to make this determination and always assumes H2 databases
* are NOT shared. This simplistic implementation could change in the future.
* @return true if the driver class is the H2 driver and the JDBC URL starts with the H2 prefix "jdbc:h2:",
* otherwise false (including a null argument)
*/
public static boolean isH2DataStore(DataSourceFactory dataSourceFactory) {
public static boolean isH2DataStore(@Nullable DataSourceFactory dataSourceFactory) {
if (isNull(dataSourceFactory)) {
return false;
}

return H2_DRIVER.equals(dataSourceFactory.getDriverClass());
return H2_DRIVER.equals(dataSourceFactory.getDriverClass()) &&
isNotBlank(dataSourceFactory.getUrl())
&& dataSourceFactory.getUrl().startsWith("jdbc:h2:");
}

/**
* Is the given {@link DataSourceFactory} configured for an embedded (in-memory or file-based) H2 database?
* <p>
* Note that since H2 Automatic Mixed Mode allows both a single embedded connection and remote
* connections, for example from separate process, this is not considered embedded.
*
* @param dataSourceFactory the DataSourceFactory to check
* @return true if the driver class is the H2 driver, and the database URL is definitely an embedded in-memory
* or file-based database connection string. If the driver is not H2, or the URL is not definitively known
* to be for an embedded database, returns false.
* @see <a href="http://www.h2database.com/html/features.html#connection_modes">H2 Connection Modes</a>
* @see <a href="http://www.h2database.com/html/features.html#database_url">H2 Database URL Overview</a>
* @see <a href="http://www.h2database.com/html/features.html#auto_mixed_mode">H2 Automatic Mixed Mode</a>
*/
public static boolean isH2EmbeddedDataStore(@Nullable DataSourceFactory dataSourceFactory) {
if (isNotH2DataStore(dataSourceFactory)) {
return false;
}

var url = requireNonNull(dataSourceFactory).getUrl();

return startsWithAny(url, H2_EMBEDDED_URL_PREFIXES) && isNotH2AutomaticMixedMode(url);
}

private static boolean isNotH2DataStore(@Nullable DataSourceFactory dataSourceFactory) {
return !isH2DataStore(dataSourceFactory);
}

private static boolean isNotH2AutomaticMixedMode(String url) {
return !url.toUpperCase(Locale.US).contains(H2_AUTOMATIC_MIXED_MODE);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.NullAndEmptySource;
import org.junit.jupiter.params.provider.ValueSource;
import org.kiwiproject.dropwizard.error.model.DataStoreType;
import org.kiwiproject.test.jdbc.RuntimeSQLException;
Expand All @@ -22,7 +24,7 @@
import java.sql.SQLException;

@DisplayName("ApplicationErrorJdbc")
@SuppressWarnings({"SqlDialectInspection", "SqlNoDataSourceInspection"})
@SuppressWarnings({ "SqlDialectInspection", "SqlNoDataSourceInspection" })
class ApplicationErrorJdbcTest {

private DataSourceFactory dataSourceFactory;
Expand Down Expand Up @@ -108,12 +110,34 @@ void shouldReturnUnknownIfExceptionThrown() throws SQLException {
@Nested
class DataStoreTypeOf {

@Test
void shouldReturn_NOT_SHARED_WhenH2Driver() {
@ParameterizedTest
@ValueSource(strings = {
"jdbc:h2:mem:",
"jdbc:h2:mem:test_db",
"jdbc:h2:~/test",
"jdbc:h2:file:/data/sample",
"jdbc:h2:file:C:/data/sample.db",
})
void shouldReturn_NOT_SHARED_WhenH2Driver_AndEmbeddedConnectionUrl(String url) {
dataSourceFactory.setDriverClass(org.h2.Driver.class.getName());
dataSourceFactory.setUrl(url);
assertThat(ApplicationErrorJdbc.dataStoreTypeOf(dataSourceFactory)).isEqualTo(DataStoreType.NOT_SHARED);
}

@ParameterizedTest
@ValueSource(strings = {
"jdbc:h2:tcp://localhost/~/test",
"jdbc:h2:tcp://dbserv:8084/~/sample",
"jdbc:h2:ssl://localhost:8085/~/sample",
"jdbc:h2:zip:~/db.zip!/test",
"jdbc:h2:/data/test;AUTO_SERVER=TRUE", // mixed mode
})
void shouldReturn_SHARED_WhenH2Driver_AndServerOrMixedModeConnectionUrl(String url) {
dataSourceFactory.setDriverClass(org.h2.Driver.class.getName());
dataSourceFactory.setUrl(url);
assertThat(ApplicationErrorJdbc.dataStoreTypeOf(dataSourceFactory)).isEqualTo(DataStoreType.SHARED);
}

@Test
void shouldReturn_SHARED_WhenPostgresDriver() {
dataSourceFactory.setDriverClass(org.postgresql.Driver.class.getName());
Expand Down Expand Up @@ -151,6 +175,7 @@ void shouldMigrate_H2InMemoryDatabase() throws SQLException {

@Test
void shouldThrow_WhenMigrationErrorOccurs() {
//noinspection resource
var conn = mock(Connection.class);

assertThatThrownBy(() -> ApplicationErrorJdbc.migrateDatabase(conn))
Expand All @@ -167,6 +192,20 @@ void shouldReturnFalse_WhenGivenNullDataSourceFactory() {
assertThat(ApplicationErrorJdbc.isH2DataStore(null)).isFalse();
}

@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = {
"jdbc:mysql://localhost:3306/test_db",
"jdbc:postgresql://localhost:5432/sample",
"jdbc:sqlite:sample.db"
})
void shouldReturnFalse_WhenDriverIsH2_ButUrlIsNotH2(String jdbcUrl) {
var dataSourceFactory = new DataSourceFactory();
dataSourceFactory.setDriverClass(Driver.class.getName());
dataSourceFactory.setUrl(jdbcUrl);
assertThat(ApplicationErrorJdbc.isH2DataStore(dataSourceFactory)).isFalse();
}

@ParameterizedTest
@ValueSource(strings = {
"org.postgresql.Driver",
Expand All @@ -179,11 +218,92 @@ void shouldReturnFalse_WhenGivenNonH2DataSourceFactory(String value) {
assertThat(ApplicationErrorJdbc.isH2DataStore(dataSourceFactory)).isFalse();
}

@Test
void shouldReturnTrue_WhenGivenH2DataSourceFactory() {
@ParameterizedTest
@ValueSource(strings = {
"jdbc:h2:mem:",
"jdbc:h2:file:/data/sample",
"jdbc:h2:tcp://localhost/~/test"
})
void shouldReturnTrue_WhenGivenH2DataSourceFactory(String jdbcUrl) {
var dataSourceFactory = new DataSourceFactory();
dataSourceFactory.setDriverClass(Driver.class.getName());
dataSourceFactory.setUrl(jdbcUrl);
assertThat(ApplicationErrorJdbc.isH2DataStore(dataSourceFactory)).isTrue();
}
}

@Nested
class IsH2EmbeddedDataStore {

@Test
void shouldReturnFalse_WhenGivenNullDataSourceFactory() {
assertThat(ApplicationErrorJdbc.isH2EmbeddedDataStore(null)).isFalse();
}

@ParameterizedTest
@CsvSource(textBlock = """
org.h2.Driver, jdbc:sqlite:sample.db
org.acme.db.Driver, jdbc:h2:~/test_db
'', jdbc:h2:~/test_db
null, jdbc:h2:~/test_db
org.h2.Driver, ''
org.h2.Driver, null
""", nullValues = "null")
void shouldReturnFalse_WhenGivenNonH2_DataSourceFactory(String driverClass, String jdbcUrl) {
var dataSourceFactory = new DataSourceFactory();
dataSourceFactory.setDriverClass(driverClass);
dataSourceFactory.setUrl(jdbcUrl);
assertThat(ApplicationErrorJdbc.isH2EmbeddedDataStore(dataSourceFactory)).isFalse();
}

@ParameterizedTest
@ValueSource(strings = {
"org.postgresql.Driver",
"com.mysql.jdbc.Driver",
"org.acme.db.Driver"
})
void shouldReturnFalse_WhenGivenNonH2DataSourceFactory(String value) {
var dataSourceFactory = new DataSourceFactory();
dataSourceFactory.setDriverClass(value);
assertThat(ApplicationErrorJdbc.isH2EmbeddedDataStore(dataSourceFactory)).isFalse();
}

/**
* We do NOT consider the "Automatic mixed mode" to be embedded, since it
* allows connections from other processes, JVMs, etc. This mode is enabled
* via the AUTO_SERVER=TRUE
*/
@ParameterizedTest
@CsvSource(textBlock = """
jdbc:h2:mem:, true
jdbc:h2:mem:test_db, true
jdbc:h2:~/test, true
jdbc:h2:~/test.db, true
jdbc:h2:file:/data/sample, true
jdbc:h2:file:/data/var/h2/test.db, true
jdbc:h2:file:C:/data/sample.db, true
jdbc:h2:file:~/secure;CIPHER=AES, true
jdbc:h2:file:~/private;CIPHER=AES;FILE_LOCK=SOCKET, true
jdbc:h2:file:~/sample;IFEXISTS=TRUE, true
jdbc:h2:file:~/sample;USER=sa;PASSWORD=123, true
jdbc:h2:~/test;MODE=MYSQL;DATABASE_TO_LOWER=TRUE, true

jdbc:h2:tcp://localhost/~/test, false
jdbc:h2:tcp://dbserv:8084/~/sample, false
jdbc:h2:tcp://localhost/mem:test, false
jdbc:h2:tcp://localhost/~/test;AUTO_RECONNECT=TRUE, false
jdbc:h2:ssl://localhost:8085/~/sample, false
jdbc:h2:ssl://localhost/~/test;CIPHER=AES, false
jdbc:h2:zip:~/db.zip!/test, false
jdbc:h2:/data/test;AUTO_SERVER=TRUE, false
jdbc:h2:/data/test;AUTO_SERVER=true, false
jdbc:h2:/data/test;auto_server=true, false
""")
void shouldReturnTrue_WhenGivenEmbeddedH2DataSourceFactory(String url, boolean isEmbeddedUrl) {
var dataSourceFactory = new DataSourceFactory();
dataSourceFactory.setDriverClass(Driver.class.getName());
dataSourceFactory.setUrl(url);
assertThat(ApplicationErrorJdbc.isH2EmbeddedDataStore(dataSourceFactory)).isEqualTo(isEmbeddedUrl);
}
}
}