diff --git a/extensions/flyway/deployment/src/main/java/io/quarkus/flyway/devui/FlywayDevUIProcessor.java b/extensions/flyway/deployment/src/main/java/io/quarkus/flyway/devui/FlywayDevUIProcessor.java new file mode 100644 index 0000000000000..45c11ce479d49 --- /dev/null +++ b/extensions/flyway/deployment/src/main/java/io/quarkus/flyway/devui/FlywayDevUIProcessor.java @@ -0,0 +1,140 @@ +package io.quarkus.flyway.devui; + +import static io.quarkus.deployment.annotations.ExecutionTime.RUNTIME_INIT; +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 java.util.function.Function; +import java.util.function.Supplier; + +import io.quarkus.agroal.spi.JdbcInitialSQLGeneratorBuildItem; +import io.quarkus.deployment.IsDevelopment; +import io.quarkus.deployment.annotations.BuildStep; +import io.quarkus.deployment.annotations.Record; +import io.quarkus.deployment.pkg.builditem.CurateOutcomeBuildItem; +import io.quarkus.dev.config.CurrentConfig; +import io.quarkus.dev.console.DevConsoleManager; +import io.quarkus.devui.spi.JsonRPCProvidersBuildItem; +import io.quarkus.devui.spi.page.CardPageBuildItem; +import io.quarkus.devui.spi.page.Page; +import io.quarkus.flyway.runtime.FlywayBuildTimeConfig; +import io.quarkus.flyway.runtime.FlywayDataSourceBuildTimeConfig; +import io.quarkus.flyway.runtime.devconsole.FlywayDevConsoleRecorder; +import io.quarkus.flyway.runtime.devconsole.FlywayJsonRpcService; +import io.quarkus.runtime.configuration.ConfigUtils; + +public class FlywayDevUIProcessor { + + @BuildStep(onlyIf = IsDevelopment.class) + @Record(value = RUNTIME_INIT, optional = true) + CardPageBuildItem create(FlywayDevConsoleRecorder recorder, FlywayBuildTimeConfig buildTimeConfig, + List generatorBuildItem, + CurateOutcomeBuildItem curateOutcomeBuildItem) { + + Map> initialSqlSuppliers = new HashMap<>(); + for (JdbcInitialSQLGeneratorBuildItem buildItem : generatorBuildItem) { + initialSqlSuppliers.put(buildItem.getDatabaseName(), buildItem.getSqlSupplier()); + } + + recorder.setInitialSqlSuppliers(initialSqlSuppliers); + + boolean isBaselineOnMigrateConfigured = ConfigUtils.isPropertyPresent("quarkus.flyway.baseline-on-migrate"); + boolean isMigrateAtStartConfigured = ConfigUtils.isPropertyPresent("quarkus.flyway.migrate-at-start"); + boolean isCleanAtStartConfigured = ConfigUtils.isPropertyPresent("quarkus.flyway.clean-at-start"); + String artifactId = curateOutcomeBuildItem.getApplicationModel().getAppArtifact().getArtifactId(); + + DevConsoleManager.register("flyway-create-initial-migration", + createInitialMigration(buildTimeConfig, + artifactId, + isBaselineOnMigrateConfigured, + isMigrateAtStartConfigured, + isCleanAtStartConfigured)); + + CardPageBuildItem card = new CardPageBuildItem(); + + card.addPage(Page.webComponentPageBuilder() + .componentLink("qwc-flyway-datasources.js") + .dynamicLabelJsonRPCMethodName("getNumberOfDatasources") + .icon("font-awesome-solid:database")); + return card; + } + + @BuildStep(onlyIf = IsDevelopment.class) + JsonRPCProvidersBuildItem registerJsonRpcBackend() { + return new JsonRPCProvidersBuildItem(FlywayJsonRpcService.class); + } + + private Function, String> createInitialMigration(FlywayBuildTimeConfig buildTimeConfig, + String artifactId, + boolean isBaselineOnMigrateConfigured, + boolean isMigrateAtStartConfigured, + boolean isCleanAtStartConfigured) { + return (map -> { + String name = map.get("ds"); + String script = map.get("script"); + if (name != null) { + try { + return createInitialMigrationScript(name, script, + buildTimeConfig, + artifactId, + isBaselineOnMigrateConfigured, + isMigrateAtStartConfigured, + isCleanAtStartConfigured); + } catch (Exception ex) { + return ex.getMessage(); + } + } + return "Datasource parameter not provided"; + }); + } + + private String createInitialMigrationScript(String name, String script, + FlywayBuildTimeConfig buildTimeConfig, + String artifactId, + boolean isBaselineOnMigrateConfigured, + boolean isMigrateAtStartConfigured, + boolean isCleanAtStartConfigured) throws Exception { + + FlywayDataSourceBuildTimeConfig config = buildTimeConfig.getConfigForDataSourceName(name); + if (config.locations.isEmpty()) { + return "Error: Datasource has no locations configured"; + } + + List resourcesDir = DevConsoleManager.getHotReplacementContext().getResourcesDir(); + if (resourcesDir.isEmpty()) { + return "Error: No resource directory found"; + } + + // 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__" + artifactId + ".sql"); + + Files.writeString(file, script); + + Map newConfig = new HashMap<>(); + if (!isBaselineOnMigrateConfigured) { + newConfig.put("quarkus.flyway.baseline-on-migrate", "true"); + } + if (!isMigrateAtStartConfigured) { + newConfig.put("quarkus.flyway.migrate-at-start", "true"); + } + for (var profile : of("test", "dev")) { + if (!isCleanAtStartConfigured) { + 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); + + return "Initial migration created, Flyway will now manage this datasource"; + } +} diff --git a/extensions/flyway/deployment/src/main/resources/dev-ui/qwc-flyway-datasources.js b/extensions/flyway/deployment/src/main/resources/dev-ui/qwc-flyway-datasources.js new file mode 100644 index 0000000000000..8ae34130bfdc7 --- /dev/null +++ b/extensions/flyway/deployment/src/main/resources/dev-ui/qwc-flyway-datasources.js @@ -0,0 +1,173 @@ +import { QwcHotReloadElement, html, css} from 'qwc-hot-reload-element'; +import { JsonRpc } from 'jsonrpc'; +import '@vaadin/icon'; +import '@vaadin/button'; +import '@vaadin/text-field'; +import '@vaadin/text-area'; +import '@vaadin/form-layout'; +import '@vaadin/progress-bar'; +import '@vaadin/checkbox'; +import '@vaadin/grid'; +import 'qui-alert'; +import { columnBodyRenderer } from '@vaadin/grid/lit.js'; +import { dialogRenderer } from '@vaadin/dialog/lit.js'; +import '@vaadin/grid/vaadin-grid-sort-column.js'; +import '@vaadin/progress-bar'; +import { notifier } from 'notifier'; + +export class QwcFlywayDatasources extends QwcHotReloadElement { + + jsonRpc = new JsonRpc(this); + + static styles = css` + .button { + cursor: pointer; + } + .clearIcon { + color: var(--lumo-warning-text-color); + }`; + + static properties = { + _ds: {state: true}, + _selectedDs: {state: true}, + _createDialogOpened: {state: true} + } + + constructor() { + super(); + this._ds = null; + this._selectedDs = null; + this._createDialogOpened = false; + } + + connectedCallback() { + super.connectedCallback(); + this.hotReload(); + } + + hotReload(){ + this.jsonRpc.getDatasources().then(jsonRpcResponse => { + this._ds = jsonRpcResponse.result; + }); + } + + render() { + if (this._ds) { + return this._renderDataSourceTable(); + } else { + return html``; + } + } + + _renderDataSourceTable() { + return html`${this._renderCreateDialog()} + + + + + + `; + } + + _actionRenderer(ds) { + return html`${this._renderMigrationButtons(ds)} + ${this._renderCreateButton(ds)}`; + } + + _renderMigrationButtons(ds) { + if(ds.hasMigrations){ + return html` + this._clean(ds)} class="button"> + Clean + + this._migrate(ds)} class="button"> + Migrate + `; + } + } + + _renderCreateButton(ds) { + if(ds.createPossible){ + return html` + this._showCreateDialog(ds)} class="button" title="Set up basic files for Flyway migrations to work. Initial file in db/migrations will be created and you can then add additional migration files"> + Create Initial Migration File + `; + } + } + + _nameRenderer(ds) { + return html`${ds.name}`; + } + + _showCreateDialog(ds){ + this._selectedDs = ds; + this._createDialogOpened = true; + } + + _renderCreateDialog(){ + return html` this._renderCreateDialogForm(), "Create")} + >`; + } + + _renderCreateDialogForm(){ + let title = this._selectedDs.name + " Datasource"; + return html`${title}
+ Set up an initial file from Hibernate ORM schema generation for Flyway migrations to work.
+ If you say yes, an initial file in db/migrations will be
+ created and you can then add additional migration files as documented. + ${this._renderDialogButtons(this._selectedDs)} + `; + } + + _renderDialogButtons(ds){ + return html`
+ this._create(this._selectedDs)}>Create + Cancel +
`; + } + + _clean(ds) { + this.jsonRpc.clean({ds: ds.name}).then(jsonRpcResponse => { + this._showResultNotification(jsonRpcResponse.result); + }); + } + + _migrate(ds) { + this.jsonRpc.migrate({ds: ds.name}).then(jsonRpcResponse => { + this._showResultNotification(jsonRpcResponse.result); + }); + } + + _create(ds) { + this.jsonRpc.create({ds: ds.name}).then(jsonRpcResponse => { + this._showResultNotification(jsonRpcResponse.result); + this._selectedDs = null; + this._createDialogOpened = false; + this.hotReload(); + }); + } + + _cancelCreate(){ + this._selectedDs = null; + this._createDialogOpened = false; + } + + _showResultNotification(response){ + if(response.type === "success"){ + notifier.showInfoMessage(response.message + " (" + response.number + ")"); + }else{ + notifier.showWarningMessage(response.message); + } + } + +} +customElements.define('qwc-flyway-datasources', QwcFlywayDatasources); \ No newline at end of file diff --git a/extensions/flyway/runtime/src/main/java/io/quarkus/flyway/runtime/devconsole/FlywayDevConsoleRecorder.java b/extensions/flyway/runtime/src/main/java/io/quarkus/flyway/runtime/devconsole/FlywayDevConsoleRecorder.java index ef51023a14c12..944f7dc4b0e4f 100644 --- a/extensions/flyway/runtime/src/main/java/io/quarkus/flyway/runtime/devconsole/FlywayDevConsoleRecorder.java +++ b/extensions/flyway/runtime/src/main/java/io/quarkus/flyway/runtime/devconsole/FlywayDevConsoleRecorder.java @@ -14,6 +14,7 @@ import io.netty.handler.codec.http.HttpHeaderNames; import io.netty.handler.codec.http.HttpResponseStatus; +import io.quarkus.arc.Arc; import io.quarkus.dev.config.CurrentConfig; import io.quarkus.dev.console.DevConsoleManager; import io.quarkus.devconsole.runtime.spi.DevConsolePostHandler; @@ -22,6 +23,7 @@ import io.quarkus.flyway.runtime.FlywayContainer; import io.quarkus.flyway.runtime.FlywayContainersSupplier; import io.quarkus.flyway.runtime.FlywayDataSourceBuildTimeConfig; +import io.quarkus.runtime.RuntimeValue; import io.quarkus.runtime.annotations.Recorder; import io.vertx.core.Handler; import io.vertx.core.MultiMap; @@ -30,6 +32,12 @@ @Recorder public class FlywayDevConsoleRecorder { + public RuntimeValue setInitialSqlSuppliers(Map> initialSqlSuppliers) { + FlywayJsonRpcService rpcService = Arc.container().instance(FlywayJsonRpcService.class).get(); + rpcService.setInitialSqlSuppliers(initialSqlSuppliers); + return new RuntimeValue<>(true); + } + public Handler datasourcesHandler() { return new DevConsolePostHandler() { @Override diff --git a/extensions/flyway/runtime/src/main/java/io/quarkus/flyway/runtime/devconsole/FlywayJsonRpcService.java b/extensions/flyway/runtime/src/main/java/io/quarkus/flyway/runtime/devconsole/FlywayJsonRpcService.java new file mode 100644 index 0000000000000..e9ec2632c9657 --- /dev/null +++ b/extensions/flyway/runtime/src/main/java/io/quarkus/flyway/runtime/devconsole/FlywayJsonRpcService.java @@ -0,0 +1,174 @@ +package io.quarkus.flyway.runtime.devconsole; + +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; + +import org.flywaydb.core.Flyway; +import org.flywaydb.core.api.output.CleanResult; +import org.flywaydb.core.api.output.MigrateResult; + +import io.quarkus.dev.console.DevConsoleManager; +import io.quarkus.flyway.runtime.FlywayContainer; +import io.quarkus.flyway.runtime.FlywayContainersSupplier; + +public class FlywayJsonRpcService { + + private Map> initialSqlSuppliers; + private Map datasources; + + public void setInitialSqlSuppliers(Map> initialSqlSuppliers) { + this.initialSqlSuppliers = initialSqlSuppliers; + } + + public Collection getDatasources() { + if (datasources == null) { + datasources = new HashMap<>(); + Collection flywayContainers = new FlywayContainersSupplier().get(); + for (FlywayContainer fc : flywayContainers) { + datasources.put(fc.getDataSourceName(), + new FlywayDatasource(fc.getDataSourceName(), fc.isHasMigrations(), fc.isCreatePossible())); + } + } + return datasources.values(); + } + + public FlywayActionResponse clean(String ds) { + Flyway flyway = getFlyway(ds); + if (flyway != null) { + CleanResult cleanResult = flyway.clean(); + if (cleanResult.warnings != null && cleanResult.warnings.size() > 0) { + return new FlywayActionResponse("warning", + "Cleaning failed", + cleanResult.warnings.size(), + null, + cleanResult.database, cleanResult.warnings); + } else { + return new FlywayActionResponse("success", + "Cleaned", + cleanResult.schemasCleaned.size(), + null, + cleanResult.database); + } + + } + return errorNoDatasource(ds); + } + + public FlywayActionResponse migrate(String ds) { + Flyway flyway = getFlyway(ds); + if (flyway != null) { + MigrateResult migrateResult = flyway.migrate(); + if (migrateResult.success) { + return new FlywayActionResponse("success", + "Migration executed", + migrateResult.migrationsExecuted, + migrateResult.schemaName, + migrateResult.database); + } else { + return new FlywayActionResponse("warning", + "Migration failed", + migrateResult.warnings.size(), + migrateResult.schemaName, + migrateResult.database, + migrateResult.warnings); + } + } + return errorNoDatasource(ds); + } + + public FlywayActionResponse create(String ds) { + + Supplier found = initialSqlSuppliers.get(ds); + if (found == null) { + return new FlywayActionResponse("error", "Unable to find SQL generator"); + } + + String script = found.get(); + + Flyway flyway = getFlyway(ds); + if (flyway != null && script != null) { + Map params = Map.of("ds", ds, "script", script); + try { + String message = DevConsoleManager.invoke("flyway-create-initial-migration", params); + if (message.startsWith("Error: ")) { + return new FlywayActionResponse("error", message); + } else { + FlywayDatasource flywayDatasource = datasources.get(ds); + flywayDatasource.hasMigrations = true; + flywayDatasource.createPossible = false; + return new FlywayActionResponse("success", message); + } + } catch (Throwable t) { + new FlywayActionResponse("error", t.getMessage()); + } + } + return errorNoDatasource(ds); + } + + public int getNumberOfDatasources() { + Collection flywayContainers = new FlywayContainersSupplier().get(); + return flywayContainers.size(); + } + + private FlywayActionResponse errorNoDatasource(String ds) { + return new FlywayActionResponse("error", "Flyway datasource not found [" + ds + "]"); + } + + private Flyway getFlyway(String ds) { + Collection flywayContainers = new FlywayContainersSupplier().get(); + for (FlywayContainer flywayContainer : flywayContainers) { + if (flywayContainer.getDataSourceName().equals(ds)) { + return flywayContainer.getFlyway(); + } + } + return null; + } + + static class FlywayDatasource { + public String name; + public boolean hasMigrations; + public boolean createPossible; + + public FlywayDatasource() { + } + + public FlywayDatasource(String name, boolean hasMigrations, boolean createPossible) { + this.name = name; + this.hasMigrations = hasMigrations; + this.createPossible = createPossible; + } + } + + static class FlywayActionResponse { + public String type; + public String message; + public int number; + public String schema; + public String database; + public List warnings; + + public FlywayActionResponse() { + } + + public FlywayActionResponse(String type, String message) { + this(type, message, -1, null, null, List.of()); + } + + public FlywayActionResponse(String type, String message, int number, String schema, String database) { + this(type, message, number, schema, database, List.of()); + } + + public FlywayActionResponse(String type, String message, int number, String schema, String database, + List warnings) { + this.type = type; + this.message = message; + this.number = number; + this.schema = schema; + this.database = database; + this.warnings = warnings; + } + } +}