Skip to content

Commit 1a4c49d

Browse files
Merge pull request #43817 from rmanibus/fix_43723_hibernate_update
generate flyway migrations from hibernate update scripts
2 parents 55be758 + a94059f commit 1a4c49d

File tree

9 files changed

+295
-9
lines changed

9 files changed

+295
-9
lines changed

docs/src/main/asciidoc/hibernate-orm.adoc

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -888,7 +888,10 @@ Add the following in your properties file.
888888
----
889889

890890
[[flyway]]
891-
== Automatically transitioning to Flyway to Manage Schemas
891+
== Flyway integration
892+
893+
[[flyway-transition-from-entities]]
894+
=== Automatically transitioning to Flyway to Manage Schemas
892895

893896
If you have the xref:flyway.adoc[Flyway extension] installed when running in development mode,
894897
Quarkus provides a simple way to initialize your Flyway configuration
@@ -908,6 +911,19 @@ link in the Flyway pane. Hit the `Create Initial Migration` button and the follo
908911
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
909912
manage your database schemas in production. In particular the `migrate-at-start` setting may not be right for all environments.
910913

914+
[[flyway-incremental-from-entities]]
915+
=== Incremental migrations from entity changes
916+
917+
After the first migration is created, Quarkus can derive a draft migration from your updated entity model, so you don’t need to hand‑write a baseline for each change.
918+
To create a new migration, click the `Generate Migration File` button in the Dev UI Flyway pane.
919+
920+
WARNING: Always review the suggested script. While Quarkus infers schema changes from your entities, domain‑specific data movements, concurrency considerations, and advanced index strategies still require human judgment.
921+
922+
Generated migration files follow the following pattern:
923+
924+
- Major version is extracted from the last existing migration in your project.
925+
- Minor version is the current timestamp at generation time.
926+
911927
[[offline]]
912928
== Offline startup
913929

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package io.quarkus.agroal.spi;
2+
3+
import java.util.function.Supplier;
4+
5+
import io.quarkus.builder.item.MultiBuildItem;
6+
7+
/**
8+
* Registers a JDBC generator that produces SQL update scripts for the specified database.
9+
* <p>
10+
* The generated SQL updates the database schema so it matches the current model.
11+
*/
12+
public final class JdbcUpdateSQLGeneratorBuildItem extends MultiBuildItem {
13+
14+
final String databaseName;
15+
final Supplier<String> sqlSupplier;
16+
17+
public JdbcUpdateSQLGeneratorBuildItem(String databaseName, Supplier<String> sqlSupplier) {
18+
this.databaseName = databaseName;
19+
this.sqlSupplier = sqlSupplier;
20+
}
21+
22+
public String getDatabaseName() {
23+
return databaseName;
24+
}
25+
26+
public Supplier<String> getSqlSupplier() {
27+
return sqlSupplier;
28+
}
29+
}

extensions/flyway/deployment/src/main/java/io/quarkus/flyway/deployment/devui/FlywayDevUIProcessor.java

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import java.util.function.Supplier;
99

