From 24bb364d40597ea594f3b20248ac9aa6aff9930b Mon Sep 17 00:00:00 2001 From: Rob Bygrave Date: Mon, 13 Nov 2023 23:12:05 +1300 Subject: [PATCH] Add read only database configuration/builder option When using DatabaseBuilder.readOnlyDatabase(true) then ebean will: - Set the DataSourceBuilder to use autoCommit=true and readOnly=true - Use the same DataSource instance for both dataSource and readOnlyDataSource This is to simplify the setup/configuration for creating a Database that will only have read-only use. Note that readOnly=true is a JDBC hint and for example H2 database effectively ignores that hint where as Postgres will enforce the read-only true nature. --- .../main/java/io/ebean/DatabaseBuilder.java | 16 +++ .../java/io/ebean/config/DatabaseConfig.java | 13 ++ .../server/core/InitDataSource.java | 21 ++-- .../PostgresReadOnlyDatabaseTest.java | 119 ++++++++++++++++++ 4 files changed, 161 insertions(+), 8 deletions(-) create mode 100644 ebean-test/src/test/java/org/tests/readonly/PostgresReadOnlyDatabaseTest.java diff --git a/ebean-api/src/main/java/io/ebean/DatabaseBuilder.java b/ebean-api/src/main/java/io/ebean/DatabaseBuilder.java index a9a856bed..bbe3afdc9 100644 --- a/ebean-api/src/main/java/io/ebean/DatabaseBuilder.java +++ b/ebean-api/src/main/java/io/ebean/DatabaseBuilder.java @@ -1053,6 +1053,14 @@ public interface DatabaseBuilder { @Deprecated DatabaseBuilder setSkipDataSourceCheck(boolean skipDataSourceCheck); + /** + * Set to true if this database is used in a read only way. + *

+ * The DataSource and read-only DataSource are expected to be the same + * and use readOnly=true and autoCommit=true. + */ + DatabaseBuilder readOnlyDatabase(boolean readOnlyDatabase); + /** * Set a DataSource. */ @@ -2607,6 +2615,14 @@ public interface DatabaseBuilder { */ boolean skipDataSourceCheck(); + /** + * Return true if this database is used in a read only way. + *

+ * The DataSource and read-only DataSource are expected to be the same + * and use readOnly=true and autoCommit=true. + */ + boolean readOnlyDatabase(); + /** * Return the DataSource. */ diff --git a/ebean-api/src/main/java/io/ebean/config/DatabaseConfig.java b/ebean-api/src/main/java/io/ebean/config/DatabaseConfig.java index 30d72e55e..eb5e25b14 100644 --- a/ebean-api/src/main/java/io/ebean/config/DatabaseConfig.java +++ b/ebean-api/src/main/java/io/ebean/config/DatabaseConfig.java @@ -304,6 +304,8 @@ public class DatabaseConfig implements DatabaseBuilder.Settings { private boolean skipDataSourceCheck; + private boolean readOnlyDatabase; + /** * The data source (if programmatically provided). */ @@ -1343,6 +1345,17 @@ public class DatabaseConfig implements DatabaseBuilder.Settings { return this; } + @Override + public DatabaseBuilder readOnlyDatabase(boolean readOnlyDatabase) { + this.readOnlyDatabase = readOnlyDatabase; + return this; + } + + @Override + public boolean readOnlyDatabase() { + return readOnlyDatabase; + } + @Override public DataSource getDataSource() { return dataSource; diff --git a/ebean-core/src/main/java/io/ebeaninternal/server/core/InitDataSource.java b/ebean-core/src/main/java/io/ebeaninternal/server/core/InitDataSource.java index c5fead0da..2c994a0c3 100644 --- a/ebean-core/src/main/java/io/ebeaninternal/server/core/InitDataSource.java +++ b/ebean-core/src/main/java/io/ebeaninternal/server/core/InitDataSource.java @@ -38,15 +38,19 @@ final class InitDataSource { * Initialise the "main" read write DataSource from configuration. */ private DataSource initDataSource() { - return createFromConfig(config.getDataSourceConfig(), false); + return createFromConfig(config.getDataSourceConfig(), config.readOnlyDatabase(), false); } /** * Initialise the "read only" DataSource from configuration. */ private DataSource initReadOnlyDataSource() { + if (config.readOnlyDatabase()) { + // using the same DataSource instance + return config.getDataSource(); + } var roConfig = readOnlyConfig(); - return roConfig == null ? null : createFromConfig(roConfig, true); + return roConfig == null ? null : createFromConfig(roConfig, false, true); } DataSourceBuilder.Settings readOnlyConfig() { @@ -76,20 +80,21 @@ final class InitDataSource { return url != null && !"none".equalsIgnoreCase(url) && !url.trim().isEmpty(); } - private DataSource createFromConfig(DataSourceBuilder.Settings dsConfig, boolean readOnly) { + private DataSource createFromConfig(DataSourceBuilder.Settings dsConfig, boolean readOnlyDB, boolean readOnly) { if (dsConfig == null) { throw new PersistenceException("No DataSourceBuilder defined for " + config.getName()); } - if (dsConfig.isOffline()) { - if (config.getDatabasePlatformName() == null) { - throw new PersistenceException("You MUST specify a DatabasePlatformName on DatabaseConfig when offline"); - } + if (dsConfig.isOffline() && config.getDatabasePlatformName() == null) { + throw new PersistenceException("You MUST specify a DatabasePlatformName on DatabaseConfig when offline"); } attachAlert(dsConfig); attachListener(dsConfig); - if (readOnly) { + if (readOnlyDB) { + dsConfig.autoCommit(true); + dsConfig.readOnly(true); + } else if (readOnly) { // setup to use AutoCommit such that we skip explicit commit var mainSettings = config.getDataSourceConfig(); dsConfig.autoCommit(true); diff --git a/ebean-test/src/test/java/org/tests/readonly/PostgresReadOnlyDatabaseTest.java b/ebean-test/src/test/java/org/tests/readonly/PostgresReadOnlyDatabaseTest.java new file mode 100644 index 000000000..c33a97738 --- /dev/null +++ b/ebean-test/src/test/java/org/tests/readonly/PostgresReadOnlyDatabaseTest.java @@ -0,0 +1,119 @@ +package org.tests.readonly; + +import io.ebean.Database; +import io.ebean.DatabaseBuilder; +import io.ebean.Transaction; +import io.ebean.annotation.Transactional; +import io.ebean.datasource.DataSourceBuilder; +import io.ebean.test.containers.PostgresContainer; +import io.ebeaninternal.api.SpiTransaction; +import jakarta.persistence.PersistenceException; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.tests.model.basic.UTDetail; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class PostgresReadOnlyDatabaseTest { + + private static Database readDb; + + @BeforeAll + static void beforeAll() { + readDb = setupReadOnlyDatabase(); + } + + @Test + void insertPreventedInReadOnlyTransaction() { + assertThatThrownBy(() -> { + try (Transaction transaction = readDb.beginTransaction()) { + UTDetail u0 = new UTDetail("u0", 45, 3D); + readDb.save(u0); + } + }).isInstanceOf(PersistenceException.class) + .hasMessageContaining("ERROR: cannot execute INSERT in a read-only transaction"); + } + + @Test + void queryInExplicitTransaction() { + try (Transaction transaction = readDb.beginTransaction()) { + List list = readDb.find(UTDetail.class).findList(); + assertThat(list).hasSize(2); + assertThat(transaction.isReadOnly()).isTrue(); + transaction.commit(); // autoCommit=true but this is still allowed + } + } + + @Test + void queryInImplicitTransaction() { + List list = readDb.find(UTDetail.class).findList(); + assertThat(list).hasSize(2); + } + + // Requires .register(true).defaultDatabase(true) ... when running this test + @Disabled + @Test + void queryInReadOnlyTransactional() { + readOnlyTransactional(); + transactionalOnlyContainingQueries(); + } + + @Transactional(readOnly = true) + void readOnlyTransactional() { + List list = readDb.find(UTDetail.class).findList(); + assertThat(list).hasSize(2); + + SpiTransaction current = (SpiTransaction) Transaction.current(); + assertThat(current.isReadOnly()).isTrue(); + } + + @Transactional + void transactionalOnlyContainingQueries() { + List list = readDb.find(UTDetail.class).findList(); + assertThat(list).hasSize(2); + + SpiTransaction current = (SpiTransaction) Transaction.current(); + assertThat(current.isReadOnly()).isTrue(); + } + + private static Database setupReadOnlyDatabase() { + PostgresContainer.builder("15") + .dbName("readonly_test") + .build() + .start(); + + var dataSourceBuilder = DataSourceBuilder.create() + .username("readonly_test") + .password("test") + .url("jdbc:postgresql://localhost:6432/readonly_test"); + + var writeDb = databaseBuilder(dataSourceBuilder) + .ddlGenerate(true) + .ddlRun(true) + .build(); + + writeDb.truncate(UTDetail.class); + writeDb.save(new UTDetail("u0", 45, 3D)); + writeDb.save(new UTDetail("u1", 42, 5D)); + + return databaseBuilder(dataSourceBuilder) + .readOnlyDatabase(true) + // register + default required for the tests using @Transactional + // .register(true).defaultDatabase(true) + .build(); + } + + private static DatabaseBuilder databaseBuilder(DataSourceBuilder dataSourceBuilder) { + return Database.builder() + .name("ro_test") + .dataSourceBuilder(dataSourceBuilder) + .ddlExtra(false) + .defaultDatabase(false) + .register(false) + .addClass(UTDetail.class); + } +}