Skip to content

Commit

Permalink
Only force embedded H2 databases to be NOT_SHARED (#309)
Browse files Browse the repository at this point in the history
* Only force embedded H2 databases to be NOT_SHARED

* Add isH2EmbeddedDataStore to ApplicationErrorJdbc
* Remove the erroneous implNote from isH2DataStore in
  ApplicationErrorJdbc; the method name tells you
  exactly wha it does
* Change ErrorContextBuilder to force NOT_SHARED only
  when the data store type has already been set, and the
  JDBC URL in the DataSourceFactory is an H2 URL that
  is for an embedded connection.

Closes  #306

* Cleanup the H2 data store logic

* Update isH2DataStore to check driver and URL
* No need to check blank URL in isH2EmbeddedDataStore anymore since
  we know it must be an H2 URL by that point in the code
* Introduce private isNotH2DataStore method because I just don't
  like reading !someThing(...) and prefer to see isNotSomeThing(...)
  in the main logic
* Update javadocs to match new logic
* IntelliJ reformatted some code, and I let it
  • Loading branch information
sleberknight authored Oct 30, 2023
1 parent 848ee9c commit faab86f
Show file tree
Hide file tree
Showing 3 changed files with 195 additions and 22 deletions.
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);
}
}
}

0 comments on commit faab86f

Please sign in to comment.