diff --git a/src/main/java/org/kiwiproject/dropwizard/error/ErrorContextBuilder.java b/src/main/java/org/kiwiproject/dropwizard/error/ErrorContextBuilder.java index e36c394..166d687 100644 --- a/src/main/java/org/kiwiproject/dropwizard/error/ErrorContextBuilder.java +++ b/src/main/java/org/kiwiproject/dropwizard/error/ErrorContextBuilder.java @@ -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; @@ -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; } @@ -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); } @@ -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; } diff --git a/src/main/java/org/kiwiproject/dropwizard/error/dao/ApplicationErrorJdbc.java b/src/main/java/org/kiwiproject/dropwizard/error/dao/ApplicationErrorJdbc.java index da27a4b..57efc6c 100644 --- a/src/main/java/org/kiwiproject/dropwizard/error/dao/ApplicationErrorJdbc.java +++ b/src/main/java/org/kiwiproject/dropwizard/error/dao/ApplicationErrorJdbc.java @@ -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; @@ -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. @@ -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). @@ -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; } @@ -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? + *
+ * 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 H2 Connection Modes + * @see H2 Database URL Overview + * @see H2 Automatic Mixed Mode + */ + 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); } /** diff --git a/src/test/java/org/kiwiproject/dropwizard/error/dao/ApplicationErrorJdbcTest.java b/src/test/java/org/kiwiproject/dropwizard/error/dao/ApplicationErrorJdbcTest.java index 385d6c8..ac86d21 100644 --- a/src/test/java/org/kiwiproject/dropwizard/error/dao/ApplicationErrorJdbcTest.java +++ b/src/test/java/org/kiwiproject/dropwizard/error/dao/ApplicationErrorJdbcTest.java @@ -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; @@ -22,7 +24,7 @@ import java.sql.SQLException; @DisplayName("ApplicationErrorJdbc") -@SuppressWarnings({"SqlDialectInspection", "SqlNoDataSourceInspection"}) +@SuppressWarnings({ "SqlDialectInspection", "SqlNoDataSourceInspection" }) class ApplicationErrorJdbcTest { private DataSourceFactory dataSourceFactory; @@ -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()); @@ -151,6 +175,7 @@ void shouldMigrate_H2InMemoryDatabase() throws SQLException { @Test void shouldThrow_WhenMigrationErrorOccurs() { + //noinspection resource var conn = mock(Connection.class); assertThatThrownBy(() -> ApplicationErrorJdbc.migrateDatabase(conn)) @@ -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", @@ -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); + } + } }