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;