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.
This commit is contained in:
Rob Bygrave
2023-11-13 23:12:05 +13:00
parent 5d54a238bd
commit 24bb364d40
4 changed files with 161 additions and 8 deletions
@@ -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.
* <p>
* 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.
* <p>
* The DataSource and read-only DataSource are expected to be the same
* and use readOnly=true and autoCommit=true.
*/
boolean readOnlyDatabase();
/**
* Return the DataSource.
*/
@@ -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;
@@ -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);
@@ -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<UTDetail> 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<UTDetail> 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<UTDetail> list = readDb.find(UTDetail.class).findList();
assertThat(list).hasSize(2);
SpiTransaction current = (SpiTransaction) Transaction.current();
assertThat(current.isReadOnly()).isTrue();
}
@Transactional
void transactionalOnlyContainingQueries() {
List<UTDetail> 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);
}
}