Add db-migrations-flyway-liquibase: Flyway vs Liquibase migrations, rollbacks and baselines on Spring Boot 4.1

Companion code for the Flyway vs Liquibase article: checksum validation, out-of-order
and repeatable migrations, baselining an existing schema, Flyway Community's undo/diff/deploy
stubs, concurrent-startup locking for both tools, Liquibase changeset identity and rollback
(auto-generated vs explicit), a verified Liquibase 5.0.3 filename-caching defect, the new
OSS license service, the FSL license change, and running both tools against one database.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Q6XdRjtsp4862EM44T7i9a
This commit is contained in:
2026-09-15 07:08:57 +00:00
co-authored by Claude Sonnet 5
parent b02fbe1416
commit 3908331431
61 changed files with 3128 additions and 0 deletions
@@ -0,0 +1,22 @@
package com.ankurm.dbmigrations;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* Runnable demo used only for the {@code /diag/migrations} endpoint (see
* {@link com.ankurm.dbmigrations.web.MigrationDiagnosticsController} and
* docs/15-production-checklist.md). Every other scenario in this module runs as a JUnit test
* against {@code ApplicationContextRunner} so it starts in milliseconds and needs no server —
* see docs/01-the-problem-and-mental-model.md for why that split was made.
*
* <p>Delete the diagnostics endpoint before shipping a real service; it prints raw migration
* bookkeeping tables with no authorization check.
*/
@SpringBootApplication
public class DbMigrationsApplication {
public static void main(String[] args) {
SpringApplication.run(DbMigrationsApplication.class, args);
}
}
@@ -0,0 +1,87 @@
package com.ankurm.dbmigrations.web;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import javax.sql.DataSource;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* Prints both tools' own bookkeeping tables side by side against whatever the classpath and
* {@code spring.flyway.enabled} / {@code spring.liquibase.enabled} left on the schema. There is
* no Flyway or Liquibase Java API call in here at all — it is plain JDBC against
* {@code INFORMATION_SCHEMA} plus the two tracking tables, which is exactly what makes it honest:
* it shows what actually landed in the database, not what either library's in-memory model
* believes happened.
*
* <p>Documented in docs/15-production-checklist.md. Delete this before a real deployment —
* it has no authorization and dumps raw schema-history rows.
*/
@RestController
public class MigrationDiagnosticsController {
private final DataSource dataSource;
public MigrationDiagnosticsController(DataSource dataSource) {
this.dataSource = dataSource;
}
@GetMapping("/diag/migrations")
public Map<String, Object> migrations() {
Map<String, Object> result = new LinkedHashMap<>();
try (Connection conn = dataSource.getConnection()) {
result.put("tables", tableNames(conn));
// Flyway creates and queries its own table using quoted lowercase identifiers, so an
// unquoted (and therefore upper-cased, by H2's default folding) query against it would
// find nothing at all — see docs/02-anatomy-of-a-migration-run.md.
result.put("flyway_schema_history", rows(conn,
"select \"installed_rank\", \"version\", \"description\", \"type\", \"checksum\", \"success\" "
+ "from \"flyway_schema_history\" order by \"installed_rank\"", true));
result.put("databasechangelog", rows(conn,
"select id, author, filename, orderexecuted, exectype, md5sum "
+ "from databasechangelog order by orderexecuted", true));
}
catch (SQLException ex) {
result.put("error", ex.getMessage());
}
return result;
}
private List<String> tableNames(Connection conn) throws SQLException {
List<String> names = new ArrayList<>();
try (ResultSet rs = conn.getMetaData().getTables(null, "PUBLIC", "%", new String[] { "TABLE" })) {
while (rs.next()) {
names.add(rs.getString("TABLE_NAME"));
}
}
return names;
}
private Object rows(Connection conn, String sql, boolean tolerateMissingTable) throws SQLException {
List<Map<String, Object>> out = new ArrayList<>();
try (var st = conn.createStatement(); ResultSet rs = st.executeQuery(sql)) {
int cols = rs.getMetaData().getColumnCount();
while (rs.next()) {
Map<String, Object> row = new LinkedHashMap<>();
for (int i = 1; i <= cols; i++) {
row.put(rs.getMetaData().getColumnLabel(i).toLowerCase(), rs.getObject(i));
}
out.add(row);
}
}
catch (SQLException ex) {
if (tolerateMissingTable) {
return "not present: " + ex.getMessage();
}
throw ex;
}
return out;
}
}
@@ -0,0 +1,47 @@
spring:
application:
name: db-migrations-flyway-liquibase
datasource:
url: jdbc:h2:file:./data/quickstart;AUTO_SERVER=TRUE
username: sa
password: ""
flyway:
enabled: true
liquibase:
enabled: false
change-log: classpath:db/changelog/db.changelog-master.yaml
management:
endpoints:
web:
exposure:
include: flyway,liquibase,health
---
# ./scripts/run.sh both-naive — both tools enabled against the same fresh database, no other
# configuration. Fails to start: Liquibase's SpringLiquibase bean runs before Flyway's, so by the
# time Flyway checks the schema it finds tables it doesn't recognize. See docs/14-running-both-at-once.md.
spring:
config:
activate:
on-profile: both-naive
datasource:
url: jdbc:h2:file:./data/both-naive;AUTO_SERVER=TRUE
liquibase:
enabled: true
---
# ./scripts/run.sh both-fixed — the fix from docs/14-running-both-at-once.md applied: Flyway is
# told to baseline the schema Liquibase already built, starting from "nothing has run yet"
# (baseline-version: 0) rather than the default "version 1 already ran" baseline.
spring:
config:
activate:
on-profile: both-fixed
datasource:
url: jdbc:h2:file:./data/both-fixed;AUTO_SERVER=TRUE
liquibase:
enabled: true
flyway:
baseline-on-migrate: true
baseline-version: 0
@@ -0,0 +1,38 @@
databaseChangeLog:
- changeSet:
id: 1-create-product
author: ankurm
changes:
- createTable:
tableName: product
columns:
- column:
name: id
type: bigint
autoIncrement: true
constraints:
primaryKey: true
- column:
name: sku
type: varchar(64)
constraints:
nullable: false
unique: true
- column:
name: price_cents
type: bigint
constraints:
nullable: false
- changeSet:
id: 2-seed-product
author: ankurm
changes:
- insert:
tableName: product
columns:
- column:
name: sku
value: WIDGET-1
- column:
name: price_cents
valueNumeric: 1999
@@ -0,0 +1,5 @@
create table customer (
id bigint generated by default as identity primary key,
name varchar(120) not null,
email varchar(200) not null unique
);
@@ -0,0 +1,2 @@
insert into customer (name, email) values ('Ada Lovelace', '[email protected]');
insert into customer (name, email) values ('Grace Hopper', '[email protected]');