From 3332623d552da0e8398c367cf544f905f3c5df78 Mon Sep 17 00:00:00 2001 From: Matthias Kurz Date: Tue, 13 Oct 2026 12:15:16 +0200 Subject: [PATCH 1/2] Write the generated evolution script to absolute evolution paths With an absolute `play.evolutions[.db.].path`, Play's evolutions read the scripts from that path as it is, but Play Ebean resolved it relative to the application's root folder, like any other path. So it wrote the script to e.g. `/opt/migrations/default/1.sql` instead of `/opt/migrations/default/1.sql`, where Play didn't find it, and it didn't see a hand-written script there. Play Ebean now resolves the path like Play's evolutions do. The new test checks for each kind of path that Play applies the generated script, and that a hand-written script in an absolute folder stays untouched. --- .../play/db/ebean/EbeanDynamicEvolutions.java | 14 +- .../ebean/EbeanEvolutionsScriptPathTest.java | 146 ++++++++++++++++++ 2 files changed, 157 insertions(+), 3 deletions(-) create mode 100644 play-ebean/src/test/java/play/db/ebean/EbeanEvolutionsScriptPathTest.java 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 6f44edd6..cc0a6a38 100644 --- a/play-ebean/src/main/java/play/db/ebean/EbeanDynamicEvolutions.java +++ b/play-ebean/src/main/java/play/db/ebean/EbeanDynamicEvolutions.java @@ -97,7 +97,7 @@ public void create() { return; } File evolutions = - environment.getFile( + evolutionsFile( Evolutions$.MODULE$.fileName( key, 1, evolutionsConfig.forDatasource(key).path())); try { @@ -109,8 +109,7 @@ public void create() { if (content.isEmpty() || content.startsWith("# --- Created by Ebean DDL") || content.startsWith("-- Created by Ebean DDL")) { - environment - .getFile( + evolutionsFile( Evolutions$.MODULE$.directoryName( key, evolutionsConfig.forDatasource(key).path())) .mkdirs(); @@ -125,6 +124,15 @@ public void create() { }); } + /** + * Resolves the location of an evolution script like Play's evolutions do: an absolute path as it + * is, any other path relative to the application's root folder. + */ + private File evolutionsFile(String path) { + File file = new File(path); + return file.isAbsolute() ? file : environment.getFile(path); + } + /** * Helper method that generates the required evolution to properly run Ebean/DB. * diff --git a/play-ebean/src/test/java/play/db/ebean/EbeanEvolutionsScriptPathTest.java b/play-ebean/src/test/java/play/db/ebean/EbeanEvolutionsScriptPathTest.java new file mode 100644 index 00000000..722673ea --- /dev/null +++ b/play-ebean/src/test/java/play/db/ebean/EbeanEvolutionsScriptPathTest.java @@ -0,0 +1,146 @@ +/* + * Copyright (C) from 2022 The Play Framework Contributors , 2011-2021 Lightbend Inc. + */ + +package play.db.ebean; + +import static org.junit.Assert.assertEquals; + +import io.ebean.DB; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import play.inject.guice.GuiceApplicationBuilder; +import play.test.Helpers; + +/** + * Checks that Play applies the generated evolution script, wherever {@code + * play.evolutions.db..path} puts it. + */ +public class EbeanEvolutionsScriptPathTest { + + private static final AtomicInteger databases = new AtomicInteger(); + + /** Contains the application's root folder {@code app}, and the folders next to it. */ + @Rule public TemporaryFolder folder = new TemporaryFolder(); + + private File root() { + return new File(folder.getRoot(), "app"); + } + + /** + * Runs the application, and returns the number of rows of the given table, which only works if + * Play applied an evolution script that creates it. + */ + private int run(Map config, String table) { + root().mkdirs(); + GuiceApplicationBuilder builder = + new GuiceApplicationBuilder() + .in(root()) + .configure( + Map.of( + "db.default.driver", + "org.h2.Driver", + "db.default.url", + "jdbc:h2:mem:play-ebean-path-" + databases.incrementAndGet(), + "ebean.default", + List.of("play.db.ebean.models.first.*"), + "play.evolutions.db.default.autoApply", + true)) + .configure(config); + int[] rows = new int[1]; + Helpers.running( + builder.build(), + () -> + rows[0] = + DB.byName("default") + .sqlQuery("select count(*) as c from " + table) + .findOne() + .getInteger("c")); + return rows[0]; + } + + /** Returns the evolution scripts in the temporary folder, relative to it. */ + private List scripts() throws IOException { + Path base = folder.getRoot().toPath(); + try (Stream files = Files.walk(base)) { + return files + .filter(file -> file.getFileName().toString().equals("1.sql")) + .map(base::relativize) + .collect(Collectors.toList()); + } + } + + private void assertGeneratesScript(Map config, String script) throws IOException { + assertEquals(0, run(config, "first_model")); + assertEquals(List.of(Path.of(script)), scripts()); + } + + @Test + public void defaultPath() throws IOException { + assertGeneratesScript(Map.of(), "app/conf/evolutions/default/1.sql"); + } + + @Test + public void folderInConf() throws IOException { + assertGeneratesScript( + Map.of("play.evolutions.db.default.path", "migrations"), + "app/conf/migrations/default/1.sql"); + } + + @Test + public void folderInProject() throws IOException { + assertGeneratesScript( + Map.of("play.evolutions.db.default.path", "./migrations"), "app/migrations/default/1.sql"); + } + + @Test + public void folderNextToProject() throws IOException { + assertGeneratesScript( + Map.of("play.evolutions.db.default.path", "../migrations"), "migrations/default/1.sql"); + } + + @Test + public void absoluteFolder() throws IOException { + String path = new File(folder.getRoot(), "migrations").getAbsolutePath(); + assertGeneratesScript( + Map.of("play.evolutions.db.default.path", path), "migrations/default/1.sql"); + } + + @Test + public void keepsHandWrittenScriptInAbsoluteFolder() throws IOException { + File migrations = new File(folder.getRoot(), "migrations"); + Path script = new File(migrations, "default/1.sql").toPath(); + String handWritten = + String.join( + "\n", + "-- !Ups", + "create table first_model (id bigint not null primary key);", + "create table hand_written (id bigint);", + "", + "-- !Downs", + "drop table hand_written;", + "drop table first_model;", + ""); + Files.createDirectories(script.getParent()); + Files.write(script, handWritten.getBytes(StandardCharsets.UTF_8)); + + assertEquals( + 0, + run( + Map.of("play.evolutions.db.default.path", migrations.getAbsolutePath()), + "hand_written")); + assertEquals(handWritten, new String(Files.readAllBytes(script), StandardCharsets.UTF_8)); + assertEquals(List.of(Path.of("migrations/default/1.sql")), scripts()); + } +} From 7a0fdebdd9727a3e80527e8c77e393772dab3c68 Mon Sep 17 00:00:00 2001 From: Matthias Kurz Date: Tue, 13 Oct 2026 12:15:17 +0200 Subject: [PATCH 2/2] Explain where Play Ebean writes the evolution script, also without Play's layout Play Ebean follows the location of the evolution scripts that Play's evolutions use. In a project that disables Play's layout, Play still looks for the scripts in `conf` first, but doesn't package that folder, so the generated script is missing in production, and it takes precedence over the user's script in `src/main/resources` in dev and test mode. The docs show how to generate the script into the resources instead, or to turn off generating it. --- docs/manual/working/javaGuide/main/sql/JavaEbean.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/manual/working/javaGuide/main/sql/JavaEbean.md b/docs/manual/working/javaGuide/main/sql/JavaEbean.md index 6cfc7d35..4f3a1cb4 100644 --- a/docs/manual/working/javaGuide/main/sql/JavaEbean.md +++ b/docs/manual/working/javaGuide/main/sql/JavaEbean.md @@ -83,6 +83,17 @@ 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. +Play Ebean writes the script where Play's evolutions look for it, so it follows the [location of the evolution scripts](https://www.playframework.com/documentation/latest/Evolutions#Location-of-the-evolution-scripts) (`play.evolutions.path` or `play.evolutions.db..path`), including absolute paths and paths relative to the project's root folder. + +If your project doesn't use Play's layout (`disablePlugins(PlayLayoutPlugin)`), so that its resources are in `src/main/resources` instead of `conf`, be aware that Play still looks for the scripts in `conf/evolutions//` first, but doesn't package that folder. So the script that Play Ebean generates there would be missing in production, and in dev and test mode it would take precedence over a script of yours in `src/main/resources/evolutions//`. You can let Play Ebean write the script into the resources in dev and test mode: + +```properties +# Generates the script in src/main/resources/evolutions/default/1.sql +play.evolutions.db.default.path = "./src/main/resources/evolutions" +``` + +Then start the application in production with `-Dplay.evolutions.db.default.path=evolutions`, so that Play reads the packaged script from the classpath. Or turn off generating the script, as shown above. That doesn't remove a script that Play Ebean generated before, so if there is a generated `conf/evolutions//1.sql` that you don't want, delete that file. + > For more information about Ebean, see the [Ebean documentation](https://ebean-orm.github.io/docs). #### Configuring the sbt plugin