1010
import io.quarkus.agroal.spi.JdbcInitialSQLGeneratorBuildItem;
11+
import io.quarkus.agroal.spi.JdbcUpdateSQLGeneratorBuildItem;
1112
import io.quarkus.deployment.IsLocalDevelopment;
1213
import io.quarkus.deployment.annotations.BuildStep;
1314
import io.quarkus.deployment.annotations.Record;
@@ -24,17 +25,23 @@ public class FlywayDevUIProcessor {
2425
@BuildStep(onlyIf = IsLocalDevelopment.class)
2526
@Record(value = RUNTIME_INIT, optional = true)
2627
CardPageBuildItem create(FlywayDevUIRecorder recorder, FlywayBuildTimeConfig buildTimeConfig,
27-
List<JdbcInitialSQLGeneratorBuildItem> generatorBuildItem,
28+
List<JdbcInitialSQLGeneratorBuildItem> createGeneratorBuildItem,
29+
List<JdbcUpdateSQLGeneratorBuildItem> updateGeneratorBuildItem,
2830
CurateOutcomeBuildItem curateOutcomeBuildItem) {
2931

3032
Map<String, Supplier<String>> initialSqlSuppliers = new HashMap<>();
31-
for (JdbcInitialSQLGeneratorBuildItem buildItem : generatorBuildItem) {
33+
for (JdbcInitialSQLGeneratorBuildItem buildItem : createGeneratorBuildItem) {
3234
initialSqlSuppliers.put(buildItem.getDatabaseName(), buildItem.getSqlSupplier());
3335
}
3436

37+
Map<String, Supplier<String>> updateSqlSuppliers = new HashMap<>();
38+
for (JdbcUpdateSQLGeneratorBuildItem buildItem : updateGeneratorBuildItem) {
39+
updateSqlSuppliers.put(buildItem.getDatabaseName(), buildItem.getSqlSupplier());
40+
}
41+
3542
String artifactId = curateOutcomeBuildItem.getApplicationModel().getAppArtifact().getArtifactId();
3643

37-
recorder.setInitialSqlSuppliers(initialSqlSuppliers, artifactId);
44+
recorder.setSqlSuppliers(initialSqlSuppliers, updateSqlSuppliers, artifactId);
3845

3946
CardPageBuildItem card = new CardPageBuildItem();
4047

extensions/flyway/deployment/src/main/resources/dev-ui/qwc-flyway-datasources.js

Lines changed: 60 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export class QwcFlywayDatasources extends QwcHotReloadElement {
3737
this._ds = null;
3838
this._selectedDs = null;
3939
this._createDialogOpened = false;
40+
this._updateDialogOpened = false;
4041
this._cleanDisabled = true;
4142
}
4243

@@ -64,7 +65,7 @@ export class QwcFlywayDatasources extends QwcHotReloadElement {
6465
}
6566

6667
_renderDataSourceTable() {
67-
return html`${this._renderCreateDialog()}
68+
return html`${this._renderCreateDialog()}${this._renderUpdateDialog()}
6869
<vaadin-grid .items="${this._ds}" class="datatable" theme="no-border">
6970
<vaadin-grid-column auto-width
7071
header="Name"
@@ -80,6 +81,7 @@ export class QwcFlywayDatasources extends QwcHotReloadElement {
8081

8182
_actionRenderer(ds) {
8283
return html`${this._renderMigrationButtons(ds)}
84+
${this._renderUpdateButton(ds)}
8385
${this._renderCreateButton(ds)}`;
8486
}
8587

@@ -99,7 +101,16 @@ export class QwcFlywayDatasources extends QwcHotReloadElement {
99101
`;
100102
}
101103
}
102-
104+
105+
_renderUpdateButton(ds) {
106+
if(ds.hasMigrations){
107+
return html`
108+
<vaadin-button theme="small" @click=${() => this._showUpdateDialog(ds)} class="button" title="Create update migration file. Always manually review the created file as it can cause data loss">
109+
<vaadin-icon icon="font-awesome-solid:plus"></vaadin-icon> Generate Migration File
110+
</vaadin-button>`;
111+
}
112+
}
113+
103114
_renderCreateButton(ds) {
104115
if(ds.createPossible){
105116
return html`
@@ -117,6 +128,11 @@ export class QwcFlywayDatasources extends QwcHotReloadElement {
117128
this._selectedDs = ds;
118129
this._createDialogOpened = true;
119130
}
131+
132+
_showUpdateDialog(ds){
133+
this._selectedDs = ds;
134+
this._updateDialogOpened = true;
135+
}
120136

121137
_renderCreateDialog(){
122138
return html`<vaadin-dialog class="createDialog"
@@ -127,23 +143,49 @@ export class QwcFlywayDatasources extends QwcHotReloadElement {
127143
></vaadin-dialog>`;
128144
}
129145

146+
_renderUpdateDialog(){
147+
return html`<vaadin-dialog class="updateDialog"
148+
header-title="Update"
149+
.opened="${this._updateDialogOpened}"
150+
@opened-changed="${(e) => (this._updateDialogOpened = e.detail.value)}"
151+
${dialogRenderer(() => this._renderUpdateDialogForm(), "Update")}
152+
></vaadin-dialog>`;
153+
}
154+
130155
_renderCreateDialogForm(){
131156
let title = this._selectedDs.name + " Datasource";
132157
return html`<b>${title}</b></br>
133158
Set up an initial file from Hibernate ORM schema generation for Flyway migrations to work.<br/>
134159
If you say yes, an initial file in <code>db/migrations</code> will be <br/>
135160
created and you can then add additional migration files as documented.
136-
${this._renderDialogButtons(this._selectedDs)}
161+
${this._renderCreateDialogButtons(this._selectedDs)}
162+
`;
163+
}
164+
165+
_renderUpdateDialogForm(){
166+
let title = this._selectedDs.name + " Datasource";
167+
return html`<b>${title}</b></br>
168+
Create an incremental migration file from Hibernate ORM schema diff.<br/>
169+
If you say yes, an additional file in <code>db/migrations</code> will be <br/>
170+
created.
171+
${this._renderUpdateDialogButtons(this._selectedDs)}
137172
`;
138173
}
139174

140-
_renderDialogButtons(ds){
175+
_renderCreateDialogButtons(ds){
141176
return html`<div style="display: flex; flex-direction: row-reverse; gap: 10px;">
142177
<vaadin-button theme="secondary" @click=${() => this._create(this._selectedDs)}>Create</vaadin-button>
143178
<vaadin-button theme="secondary error" @click=${this._cancelCreate}>Cancel</vaadin-button>
144179
</div>`;
145180
}
146181

182+
_renderUpdateDialogButtons(ds){
183+
return html`<div style="display: flex; flex-direction: row-reverse; gap: 10px;">
184+
<vaadin-button theme="secondary" @click=${() => this._update(this._selectedDs)}>Update</vaadin-button>
185+
<vaadin-button theme="secondary error" @click=${this._cancelUpdate}>Cancel</vaadin-button>
186+
</div>`;
187+
}
188+
147189
_clean(ds) {
148190
if (confirm('This will drop all objects (tables, views, procedures, triggers, ...) in the configured schema. Do you want to continue?')) {
149191
this.jsonRpc.clean({ds: ds.name}).then(jsonRpcResponse => {
@@ -168,11 +210,25 @@ export class QwcFlywayDatasources extends QwcHotReloadElement {
168210
});
169211
}
170212

213+
_update(ds) {
214+
this.jsonRpc.update({ds: ds.name}).then(jsonRpcResponse => {
215+
this._showResultNotification(jsonRpcResponse.result);
216+
this._selectedDs = null;
217+
this._updateDialogOpened = false;
218+
this.hotReload();
219+
});
220+
}
221+
171222
_cancelCreate(){
172223
this._selectedDs = null;
173224
this._createDialogOpened = false;
174225
}
175226

227+
_cancelUpdate(){
228+
this._selectedDs = null;
229+
this._updateDialogOpened = false;
230+
}
231+
176232
_showResultNotification(response){
177233
if(response.type === "success"){
178234
notifier.showInfoMessage(response.message + " (" + response.number + ")");
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
package io.quarkus.flyway.test;
2+
3+
import java.io.File;
4+
import java.nio.file.Files;
5+
import java.util.Map;
6+
7+
import org.jboss.shrinkwrap.api.ShrinkWrap;
8+
import org.jboss.shrinkwrap.api.asset.StringAsset;
9+
import org.jboss.shrinkwrap.api.spec.JavaArchive;
10+
import org.junit.jupiter.api.Assertions;
11+
import org.junit.jupiter.api.Test;
12+
import org.junit.jupiter.api.extension.RegisterExtension;
13+
14+
import com.fasterxml.jackson.databind.JsonNode;
15+
16+
import io.quarkus.dev.console.DevConsoleManager;
17+
import io.quarkus.devui.tests.DevUIJsonRPCTest;
18+
import io.quarkus.test.QuarkusDevModeTest;
19+
20+
public class FlywayDevModeUpdateFromHibernateTest extends DevUIJsonRPCTest {
21+
22+
public FlywayDevModeUpdateFromHibernateTest() {
23+
super("io.quarkus.quarkus-flyway");
24+
}
25+
26+
@RegisterExtension
27+
static final QuarkusDevModeTest config = new QuarkusDevModeTest()
28+
.setArchiveProducer(() -> ShrinkWrap.create(JavaArchive.class)
29+
.addClasses(FlywayDevModeUpdateFromHibernateTest.class, Fruit.class)
30+
.addAsResource(new StringAsset(
31+
"""
32+
create sequence Fruit_SEQ start with 1 increment by 50;
33+
34+
create table Fruit (
35+
id integer not null,
36+
primary key (id)
37+
);"""),
38+
"db/update/V2.0.0__Quarkus.sql")
39+
.addAsResource(new StringAsset(
40+
"quarkus.flyway.migrate-at-start=true\nquarkus.flyway.locations=db/update"),
41+
"application.properties"));
42+
43+
@Test
44+
public void testGenerateMigrationFromHibernate() throws Exception {
45+
46+
Map<String, Object> params = Map.of("ds", "<default>");
47+
JsonNode devuiresponse = super.executeJsonRPCMethod("update", params);
48+
49+
Assertions.assertNotNull(devuiresponse);
50+
String type = devuiresponse.get("type").asText();
51+
Assertions.assertNotNull(type);
52+
Assertions.assertEquals("success", type);
53+
54+
File migrationsDir = DevConsoleManager.getHotReplacementContext().getResourcesDir().get(0).resolve("db/update")
55+
.toFile();
56+
File[] newMigrations = migrationsDir.listFiles((dir, name) -> !name.equals("V2.0.0__Quarkus.sql"));
57+
Assertions.assertNotNull(newMigrations);
58+
Assertions.assertEquals(1, newMigrations.length);
59+
Assertions.assertTrue(newMigrations[0].getName().startsWith("V2."));
60+
61+
String content = Files.readString(newMigrations[0].toPath())
62+
// Windows is weird.
63+
.replaceAll("\\r\\n?", "\n");
64+
65+
Assertions.assertEquals("""
66+
67+
alter table if exists Fruit\s
68+
add column name varchar(40);
69+
70+
alter table if exists Fruit\s
71+
drop constraint if exists UKqn1mp5t3oovyl0h02glapi2iv;
72+
73+
alter table if exists Fruit\s
74+
add constraint UKqn1mp5t3oovyl0h02glapi2iv unique (name);
75+
""", content);
76+
}
77+
78+
}

extensions/flyway/runtime-dev/src/main/java/io/quarkus/flyway/runtime/dev/ui/FlywayDevUIRecorder.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,11 @@
1010
@Recorder
1111
public class FlywayDevUIRecorder {
1212

13-
public RuntimeValue<Boolean> setInitialSqlSuppliers(Map<String, Supplier<String>> initialSqlSuppliers, String artifactId) {
13+
public RuntimeValue<Boolean> setSqlSuppliers(Map<String, Supplier<String>> initialSqlSuppliers,
14+
Map<String, Supplier<String>> updateSuppliers, String artifactId) {
1415
FlywayJsonRpcService rpcService = Arc.container().instance(FlywayJsonRpcService.class).get();
1516
rpcService.setInitialSqlSuppliers(initialSqlSuppliers);
17+
rpcService.setUpdateSqlSuppliers(updateSuppliers);
1618
rpcService.setArtifactId(artifactId);
1719
return new RuntimeValue<>(true);
1820
}

0 commit comments

Comments
 (0)