From 9c4e398b3944298dc37a6edd87709fd20cb8f0e6 Mon Sep 17 00:00:00 2001 From: Magnus Persson <1431685+magnusp@users.noreply.github.com> Date: Sun, 1 Nov 2026 09:38:24 +0100 Subject: [PATCH] test(flyway): cover foreign-key actions across a table rebuild A downstream Spring Boot application reported a fixture row that vanished while Flyway reported success, with PRAGMA foreign_keys = ON for every connection. The sample separates the two shapes involved: - seeding a parent and then INSERT OR IGNORE children across migrations keeps every row; - a later migration that rebuilds a parent table with copy, drop, rename removes the child rows, because DROP TABLE runs an implicit DELETE FROM and ON DELETE CASCADE fires. The cascade is SQLite's documented behavior, not row loss in the driver. With a child foreign key that has no cascade, the same rebuild fails with FOREIGN KEY constraint failed, which pins that enforcement is active for every connection Flyway takes. FlywaySqldSupport now takes the foreign_keys pragma instead of a boolean, so the enabled and disabled cases share one code path. --- .../jdbc/flyway/FlywayForeignKeySeedIT.java | 91 +++++++++++++++++++ .../libsql/jdbc/flyway/FlywaySqldSupport.java | 19 ++-- .../flyway/foreign-keys/V1__reseller.sql | 8 ++ .../V2__operator_customer_job.sql | 31 +++++++ .../foreign-keys/V3__rebuild_customer.sql | 16 ++++ 5 files changed, 158 insertions(+), 7 deletions(-) create mode 100644 libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/flyway/FlywayForeignKeySeedIT.java create mode 100644 libsql-jdbc/src/test/resources/flyway/foreign-keys/V1__reseller.sql create mode 100644 libsql-jdbc/src/test/resources/flyway/foreign-keys/V2__operator_customer_job.sql create mode 100644 libsql-jdbc/src/test/resources/flyway/foreign-keys/V3__rebuild_customer.sql diff --git a/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/flyway/FlywayForeignKeySeedIT.java b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/flyway/FlywayForeignKeySeedIT.java new file mode 100644 index 0000000..306945d --- /dev/null +++ b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/flyway/FlywayForeignKeySeedIT.java @@ -0,0 +1,91 @@ +package io.github.magnusp.libsql.jdbc.flyway; + +import org.flywaydb.core.Flyway; +import org.flywaydb.core.api.output.MigrateResult; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * A generalized sample of a downstream report: a seeding migration appeared to lose + * a child row while Flyway reported success, with {@code PRAGMA foreign_keys = ON} + * on every connection. + * + *
Two shapes are separated here. Seeding a parent and then an + * {@code INSERT OR IGNORE} child across migrations keeps every row. Rebuilding a + * parent table later with the copy, drop, rename sequence does not: with foreign + * keys enforced, {@code DROP TABLE} runs an implicit {@code DELETE FROM}, and + * {@code ON DELETE CASCADE} removes the child rows. That second test pins the + * behavior instead of treating it as row loss. + * + *
Foreign keys are off in SQLite unless the connection turns them on, which is + * why {@link FlywaySqldSupport#dataSourceWithForeignKeysEnabled()} exists. The + * migrations live in {@code flyway/foreign-keys}: V1 writes a parent in an earlier + * migration, V2 seeds two job rows, and V3 rebuilds the customer table. + */ +class FlywayForeignKeySeedIT extends FlywaySqldSupport { + + private static Flyway flyway() { + return flyway(null); + } + + private static Flyway flyway(String target) { + var configuration = Flyway.configure() + .dataSource(dataSourceWithForeignKeysEnabled()) + .locations("classpath:flyway/foreign-keys") + .cleanDisabled(false); + if (target != null) { + configuration.target(target); + } + return configuration.load(); + } + + @BeforeEach + void emptyDatabase() throws Exception { + resetDatabase(); + } + + @Test + void seedingParentsAndChildrenAcrossMigrationsKeepsEveryRow() throws Exception { + MigrateResult result = flyway("2").migrate(); + + assertThat(result.migrationsExecuted).isEqualTo(2); + assertThat(scalarInt("SELECT count(*) FROM reseller")).isEqualTo(1); + assertThat(scalarInt("SELECT count(*) FROM job")) + .as("INSERT OR IGNORE and INSERT are both visible to a foreign-key-enabled migration") + .isEqualTo(2); + assertThat(scalarInt("SELECT count(*) FROM job WHERE id = 'job-ignored'")) + .as("the ignored insert references three parents, one from an earlier migration") + .isEqualTo(1); + assertThat(scalarInt("SELECT count(*) FROM job WHERE id = 'job-plain'")).isEqualTo(1); + } + + @Test + void rebuildingAParentTableCascadesChildRows() throws Exception { + MigrateResult result = flyway().migrate(); + + assertThat(result.migrationsExecuted).isEqualTo(3); + assertThat(scalarInt("SELECT count(*) FROM customer")) + .as("the rebuild copies the parent rows into the replacement table") + .isEqualTo(1); + assertThat(scalarInt("SELECT count(*) FROM job")) + .as("DROP TABLE runs an implicit DELETE FROM and ON DELETE CASCADE removes both jobs") + .isZero(); + assertThat(scalarInt("SELECT count(*) FROM operator")).isEqualTo(1); + } + + @Test + void foreignKeysAreOnForTheMigrationConnection() throws Exception { + flyway().migrate(); + + try (var connection = dataSourceWithForeignKeysEnabled().getConnection(); + var statement = connection.createStatement(); + var resultSet = statement.executeQuery("PRAGMA foreign_keys")) { + assertThat(resultSet.next()).isTrue(); + assertThat(resultSet.getInt(1)) + .as("the DataSource enables foreign keys before Flyway takes the connection") + .isEqualTo(1); + } + } +} diff --git a/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/flyway/FlywaySqldSupport.java b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/flyway/FlywaySqldSupport.java index 48639f1..e64e56e 100644 --- a/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/flyway/FlywaySqldSupport.java +++ b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/flyway/FlywaySqldSupport.java @@ -55,9 +55,14 @@ static DataSource dataSource() { return new DriverManagerDataSource(jdbcUrl()); } + /** A DataSource that enables foreign keys before handing the connection to Flyway. */ + static DataSource dataSourceWithForeignKeysEnabled() { + return new DriverManagerDataSource(jdbcUrl(), "ON"); + } + /** A DataSource that disables foreign keys before handing the connection to Flyway. */ static DataSource dataSourceWithForeignKeysDisabled() { - return new DriverManagerDataSource(jdbcUrl(), true); + return new DriverManagerDataSource(jdbcUrl(), "OFF"); } static Connection connect() throws SQLException { @@ -143,25 +148,25 @@ private static String requireLibsqlImage() { static final class DriverManagerDataSource implements DataSource { private final String url; - private final boolean disableForeignKeys; + private final String foreignKeysPragma; DriverManagerDataSource(String url) { - this(url, false); + this(url, null); } - DriverManagerDataSource(String url, boolean disableForeignKeys) { + DriverManagerDataSource(String url, String foreignKeysPragma) { this.url = url; - this.disableForeignKeys = disableForeignKeys; + this.foreignKeysPragma = foreignKeysPragma; } @Override public Connection getConnection() throws SQLException { Connection connection = DriverManager.getConnection(url); - if (!disableForeignKeys) { + if (foreignKeysPragma == null) { return connection; } try (Statement statement = connection.createStatement()) { - statement.execute("PRAGMA foreign_keys = OFF"); + statement.execute("PRAGMA foreign_keys = " + foreignKeysPragma); return connection; } catch (SQLException e) { connection.close(); diff --git a/libsql-jdbc/src/test/resources/flyway/foreign-keys/V1__reseller.sql b/libsql-jdbc/src/test/resources/flyway/foreign-keys/V1__reseller.sql new file mode 100644 index 0000000..7445322 --- /dev/null +++ b/libsql-jdbc/src/test/resources/flyway/foreign-keys/V1__reseller.sql @@ -0,0 +1,8 @@ +-- The parent written in an earlier migration than the child, as in the reported +-- application where the reseller row lives in a first fixture migration. +CREATE TABLE reseller ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL +); + +INSERT INTO reseller (id, name) VALUES ('reseller-1', 'reseller'); diff --git a/libsql-jdbc/src/test/resources/flyway/foreign-keys/V2__operator_customer_job.sql b/libsql-jdbc/src/test/resources/flyway/foreign-keys/V2__operator_customer_job.sql new file mode 100644 index 0000000..c11f6b0 --- /dev/null +++ b/libsql-jdbc/src/test/resources/flyway/foreign-keys/V2__operator_customer_job.sql @@ -0,0 +1,31 @@ +-- One migration does the DDL and the seeding: operator and customer are written +-- first, then two job rows reference all three parents. `job-ignored` uses +-- INSERT OR IGNORE and `job-plain` uses INSERT, so a difference between them +-- isolates constraint handling from ordinary insert ordering. +CREATE TABLE operator ( + id TEXT PRIMARY KEY, + account_id TEXT NOT NULL +); + +CREATE TABLE customer ( + id TEXT PRIMARY KEY, + account_id TEXT NOT NULL +); + +CREATE TABLE job ( + id TEXT PRIMARY KEY, + reseller_id TEXT NOT NULL REFERENCES reseller (id), + customer_id TEXT NOT NULL REFERENCES customer (id) ON DELETE CASCADE, + operator_id TEXT NOT NULL REFERENCES operator (id), + reference_code TEXT NOT NULL UNIQUE, + status TEXT NOT NULL DEFAULT 'ONGOING' CHECK (status IN ('ONGOING', 'DONE')) +); + +INSERT INTO operator (id, account_id) VALUES ('operator-1', 'account-2'); +INSERT INTO customer (id, account_id) VALUES ('customer-1', 'account-3'); + +INSERT OR IGNORE INTO job (id, reseller_id, customer_id, operator_id, reference_code) +VALUES ('job-ignored', 'reseller-1', 'customer-1', 'operator-1', 'JOB-IGNORED'); + +INSERT INTO job (id, reseller_id, customer_id, operator_id, reference_code) +VALUES ('job-plain', 'reseller-1', 'customer-1', 'operator-1', 'JOB-PLAIN'); diff --git a/libsql-jdbc/src/test/resources/flyway/foreign-keys/V3__rebuild_customer.sql b/libsql-jdbc/src/test/resources/flyway/foreign-keys/V3__rebuild_customer.sql new file mode 100644 index 0000000..dd7862f --- /dev/null +++ b/libsql-jdbc/src/test/resources/flyway/foreign-keys/V3__rebuild_customer.sql @@ -0,0 +1,16 @@ +-- SQLite cannot alter a column type, so widening one means rebuilding the table: +-- copy into a temporary table, drop the original, rename. With foreign keys +-- enabled, DROP TABLE runs an implicit DELETE FROM, and that fires ON DELETE +-- actions in child tables. This is the shape that removed every job row in the +-- downstream report; the test asserts the cascade rather than a lost update. +CREATE TABLE customer_dg_tmp ( + id TEXT PRIMARY KEY, + account_id TEXT NOT NULL, + email TEXT NOT NULL DEFAULT '' +); + +INSERT INTO customer_dg_tmp (id, account_id) SELECT id, account_id FROM customer; + +DROP TABLE customer; + +ALTER TABLE customer_dg_tmp RENAME TO customer;