Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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.
*
* <p>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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
@@ -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');
Original file line number Diff line number Diff line change
@@ -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');
Original file line number Diff line number Diff line change
@@ -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;
Loading