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;
}
}