Skip to content

Commit

Permalink
Add the ability to auto generate Flyway migration
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
stuartwdouglas authored and gsmet committed Nov 2, 2021
1 parent 9873f06 commit fa62e3f
Show file tree
Hide file tree
Showing 19 changed files with 554 additions and 64 deletions.
18 changes: 18 additions & 0 deletions docs/src/main/asciidoc/hibernate-orm.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> sqlSupplier;

public JdbcInitialSQLGeneratorBuildItem(String databaseName, Supplier<String> sqlSupplier) {
this.databaseName = databaseName;
this.sqlSupplier = sqlSupplier;
}

public String getDatabaseName() {
return databaseName;
}

public Supplier<String> getSqlSupplier() {
return sqlSupplier;
}
}
7 changes: 6 additions & 1 deletion extensions/flyway/deployment/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,12 @@
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-resteasy-deployment</artifactId>
<artifactId>quarkus-resteasy-jackson-deployment</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-hibernate-orm-deployment</artifactId>
<scope>test</scope>
</dependency>
<dependency>
Expand Down
Original file line number Diff line number Diff line change
@@ -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<JdbcInitialSQLGeneratorBuildItem> 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<Path> 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<String, String> 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();
}
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -80,7 +83,7 @@ IndexDependencyBuildItem indexFlyway() {

@Record(STATIC_INIT)
@BuildStep
void build(BuildProducer<FeatureBuildItem> featureProducer,
MigrationStateBuildItem build(BuildProducer<FeatureBuildItem> featureProducer,
BuildProducer<NativeImageResourceBuildItem> resourceProducer,
BuildProducer<ReflectiveClassBuildItem> reflectiveClassProducer,
FlywayRecorder recorder,
Expand All @@ -91,8 +94,24 @@ void build(BuildProducer<FeatureBuildItem> featureProducer,
featureProducer.produce(new FeatureBuildItem(Feature.FLYWAY));

Collection<String> dataSourceNames = getDataSourceNames(jdbcDataSourceBuildItems);
Map<String, Collection<String>> applicationMigrationsToDs = new HashMap<>();
for (var i : dataSourceNames) {
Collection<String> migrationLocations = discoverApplicationMigrations(
flywayBuildConfig.getConfigForDataSourceName(i).locations);
applicationMigrationsToDs.put(i, migrationLocations);
}
Set<String> datasourcesWithMigrations = new HashSet<>();
Set<String> datasourcesWithoutMigrations = new HashSet<>();
for (var e : applicationMigrationsToDs.entrySet()) {
if (e.getValue().isEmpty()) {
datasourcesWithoutMigrations.add(e.getKey());
} else {
datasourcesWithMigrations.add(e.getKey());
}
}

Collection<String> applicationMigrations = discoverApplicationMigrations(getMigrationLocations(dataSourceNames));
Collection<String> applicationMigrations = applicationMigrationsToDs.values().stream().collect(HashSet::new,
AbstractCollection::addAll, HashSet::addAll);
recorder.setApplicationMigrationFiles(applicationMigrations);

Set<Class<? extends JavaMigration>> javaMigrationClasses = new HashSet<>();
Expand All @@ -108,6 +127,7 @@ void build(BuildProducer<FeatureBuildItem> featureProducer,
recorder.setApplicationCallbackClasses(callbacks);

resourceProducer.produce(new NativeImageResourceBuildItem(applicationMigrations.toArray(new String[0])));
return new MigrationStateBuildItem(datasourcesWithMigrations, datasourcesWithoutMigrations);
}

@SuppressWarnings("unchecked")
Expand All @@ -128,9 +148,11 @@ private void addJavaMigrations(Collection<ClassInfo> candidates, RecorderContext
@Record(ExecutionTime.RUNTIME_INIT)
ServiceStartBuildItem createBeansAndStartActions(FlywayRecorder recorder,
List<JdbcDataSourceBuildItem> jdbcDataSourceBuildItems,
List<JdbcInitialSQLGeneratorBuildItem> sqlGeneratorBuildItems,
BuildProducer<AdditionalBeanBuildItem> additionalBeans,
BuildProducer<SyntheticBeanBuildItem> syntheticBeanBuildItemBuildProducer,
BuildProducer<JdbcDataSourceSchemaReadyBuildItem> schemaReadyBuildItem) {
BuildProducer<JdbcDataSourceSchemaReadyBuildItem> schemaReadyBuildItem,
MigrationStateBuildItem migrationsBuildItem) {

// make a FlywayContainerProducer bean
additionalBeans.produce(AdditionalBeanBuildItem.builder().addBeanClasses(FlywayContainerProducer.class).setUnremovable()
Expand All @@ -143,12 +165,18 @@ ServiceStartBuildItem createBeansAndStartActions(FlywayRecorder recorder,
Collection<String> 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);
Expand All @@ -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");
}
Expand All @@ -181,17 +209,6 @@ private Set<String> getDataSourceNames(List<JdbcDataSourceBuildItem> jdbcDataSou
return result;
}

/**
* Collects the configured migration locations for the default and all named DataSources.
*/
private Collection<String> getMigrationLocations(Collection<String> dataSourceNames) {
Collection<String> migrationLocations = dataSourceNames.stream()
.map(flywayBuildConfig::getConfigForDataSourceName)
.flatMap(config -> config.locations.stream())
.collect(Collectors.toCollection(LinkedHashSet::new));
return migrationLocations;
}

private Collection<String> discoverApplicationMigrations(Collection<String> locations)
throws IOException, URISyntaxException {
try {
Expand Down Expand Up @@ -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<String> hasMigrations;
final Set<String> missingMigrations;

MigrationStateBuildItem(Set<String> hasMigrations, Set<String> missingMigrations) {
this.hasMigrations = hasMigrations;
this.missingMigrations = missingMigrations;
}
}
}

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
</td>
<td>
<div class="btn-group">
{#if container.hasMigrations}
<form method="post" enctype="application/x-www-form-urlencoded">
<input type="hidden" name="datasource" value="{container.dataSourceName}">
<input type="hidden" name="operation" value="clean">
Expand All @@ -27,6 +28,13 @@
<input type="hidden" name="operation" value="migrate">
<input id="migrate" type="submit" class="btn btn-primary btn-sm" value="Migrate" >
</form>
{/if}
{#if container.createPossible}
<form method="post" enctype="application/x-www-form-urlencoded" action="create-initial-migration">
<input type="hidden" name="datasource" value="{container.dataSourceName}">
<input id="create" type="submit" class="btn btn-primary btn-sm" value="Create Initial Migration File" >
</form>
{/if}
</div>
</td>
{/for}
Expand Down
Loading

0 comments on commit fa62e3f

Please sign in to comment.