From fa62e3f67dfab1957c93fadb7fb6e0e4e551847d Mon Sep 17 00:00:00 2001 From: Stuart Douglas Date: Fri, 17 Sep 2021 11:37:37 +1000 Subject: [PATCH] Add the ability to auto generate Flyway migration This will be generated from the Hibernate schema with the press of a button Also adds support for post-boot schema validation with Hibernate, so developers can easily see if there are schema problems, without it interupting their workflow. --- docs/src/main/asciidoc/hibernate-orm.adoc | 18 ++++ .../spi/JdbcInitialSQLGeneratorBuildItem.java | 24 +++++ extensions/flyway/deployment/pom.xml | 7 +- .../flyway/FlywayDevConsoleProcessor.java | 102 ++++++++++++++++++ .../io/quarkus/flyway/FlywayProcessor.java | 60 ++++++++--- .../devconsole/FlywayDevConsoleProcessor.java | 25 ----- .../resources/dev-templates/datasources.html | 8 ++ .../FlywayDevModeCreateFromHibernateTest.java | 97 +++++++++++++++++ .../java/io/quarkus/flyway/test/Fruit.java | 35 ++++++ .../flyway/runtime/FlywayContainer.java | 15 ++- .../runtime/FlywayContainerProducer.java | 5 +- .../flyway/runtime/FlywayRecorder.java | 5 +- .../HibernateOrmConfigPersistenceUnit.java | 8 ++ .../orm/deployment/HibernateOrmProcessor.java | 89 +++++++++++++-- .../orm/runtime/HibernateOrmRecorder.java | 22 ++++ ...bernateOrmDevConsoleCreateDDLSupplier.java | 33 ++++++ .../HibernateOrmDevConsoleInfoSupplier.java | 2 +- .../schema/SchemaManagementIntegrator.java | 55 +++++++++- .../runtime/spi/DevConsolePostHandler.java | 8 +- 19 files changed, 554 insertions(+), 64 deletions(-) create mode 100644 extensions/agroal/spi/src/main/java/io/quarkus/agroal/spi/JdbcInitialSQLGeneratorBuildItem.java create mode 100644 extensions/flyway/deployment/src/main/java/io/quarkus/flyway/FlywayDevConsoleProcessor.java delete mode 100644 extensions/flyway/deployment/src/main/java/io/quarkus/flyway/devconsole/FlywayDevConsoleProcessor.java create mode 100644 extensions/flyway/deployment/src/test/java/io/quarkus/flyway/test/FlywayDevModeCreateFromHibernateTest.java create mode 100644 extensions/flyway/deployment/src/test/java/io/quarkus/flyway/test/Fruit.java create mode 100644 extensions/hibernate-orm/runtime/src/main/java/io/quarkus/hibernate/orm/runtime/devconsole/HibernateOrmDevConsoleCreateDDLSupplier.java diff --git a/docs/src/main/asciidoc/hibernate-orm.adoc b/docs/src/main/asciidoc/hibernate-orm.adoc index f49c694f7cff4..07ae5996b46b4 100644 --- a/docs/src/main/asciidoc/hibernate-orm.adoc +++ b/docs/src/main/asciidoc/hibernate-orm.adoc @@ -532,6 +532,24 @@ Add the following in your properties file. %prod.quarkus.hibernate-orm.sql-load-script = no-file ---- +== Automatically transitioning to Flyway to Manage Schemas + +If you have the Flyway extension installed when running in development mode Quarkus provides a simple way to turn +your Hibernate auto generated schema into a Flyway migration file. This is intended to make is easy to move from +the early development phase, where Hibernate can be used to quickly setup the schema, to the production phase, where +Flyway is used to manage schema changes. + +To use this feature simply open the Dev UI when the `quarkus-flyway` extension is installed and click in the `Datasources` +link in the Flyway pane. Hit the `Create Initial Migration` button and the following will happen: + +- A `db/migration/V1.0.0__\{appname\}.sql` file will be created, containing the SQL Hibernate is running to generate the schema +- `quarkus.flyway.baseline-on-migrate` will be set, telling Flyway to automatically create its baseline tables +- `quarkus.flyway.migrate-at-start` will be set, telling Flyway to automatically apply migrations on application startup +- `%dev.quarkus.flyway.clean-at-start` and ``%test.quarkus.flyway.clean-at-start` will be set, to clean the DB after reload in dev/test mode + +WARNING: This button is simply a convenience to quickly get you started with Flyway, it is up to you to determine how you want to +manage your database schemas in production. In particular the `migrate-at-start` setting may not be rights for all environments. + [[caching]] == Caching diff --git a/extensions/agroal/spi/src/main/java/io/quarkus/agroal/spi/JdbcInitialSQLGeneratorBuildItem.java b/extensions/agroal/spi/src/main/java/io/quarkus/agroal/spi/JdbcInitialSQLGeneratorBuildItem.java new file mode 100644 index 0000000000000..8e3a6fbd1eb3c --- /dev/null +++ b/extensions/agroal/spi/src/main/java/io/quarkus/agroal/spi/JdbcInitialSQLGeneratorBuildItem.java @@ -0,0 +1,24 @@ +package io.quarkus.agroal.spi; + +import java.util.function.Supplier; + +import io.quarkus.builder.item.MultiBuildItem; + +public final class JdbcInitialSQLGeneratorBuildItem extends MultiBuildItem { + + final String databaseName; + final Supplier sqlSupplier; + + public JdbcInitialSQLGeneratorBuildItem(String databaseName, Supplier sqlSupplier) { + this.databaseName = databaseName; + this.sqlSupplier = sqlSupplier; + } + + public String getDatabaseName() { + return databaseName; + } + + public Supplier getSqlSupplier() { + return sqlSupplier; + } +} diff --git a/extensions/flyway/deployment/pom.xml b/extensions/flyway/deployment/pom.xml index 0959b2a77c9cc..2220e4e2ca291 100644 --- a/extensions/flyway/deployment/pom.xml +++ b/extensions/flyway/deployment/pom.xml @@ -46,7 +46,12 @@ io.quarkus - quarkus-resteasy-deployment + quarkus-resteasy-jackson-deployment + test + + + io.quarkus + quarkus-hibernate-orm-deployment test diff --git a/extensions/flyway/deployment/src/main/java/io/quarkus/flyway/FlywayDevConsoleProcessor.java b/extensions/flyway/deployment/src/main/java/io/quarkus/flyway/FlywayDevConsoleProcessor.java new file mode 100644 index 0000000000000..41767a8ecc272 --- /dev/null +++ b/extensions/flyway/deployment/src/main/java/io/quarkus/flyway/FlywayDevConsoleProcessor.java @@ -0,0 +1,102 @@ +package io.quarkus.flyway; + +import static java.util.List.of; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.eclipse.microprofile.config.ConfigProvider; + +import io.netty.handler.codec.http.HttpHeaderNames; +import io.netty.handler.codec.http.HttpResponseStatus; +import io.quarkus.agroal.spi.JdbcInitialSQLGeneratorBuildItem; +import io.quarkus.deployment.IsDevelopment; +import io.quarkus.deployment.annotations.BuildStep; +import io.quarkus.deployment.pkg.builditem.CurateOutcomeBuildItem; +import io.quarkus.dev.config.CurrentConfig; +import io.quarkus.dev.console.DevConsoleManager; +import io.quarkus.devconsole.runtime.spi.DevConsolePostHandler; +import io.quarkus.devconsole.spi.DevConsoleRouteBuildItem; +import io.quarkus.devconsole.spi.DevConsoleRuntimeTemplateInfoBuildItem; +import io.quarkus.flyway.runtime.FlywayBuildTimeConfig; +import io.quarkus.flyway.runtime.FlywayContainersSupplier; +import io.quarkus.flyway.runtime.FlywayDataSourceBuildTimeConfig; +import io.vertx.core.MultiMap; +import io.vertx.ext.web.RoutingContext; + +public class FlywayDevConsoleProcessor { + + @BuildStep(onlyIf = IsDevelopment.class) + public DevConsoleRuntimeTemplateInfoBuildItem collectBeanInfo( + FlywayProcessor.MigrationStateBuildItem migrationStateBuildItem) { + return new DevConsoleRuntimeTemplateInfoBuildItem("containers", new FlywayContainersSupplier()); + } + + @BuildStep + DevConsoleRouteBuildItem invokeEndpoint(List generatorBuildItem, + FlywayBuildTimeConfig buildTimeConfig, + CurateOutcomeBuildItem curateOutcomeBuildItem) { + return new DevConsoleRouteBuildItem("create-initial-migration", "POST", new DevConsolePostHandler() { + @Override + protected void handlePostAsync(RoutingContext event, MultiMap form) throws Exception { + String name = form.get("datasource"); + JdbcInitialSQLGeneratorBuildItem found = null; + for (var i : generatorBuildItem) { + if (i.getDatabaseName().equals(name)) { + found = i; + break; + } + } + if (found == null) { + flashMessage(event, "Unable to find SQL generator"); + return; + } + FlywayDataSourceBuildTimeConfig config = buildTimeConfig.getConfigForDataSourceName(name); + if (config.locations.isEmpty()) { + flashMessage(event, "Datasource has no locations configured"); + return; + } + System.out.println(found.getSqlSupplier().get()); + + List resourcesDir = DevConsoleManager.getHotReplacementContext().getResourcesDir(); + if (resourcesDir.isEmpty()) { + flashMessage(event, "No resource directory found"); + return; + } + + // In the current project only + Path path = resourcesDir.get(0); + + Path migrationDir = path.resolve(config.locations.get(0)); + Files.createDirectories(migrationDir); + Path file = migrationDir.resolve( + "V1.0.0__" + curateOutcomeBuildItem.getEffectiveModel().getAppArtifact().getArtifactId() + ".sql"); + Files.writeString(file, found.getSqlSupplier().get()); + flashMessage(event, file + " was created"); + Map newConfig = new HashMap<>(); + if (ConfigProvider.getConfig().getOptionalValue("quarkus.flyway.baseline-on-migrate", String.class).isEmpty()) { + newConfig.put("quarkus.flyway.baseline-on-migrate", "true"); + } + if (ConfigProvider.getConfig().getOptionalValue("quarkus.flyway.migrate-at-start", String.class).isEmpty()) { + newConfig.put("quarkus.flyway.migrate-at-start", "true"); + } + for (var profile : of("test", "dev")) { + if (ConfigProvider.getConfig().getOptionalValue("quarkus.flyway.clean-at-start", String.class).isEmpty()) { + newConfig.put("%" + profile + ".quarkus.flyway.clean-at-start", "true"); + } + } + CurrentConfig.EDITOR.accept(newConfig); + //force a scan, to make sure everything is up-to-date + DevConsoleManager.getHotReplacementContext().doScan(true); + flashMessage(event, "Initial migration created, Flyway will now manage this datasource"); + event.response().setStatusCode(HttpResponseStatus.SEE_OTHER.code()).headers() + .set(HttpHeaderNames.LOCATION, + event.request().absoluteURI().replace("create-initial-migration", "datasources")); + event.response().end(); + } + }); + } +} diff --git a/extensions/flyway/deployment/src/main/java/io/quarkus/flyway/FlywayProcessor.java b/extensions/flyway/deployment/src/main/java/io/quarkus/flyway/FlywayProcessor.java index 099285021369f..09de9a48b98db 100644 --- a/extensions/flyway/deployment/src/main/java/io/quarkus/flyway/FlywayProcessor.java +++ b/extensions/flyway/deployment/src/main/java/io/quarkus/flyway/FlywayProcessor.java @@ -12,6 +12,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.util.AbstractCollection; import java.util.Collection; import java.util.Enumeration; import java.util.HashMap; @@ -36,9 +37,11 @@ import io.quarkus.agroal.spi.JdbcDataSourceBuildItem; import io.quarkus.agroal.spi.JdbcDataSourceSchemaReadyBuildItem; +import io.quarkus.agroal.spi.JdbcInitialSQLGeneratorBuildItem; import io.quarkus.arc.deployment.AdditionalBeanBuildItem; import io.quarkus.arc.deployment.SyntheticBeanBuildItem; import io.quarkus.arc.processor.DotNames; +import io.quarkus.builder.item.SimpleBuildItem; import io.quarkus.datasource.common.runtime.DataSourceUtil; import io.quarkus.deployment.Feature; import io.quarkus.deployment.annotations.BuildProducer; @@ -80,7 +83,7 @@ IndexDependencyBuildItem indexFlyway() { @Record(STATIC_INIT) @BuildStep - void build(BuildProducer featureProducer, + MigrationStateBuildItem build(BuildProducer featureProducer, BuildProducer resourceProducer, BuildProducer reflectiveClassProducer, FlywayRecorder recorder, @@ -91,8 +94,24 @@ void build(BuildProducer featureProducer, featureProducer.produce(new FeatureBuildItem(Feature.FLYWAY)); Collection dataSourceNames = getDataSourceNames(jdbcDataSourceBuildItems); + Map> applicationMigrationsToDs = new HashMap<>(); + for (var i : dataSourceNames) { + Collection migrationLocations = discoverApplicationMigrations( + flywayBuildConfig.getConfigForDataSourceName(i).locations); + applicationMigrationsToDs.put(i, migrationLocations); + } + Set datasourcesWithMigrations = new HashSet<>(); + Set datasourcesWithoutMigrations = new HashSet<>(); + for (var e : applicationMigrationsToDs.entrySet()) { + if (e.getValue().isEmpty()) { + datasourcesWithoutMigrations.add(e.getKey()); + } else { + datasourcesWithMigrations.add(e.getKey()); + } + } - Collection applicationMigrations = discoverApplicationMigrations(getMigrationLocations(dataSourceNames)); + Collection applicationMigrations = applicationMigrationsToDs.values().stream().collect(HashSet::new, + AbstractCollection::addAll, HashSet::addAll); recorder.setApplicationMigrationFiles(applicationMigrations); Set> javaMigrationClasses = new HashSet<>(); @@ -108,6 +127,7 @@ void build(BuildProducer featureProducer, recorder.setApplicationCallbackClasses(callbacks); resourceProducer.produce(new NativeImageResourceBuildItem(applicationMigrations.toArray(new String[0]))); + return new MigrationStateBuildItem(datasourcesWithMigrations, datasourcesWithoutMigrations); } @SuppressWarnings("unchecked") @@ -128,9 +148,11 @@ private void addJavaMigrations(Collection candidates, RecorderContext @Record(ExecutionTime.RUNTIME_INIT) ServiceStartBuildItem createBeansAndStartActions(FlywayRecorder recorder, List jdbcDataSourceBuildItems, + List sqlGeneratorBuildItems, BuildProducer additionalBeans, BuildProducer syntheticBeanBuildItemBuildProducer, - BuildProducer schemaReadyBuildItem) { + BuildProducer schemaReadyBuildItem, + MigrationStateBuildItem migrationsBuildItem) { // make a FlywayContainerProducer bean additionalBeans.produce(AdditionalBeanBuildItem.builder().addBeanClasses(FlywayContainerProducer.class).setUnremovable() @@ -143,12 +165,18 @@ ServiceStartBuildItem createBeansAndStartActions(FlywayRecorder recorder, Collection dataSourceNames = getDataSourceNames(jdbcDataSourceBuildItems); for (String dataSourceName : dataSourceNames) { + boolean hasMigrations = migrationsBuildItem.hasMigrations.contains(dataSourceName); + boolean createPossible = false; + if (!hasMigrations) { + createPossible = sqlGeneratorBuildItems.stream().anyMatch(s -> s.getDatabaseName().equals(dataSourceName)); + } SyntheticBeanBuildItem.ExtendedBeanConfigurator configurator = SyntheticBeanBuildItem .configure(Flyway.class) .scope(Dependent.class) // this is what the existing code does, but it doesn't seem reasonable .setRuntimeInit() .unremovable() - .supplier(recorder.flywaySupplier(dataSourceName)); + .supplier(recorder.flywaySupplier(dataSourceName, + hasMigrations, createPossible)); if (DataSourceUtil.isDefault(dataSourceName)) { configurator.addQualifier(Default.class); @@ -168,7 +196,7 @@ ServiceStartBuildItem createBeansAndStartActions(FlywayRecorder recorder, // once we are done running the migrations, we produce a build item indicating that the // schema is "ready" - schemaReadyBuildItem.produce(new JdbcDataSourceSchemaReadyBuildItem(dataSourceNames)); + schemaReadyBuildItem.produce(new JdbcDataSourceSchemaReadyBuildItem(migrationsBuildItem.hasMigrations)); return new ServiceStartBuildItem("flyway"); } @@ -181,17 +209,6 @@ private Set getDataSourceNames(List jdbcDataSou return result; } - /** - * Collects the configured migration locations for the default and all named DataSources. - */ - private Collection getMigrationLocations(Collection dataSourceNames) { - Collection migrationLocations = dataSourceNames.stream() - .map(flywayBuildConfig::getConfigForDataSourceName) - .flatMap(config -> config.locations.stream()) - .collect(Collectors.toCollection(LinkedHashSet::new)); - return migrationLocations; - } - private Collection discoverApplicationMigrations(Collection locations) throws IOException, URISyntaxException { try { @@ -281,4 +298,15 @@ public RuntimeReinitializedClassBuildItem reinitInsertRowLock() { return new RuntimeReinitializedClassBuildItem( "org.flywaydb.core.internal.database.InsertRowLock"); } + + public static final class MigrationStateBuildItem extends SimpleBuildItem { + + final Set hasMigrations; + final Set missingMigrations; + + MigrationStateBuildItem(Set hasMigrations, Set missingMigrations) { + this.hasMigrations = hasMigrations; + this.missingMigrations = missingMigrations; + } + } } diff --git a/extensions/flyway/deployment/src/main/java/io/quarkus/flyway/devconsole/FlywayDevConsoleProcessor.java b/extensions/flyway/deployment/src/main/java/io/quarkus/flyway/devconsole/FlywayDevConsoleProcessor.java deleted file mode 100644 index 0d87e686216a8..0000000000000 --- a/extensions/flyway/deployment/src/main/java/io/quarkus/flyway/devconsole/FlywayDevConsoleProcessor.java +++ /dev/null @@ -1,25 +0,0 @@ -package io.quarkus.flyway.devconsole; - -import static io.quarkus.deployment.annotations.ExecutionTime.RUNTIME_INIT; - -import io.quarkus.deployment.IsDevelopment; -import io.quarkus.deployment.annotations.BuildStep; -import io.quarkus.deployment.annotations.Record; -import io.quarkus.devconsole.spi.DevConsoleRouteBuildItem; -import io.quarkus.devconsole.spi.DevConsoleRuntimeTemplateInfoBuildItem; -import io.quarkus.flyway.runtime.FlywayContainersSupplier; -import io.quarkus.flyway.runtime.devconsole.FlywayDevConsoleRecorder; - -public class FlywayDevConsoleProcessor { - - @BuildStep(onlyIf = IsDevelopment.class) - public DevConsoleRuntimeTemplateInfoBuildItem collectBeanInfo() { - return new DevConsoleRuntimeTemplateInfoBuildItem("containers", new FlywayContainersSupplier()); - } - - @BuildStep - @Record(value = RUNTIME_INIT, optional = true) - DevConsoleRouteBuildItem invokeEndpoint(FlywayDevConsoleRecorder recorder) { - return new DevConsoleRouteBuildItem("datasources", "POST", recorder.handler()); - } -} diff --git a/extensions/flyway/deployment/src/main/resources/dev-templates/datasources.html b/extensions/flyway/deployment/src/main/resources/dev-templates/datasources.html index 41e7bcddc9b4c..63d0d7a6c27fe 100644 --- a/extensions/flyway/deployment/src/main/resources/dev-templates/datasources.html +++ b/extensions/flyway/deployment/src/main/resources/dev-templates/datasources.html @@ -16,6 +16,7 @@
+ {#if container.hasMigrations}
@@ -27,6 +28,13 @@
+ {/if} + {#if container.createPossible} +
+ + +
+ {/if}
{/for} diff --git a/extensions/flyway/deployment/src/test/java/io/quarkus/flyway/test/FlywayDevModeCreateFromHibernateTest.java b/extensions/flyway/deployment/src/test/java/io/quarkus/flyway/test/FlywayDevModeCreateFromHibernateTest.java new file mode 100644 index 0000000000000..52dee0b7037c7 --- /dev/null +++ b/extensions/flyway/deployment/src/test/java/io/quarkus/flyway/test/FlywayDevModeCreateFromHibernateTest.java @@ -0,0 +1,97 @@ +package io.quarkus.flyway.test; + +import java.util.List; +import java.util.function.Function; + +import javax.annotation.PostConstruct; +import javax.inject.Inject; +import javax.persistence.EntityManager; +import javax.transaction.Transactional; +import javax.transaction.UserTransaction; +import javax.ws.rs.GET; +import javax.ws.rs.Path; + +import org.hamcrest.CoreMatchers; +import org.jboss.shrinkwrap.api.ShrinkWrap; +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.jboss.shrinkwrap.api.spec.JavaArchive; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.runtime.Startup; +import io.quarkus.test.QuarkusDevModeTest; +import io.restassured.RestAssured; + +public class FlywayDevModeCreateFromHibernateTest { + + @RegisterExtension + static final QuarkusDevModeTest config = new QuarkusDevModeTest() + .setArchiveProducer(() -> ShrinkWrap.create(JavaArchive.class) + .addClasses(FlywayDevModeCreateFromHibernateTest.class, Endpoint.class, Fruit.class) + .addAsResource(new StringAsset( + "quarkus.flyway.locations=db/create"), "application.properties")); + + @Test + public void testGenerateMigrationFromHibernate() { + RestAssured.get("fruit").then().statusCode(200) + .body("[0].name", CoreMatchers.is("Orange")); + RestAssured.given().redirects().follow(false).formParam("datasource", "") + .post("/q/dev/io.quarkus.quarkus-flyway/create-initial-migration").then().statusCode(303); + + config.modifySourceFile(Fruit.class, s -> s.replace("Fruit {", "Fruit {\n" + + " \n" + + " private String color;\n" + + "\n" + + " public String getColor() {\n" + + " return color;\n" + + " }\n" + + "\n" + + " public Fruit setColor(String color) {\n" + + " this.color = color;\n" + + " return this;\n" + + " }")); + //added a field, should now fail (if hibernate were still in charge this would work) + RestAssured.get("fruit").then().statusCode(500); + //now update out sql + config.modifyResourceFile("db/create/V1.0.0__quarkus-flyway-deployment.sql", new Function() { + @Override + public String apply(String s) { + return s + "\nalter table FRUIT add column color VARCHAR;"; + } + }); + RestAssured.get("fruit").then().statusCode(200) + .body("[0].name", CoreMatchers.is("Orange")); + } + + @Path("/fruit") + @Startup + public static class Endpoint { + + @Inject + EntityManager entityManager; + + @Inject + UserTransaction tx; + + @GET + public List list() { + return entityManager.createQuery("from Fruit").getResultList(); + } + + @PostConstruct + @Transactional + public void add() throws Exception { + tx.begin(); + try { + Fruit f = new Fruit(); + f.setName("Orange"); + entityManager.persist(f); + tx.commit(); + } catch (Exception e) { + tx.rollback(); + throw e; + } + } + + } +} diff --git a/extensions/flyway/deployment/src/test/java/io/quarkus/flyway/test/Fruit.java b/extensions/flyway/deployment/src/test/java/io/quarkus/flyway/test/Fruit.java new file mode 100644 index 0000000000000..ea2c34b0fbb2c --- /dev/null +++ b/extensions/flyway/deployment/src/test/java/io/quarkus/flyway/test/Fruit.java @@ -0,0 +1,35 @@ +package io.quarkus.flyway.test; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.Id; + +@Entity +public class Fruit { + @Id + @GeneratedValue + private Integer id; + + @Column(length = 40, unique = true) + private String name; + + public Fruit() { + } + + public Integer getId() { + return id; + } + + public void setId(Integer id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } +} diff --git a/extensions/flyway/runtime/src/main/java/io/quarkus/flyway/runtime/FlywayContainer.java b/extensions/flyway/runtime/src/main/java/io/quarkus/flyway/runtime/FlywayContainer.java index b792887bc60d8..e7974e5e2558c 100644 --- a/extensions/flyway/runtime/src/main/java/io/quarkus/flyway/runtime/FlywayContainer.java +++ b/extensions/flyway/runtime/src/main/java/io/quarkus/flyway/runtime/FlywayContainer.java @@ -8,12 +8,17 @@ public class FlywayContainer { private final boolean cleanAtStart; private final boolean migrateAtStart; private final String dataSourceName; + private final boolean hasMigrations; + private final boolean createPossible; - public FlywayContainer(Flyway flyway, boolean cleanAtStart, boolean migrateAtStart, String dataSourceName) { + public FlywayContainer(Flyway flyway, boolean cleanAtStart, boolean migrateAtStart, String dataSourceName, + boolean hasMigrations, boolean createPossible) { this.flyway = flyway; this.cleanAtStart = cleanAtStart; this.migrateAtStart = migrateAtStart; this.dataSourceName = dataSourceName; + this.hasMigrations = hasMigrations; + this.createPossible = createPossible; } public Flyway getFlyway() { @@ -31,4 +36,12 @@ public boolean isMigrateAtStart() { public String getDataSourceName() { return dataSourceName; } + + public boolean isHasMigrations() { + return hasMigrations; + } + + public boolean isCreatePossible() { + return createPossible; + } } diff --git a/extensions/flyway/runtime/src/main/java/io/quarkus/flyway/runtime/FlywayContainerProducer.java b/extensions/flyway/runtime/src/main/java/io/quarkus/flyway/runtime/FlywayContainerProducer.java index b46b380934152..03ea79b3ec8fa 100644 --- a/extensions/flyway/runtime/src/main/java/io/quarkus/flyway/runtime/FlywayContainerProducer.java +++ b/extensions/flyway/runtime/src/main/java/io/quarkus/flyway/runtime/FlywayContainerProducer.java @@ -28,13 +28,14 @@ public FlywayContainerProducer(FlywayRuntimeConfig flywayRuntimeConfig, FlywayBu this.flywayBuildConfig = flywayBuildConfig; } - public FlywayContainer createFlyway(DataSource dataSource, String dataSourceName) { + public FlywayContainer createFlyway(DataSource dataSource, String dataSourceName, boolean hasMigrations, + boolean createPossible) { FlywayDataSourceRuntimeConfig matchingRuntimeConfig = flywayRuntimeConfig.getConfigForDataSourceName(dataSourceName); FlywayDataSourceBuildTimeConfig matchingBuildTimeConfig = flywayBuildConfig.getConfigForDataSourceName(dataSourceName); final Collection callbacks = QuarkusPathLocationScanner.callbacksForDataSource(dataSourceName); final Flyway flyway = new FlywayCreator(matchingRuntimeConfig, matchingBuildTimeConfig).withCallbacks(callbacks) .createFlyway(dataSource); return new FlywayContainer(flyway, matchingRuntimeConfig.cleanAtStart, matchingRuntimeConfig.migrateAtStart, - dataSourceName); + dataSourceName, hasMigrations, createPossible); } } diff --git a/extensions/flyway/runtime/src/main/java/io/quarkus/flyway/runtime/FlywayRecorder.java b/extensions/flyway/runtime/src/main/java/io/quarkus/flyway/runtime/FlywayRecorder.java index c2da29f02b0d3..19c919531028c 100644 --- a/extensions/flyway/runtime/src/main/java/io/quarkus/flyway/runtime/FlywayRecorder.java +++ b/extensions/flyway/runtime/src/main/java/io/quarkus/flyway/runtime/FlywayRecorder.java @@ -45,7 +45,7 @@ public void resetFlywayContainers() { FLYWAY_CONTAINERS.clear(); } - public Supplier flywaySupplier(String dataSourceName) { + public Supplier flywaySupplier(String dataSourceName, boolean hasMigrations, boolean createPossible) { DataSource dataSource = DataSources.fromName(dataSourceName); if (dataSource instanceof UnconfiguredDataSource) { return new Supplier() { @@ -56,7 +56,8 @@ public Flyway get() { }; } FlywayContainerProducer flywayProducer = Arc.container().instance(FlywayContainerProducer.class).get(); - FlywayContainer flywayContainer = flywayProducer.createFlyway(dataSource, dataSourceName); + FlywayContainer flywayContainer = flywayProducer.createFlyway(dataSource, dataSourceName, hasMigrations, + createPossible); FLYWAY_CONTAINERS.add(flywayContainer); return new Supplier() { @Override diff --git a/extensions/hibernate-orm/deployment/src/main/java/io/quarkus/hibernate/orm/deployment/HibernateOrmConfigPersistenceUnit.java b/extensions/hibernate-orm/deployment/src/main/java/io/quarkus/hibernate/orm/deployment/HibernateOrmConfigPersistenceUnit.java index 302de3ef747b2..4602799187b8d 100644 --- a/extensions/hibernate-orm/deployment/src/main/java/io/quarkus/hibernate/orm/deployment/HibernateOrmConfigPersistenceUnit.java +++ b/extensions/hibernate-orm/deployment/src/main/java/io/quarkus/hibernate/orm/deployment/HibernateOrmConfigPersistenceUnit.java @@ -210,6 +210,14 @@ public class HibernateOrmConfigPersistenceUnit { @ConfigItem public Optional multitenantSchemaDatasource; + /** + * If hibernate is not auto generating the schema, and Quarkus is running in development mode + * then Quarkus will attempt to validate the database after startup and print a log message if + * there are any problems. + */ + @ConfigItem(defaultValue = "true") + public boolean validateInDevMode; + public boolean isAnyPropertySet() { return datasource.isPresent() || packages.isPresent() || diff --git a/extensions/hibernate-orm/deployment/src/main/java/io/quarkus/hibernate/orm/deployment/HibernateOrmProcessor.java b/extensions/hibernate-orm/deployment/src/main/java/io/quarkus/hibernate/orm/deployment/HibernateOrmProcessor.java index 73a98a7958a43..9bbba43f3ba03 100644 --- a/extensions/hibernate-orm/deployment/src/main/java/io/quarkus/hibernate/orm/deployment/HibernateOrmProcessor.java +++ b/extensions/hibernate-orm/deployment/src/main/java/io/quarkus/hibernate/orm/deployment/HibernateOrmProcessor.java @@ -30,6 +30,7 @@ import java.util.Set; import java.util.TreeMap; import java.util.TreeSet; +import java.util.function.Supplier; import java.util.stream.Collectors; import javax.enterprise.context.ApplicationScoped; @@ -65,6 +66,7 @@ import io.quarkus.agroal.spi.JdbcDataSourceBuildItem; import io.quarkus.agroal.spi.JdbcDataSourceSchemaReadyBuildItem; +import io.quarkus.agroal.spi.JdbcInitialSQLGeneratorBuildItem; import io.quarkus.arc.deployment.AdditionalBeanBuildItem; import io.quarkus.arc.deployment.BeanContainerBuildItem; import io.quarkus.arc.deployment.BeanContainerListenerBuildItem; @@ -78,6 +80,8 @@ import io.quarkus.deployment.Capabilities; import io.quarkus.deployment.Capability; import io.quarkus.deployment.Feature; +import io.quarkus.deployment.IsDevelopment; +import io.quarkus.deployment.IsNormal; import io.quarkus.deployment.annotations.BuildProducer; import io.quarkus.deployment.annotations.BuildStep; import io.quarkus.deployment.annotations.Consume; @@ -106,6 +110,8 @@ import io.quarkus.deployment.recording.RecorderContext; import io.quarkus.deployment.util.IoUtil; import io.quarkus.deployment.util.ServiceUtil; +import io.quarkus.dev.console.DevConsoleManager; +import io.quarkus.devconsole.spi.DevConsoleRuntimeTemplateInfoBuildItem; import io.quarkus.hibernate.orm.PersistenceUnit; import io.quarkus.hibernate.orm.deployment.integration.HibernateOrmIntegrationRuntimeConfiguredBuildItem; import io.quarkus.hibernate.orm.deployment.integration.HibernateOrmIntegrationStaticConfiguredBuildItem; @@ -120,6 +126,7 @@ import io.quarkus.hibernate.orm.runtime.boot.scan.QuarkusScanner; import io.quarkus.hibernate.orm.runtime.boot.xml.RecordableXmlMapping; import io.quarkus.hibernate.orm.runtime.cdi.QuarkusArcBeanContainer; +import io.quarkus.hibernate.orm.runtime.devconsole.HibernateOrmDevConsoleCreateDDLSupplier; import io.quarkus.hibernate.orm.runtime.devconsole.HibernateOrmDevConsoleIntegrator; import io.quarkus.hibernate.orm.runtime.integration.HibernateOrmIntegrationStaticDescriptor; import io.quarkus.hibernate.orm.runtime.proxies.PreGeneratedProxies; @@ -180,15 +187,74 @@ void includeArchivesHostingEntityPackagesInIndex(HibernateOrmConfig hibernateOrm } } - @BuildStep + @BuildStep(onlyIf = IsDevelopment.class) + void handleMoveSql(BuildProducer runtimeInfoProducer, + BuildProducer initialSQLGeneratorBuildItemBuildProducer, + HibernateOrmConfig config) { + + DevConsoleRuntimeTemplateInfoBuildItem devConsoleRuntimeTemplateInfoBuildItem = new DevConsoleRuntimeTemplateInfoBuildItem( + "create-ddl." + PersistenceUnitUtil.DEFAULT_PERSISTENCE_UNIT_NAME, + new HibernateOrmDevConsoleCreateDDLSupplier(PersistenceUnitUtil.DEFAULT_PERSISTENCE_UNIT_NAME)); + runtimeInfoProducer.produce(devConsoleRuntimeTemplateInfoBuildItem); + if (config.defaultPersistenceUnit.datasource.isEmpty()) { + handleGenerateSqlForPu(runtimeInfoProducer, initialSQLGeneratorBuildItemBuildProducer, + PersistenceUnitUtil.DEFAULT_PERSISTENCE_UNIT_NAME, DataSourceUtil.DEFAULT_DATASOURCE_NAME); + } else { + handleGenerateSqlForPu(runtimeInfoProducer, initialSQLGeneratorBuildItemBuildProducer, + PersistenceUnitUtil.DEFAULT_PERSISTENCE_UNIT_NAME, config.defaultPersistenceUnit.datasource.get()); + } + for (Entry entry : config.persistenceUnits.entrySet()) { + + if (entry.getValue().datasource.isEmpty()) { + handleGenerateSqlForPu(runtimeInfoProducer, initialSQLGeneratorBuildItemBuildProducer, entry.getKey(), + DataSourceUtil.DEFAULT_DATASOURCE_NAME); + } else { + handleGenerateSqlForPu(runtimeInfoProducer, initialSQLGeneratorBuildItemBuildProducer, entry.getKey(), + entry.getValue().datasource.get()); + } + } + } + + private void handleGenerateSqlForPu(BuildProducer runtimeInfoProducer, + BuildProducer initialSQLGeneratorBuildItemBuildProducer, String puName, + String dsName) { + DevConsoleRuntimeTemplateInfoBuildItem devConsoleRuntimeTemplateInfoBuildItem = new DevConsoleRuntimeTemplateInfoBuildItem( + "create-ddl." + puName, new HibernateOrmDevConsoleCreateDDLSupplier(puName)); + runtimeInfoProducer.produce(devConsoleRuntimeTemplateInfoBuildItem); + initialSQLGeneratorBuildItemBuildProducer.produce(new JdbcInitialSQLGeneratorBuildItem(dsName, new Supplier() { + @Override + public String get() { + return DevConsoleManager.getTemplateInfo() + .get(devConsoleRuntimeTemplateInfoBuildItem.getGroupId() + "." + + devConsoleRuntimeTemplateInfoBuildItem.getArtifactId()) + .get(devConsoleRuntimeTemplateInfoBuildItem.getName()).toString(); + } + })); + } + + @Record(RUNTIME_INIT) + @Consume(ServiceStartBuildItem.class) + @BuildStep(onlyIf = IsDevelopment.class) + void warnOfSchemaProblems(HibernateOrmConfig config, HibernateOrmRecorder recorder) { + if (config.defaultPersistenceUnit.validateInDevMode) { + recorder.doValidation(PersistenceUnitUtil.DEFAULT_PERSISTENCE_UNIT_NAME); + } + for (var e : config.persistenceUnits.entrySet()) { + if (e.getValue().validateInDevMode) { + recorder.doValidation(e.getKey()); + } + } + + } + + @BuildStep(onlyIfNot = IsNormal.class) void devServicesAutoGenerateByDefault(DevServicesLauncherConfigResultBuildItem devServicesResult, List schemaReadyBuildItems, HibernateOrmConfig config, BuildProducer runTimeConfigurationDefaultBuildItemBuildProducer) { - if (!schemaReadyBuildItems.isEmpty()) { - //we don't want to enable auto generation if somebody else is managing the schema - return; - } + Set managedSources = schemaReadyBuildItems.stream().map(JdbcDataSourceSchemaReadyBuildItem::getDatasourceNames) + .collect(HashSet::new, Collection::addAll, Collection::addAll); + String dsName; if (config.defaultPersistenceUnit.datasource.isEmpty()) { dsName = "quarkus.datasource.username"; @@ -197,7 +263,8 @@ void devServicesAutoGenerateByDefault(DevServicesLauncherConfigResultBuildItem d } if (!ConfigUtils.isPropertyPresent(dsName)) { - if (devServicesResult.getConfig().containsKey(dsName)) { + if (devServicesResult.getConfig().containsKey(dsName) + && !managedSources.contains(DataSourceUtil.DEFAULT_DATASOURCE_NAME)) { if (!ConfigUtils.isPropertyPresent("quarkus.hibernate-orm.database.generation")) { LOG.info( "Setting quarkus.hibernate-orm.database.generation=drop-and-create to initialize Dev Services managed database"); @@ -209,13 +276,16 @@ void devServicesAutoGenerateByDefault(DevServicesLauncherConfigResultBuildItem d for (Entry entry : config.persistenceUnits.entrySet()) { - if (entry.getValue().datasource.isEmpty()) { + Optional ds = entry.getValue().datasource; + if (ds.isEmpty()) { dsName = "quarkus.datasource.jdbc.url"; } else { - dsName = "quarkus.datasource." + entry.getValue().datasource.get() + ".username"; + dsName = "quarkus.datasource." + ds.get() + ".username"; } + if (!ConfigUtils.isPropertyPresent(dsName)) { - if (devServicesResult.getConfig().containsKey(dsName)) { + if (devServicesResult.getConfig().containsKey(dsName) + && !managedSources.contains(ds.isEmpty() ? DataSourceUtil.DEFAULT_DATASOURCE_NAME : ds)) { String propertyName = "quarkus.hibernate-orm." + entry.getKey() + ".database.generation"; if (!ConfigUtils.isPropertyPresent(propertyName)) { LOG.info("Setting " + propertyName + "=drop-and-create to initialize Dev Services managed database"); @@ -225,7 +295,6 @@ void devServicesAutoGenerateByDefault(DevServicesLauncherConfigResultBuildItem d } } } - } @BuildStep diff --git a/extensions/hibernate-orm/runtime/src/main/java/io/quarkus/hibernate/orm/runtime/HibernateOrmRecorder.java b/extensions/hibernate-orm/runtime/src/main/java/io/quarkus/hibernate/orm/runtime/HibernateOrmRecorder.java index 3e40408940378..da8ea35090e7d 100644 --- a/extensions/hibernate-orm/runtime/src/main/java/io/quarkus/hibernate/orm/runtime/HibernateOrmRecorder.java +++ b/extensions/hibernate-orm/runtime/src/main/java/io/quarkus/hibernate/orm/runtime/HibernateOrmRecorder.java @@ -8,6 +8,7 @@ import java.util.Set; import java.util.function.Supplier; +import org.eclipse.microprofile.config.ConfigProvider; import org.hibernate.MultiTenancyStrategy; import org.hibernate.Session; import org.hibernate.SessionFactory; @@ -126,4 +127,25 @@ protected Session delegate() { } }; } + + public void doValidation(String puName) { + Optional val; + if (puName.equals(PersistenceUnitUtil.DEFAULT_PERSISTENCE_UNIT_NAME)) { + val = ConfigProvider.getConfig().getOptionalValue("quarkus.hibernate-orm.database.generation", String.class); + } else { + val = ConfigProvider.getConfig().getOptionalValue("quarkus.hibernate-orm.\"" + puName + "\".database.generation", + String.class); + } + //if hibernate is already managing the schema we don't do this + if (val.isPresent() && !val.get().equals("none")) { + return; + } + new Thread(new Runnable() { + @Override + public void run() { + SchemaManagementIntegrator.runPostBootValidation(puName); + } + }, "Hibernate post-boot validation thread for " + puName).start(); + + } } diff --git a/extensions/hibernate-orm/runtime/src/main/java/io/quarkus/hibernate/orm/runtime/devconsole/HibernateOrmDevConsoleCreateDDLSupplier.java b/extensions/hibernate-orm/runtime/src/main/java/io/quarkus/hibernate/orm/runtime/devconsole/HibernateOrmDevConsoleCreateDDLSupplier.java new file mode 100644 index 0000000000000..f533b93eb0eff --- /dev/null +++ b/extensions/hibernate-orm/runtime/src/main/java/io/quarkus/hibernate/orm/runtime/devconsole/HibernateOrmDevConsoleCreateDDLSupplier.java @@ -0,0 +1,33 @@ +package io.quarkus.hibernate.orm.runtime.devconsole; + +import java.util.Collection; +import java.util.Objects; +import java.util.function.Supplier; + +import io.quarkus.runtime.annotations.RecordableConstructor; + +public class HibernateOrmDevConsoleCreateDDLSupplier implements Supplier { + + private final String puName; + + @RecordableConstructor + public HibernateOrmDevConsoleCreateDDLSupplier(String puName) { + this.puName = puName; + } + + @Override + public String get() { + Collection persistenceUnits = HibernateOrmDevConsoleInfoSupplier.INSTANCE + .getPersistenceUnits(); + for (var p : persistenceUnits) { + if (Objects.equals(puName, p.getName())) { + return p.getCreateDDL(); + } + } + return null; + } + + public String getPuName() { + return puName; + } +} diff --git a/extensions/hibernate-orm/runtime/src/main/java/io/quarkus/hibernate/orm/runtime/devconsole/HibernateOrmDevConsoleInfoSupplier.java b/extensions/hibernate-orm/runtime/src/main/java/io/quarkus/hibernate/orm/runtime/devconsole/HibernateOrmDevConsoleInfoSupplier.java index 2910560ba4f64..afc5916fdaa2f 100644 --- a/extensions/hibernate-orm/runtime/src/main/java/io/quarkus/hibernate/orm/runtime/devconsole/HibernateOrmDevConsoleInfoSupplier.java +++ b/extensions/hibernate-orm/runtime/src/main/java/io/quarkus/hibernate/orm/runtime/devconsole/HibernateOrmDevConsoleInfoSupplier.java @@ -27,7 +27,7 @@ public class HibernateOrmDevConsoleInfoSupplier implements Supplier getTargetTypes() { + return EnumSet.of(TargetType.SCRIPT); + } + + @Override + public ScriptTargetOutput getScriptTargetOutput() { + return new ScriptTargetOutputToWriter(writer) { + @Override + public void accept(String command) { + super.accept(command); + } + }; + } + }); + log.error( + "The following SQL may resolve the database issues, as generated by the Hibernate schema migration tool. WARNING: You must manually verify this SQL is correct, this is a best effort guess, do not copy/paste it without verifying that it does what you expect.\n\n" + + writer.toString()); + } + } catch (Throwable t) { + log.error("Failed to run post-boot validation", t); + } + } + @Override public void resetDatabase(String dbName) { String name = datasourceToPuMap.get(dbName); diff --git a/extensions/vertx-http/dev-console-runtime-spi/src/main/java/io/quarkus/devconsole/runtime/spi/DevConsolePostHandler.java b/extensions/vertx-http/dev-console-runtime-spi/src/main/java/io/quarkus/devconsole/runtime/spi/DevConsolePostHandler.java index b97a15b81901f..3a78f006ad62e 100644 --- a/extensions/vertx-http/dev-console-runtime-spi/src/main/java/io/quarkus/devconsole/runtime/spi/DevConsolePostHandler.java +++ b/extensions/vertx-http/dev-console-runtime-spi/src/main/java/io/quarkus/devconsole/runtime/spi/DevConsolePostHandler.java @@ -73,9 +73,11 @@ public void run() { } protected void actionSuccess(RoutingContext event) { - event.response().setStatusCode(HttpResponseStatus.SEE_OTHER.code()).headers() - .set(HttpHeaderNames.LOCATION, event.request().absoluteURI()); - event.response().end(); + if (!event.response().ended()) { + event.response().setStatusCode(HttpResponseStatus.SEE_OTHER.code()).headers() + .set(HttpHeaderNames.LOCATION, event.request().absoluteURI()); + event.response().end(); + } } protected void flashMessage(RoutingContext event, String message) {