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,99 @@
package com.ankurm.dbmigrations;
import java.nio.file.Path;
import java.sql.Connection;
import javax.sql.DataSource;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.flyway.autoconfigure.FlywayAutoConfiguration;
import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration;
import org.springframework.boot.liquibase.autoconfigure.LiquibaseAutoConfiguration;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Both starters, both enabled, one database — the question every "which one do I add" thread
* on Stack Overflow eventually asks in a comment. The naive answer is no: Boot wires Liquibase's
* {@code SpringLiquibase} bean before Flyway's {@code FlywayMigrationInitializer} in this
* configuration (an artifact of bean registration order here, not a documented contract), so by
* the time Flyway runs, Liquibase has already created tables, and Flyway's own safety check —
* "I found tables I don't recognize and no schema history table" — refuses to start. Turning on
* {@code baselineOnMigrate} alone is not enough here: its default {@code baselineVersion} (1)
* tells Flyway "pretend V1 already ran", which only makes sense if the existing schema really
* matches what V1 would have built. Liquibase's changelog built an unrelated {@code product}
* table, not {@code customer}, so V1 also has to be told to baseline-version 0 (nothing has run
* yet, from Flyway's point of view) so it runs its own migration set from scratch alongside
* whatever Liquibase already put there. See docs/14-running-both-at-once.md.
*/
class BothTogetherTest {
@Test
void enablingBothNaivelyFailsUntilFlywayIsToldToBaseline(@TempDir Path tmp) throws Exception {
Transcript t = Transcript.start("15-both-together-same-datasource",
"Flyway and Liquibase, both enabled: the naive failure, then the fix");
Path naiveDb = tmp.resolve("both-together-naive");
t.section("naive: both enabled, no other configuration");
new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(DataSourceAutoConfiguration.class,
FlywayAutoConfiguration.class, LiquibaseAutoConfiguration.class))
.withPropertyValues(
"spring.datasource.url=jdbc:h2:file:" + naiveDb,
"spring.datasource.username=sa",
"spring.flyway.locations=classpath:db/migration",
"spring.liquibase.change-log=classpath:db/changelog/db.changelog-master.yaml")
.run(ctx -> {
assertThat(ctx).hasFailed();
String message = rootMessage(ctx.getStartupFailure());
t.line("context failed to start: " + message);
assertThat(message).containsIgnoringCase("non-empty").containsIgnoringCase("baseline");
});
Path fixedDb = tmp.resolve("both-together-fixed");
t.section("fixed: spring.flyway.baseline-on-migrate=true, spring.flyway.baseline-version=0");
new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(DataSourceAutoConfiguration.class,
FlywayAutoConfiguration.class, LiquibaseAutoConfiguration.class))
.withPropertyValues(
"spring.datasource.url=jdbc:h2:file:" + fixedDb,
"spring.datasource.username=sa",
"spring.flyway.locations=classpath:db/migration",
"spring.flyway.baseline-on-migrate=true",
"spring.flyway.baseline-version=0",
"spring.liquibase.change-log=classpath:db/changelog/db.changelog-master.yaml")
.run(ctx -> {
assertThat(ctx).hasNotFailed();
DataSource ds = ctx.getBean(DataSource.class);
try (Connection conn = ds.getConnection()) {
t.line(DbDump.table(conn,
"select table_name from information_schema.tables where table_schema='PUBLIC' order by table_name"));
t.section("flyway_schema_history — baseline row at 0, then V1/V2 ran for real");
String flywayHistory = DbDump.table(conn,
"select \"version\", \"description\", \"type\", \"success\" from \"flyway_schema_history\" order by \"installed_rank\"");
t.line(flywayHistory);
t.section("databasechangelog — Liquibase's own bookkeeping, untouched by Flyway");
t.line(DbDump.table(conn, "select id, author, exectype from databasechangelog order by orderexecuted"));
String tables = DbDump.table(conn,
"select table_name from information_schema.tables where table_schema='PUBLIC' order by table_name");
assertThat(tables).contains("CUSTOMER").contains("PRODUCT").contains("DATABASECHANGELOG");
assertThat(flywayHistory).contains("create customer").contains("seed customer");
}
});
t.write();
}
private static String rootMessage(Throwable t) {
Throwable cause = t;
while (cause.getCause() != null) {
cause = cause.getCause();
}
return cause.getClass().getSimpleName() + ": " + cause.getMessage();
}
}
@@ -0,0 +1,72 @@
package com.ankurm.dbmigrations;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.ResultSetMetaData;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;
/** Renders a JDBC query as a plain-text table for transcripts — no ORM, no formatting library. */
public final class DbDump {
private DbDump() {
}
public static String table(Connection conn, String sql) {
try (Statement st = conn.createStatement(); ResultSet rs = st.executeQuery(sql)) {
ResultSetMetaData meta = rs.getMetaData();
int cols = meta.getColumnCount();
List<String> headers = new ArrayList<>();
for (int i = 1; i <= cols; i++) {
headers.add(meta.getColumnLabel(i));
}
List<List<String>> rows = new ArrayList<>();
while (rs.next()) {
List<String> row = new ArrayList<>();
for (int i = 1; i <= cols; i++) {
Object v = rs.getObject(i);
row.add(v == null ? "NULL" : v.toString());
}
rows.add(row);
}
int[] widths = new int[cols];
for (int i = 0; i < cols; i++) {
widths[i] = headers.get(i).length();
}
for (List<String> row : rows) {
for (int i = 0; i < cols; i++) {
widths[i] = Math.max(widths[i], row.get(i).length());
}
}
StringBuilder sb = new StringBuilder();
appendRow(sb, headers, widths);
StringBuilder sep = new StringBuilder();
for (int i = 0; i < cols; i++) {
sep.append("-".repeat(widths[i])).append(i < cols - 1 ? "-+-" : "");
}
sb.append(sep).append(System.lineSeparator());
for (List<String> row : rows) {
appendRow(sb, row, widths);
}
sb.append("(").append(rows.size()).append(" row").append(rows.size() == 1 ? "" : "s").append(")");
return sb.toString();
}
catch (SQLException ex) {
return "query failed: " + ex.getMessage();
}
}
private static void appendRow(StringBuilder sb, List<String> values, int[] widths) {
for (int i = 0; i < values.size(); i++) {
sb.append(pad(values.get(i), widths[i]));
sb.append(i < values.size() - 1 ? " | " : "");
}
sb.append(System.lineSeparator());
}
private static String pad(String s, int width) {
return s + " ".repeat(Math.max(0, width - s.length()));
}
}
@@ -0,0 +1,62 @@
package com.ankurm.dbmigrations;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.time.Instant;
/**
* Writes a named transcript under {@code docs/output/} while the test that produced it runs.
* Every console-line quoted in the companion article for this module comes from one of these
* files, and the file is only ever written by a test assertion — never hand-typed.
*
* See docs/15-production-checklist.md for how this module's real output is regenerated.
*/
public final class Transcript {
private static final Path OUTPUT_DIR = Paths.get("docs", "output");
private final StringBuilder buffer = new StringBuilder();
private final String name;
private Transcript(String name) {
this.name = name;
}
public static Transcript start(String name, String heading) {
Transcript t = new Transcript(name);
t.line("=".repeat(Math.min(78, heading.length() + 4)));
t.line(heading);
t.line("=".repeat(Math.min(78, heading.length() + 4)));
t.line("captured: " + Instant.now());
t.line("");
return t;
}
public Transcript line(String text) {
buffer.append(text).append(System.lineSeparator());
System.out.println(text);
return this;
}
public Transcript section(String title) {
line("");
line("-- " + title + " --");
return this;
}
public void write() {
try {
Files.createDirectories(OUTPUT_DIR);
Path target = OUTPUT_DIR.resolve(name + ".txt");
Files.writeString(target, buffer.toString(), StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING);
}
catch (IOException ex) {
throw new UncheckedIOException(ex);
}
}
}
@@ -0,0 +1,103 @@
package com.ankurm.dbmigrations.flyway;
import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.Statement;
import javax.sql.DataSource;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.flyway.autoconfigure.FlywayAutoConfiguration;
import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import com.ankurm.dbmigrations.DbDump;
import com.ankurm.dbmigrations.Transcript;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Adopting Flyway into a database that already has a schema — the case every "add Flyway to our
* five-year-old app" migration hits on day one. See docs/06-baselining-an-existing-database.md.
*/
class FlywayBaselineTest {
@Test
void adoptingFlywayAgainstAnExistingSchemaNeedsABaseline(@TempDir Path tmp) throws Exception {
Path db = tmp.resolve("baseline-demo");
String url = "jdbc:h2:file:" + db;
// Simulate five years of hand-run DDL: the table already exists, with no Flyway involved.
try (Connection conn = DriverManager.getConnection(url, "sa", ""); Statement st = conn.createStatement()) {
st.execute("create table legacy_account (id bigint primary key, owner varchar(100))");
st.execute("insert into legacy_account values (1, 'pre-flyway-data')");
}
Path migrations = tmp.resolve("migrations");
Files.createDirectories(migrations);
// V1 describes the schema that ALREADY exists — this is what makes it a baseline candidate.
Files.writeString(migrations.resolve("V1__init.sql"),
"create table legacy_account (id bigint primary key, owner varchar(100));\n");
Files.writeString(migrations.resolve("V2__add_status_column.sql"),
"alter table legacy_account add column status varchar(20) default 'ACTIVE';\n");
Transcript t = Transcript.start("05-flyway-baseline", "Flyway: adopting an existing, unmanaged schema");
t.section("migrate() with no baseline configuration, against a non-empty schema");
new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(DataSourceAutoConfiguration.class, FlywayAutoConfiguration.class))
.withPropertyValues(
"spring.datasource.url=" + url,
"spring.datasource.username=sa",
"spring.flyway.locations=filesystem:" + migrations)
.run(ctx -> {
assertThat(ctx).hasFailed();
String message = rootMessage(ctx.getStartupFailure());
t.line(message);
assertThat(message).containsIgnoringCase("non-empty").containsIgnoringCase("baseline");
});
t.section("migrate() with baselineOnMigrate=true, baselineVersion=1");
new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(DataSourceAutoConfiguration.class, FlywayAutoConfiguration.class))
.withPropertyValues(
"spring.datasource.url=" + url,
"spring.datasource.username=sa",
"spring.flyway.locations=filesystem:" + migrations,
"spring.flyway.baseline-on-migrate=true",
"spring.flyway.baseline-version=1",
"spring.flyway.baseline-description=pre-flyway schema")
.run(ctx -> {
assertThat(ctx).hasNotFailed();
DataSource ds = ctx.getBean(DataSource.class);
try (Connection conn = ds.getConnection()) {
String history = DbDump.table(conn,
"select \"installed_rank\", \"version\", \"description\", \"type\", \"success\" from \"flyway_schema_history\" order by \"installed_rank\"");
t.line(history);
// V1's own SQL never runs a second time — it would fail, the table already exists.
// The baseline row's description is whatever baseline-description was set to, and its
// type is BASELINE, not a fixed "<< Flyway Baseline >>" marker (that text is only the
// default when no baseline-description is given).
assertThat(history).contains("BASELINE").contains("pre-flyway schema").contains("add status column");
t.section("legacy_account keeps its pre-existing row and gains the new column");
t.line(DbDump.table(conn, "select id, owner, status from legacy_account"));
}
});
t.write();
}
private static String rootMessage(Throwable t) {
Throwable cause = t;
while (cause.getCause() != null) {
cause = cause.getCause();
}
return cause.getClass().getSimpleName() + ": " + cause.getMessage();
}
}
@@ -0,0 +1,72 @@
package com.ankurm.dbmigrations.flyway;
import java.nio.file.Files;
import java.nio.file.Path;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.flyway.autoconfigure.FlywayAutoConfiguration;
import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import com.ankurm.dbmigrations.Transcript;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Edits an already-applied migration file and restarts the context against the same database.
* Flyway validates checksums before migrating by default ({@code validateOnMigrate=true}), so
* the second startup fails — this is the real exception message, not a paraphrase.
* See docs/03-checksum-validation.md.
*/
class FlywayChecksumMismatchTest {
@Test
void editingAnAppliedMigrationFailsValidation(@TempDir Path tmp) throws Exception {
Path db = tmp.resolve("checksum-demo");
Path migrations = tmp.resolve("migrations");
Files.createDirectories(migrations);
Path v1 = migrations.resolve("V1__init.sql");
Files.writeString(v1, "create table widget (id bigint primary key, name varchar(50));\n");
Transcript t = Transcript.start("02-flyway-checksum-mismatch",
"Flyway: editing an already-applied migration file");
ApplicationContextRunner runner = new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(DataSourceAutoConfiguration.class, FlywayAutoConfiguration.class))
.withPropertyValues(
"spring.datasource.url=jdbc:h2:file:" + db,
"spring.datasource.username=sa",
"spring.flyway.locations=filesystem:" + migrations);
t.section("first startup — V1 applied as originally written");
runner.run(ctx -> {
assertThat(ctx).hasNotFailed();
t.line("context started cleanly, V1__init.sql applied");
});
// Someone "just tweaks" the already-applied migration instead of writing a new one.
Files.writeString(v1, "create table widget (id bigint primary key, name varchar(80));\n");
t.section("second startup — V1__init.sql edited after being applied");
runner.run(ctx -> {
assertThat(ctx).hasFailed();
Throwable failure = ctx.getStartupFailure();
String message = rootMessage(failure);
t.line(message);
assertThat(message).contains("checksum").containsIgnoringCase("mismatch");
});
t.write();
}
private static String rootMessage(Throwable t) {
Throwable cause = t;
while (cause.getCause() != null) {
cause = cause.getCause();
}
return cause.getClass().getSimpleName() + ": " + cause.getMessage();
}
}
@@ -0,0 +1,63 @@
package com.ankurm.dbmigrations.flyway;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.file.FileSystem;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.TreeSet;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.junit.jupiter.api.Test;
import com.ankurm.dbmigrations.Transcript;
import static org.assertj.core.api.Assertions.assertThat;
/**
* {@code undo} ({@link FlywayUndoTest}) is not a special case — it is one of eight commands that
* ship as do-nothing stubs in {@code flyway-core}. This test walks the actual jar on the test
* classpath and lists every class under {@code org.flywaydb.core.internal.proprietaryStubs}, so
* the list below is read off the artifact Maven Central serves, not copied from a web page.
* See docs/07-why-there-is-no-undo.md.
*/
class FlywayCommunityCommandSurfaceTest {
@Test
void listsEveryProprietaryStubOnTheClasspath() throws Exception {
String pkg = "org/flywaydb/core/internal/proprietaryStubs/";
URI jarUri = requireOnClasspath(pkg);
List<String> stubs;
try (FileSystem fs = FileSystems.newFileSystem(jarUri, java.util.Map.of())) {
Path root = fs.getPath("/" + pkg);
try (Stream<Path> walk = Files.list(root)) {
stubs = walk.map(p -> p.getFileName().toString())
.filter(n -> n.endsWith("Stub.class"))
.map(n -> n.replace("CommandExtensionStub.class", ""))
.collect(Collectors.toCollection(TreeSet::new))
.stream().toList();
}
}
Transcript t = Transcript.start("07-flyway-proprietary-stub-commands",
"Flyway: every command name that resolves to a Redgate-edition-required stub in flyway-core");
stubs.forEach(name -> t.line("- " + name.toLowerCase()));
t.write();
assertThat(stubs).contains("Undo", "Diff", "Check", "Deploy", "Generate", "Model", "Prepare", "Auth");
}
private static URI requireOnClasspath(String resourcePackage) throws IOException, URISyntaxException {
URI uri = FlywayCommunityCommandSurfaceTest.class.getClassLoader()
.getResource(resourcePackage)
.toURI();
assertThat(uri.getScheme()).as("expected the proprietary stub package inside a jar on the test classpath")
.isEqualTo("jar");
return uri;
}
}
@@ -0,0 +1,85 @@
package com.ankurm.dbmigrations.flyway;
import java.nio.file.Path;
import java.sql.Connection;
import java.sql.DriverManager;
import java.time.Duration;
import java.time.Instant;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import org.flywaydb.core.Flyway;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import com.ankurm.dbmigrations.DbDump;
import com.ankurm.dbmigrations.Transcript;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Two application instances started at the same moment against the same database — the ordinary
* rolling-deploy case. Flyway's own locking (a row lock on its schema history table, not an
* external coordinator) is what keeps them from racing. See docs/08-concurrent-startup-and-locking.md.
*/
class FlywayConcurrentMigrateTest {
@Test
void twoInstancesMigratingAtOnceAreSerializedNotDuplicated(@TempDir Path tmp) throws Exception {
Path db = tmp.resolve("concurrent-demo");
String url = "jdbc:h2:file:" + db + ";AUTO_SERVER=TRUE";
ExecutorService pool = Executors.newFixedThreadPool(2);
Instant start = Instant.now();
try {
Future<Long> instanceA = pool.submit(() -> runMigrate(url));
Future<Long> instanceB = pool.submit(() -> runMigrate(url));
long millisA = instanceA.get();
long millisB = instanceB.get();
Duration total = Duration.between(start, Instant.now());
Transcript t = Transcript.start("08-flyway-concurrent-lock",
"Flyway: two instances calling migrate() at the same moment");
t.line("instance A migrate() took " + millisA + "ms");
t.line("instance B migrate() took " + millisB + "ms");
t.line("wall-clock time for both, run concurrently: " + total.toMillis() + "ms");
t.section("flyway_schema_history after both finished");
try (Connection conn = DriverManager.getConnection(url, "sa", "")) {
String history = DbDump.table(conn,
"select \"installed_rank\", \"version\", \"description\", \"success\" from \"flyway_schema_history\" order by \"installed_rank\"");
t.line(history);
// Java-based migrations keep the class name as-is (no underscore-to-space filename
// conversion) — the description here is literally "SlowMigration".
assertThat(history.lines().filter(l -> l.contains("SlowMigration")).count())
.as("the slow migration must have run exactly once despite two concurrent migrate() calls")
.isEqualTo(1);
}
t.write();
// Both instances were submitted at the same instant, so a sum-vs-wall-clock
// comparison is the wrong shape here: the loser's migrate() call is blocked waiting
// for the winner's row lock for almost the whole 800ms sleep, then finds nothing left
// to do and returns almost instantly — so the WALL CLOCK for both together is close to
// one migration's duration, not their sum. The direct proof of serialization is that
// the loser's own call took nearly as long as the winner's, instead of returning near
// instantly the way an unlocked, do-nothing migrate() call would.
assertThat(millisA).isGreaterThan(600);
assertThat(millisB).isGreaterThan(600);
}
finally {
pool.shutdown();
}
}
private static long runMigrate(String url) {
Instant t0 = Instant.now();
Flyway flyway = Flyway.configure()
.dataSource(url, "sa", "")
.javaMigrations(new V1__SlowMigration())
.locations("classpath:db/no-sql-migrations-here")
.load();
flyway.migrate();
return Duration.between(t0, Instant.now()).toMillis();
}
}
@@ -0,0 +1,60 @@
package com.ankurm.dbmigrations.flyway;
import java.nio.file.Path;
import java.sql.Connection;
import javax.sql.DataSource;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.flyway.autoconfigure.FlywayAutoConfiguration;
import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import com.ankurm.dbmigrations.DbDump;
import com.ankurm.dbmigrations.Transcript;
import static org.assertj.core.api.Assertions.assertThat;
/**
* The smallest thing that works: two versioned migrations, run through Boot's own
* {@code FlywayAutoConfiguration} rather than the Flyway API directly, because that is what
* every Spring Boot application actually exercises. See docs/02-anatomy-of-a-migration-run.md.
*/
class FlywayHappyPathTest {
@Test
void appliesVersionedMigrationsInOrder(@TempDir Path tmp) throws Exception {
Path db = tmp.resolve("happy-path");
Transcript t = Transcript.start("01-flyway-happy-path", "Flyway: two versioned migrations, applied on startup");
new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(DataSourceAutoConfiguration.class, FlywayAutoConfiguration.class))
.withPropertyValues(
"spring.datasource.url=jdbc:h2:file:" + db,
"spring.datasource.username=sa",
"spring.flyway.locations=classpath:db/migration")
.run(ctx -> {
assertThat(ctx).hasNotFailed();
DataSource ds = ctx.getBean(DataSource.class);
try (Connection conn = ds.getConnection()) {
t.section("flyway_schema_history");
String history = DbDump.table(conn,
"select \"installed_rank\", \"version\", \"description\", \"type\", \"checksum\", \"success\" "
+ "from \"flyway_schema_history\" order by \"installed_rank\"");
t.line(history);
// Flyway derives the description from the filename by turning underscores into spaces.
assertThat(history).contains("1").contains("2").contains("create customer").contains("seed customer");
t.section("customer table");
String customers = DbDump.table(conn, "select id, name, email from customer order by id");
t.line(customers);
assertThat(customers).contains("Ada Lovelace").contains("Grace Hopper");
}
});
t.write();
}
}
@@ -0,0 +1,112 @@
package com.ankurm.dbmigrations.flyway;
import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.Connection;
import javax.sql.DataSource;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.flyway.autoconfigure.FlywayAutoConfiguration;
import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import com.ankurm.dbmigrations.DbDump;
import com.ankurm.dbmigrations.Transcript;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Two branches both add the next migration and both call it something different: one team ships
* V3, the other's V2 lands later after a slow-to-merge pull request. See
* docs/04-out-of-order-migrations.md for what {@code outOfOrder} actually controls.
*/
class FlywayOutOfOrderTest {
@Test
void aLateArrivingLowerVersionIsSkippedUnlessOutOfOrderIsEnabled(@TempDir Path tmp) throws Exception {
Path db = tmp.resolve("out-of-order-demo");
Path migrations = tmp.resolve("migrations");
Files.createDirectories(migrations);
Files.writeString(migrations.resolve("V1__init.sql"),
"create table ledger (id bigint primary key, note varchar(100));\n");
Files.writeString(migrations.resolve("V3__add_note_index.sql"),
"create index idx_ledger_note on ledger(note);\n");
Transcript t = Transcript.start("03-flyway-out-of-order",
"Flyway: a lower-versioned migration lands after a higher one is already applied");
t.section("first startup — V1 and V3 only");
new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(DataSourceAutoConfiguration.class, FlywayAutoConfiguration.class))
.withPropertyValues(
"spring.datasource.url=jdbc:h2:file:" + db,
"spring.datasource.username=sa",
"spring.flyway.locations=filesystem:" + migrations)
.run(ctx -> {
assertThat(ctx).hasNotFailed();
t.line("applied: V1, V3");
});
// V2 merges late — its version number is lower than the V3 already applied to every environment.
Files.writeString(migrations.resolve("V2__add_note_length_check.sql"),
"alter table ledger add constraint chk_note_length check (char_length(note) <= 100);\n");
t.section("second startup — outOfOrder=false (Flyway default)");
new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(DataSourceAutoConfiguration.class, FlywayAutoConfiguration.class))
.withPropertyValues(
"spring.datasource.url=jdbc:h2:file:" + db,
"spring.datasource.username=sa",
"spring.flyway.locations=filesystem:" + migrations,
"spring.flyway.out-of-order=false")
.run(ctx -> {
// Default (outOfOrder=false): Flyway does not quietly skip V2 — it refuses to
// start at all. Validation runs before migration and treats a lower-versioned
// migration than the highest already applied as a validation failure.
assertThat(ctx).hasFailed();
String message = rootMessage(ctx.getStartupFailure());
t.line("context failed to start: " + message);
assertThat(message).contains("outOfOrder").as("Flyway's own message names the fix");
// Autoconfiguration tears the DataSource bean down along with everything else
// on a refresh failure — reopen the same file directly to see what landed.
try (Connection conn = java.sql.DriverManager.getConnection("jdbc:h2:file:" + db, "sa", "")) {
t.line(DbDump.table(conn,
"select \"installed_rank\", \"version\", \"description\", \"success\" from \"flyway_schema_history\" order by \"installed_rank\""));
}
});
t.section("third startup — outOfOrder=true");
new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(DataSourceAutoConfiguration.class, FlywayAutoConfiguration.class))
.withPropertyValues(
"spring.datasource.url=jdbc:h2:file:" + db,
"spring.datasource.username=sa",
"spring.flyway.locations=filesystem:" + migrations,
"spring.flyway.out-of-order=true")
.run(ctx -> {
assertThat(ctx).hasNotFailed();
DataSource ds = ctx.getBean(DataSource.class);
try (Connection conn = ds.getConnection()) {
String history = DbDump.table(conn,
"select \"installed_rank\", \"version\", \"description\", \"success\" from \"flyway_schema_history\" order by \"installed_rank\"");
t.line(history);
assertThat(history).contains("add note length check");
}
});
t.write();
}
private static String rootMessage(Throwable t) {
Throwable cause = t;
while (cause.getCause() != null) {
cause = cause.getCause();
}
return cause.getClass().getSimpleName() + ": " + cause.getMessage();
}
}
@@ -0,0 +1,88 @@
package com.ankurm.dbmigrations.flyway;
import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.Connection;
import javax.sql.DataSource;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.flyway.autoconfigure.FlywayAutoConfiguration;
import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import com.ankurm.dbmigrations.DbDump;
import com.ankurm.dbmigrations.Transcript;
import static org.assertj.core.api.Assertions.assertThat;
/**
* A repeatable migration ({@code R__}) is not versioned at all — Flyway reruns it whenever its
* checksum changes, no matter where it sits relative to versioned migrations. That is a genuinely
* different rule from {@link FlywayOutOfOrderTest}'s versioned ones. See docs/05-repeatable-migrations.md.
*/
class FlywayRepeatableTest {
@Test
void aRepeatableMigrationRerunsWhenItsContentChanges(@TempDir Path tmp) throws Exception {
Path db = tmp.resolve("repeatable-demo");
Path migrations = tmp.resolve("migrations");
Files.createDirectories(migrations);
Files.writeString(migrations.resolve("V1__init.sql"),
"create table invoice (id bigint primary key, amount_cents bigint, status varchar(20));\n"
+ "insert into invoice values (1, 5000, 'PAID');\n"
+ "insert into invoice values (2, 3000, 'PENDING');\n");
Path view = migrations.resolve("R__invoice_summary_view.sql");
Files.writeString(view,
"create or replace view invoice_summary as select status, count(*) as cnt from invoice group by status;\n");
ApplicationContextRunner runner = new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(DataSourceAutoConfiguration.class, FlywayAutoConfiguration.class))
.withPropertyValues(
"spring.datasource.url=jdbc:h2:file:" + db,
"spring.datasource.username=sa",
"spring.flyway.locations=filesystem:" + migrations);
Transcript t = Transcript.start("04-flyway-repeatable",
"Flyway: a repeatable migration reruns on checksum change alone");
t.section("first startup");
runner.run(ctx -> {
assertThat(ctx).hasNotFailed();
DataSource ds = ctx.getBean(DataSource.class);
try (Connection conn = ds.getConnection()) {
t.line(DbDump.table(conn,
"select \"installed_rank\", \"version\", \"description\", \"type\", \"checksum\" from \"flyway_schema_history\" order by \"installed_rank\""));
t.section("invoice_summary right after creation");
t.line(DbDump.table(conn, "select status, cnt from invoice_summary order by status"));
}
});
// Widen the view without touching the version numbers at all.
Files.writeString(view,
"create or replace view invoice_summary as select status, count(*) as cnt, sum(amount_cents) as total_cents "
+ "from invoice group by status;\n");
t.section("second startup — only R__invoice_summary_view.sql changed");
runner.run(ctx -> {
assertThat(ctx).hasNotFailed();
DataSource ds = ctx.getBean(DataSource.class);
try (Connection conn = ds.getConnection()) {
String history = DbDump.table(conn,
"select \"installed_rank\", \"version\", \"description\", \"type\", \"checksum\" from \"flyway_schema_history\" order by \"installed_rank\"");
t.line(history);
assertThat(history.lines().filter(l -> l.contains("invoice summary view")).count()).isEqualTo(2);
t.section("invoice_summary after the repeatable migration reran");
String summary = DbDump.table(conn, "select status, cnt, total_cents from invoice_summary order by status");
t.line(summary);
assertThat(summary).contains("TOTAL_CENTS");
}
});
t.write();
}
}
@@ -0,0 +1,57 @@
package com.ankurm.dbmigrations.flyway;
import java.nio.file.Files;
import java.nio.file.Path;
import org.flywaydb.core.Flyway;
import org.flywaydb.core.api.FlywayException;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import com.ankurm.dbmigrations.Transcript;
import static org.assertj.core.api.Assertions.assertThat;
/**
* {@code Flyway.undo()} is a real, public method on the Community jar — it compiles, and nothing
* about its signature says it will not run. What actually resolves it at runtime is a stub. See
* docs/07-why-there-is-no-undo.md for how {@code flyway-core}'s own bytecode was read to confirm
* this rather than trusting Redgate's marketing pages, which was the point of this test.
*/
class FlywayUndoTest {
@Test
void undoOnTheCommunityJarThrowsAnEditionRequiredException(@TempDir Path tmp) throws Exception {
Path db = tmp.resolve("undo-demo");
Path migrations = tmp.resolve("migrations");
Files.createDirectories(migrations);
Files.writeString(migrations.resolve("V1__init.sql"), "create table t (id int primary key);\n");
Flyway flyway = Flyway.configure()
.dataSource("jdbc:h2:file:" + db, "sa", "")
.locations("filesystem:" + migrations)
.load();
flyway.migrate();
Transcript t = Transcript.start("06-flyway-undo-teams-required",
"Flyway: calling the public undo() API on the Community jar");
FlywayException ex = catchFlywayException(flyway);
t.line("flyway.undo() threw: " + ex.getClass().getName());
t.line("message: " + ex.getMessage());
t.write();
assertThat(ex.getClass().getSimpleName()).isEqualTo("FlywayRedgateEditionRequiredException");
assertThat(ex.getMessage()).containsIgnoringCase("undo");
}
private static FlywayException catchFlywayException(Flyway flyway) {
try {
flyway.undo();
}
catch (FlywayException ex) {
return ex;
}
throw new AssertionError("expected flyway.undo() to throw FlywayException on the Community jar");
}
}
@@ -0,0 +1,22 @@
package com.ankurm.dbmigrations.flyway;
import org.flywaydb.core.api.migration.BaseJavaMigration;
import org.flywaydb.core.api.migration.Context;
/**
* A deliberately slow Java-based migration used only by {@link FlywayConcurrentMigrateTest} to
* widen the window in which a second {@code migrate()} call can try to run at the same time.
* Flyway derives the version (1) from this class name the same way it would from
* {@code V1__SlowMigration.sql} — but the description is the raw remainder, "SlowMigration",
* with no underscore-to-space conversion. That conversion is filename-specific.
*/
public class V1__SlowMigration extends BaseJavaMigration {
@Override
public void migrate(Context context) throws Exception {
try (var st = context.getConnection().createStatement()) {
st.execute("create table slow_migration_marker (id int primary key)");
}
Thread.sleep(800);
}
}
@@ -0,0 +1,121 @@
package com.ankurm.dbmigrations.liquibase;
import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.Connection;
import java.sql.DriverManager;
import java.time.Duration;
import java.time.Instant;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import liquibase.Contexts;
import liquibase.Liquibase;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import com.ankurm.dbmigrations.DbDump;
import com.ankurm.dbmigrations.Transcript;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Liquibase's answer to {@code FlywayConcurrentMigrateTest}: a single-row lock table,
* {@code DATABASECHANGELOGLOCK}, that a real second {@code update()} call has to wait for.
* <p>
* Two Liquibase instances racing to create {@code DATABASECHANGELOG}/{@code DATABASECHANGELOGLOCK}
* for the very first time is a real, separate failure mode from the lock contention this test is
* about — the loser gets a plain {@code DatabaseException} ("table already exists"), because the
* lock table that would make it wait gracefully doesn't exist yet either. So a bootstrap step runs
* first, on its own {@link Liquibase} instance, against an empty changelog, purely to get those two
* tracking tables created before the real race starts.
* <p>
* That bootstrap changelog file is deliberately named {@code bootstrap-only.yaml}, not
* {@code master.yaml} — reusing the same filename for the bootstrap and the real changelog (even
* from a different directory, via a different {@code DirectoryResourceAccessor}) reliably
* reproduced a genuine Liquibase 5.0.3 defect: both real instances would log a normal
* {@code Run: 1} / "successful" summary, but the changeset's own code never ran and
* {@code DATABASECHANGELOG} stayed empty — a phantom success caused by something in Liquibase's
* changelog-history handling keying off the changelog's simple filename rather than the full
* resource path. Verified by toggling only the bootstrap file's name with everything else held
* constant: same name reproduces it every time, a distinct name never does. See
* docs/11-liquibase-locking.md.
*/
class LiquibaseConcurrentUpdateTest {
@Test
void twoInstancesUpdatingAtOnceAreSerializedNotDuplicated(@TempDir Path tmp) throws Exception {
Path changelogDir = tmp.resolve("changelog");
Files.createDirectories(changelogDir);
Files.writeString(changelogDir.resolve("master.yaml"), """
databaseChangeLog:
- changeSet:
id: 1-slow-change
author: ankurm
changes:
- customChange:
class: com.ankurm.dbmigrations.liquibase.SlowCustomChange
""");
Path bootstrapChangelogDir = tmp.resolve("bootstrap-changelog");
Files.createDirectories(bootstrapChangelogDir);
Files.writeString(bootstrapChangelogDir.resolve("bootstrap-only.yaml"), "databaseChangeLog: []\n");
String url = "jdbc:h2:file:" + tmp.resolve("liquibase-concurrent") + ";AUTO_SERVER=TRUE";
// Bootstrap DATABASECHANGELOG / DATABASECHANGELOGLOCK first, on a changelog file with a
// name distinct from the real one (see the class javadoc for why that distinction matters).
try (Liquibase bootstrap = LiquibaseTestSupport.open(url, bootstrapChangelogDir, "bootstrap-only.yaml")) {
bootstrap.update(new Contexts());
}
ExecutorService pool = Executors.newFixedThreadPool(2);
Instant start = Instant.now();
try {
Future<Long> instanceA = pool.submit(() -> runUpdate(url, changelogDir));
Future<Long> instanceB = pool.submit(() -> runUpdate(url, changelogDir));
long millisA = instanceA.get();
long millisB = instanceB.get();
Duration total = Duration.between(start, Instant.now());
Transcript t = Transcript.start("13-liquibase-lock-contention",
"Liquibase: two instances calling update() at the same moment");
t.line("instance A update() took " + millisA + "ms");
t.line("instance B update() took " + millisB + "ms");
t.line("wall-clock time for both, run concurrently: " + total.toMillis() + "ms");
try (Connection conn = DriverManager.getConnection(url, "sa", "")) {
t.section("databasechangeloglock after both finished");
t.line(DbDump.table(conn, "select id, locked, lockedby from databasechangeloglock"));
t.section("databasechangelog after both finished");
String log = DbDump.table(conn, "select id, author, exectype from databasechangelog");
t.line(log);
assertThat(log.lines().filter(l -> l.contains("1-slow-change")).count())
.as("the slow changeset must run exactly once despite two concurrent update() calls")
.isEqualTo(1);
}
t.write();
// Exactly one of the two calls does the real 800ms of work; the other blocks on
// DATABASECHANGELOGLOCK for roughly that same window before finding nothing left to
// do. That means the wall clock for both together is close to ONE migration's
// duration, not their sum — so the direct proof of serialization is that whichever
// call "lost" the race still took nearly as long as the winner, instead of returning
// near-instantly the way an unlocked, do-nothing update() call would.
assertThat(millisA).isGreaterThan(600);
assertThat(millisB).isGreaterThan(600);
}
finally {
pool.shutdown();
}
}
private static long runUpdate(String url, Path changelogDir) throws Exception {
Instant t0 = Instant.now();
try (Liquibase liquibase = LiquibaseTestSupport.open(url, changelogDir, "master.yaml")) {
liquibase.update(new Contexts());
}
return Duration.between(t0, Instant.now()).toMillis();
}
}
@@ -0,0 +1,68 @@
package com.ankurm.dbmigrations.liquibase;
import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.Connection;
import java.sql.DriverManager;
import liquibase.Liquibase;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import com.ankurm.dbmigrations.DbDump;
import com.ankurm.dbmigrations.Transcript;
import static org.assertj.core.api.Assertions.assertThat;
/**
* The Liquibase equivalent of {@code FlywayHappyPathTest}: two changesets, applied once. See
* docs/09-liquibase-anatomy-of-an-update.md.
*/
class LiquibaseHappyPathTest {
@Test
void appliesChangeSetsInOrder(@TempDir Path tmp) throws Exception {
Path changelogDir = tmp.resolve("changelog");
Files.createDirectories(changelogDir);
Files.writeString(changelogDir.resolve("master.yaml"), """
databaseChangeLog:
- changeSet:
id: 1-create-account
author: ankurm
changes:
- createTable:
tableName: account
columns:
- column: {name: id, type: bigint, autoIncrement: true, constraints: {primaryKey: true}}
- column: {name: owner, type: varchar(100), constraints: {nullable: false}}
- changeSet:
id: 2-seed-account
author: ankurm
changes:
- insert:
tableName: account
columns:
- {column: {name: owner, value: 'Katherine Johnson'}}
""");
String url = "jdbc:h2:file:" + tmp.resolve("liquibase-happy-path");
Transcript t = Transcript.start("09-liquibase-happy-path", "Liquibase: two changesets, applied on startup");
Liquibase liquibase = LiquibaseTestSupport.open(url, changelogDir, "master.yaml");
liquibase.update();
try (Connection conn = DriverManager.getConnection(url, "sa", "")) {
t.section("databasechangelog");
String log = DbDump.table(conn,
"select id, author, filename, orderexecuted, exectype from databasechangelog order by orderexecuted");
t.line(log);
assertThat(log).contains("1-create-account").contains("2-seed-account");
t.section("account table");
String rows = DbDump.table(conn, "select id, owner from account");
t.line(rows);
assertThat(rows).contains("Katherine Johnson");
}
t.write();
}
}
@@ -0,0 +1,35 @@
package com.ankurm.dbmigrations.liquibase;
import liquibase.Scope;
import liquibase.license.LicenseServiceFactory;
import org.junit.jupiter.api.Test;
import com.ankurm.dbmigrations.Transcript;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Liquibase Community 5.0 shipped a {@code liquibase.license} package inside {@code liquibase-core}
* itself — new since the Functional Source License change (docs/13-the-fsl-license-change.md).
* This test asks the actual service Spring Boot's {@code LiquibaseAutoConfiguration} runs on top
* of whether it is licensed, rather than repeating what the release notes say. See
* docs/12-the-oss-license-service.md.
*/
class LiquibaseLicenseServiceTest {
@Test
void theOssServiceReportsNoLicenseAndDoesNotFail() {
var service = Scope.getCurrentScope().getSingleton(LicenseServiceFactory.class).getLicenseService();
Transcript t = Transcript.start("14-liquibase-oss-license-service",
"Liquibase: the LicenseService actually wired up on the classpath Boot uses");
t.line("implementation: " + service.getClass().getName());
t.line("licenseIsInstalled(): " + service.licenseIsInstalled());
t.line("licenseIsValid(\"any\"): " + service.licenseIsValid("any"));
t.line("getLicenseInfo(): \"" + service.getLicenseInfo() + "\"");
t.write();
assertThat(service.getClass().getSimpleName()).isEqualTo("OSSLicenseService");
assertThat(service.licenseIsInstalled()).isFalse();
}
}
@@ -0,0 +1,73 @@
package com.ankurm.dbmigrations.liquibase;
import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import liquibase.Contexts;
import liquibase.LabelExpression;
import liquibase.Liquibase;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import com.ankurm.dbmigrations.DbDump;
import com.ankurm.dbmigrations.Transcript;
import static org.assertj.core.api.Assertions.assertThat;
/**
* {@code createTable} is one of the change types Liquibase can invert on its own — it just runs
* {@code DROP TABLE}. No {@code <rollback>} block was written anywhere in this changelog.
* See docs/10-rollback-auto-generated-vs-explicit.md.
*/
class LiquibaseRollbackAutoTest {
@Test
void rollingBackACreateTableChangeSetNeedsNoExplicitRollback(@TempDir Path tmp) throws Exception {
Path changelogDir = tmp.resolve("changelog");
Files.createDirectories(changelogDir);
Files.writeString(changelogDir.resolve("master.yaml"), """
databaseChangeLog:
- changeSet:
id: 1-create-session
author: ankurm
changes:
- createTable:
tableName: session
columns:
- column: {name: id, type: bigint, autoIncrement: true, constraints: {primaryKey: true}}
- column: {name: token, type: varchar(64)}
""");
String url = "jdbc:h2:file:" + tmp.resolve("rollback-auto");
Transcript t = Transcript.start("10-liquibase-rollback-auto", "Liquibase: auto-generated rollback for createTable");
try (Liquibase liquibase = LiquibaseTestSupport.open(url, changelogDir, "master.yaml")) {
liquibase.update();
}
t.line("after update(): session table exists = " + tableExists(url, "SESSION"));
try (Liquibase liquibase = LiquibaseTestSupport.open(url, changelogDir, "master.yaml")) {
liquibase.rollback(1, new Contexts(), new LabelExpression());
}
boolean existsAfterRollback = tableExists(url, "SESSION");
t.line("after rollback(1): session table exists = " + existsAfterRollback);
try (Connection conn = DriverManager.getConnection(url, "sa", "")) {
t.section("databasechangelog after rollback");
t.line(DbDump.table(conn, "select id, exectype from databasechangelog"));
}
t.write();
assertThat(existsAfterRollback).isFalse();
}
private static boolean tableExists(String url, String tableName) throws SQLException {
try (Connection conn = DriverManager.getConnection(url, "sa", "");
var rs = conn.getMetaData().getTables(null, "PUBLIC", tableName, null)) {
return rs.next();
}
}
}
@@ -0,0 +1,77 @@
package com.ankurm.dbmigrations.liquibase;
import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.Connection;
import java.sql.DriverManager;
import liquibase.Contexts;
import liquibase.LabelExpression;
import liquibase.Liquibase;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import com.ankurm.dbmigrations.DbDump;
import com.ankurm.dbmigrations.Transcript;
import static org.assertj.core.api.Assertions.assertThat;
/**
* The same {@code insert} changeset as {@link LiquibaseRollbackFailTest}, this time with an
* explicit {@code rollback:} block — the fix for that failure, not just a description of it.
* See docs/10-rollback-auto-generated-vs-explicit.md.
*/
class LiquibaseRollbackExplicitTest {
@Test
void anExplicitRollbackBlockMakesTheSameInsertReversible(@TempDir Path tmp) throws Exception {
Path changelogDir = tmp.resolve("changelog");
Files.createDirectories(changelogDir);
Files.writeString(changelogDir.resolve("master.yaml"), """
databaseChangeLog:
- changeSet:
id: 1-create-audit-log
author: ankurm
changes:
- createTable:
tableName: audit_log
columns:
- column: {name: id, type: bigint, autoIncrement: true, constraints: {primaryKey: true}}
- column: {name: event, type: varchar(100)}
- changeSet:
id: 2-seed-audit-log
author: ankurm
changes:
- insert:
tableName: audit_log
columns:
- {column: {name: event, value: 'system-start'}}
rollback:
- delete:
tableName: audit_log
where: event='system-start'
""");
String url = "jdbc:h2:file:" + tmp.resolve("rollback-explicit");
Transcript t = Transcript.start("12-liquibase-rollback-explicit",
"Liquibase: the same insert, now with an explicit rollback block");
Liquibase liquibase = LiquibaseTestSupport.open(url, changelogDir, "master.yaml");
liquibase.update();
try (Connection conn = DriverManager.getConnection(url, "sa", "")) {
t.section("audit_log right after update()");
t.line(DbDump.table(conn, "select id, event from audit_log"));
}
Liquibase forRollback = LiquibaseTestSupport.open(url, changelogDir, "master.yaml");
forRollback.rollback(1, new Contexts(), new LabelExpression());
try (Connection conn = DriverManager.getConnection(url, "sa", "")) {
t.section("audit_log after rollback(1) — table still exists, the row is gone");
String rows = DbDump.table(conn, "select id, event from audit_log");
t.line(rows);
assertThat(rows).contains("(0 rows)");
}
t.write();
}
}
@@ -0,0 +1,75 @@
package com.ankurm.dbmigrations.liquibase;
import java.nio.file.Files;
import java.nio.file.Path;
import liquibase.Contexts;
import liquibase.LabelExpression;
import liquibase.Liquibase;
import liquibase.exception.LiquibaseException;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import com.ankurm.dbmigrations.Transcript;
import static org.assertj.core.api.Assertions.assertThat;
/**
* {@code insert} is not in the list of change types Liquibase can invert automatically. With no
* {@code <rollback>} block, rolling it back fails — for real, not as a documented limitation
* taken on faith. See docs/10-rollback-auto-generated-vs-explicit.md.
*/
class LiquibaseRollbackFailTest {
@Test
void rollingBackAnInsertWithNoExplicitRollbackFails(@TempDir Path tmp) throws Exception {
Path changelogDir = tmp.resolve("changelog");
Files.createDirectories(changelogDir);
Files.writeString(changelogDir.resolve("master.yaml"), """
databaseChangeLog:
- changeSet:
id: 1-create-audit-log
author: ankurm
changes:
- createTable:
tableName: audit_log
columns:
- column: {name: id, type: bigint, autoIncrement: true, constraints: {primaryKey: true}}
- column: {name: event, type: varchar(100)}
- changeSet:
id: 2-seed-audit-log
author: ankurm
changes:
- insert:
tableName: audit_log
columns:
- {column: {name: event, value: 'system-start'}}
""");
String url = "jdbc:h2:file:" + tmp.resolve("rollback-fail");
Transcript t = Transcript.start("11-liquibase-rollback-no-inverse",
"Liquibase: rolling back an insert changeset with no <rollback> block");
Liquibase liquibase = LiquibaseTestSupport.open(url, changelogDir, "master.yaml");
liquibase.update();
t.line("after update(): both changesets applied");
Liquibase forRollback = LiquibaseTestSupport.open(url, changelogDir, "master.yaml");
LiquibaseException failure = catchRollbackFailure(forRollback);
t.line("rollback(1) threw: " + failure.getClass().getName());
t.line("message: " + failure.getMessage());
t.write();
assertThat(failure.getMessage()).containsIgnoringCase("no inverse");
}
private static LiquibaseException catchRollbackFailure(Liquibase liquibase) {
try {
liquibase.rollback(1, new Contexts(), new LabelExpression());
}
catch (LiquibaseException ex) {
return ex;
}
throw new AssertionError("expected rollback(1) to fail for a changeset with no explicit rollback");
}
}
@@ -0,0 +1,24 @@
package com.ankurm.dbmigrations.liquibase;
import java.nio.file.Path;
import java.sql.Connection;
import java.sql.DriverManager;
import liquibase.Liquibase;
import liquibase.database.Database;
import liquibase.database.DatabaseFactory;
import liquibase.database.jvm.JdbcConnection;
import liquibase.resource.DirectoryResourceAccessor;
/** Wires up the classic {@link liquibase.Liquibase} facade against a filesystem changelog — no Spring involved. */
final class LiquibaseTestSupport {
private LiquibaseTestSupport() {
}
static Liquibase open(String jdbcUrl, Path changelogDir, String changelogFile) throws Exception {
Connection conn = DriverManager.getConnection(jdbcUrl, "sa", "");
Database database = DatabaseFactory.getInstance().findCorrectDatabaseImplementation(new JdbcConnection(conn));
return new Liquibase(changelogFile, new DirectoryResourceAccessor(changelogDir), database);
}
}
@@ -0,0 +1,48 @@
package com.ankurm.dbmigrations.liquibase;
import liquibase.change.custom.CustomTaskChange;
import liquibase.database.Database;
import liquibase.database.jvm.JdbcConnection;
import liquibase.exception.CustomChangeException;
import liquibase.exception.SetupException;
import liquibase.exception.ValidationErrors;
import liquibase.resource.ResourceAccessor;
/**
* A deliberately slow {@code customChange}, used only by {@link LiquibaseConcurrentUpdateTest} to
* widen the window in which a second {@code update()} can try to run against the same database.
*/
public class SlowCustomChange implements CustomTaskChange {
@Override
public void execute(Database database) throws CustomChangeException {
try {
JdbcConnection conn = (JdbcConnection) database.getConnection();
try (var st = conn.createStatement()) {
st.execute("create table slow_changeset_marker (id int primary key)");
}
Thread.sleep(800);
}
catch (Exception ex) {
throw new CustomChangeException(ex);
}
}
@Override
public String getConfirmationMessage() {
return "slow custom change applied";
}
@Override
public void setUp() throws SetupException {
}
@Override
public void setFileOpener(ResourceAccessor resourceAccessor) {
}
@Override
public ValidationErrors validate(Database database) {
return new ValidationErrors();
}
}