diff --git a/build.sbt b/build.sbt index e1ab5027..da6ef310 100644 --- a/build.sbt +++ b/build.sbt @@ -65,6 +65,13 @@ lazy val core = project (Compile / classDirectory).value, "play/db/ebean/**" ), + // The models of the tests need to be enhanced as well + Test / compile := enhanceEbeanClasses( + (Test / dependencyClasspath).value, + (Test / compile).value, + (Test / classDirectory).value, + "play/db/ebean/models/**" + ), ) lazy val plugin = project diff --git a/docs/manual/working/javaGuide/main/sql/JavaEbean.md b/docs/manual/working/javaGuide/main/sql/JavaEbean.md index 96da08e4..e60eddf2 100644 --- a/docs/manual/working/javaGuide/main/sql/JavaEbean.md +++ b/docs/manual/working/javaGuide/main/sql/JavaEbean.md @@ -51,7 +51,7 @@ Note that Ebean will also make use of a `conf/orm.xml` file (if present), to con Ebean can run queries against a separate, read-only data source, typically a [read replica](https://ebean.io/docs/read-replicas/). This applies to queries executed outside of a transaction and to explicitly read-only transactions (e.g. `TxScope.required().setReadOnly(true)`). All writes and all queries inside of other transactions, including the ones of `@Transactional` actions, keep using the main data source. -To use a read replica, configure it as an additional Play database and map it to the Ebean server via `play.ebean.readOnlyDatasources`: +To use a read replica, configure it as an additional Play database and set it as the `readOnlyDatasource` of the Ebean server: ```properties db.default.url = "jdbc:postgresql://primary-host/app" @@ -59,13 +59,28 @@ db.replica.url = "jdbc:postgresql://replica-host/app" db.replica.hikaricp.readOnly = true ebean.default = ["models.*"] -play.ebean.readOnlyDatasources.default = "replica" +play.ebean.db.default.readOnlyDatasource = "replica" ``` -The key is the name of the Ebean server (here `default`) and the value is the name of the Play database used for its read-only queries. Play manages the connection pool of the read replica like that of any other database, so all the usual `db` settings apply. +Here `default` is the name of the Ebean server and `replica` the name of the Play database used for its read-only queries. Play manages the connection pool of the read replica like that of any other database, so all the usual `db` settings apply. Ebean supports one read-only data source per Ebean server. To spread the read-only queries across multiple read replicas, use a JDBC URL that lists all of them (if supported by your JDBC driver, e.g. the PostgreSQL driver's `loadBalanceHosts` and `targetServerType` parameters), or point the URL to a load balancer or the reader endpoint of your database service. +#### Generating evolution scripts + +In dev and test mode, Play Ebean generates the evolution script `conf/evolutions//1.sql` of each Ebean server from its models, so that Play's [evolutions](https://www.playframework.com/documentation/latest/Evolutions) can create the database schema. Whenever the models change, Play Ebean updates the script, as long as it starts with the `-- Created by Ebean DDL` comment. To write the evolutions yourself, remove this comment (both lines), then Play Ebean doesn't touch the file anymore. + +You can turn off generating the scripts for all Ebean servers, and override that for single Ebean servers: + +```properties +# Don't generate the scripts of the Ebean servers... +play.ebean.generateEvolutionsScripts = false +# ...except the one of the "default" Ebean server +play.ebean.db.default.generateEvolutionsScripts = true +``` + +This is independent of whether Play applies the evolutions to a database (`play.evolutions.db..enabled`). For example, turn off generating the script for a read-only database, or for a database whose evolution scripts come from the classpath (e.g. from a dependency), because a `conf/evolutions//1.sql` file takes precedence over these. + > For more information about Ebean, see the [Ebean documentation](https://ebean-orm.github.io/docs). #### Configuring the sbt plugin diff --git a/play-ebean/src/main/java/play/db/ebean/DefaultEbeanConfig.java b/play-ebean/src/main/java/play/db/ebean/DefaultEbeanConfig.java index eef195eb..28a09397 100644 --- a/play-ebean/src/main/java/play/db/ebean/DefaultEbeanConfig.java +++ b/play-ebean/src/main/java/play/db/ebean/DefaultEbeanConfig.java @@ -31,13 +31,24 @@ public class DefaultEbeanConfig implements EbeanConfig { private final boolean generateEvolutionsScripts; + private final Map generateEvolutionsScriptsByServer; + public DefaultEbeanConfig( String defaultServer, Map serverConfigs, - boolean generateEvolutionsScripts) { + boolean generateEvolutionsScripts, + Map generateEvolutionsScriptsByServer) { this.defaultServer = defaultServer; this.serverConfigs = serverConfigs; this.generateEvolutionsScripts = generateEvolutionsScripts; + this.generateEvolutionsScriptsByServer = generateEvolutionsScriptsByServer; + } + + public DefaultEbeanConfig( + String defaultServer, + Map serverConfigs, + boolean generateEvolutionsScripts) { + this(defaultServer, serverConfigs, generateEvolutionsScripts, Collections.emptyMap()); } public DefaultEbeanConfig(String defaultServer, Map serverConfigs) { @@ -59,6 +70,11 @@ public boolean generateEvolutionsScripts() { return generateEvolutionsScripts; } + @Override + public boolean generateEvolutionsScripts(String server) { + return generateEvolutionsScriptsByServer.getOrDefault(server, generateEvolutionsScripts); + } + @Singleton public static class EbeanConfigParser implements Provider { @@ -91,10 +107,12 @@ public EbeanConfig parse() { EbeanParsedConfig ebeanConfig = EbeanParsedConfig.parseFromConfig(config); - for (String key : ebeanConfig.getReadOnlyDatasources().keySet()) { + Set configuredServers = new HashSet<>(ebeanConfig.getReadOnlyDatasources().keySet()); + configuredServers.addAll(ebeanConfig.getGenerateEvolutionsScriptsByServer().keySet()); + for (String key : configuredServers) { if (!ebeanConfig.getDatasourceModels().containsKey(key)) { throw new ConfigException.BadValue( - "play.ebean.readOnlyDatasources." + key, "There is no Ebean server '" + key + "'"); + "play.ebean.db." + key, "There is no Ebean server '" + key + "'"); } } @@ -132,7 +150,8 @@ public EbeanConfig parse() { return new DefaultEbeanConfig( ebeanConfig.getDefaultDatasource(), serverConfigs, - ebeanConfig.generateEvolutionsScripts()); + ebeanConfig.generateEvolutionsScripts(), + ebeanConfig.getGenerateEvolutionsScriptsByServer()); } private void setServerConfigDataSource(String key, DatabaseConfig serverConfig) { @@ -150,7 +169,7 @@ private void setServerConfigReadOnlyDataSource( } if (dbApi.getDatabase(readOnlyDatasource) == null) { throw new ConfigException.BadValue( - "play.ebean.readOnlyDatasources." + key, + "play.ebean.db." + key + ".readOnlyDatasource", "There is no Play database '" + readOnlyDatasource + "'"); } // Not wrapped: Ebean's implicit read-only transactions work best with auto-commit diff --git a/play-ebean/src/main/java/play/db/ebean/EbeanConfig.java b/play-ebean/src/main/java/play/db/ebean/EbeanConfig.java index f2a2b4f2..cde632a7 100644 --- a/play-ebean/src/main/java/play/db/ebean/EbeanConfig.java +++ b/play-ebean/src/main/java/play/db/ebean/EbeanConfig.java @@ -14,4 +14,12 @@ public interface EbeanConfig { Map serverConfigs(); boolean generateEvolutionsScripts(); + + /** + * @param server the name of the Ebean server + * @return whether to generate the evolutions script of the given Ebean server + */ + default boolean generateEvolutionsScripts(String server) { + return generateEvolutionsScripts(); + } } diff --git a/play-ebean/src/main/java/play/db/ebean/EbeanDynamicEvolutions.java b/play-ebean/src/main/java/play/db/ebean/EbeanDynamicEvolutions.java index 6dc66cb1..80a3b961 100644 --- a/play-ebean/src/main/java/play/db/ebean/EbeanDynamicEvolutions.java +++ b/play-ebean/src/main/java/play/db/ebean/EbeanDynamicEvolutions.java @@ -64,13 +64,13 @@ public void create() { if (environment.isProd()) { return; } - if (!config.generateEvolutionsScripts()) { - return; - } config .serverConfigs() .forEach( (key, serverConfig) -> { + if (!config.generateEvolutionsScripts(key)) { + return; + } String evolutionScript = generateEvolutionScript(databases.get(key)); if (evolutionScript == null) { return; diff --git a/play-ebean/src/main/java/play/db/ebean/EbeanParsedConfig.java b/play-ebean/src/main/java/play/db/ebean/EbeanParsedConfig.java index 69e6c615..0caa661c 100644 --- a/play-ebean/src/main/java/play/db/ebean/EbeanParsedConfig.java +++ b/play-ebean/src/main/java/play/db/ebean/EbeanParsedConfig.java @@ -24,16 +24,20 @@ public class EbeanParsedConfig { private final boolean generateEvolutionsScripts; + private final Map generateEvolutionsScriptsByServer; + private final Map readOnlyDatasources; public EbeanParsedConfig( String defaultDatasource, Map> datasourceModels, boolean generateEvolutionsScripts, + Map generateEvolutionsScriptsByServer, Map readOnlyDatasources) { this.defaultDatasource = defaultDatasource; this.datasourceModels = datasourceModels; this.generateEvolutionsScripts = generateEvolutionsScripts; + this.generateEvolutionsScriptsByServer = generateEvolutionsScriptsByServer; this.readOnlyDatasources = readOnlyDatasources; } @@ -41,7 +45,12 @@ public EbeanParsedConfig( String defaultDatasource, Map> datasourceModels, boolean generateEvolutionsScripts) { - this(defaultDatasource, datasourceModels, generateEvolutionsScripts, Collections.emptyMap()); + this( + defaultDatasource, + datasourceModels, + generateEvolutionsScripts, + Collections.emptyMap(), + Collections.emptyMap()); } public EbeanParsedConfig(String defaultDatasource, Map> datasourceModels) { @@ -60,6 +69,22 @@ public boolean generateEvolutionsScripts() { return generateEvolutionsScripts; } + /** + * @param server the name of the Ebean server + * @return whether to generate the evolutions script of the given Ebean server + */ + public boolean generateEvolutionsScripts(String server) { + return generateEvolutionsScriptsByServer.getOrDefault(server, generateEvolutionsScripts); + } + + /** + * @return the explicit settings whether to generate the evolutions scripts, keyed by Ebean server + * name. + */ + public Map getGenerateEvolutionsScriptsByServer() { + return generateEvolutionsScriptsByServer; + } + /** * @return the names of the Play databases the Ebean servers use for read-only queries, keyed by * Ebean server name. @@ -100,14 +125,29 @@ public static EbeanParsedConfig parseFromConfig(Config config) { datasourceModels.put(key, models); }); } + Map generateEvolutionsScriptsByServer = new HashMap<>(); Map readOnlyDatasources = new HashMap<>(); - Config readOnlyDatasourcesConfig = playEbeanConfig.getConfig("readOnlyDatasources"); - readOnlyDatasourcesConfig + Config serversConfig = playEbeanConfig.getConfig("db"); + serversConfig .root() .keySet() - .forEach(key -> readOnlyDatasources.put(key, readOnlyDatasourcesConfig.getString(key))); + .forEach( + server -> { + Config serverConfig = serversConfig.getConfig(server); + if (serverConfig.hasPath("generateEvolutionsScripts")) { + generateEvolutionsScriptsByServer.put( + server, serverConfig.getBoolean("generateEvolutionsScripts")); + } + if (serverConfig.hasPath("readOnlyDatasource")) { + readOnlyDatasources.put(server, serverConfig.getString("readOnlyDatasource")); + } + }); return new EbeanParsedConfig( - defaultDatasource, datasourceModels, generateEvolutionsScripts, readOnlyDatasources); + defaultDatasource, + datasourceModels, + generateEvolutionsScripts, + generateEvolutionsScriptsByServer, + readOnlyDatasources); } } diff --git a/play-ebean/src/main/resources/reference.conf b/play-ebean/src/main/resources/reference.conf index 03a2e294..21705648 100644 --- a/play-ebean/src/main/resources/reference.conf +++ b/play-ebean/src/main/resources/reference.conf @@ -10,12 +10,16 @@ play { # The name of the default ebean datasource defaultDatasource = "default" - # The key to control the generation of the Evolutions scripts + # Whether to generate the evolutions scripts of the Ebean servers (in dev and test mode). Can be + # overridden for single Ebean servers, see `db` below. generateEvolutionsScripts = true - # Maps Ebean servers to the Play databases their read-only queries should use (e.g. a read - # replica). For example, `default = "replica"` makes the default Ebean server run queries - # outside of transactions against the `replica` database. - readOnlyDatasources {} + # Settings of single Ebean servers, keyed by the name of the Ebean server: + # - readOnlyDatasource: the Play database the Ebean server should use for its read-only queries + # (e.g. a read replica). For example, `db.default.readOnlyDatasource = "replica"` makes the + # default Ebean server run queries outside of transactions against the `replica` database. + # - generateEvolutionsScripts: whether to generate the evolutions script of the Ebean server, + # overriding the global generateEvolutionsScripts setting above. + db {} } } diff --git a/play-ebean/src/test/java/play/db/ebean/EbeanEvolutionsScriptsTest.java b/play-ebean/src/test/java/play/db/ebean/EbeanEvolutionsScriptsTest.java new file mode 100644 index 00000000..5ebd6279 --- /dev/null +++ b/play-ebean/src/test/java/play/db/ebean/EbeanEvolutionsScriptsTest.java @@ -0,0 +1,89 @@ +/* + * Copyright (C) from 2022 The Play Framework Contributors , 2011-2021 Lightbend Inc. + */ + +package play.db.ebean; + +import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +import java.io.File; +import java.util.List; +import java.util.Map; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import play.inject.guice.GuiceApplicationBuilder; +import play.test.Helpers; + +public class EbeanEvolutionsScriptsTest { + + @Rule public TemporaryFolder rootPath = new TemporaryFolder(); + + private GuiceApplicationBuilder appBuilder(Map config) { + return new GuiceApplicationBuilder() + .in(rootPath.getRoot()) + .configure( + Map.of( + "db.first.driver", "org.h2.Driver", + "db.first.url", "jdbc:h2:mem:play-ebean-first", + "db.second.driver", "org.h2.Driver", + "db.second.url", "jdbc:h2:mem:play-ebean-second", + "ebean.first", List.of("play.db.ebean.models.first.*"), + "ebean.second", List.of("play.db.ebean.models.second.*"), + "play.ebean.defaultDatasource", "first", + // Generating the scripts doesn't depend on Play applying evolutions + "play.evolutions.enabled", false)) + .configure(config); + } + + /** Runs the application and returns for which Ebean servers it generated evolution scripts. */ + private List generatedScripts(Map config) { + Helpers.running(appBuilder(config).build(), () -> {}); + return List.of(scriptExists("first"), scriptExists("second")); + } + + private boolean scriptExists(String server) { + return new File(rootPath.getRoot(), "conf/evolutions/" + server + "/1.sql").isFile(); + } + + @Test + public void generatesScriptsByDefault() { + assertEquals(List.of(true, true), generatedScripts(Map.of())); + } + + @Test + public void doesNotGenerateScriptsWhenDisabled() { + assertEquals( + List.of(false, false), + generatedScripts(Map.of("play.ebean.generateEvolutionsScripts", false))); + } + + @Test + public void doesNotGenerateScriptOfServerWhenDisabledForServer() { + assertEquals( + List.of(true, false), + generatedScripts(Map.of("play.ebean.db.second.generateEvolutionsScripts", false))); + } + + @Test + public void generatesScriptOfServerWhenEnabledForServer() { + assertEquals( + List.of(false, true), + generatedScripts( + Map.of( + "play.ebean.generateEvolutionsScripts", false, + "play.ebean.db.second.generateEvolutionsScripts", true))); + } + + @Test + public void failsForUnknownEbeanServer() { + GuiceApplicationBuilder builder = + appBuilder(Map.of("play.ebean.db.unknown.generateEvolutionsScripts", false)); + Exception e = assertThrows(Exception.class, builder::build); + assertThat(e.getMessage(), containsString("play.ebean.db.unknown")); + assertThat(e.getMessage(), containsString("There is no Ebean server 'unknown'")); + } +} diff --git a/play-ebean/src/test/java/play/db/ebean/EbeanParsedConfigTest.java b/play-ebean/src/test/java/play/db/ebean/EbeanParsedConfigTest.java index f277b916..1987e384 100644 --- a/play-ebean/src/test/java/play/db/ebean/EbeanParsedConfigTest.java +++ b/play-ebean/src/test/java/play/db/ebean/EbeanParsedConfigTest.java @@ -28,6 +28,7 @@ public void defaultConfig() { assertThat(config.getDatasourceModels().size(), equalTo(0)); assertThat(config.generateEvolutionsScripts(), equalTo(Boolean.TRUE)); assertThat(config.getReadOnlyDatasources().size(), equalTo(0)); + assertThat(config.getGenerateEvolutionsScriptsByServer().size(), equalTo(0)); } @Test @@ -61,10 +62,29 @@ public void disableGenerateEvolutionsScripts() { assertThat(config.generateEvolutionsScripts(), equalTo(Boolean.FALSE)); } + @Test + public void disableGenerateEvolutionsScriptsForServer() { + EbeanParsedConfig config = + parse(ImmutableMap.of("play.ebean.db.other.generateEvolutionsScripts", false)); + assertThat(config.generateEvolutionsScripts("default"), equalTo(Boolean.TRUE)); + assertThat(config.generateEvolutionsScripts("other"), equalTo(Boolean.FALSE)); + } + + @Test + public void enableGenerateEvolutionsScriptsForServer() { + EbeanParsedConfig config = + parse( + ImmutableMap.of( + "play.ebean.generateEvolutionsScripts", false, + "play.ebean.db.other.generateEvolutionsScripts", true)); + assertThat(config.generateEvolutionsScripts("default"), equalTo(Boolean.FALSE)); + assertThat(config.generateEvolutionsScripts("other"), equalTo(Boolean.TRUE)); + } + @Test public void readOnlyDatasources() { EbeanParsedConfig config = - parse(ImmutableMap.of("play.ebean.readOnlyDatasources.default", "replica")); + parse(ImmutableMap.of("play.ebean.db.default.readOnlyDatasource", "replica")); assertThat(config.getReadOnlyDatasources().size(), equalTo(1)); assertThat(config.getReadOnlyDatasources().get("default"), equalTo("replica")); } diff --git a/play-ebean/src/test/java/play/db/ebean/EbeanReadOnlyDatasourceTest.java b/play-ebean/src/test/java/play/db/ebean/EbeanReadOnlyDatasourceTest.java index 488e8972..3dbd3004 100644 --- a/play-ebean/src/test/java/play/db/ebean/EbeanReadOnlyDatasourceTest.java +++ b/play-ebean/src/test/java/play/db/ebean/EbeanReadOnlyDatasourceTest.java @@ -30,7 +30,10 @@ private static GuiceApplicationBuilder appBuilder(Map config) { "db.default.url", "jdbc:h2:mem:play-ebean-primary", "db.replica.driver", "org.h2.Driver", "db.replica.url", "jdbc:h2:mem:play-ebean-replica", - "ebean.default", List.of())) + "ebean.default", List.of(), + // Without models, Ebean searches the classpath and finds the test models, so don't + // write their evolutions script into the working directory + "play.ebean.generateEvolutionsScripts", false)) .configure(config); } @@ -41,7 +44,7 @@ private static String currentDatabase(Database database) { @Test public void queriesOutsideOfTransactionsUseReadOnlyDatasource() { Application app = - appBuilder(Map.of("play.ebean.readOnlyDatasources.default", "replica")).build(); + appBuilder(Map.of("play.ebean.db.default.readOnlyDatasource", "replica")).build(); Helpers.start(app); try { Database database = DB.getDefault(); @@ -71,18 +74,18 @@ public void queriesUseMainDatasourceByDefault() { @Test public void failsForUnknownPlayDatabase() { GuiceApplicationBuilder builder = - appBuilder(Map.of("play.ebean.readOnlyDatasources.default", "unknown")); + appBuilder(Map.of("play.ebean.db.default.readOnlyDatasource", "unknown")); Exception e = assertThrows(Exception.class, builder::build); - assertThat(e.getMessage(), containsString("play.ebean.readOnlyDatasources.default")); + assertThat(e.getMessage(), containsString("play.ebean.db.default.readOnlyDatasource")); assertThat(e.getMessage(), containsString("There is no Play database 'unknown'")); } @Test public void failsForUnknownEbeanServer() { GuiceApplicationBuilder builder = - appBuilder(Map.of("play.ebean.readOnlyDatasources.unknown", "replica")); + appBuilder(Map.of("play.ebean.db.unknown.readOnlyDatasource", "replica")); Exception e = assertThrows(Exception.class, builder::build); - assertThat(e.getMessage(), containsString("play.ebean.readOnlyDatasources.unknown")); + assertThat(e.getMessage(), containsString("play.ebean.db.unknown")); assertThat(e.getMessage(), containsString("There is no Ebean server 'unknown'")); } } diff --git a/play-ebean/src/test/java/play/db/ebean/EbeanShutdownTest.java b/play-ebean/src/test/java/play/db/ebean/EbeanShutdownTest.java index 14895a97..d9921d2a 100644 --- a/play-ebean/src/test/java/play/db/ebean/EbeanShutdownTest.java +++ b/play-ebean/src/test/java/play/db/ebean/EbeanShutdownTest.java @@ -26,9 +26,17 @@ public void ebeanShutdownHookDoesNotShutDownDatabasesBeforeApplicationStops() th new GuiceApplicationBuilder() .configure( Map.of( - "db.default.driver", "org.h2.Driver", - "db.default.url", "jdbc:h2:mem:play-ebean-shutdown", - "ebean.default", List.of())) + "db.default.driver", + "org.h2.Driver", + "db.default.url", + "jdbc:h2:mem:play-ebean-shutdown", + "ebean.default", + List.of(), + // Without models, Ebean searches the classpath and finds the test models, so + // don't + // write their evolutions script into the working directory + "play.ebean.generateEvolutionsScripts", + false)) .build(); Helpers.start(app); Database database = DB.getDefault(); diff --git a/play-ebean/src/test/java/play/db/ebean/models/first/FirstModel.java b/play-ebean/src/test/java/play/db/ebean/models/first/FirstModel.java new file mode 100644 index 00000000..1293de31 --- /dev/null +++ b/play-ebean/src/test/java/play/db/ebean/models/first/FirstModel.java @@ -0,0 +1,13 @@ +/* + * Copyright (C) from 2022 The Play Framework Contributors , 2011-2021 Lightbend Inc. + */ + +package play.db.ebean.models.first; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; + +@Entity +public class FirstModel { + @Id public Long id; +} diff --git a/play-ebean/src/test/java/play/db/ebean/models/second/SecondModel.java b/play-ebean/src/test/java/play/db/ebean/models/second/SecondModel.java new file mode 100644 index 00000000..783a1a5a --- /dev/null +++ b/play-ebean/src/test/java/play/db/ebean/models/second/SecondModel.java @@ -0,0 +1,13 @@ +/* + * Copyright (C) from 2022 The Play Framework Contributors , 2011-2021 Lightbend Inc. + */ + +package play.db.ebean.models.second; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; + +@Entity +public class SecondModel { + @Id public Long id; +}