These assertions describe current behavior, not desired behavior. If a test + * starts failing, the driver improved and the workaround in the assertion message + * can be removed. + */ +class FlywayKnownGapsIT extends FlywaySqldSupport { + + private static Flyway flyway() { + return Flyway.configure() + .dataSource(dataSource()) + .locations("classpath:flyway/migration") + .cleanDisabled(false) + .load(); + } + + @BeforeEach + void emptyDatabase() throws Exception { + resetDatabase(); + } + + @Test + void libsqlUrlIsNotRecognizedByFlyway() { + assertThatThrownBy(() -> Flyway.configure() + .dataSource(jdbcUrl(), "", "") + .load() + .migrate()) + .as("Flyway 13's SQLite plugin claims only jdbc:sqlite:, jdbc:sqldroid: and" + + " p6spy prefixes. Use the jdbc:sqlite: alias or pass a DataSource.") + .isInstanceOf(FlywayException.class) + .hasMessageContaining("No Flyway database plugin found to handle") + .hasMessageContaining("jdbc:libsql:"); + } + + @Test + void sqliteAliasUrlCannotUseTheLibsqlDriver() { + assertThatThrownBy(() -> Flyway.configure() + .driver("io.github.magnusp.libsql.jdbc.LibsqlDriver") + .dataSource("jdbc:sqlite:" + httpUrl(), "", "") + .load() + .migrate()) + .as("Flyway 13.12 builds its URL-based DriverDataSource eagerly and passes a" + + " null driver class, so driver(...) is ignored for this path. Flyway" + + " falls back to org.sqlite.JDBC, which is not on the classpath." + + " Pass a DataSource instead.") + .isInstanceOf(FlywayException.class) + .hasMessageContaining("No JDBC driver found to handle"); + } + + @Test + void productNameStartsWithSqliteSoProductDetectionWorks() throws SQLException { + try (Connection connection = connect()) { + assertThat(connection.getMetaData().getDatabaseProductName()) + .as("Flyway matches SQLite with startsWith(\"SQLite\"), so the suffix is safe") + .startsWith("SQLite"); + assertThat(connection.getMetaData().getDatabaseMajorVersion()).isNotZero(); + } + } + + @Test + void cleanIsBlockedUntilMultiStatementSupportLands() throws Exception { + Flyway flyway = flyway(); + flyway.migrate(); + + assertThat(scalarInt("PRAGMA foreign_keys")) + .as("sqld enables foreign keys on a fresh connection, unlike vanilla SQLite") + .isEqualTo(1); + + // The decision is to support clean(); see #258. This pin flips when + // multi-statement PRAGMA/DROP scripts are supported. + assertThatThrownBy(flyway::clean) + .as("Flyway sees foreign keys enabled and sends" + + " 'PRAGMA foreign_keys = OFF; DROP TABLE ...; PRAGMA foreign_keys = ON'" + + " as one statement. sqld rejects that string, so clean cannot finish." + + " Supporting clean() is tracked in #258.") + .isInstanceOf(FlywayException.class) + .hasMessageContaining("Unable to drop") + .hasStackTraceContaining("SQL string contains more than one statement"); + + assertThat(userTables()) + .as("clean stopped at the first table") + .contains("author"); + } + + @Test + void autoCommitStatementsDoNotShareSessionState() throws SQLException { + try (Connection connection = connect(); Statement statement = connection.createStatement()) { + statement.execute("PRAGMA foreign_keys = OFF"); + + try (ResultSet resultSet = statement.executeQuery("PRAGMA foreign_keys")) { + assertThat(resultSet.next()).isTrue(); + assertThat(resultSet.getInt(1)) + .as("each auto-commit statement runs as a one-shot Hrana stream, so" + + " connection-scoped state set by PRAGMA is gone by the next" + + " statement. Wrap related statements in a transaction.") + .isEqualTo(1); + } + } + } +} diff --git a/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/flyway/FlywayMigrationIT.java b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/flyway/FlywayMigrationIT.java new file mode 100644 index 0000000..b05f594 --- /dev/null +++ b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/flyway/FlywayMigrationIT.java @@ -0,0 +1,176 @@ +package io.github.magnusp.libsql.jdbc.flyway; + +import org.flywaydb.core.Flyway; +import org.flywaydb.core.api.Location; +import org.flywaydb.core.api.MigrationInfo; +import org.flywaydb.core.api.configuration.ClassicConfiguration; +import org.flywaydb.core.api.output.MigrateResult; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.Statement; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * End-to-end Flyway probes. Every migration below travels through the libSQL JDBC + * driver to a real sqld container. + * + *
Flyway owns the schema, so these tests cover the driver paths Flyway depends + * on: multi-statement migration scripts, DDL inside a transaction, the schema + * history table and its timestamp column, baseline, and repeatable migrations. + * + *
Two Flyway-facing limitations are pinned in {@link FlywayKnownGapsIT}. + */ +class FlywayMigrationIT extends FlywaySqldSupport { + + private static Flyway flyway(String... locations) { + return Flyway.configure() + .dataSource(dataSource()) + .locations(locations.length == 0 ? new String[] {"classpath:flyway/migration"} : locations) + .cleanDisabled(false) + .load(); + } + + @BeforeEach + void emptyDatabase() throws Exception { + resetDatabase(); + } + + @Test + void migratesThroughADataSource() throws Exception { + Flyway flyway = flyway(); + + MigrateResult result = flyway.migrate(); + + assertThat(result.migrationsExecuted) + .as("V1, V2, V3 and the repeatable view") + .isEqualTo(4); + assertThat(userTables()).contains("author", "book", "flyway_schema_history"); + + assertThat(scalarInt("SELECT count(*) FROM author")).isEqualTo(2); + assertThat(scalarInt("SELECT count(*) FROM book")).isEqualTo(2); + assertThat(scalarInt("SELECT book_count FROM author_summary WHERE name = 'Ursula K. Le Guin'")) + .isEqualTo(1); + + assertThat(flyway.info().applied()) + .extracting(info -> info.getVersion() == null ? "repeatable" : info.getVersion().toString()) + .containsExactly("1", "2", "3", "repeatable"); + assertThat(flyway.info().current().getState().isApplied()).isTrue(); + assertThat(flyway.info().pending()).isEmpty(); + + flyway.validate(); + } + + @Test + void secondMigrateIsANoOp() { + Flyway flyway = flyway(); + flyway.migrate(); + + assertThat(flyway.migrate().migrationsExecuted).isZero(); + } + + @Test + void schemaHistoryIsReadableThroughTheDriver() throws Exception { + flyway().migrate(); + + try (Connection connection = connect(); + Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery( + "SELECT installed_rank, version, description, type, success, installed_on" + + " FROM flyway_schema_history ORDER BY installed_rank")) { + assertThat(resultSet.next()).isTrue(); + assertThat(resultSet.getInt("installed_rank")).isEqualTo(1); + assertThat(resultSet.getString("version")).isEqualTo("1"); + assertThat(resultSet.getString("description")).isEqualTo("create author"); + assertThat(resultSet.getString("type")).isEqualTo("SQL"); + assertThat(resultSet.getBoolean("success")).isTrue(); + assertThat(resultSet.getTimestamp("installed_on")) + .as("installed_on is TEXT with a strftime() default") + .isNotNull(); + assertThat(resultSet.wasNull()).isFalse(); + } + } + + @Test + void migrateWithASqliteAliasUrlAndADataSource() throws Exception { + // jdbc:sqlite: is the only prefix Flyway's SQLite plugin accepts. Passing a + // DataSource still avoids its URL-based driver pick, which hardcodes + // org.sqlite.JDBC. This proves the alias itself works through the driver. + Flyway flyway = Flyway.configure() + .dataSource(new FlywaySqldSupport.DriverManagerDataSource( + "jdbc:sqlite:" + httpUrl())) + .locations("classpath:flyway/migration") + .load(); + + MigrateResult result = flyway.migrate(); + + assertThat(result.migrationsExecuted).isEqualTo(4); + assertThat(scalarInt("SELECT count(*) FROM author")).isEqualTo(2); + } + + @Test + void migrateWithUrlConfigurationAndExplicitDriver() throws Exception { + // This mirrors the Maven plugin / flyway.properties path: url(...) plus an + // explicit driver is honored, because unlike dataSource(url) it does not + // build the DriverDataSource eagerly with a null driver class. The URL + // still has to use the jdbc:sqlite: alias that Flyway's plugin claims. + ClassicConfiguration configuration = new ClassicConfiguration(); + configuration.setUrl("jdbc:sqlite:" + httpUrl()); + configuration.setDriver("io.github.magnusp.libsql.jdbc.LibsqlDriver"); + configuration.setLocations(new Location("classpath:flyway/migration")); + Flyway flyway = new Flyway(configuration); + + assertThat(flyway.migrate().migrationsExecuted).isEqualTo(4); + assertThat(scalarInt("SELECT count(*) FROM author")).isEqualTo(2); + } + + @Test + void failedMigrationRollsBackItsDdl() throws Exception { + Flyway flyway = flyway("classpath:flyway/broken"); + + assertThatThrownBy(flyway::migrate) + .as("V2 inserts into a table that does not exist") + .hasStackTraceContaining("table_that_does_not_exist"); + + assertThat(tableExists("migration_probe")) + .as("V1 committed") + .isTrue(); + assertThat(tableExists("partial_table")) + .as("V2's CREATE TABLE must roll back with the failed insert") + .isFalse(); + + assertThat(flyway.info().applied()) + .extracting(info -> info.getVersion().toString()) + .containsExactly("1"); + } + + @Test + void baselineAnExistingSchemaThenMigrate() throws Exception { + try (Connection connection = connect(); Statement statement = connection.createStatement()) { + statement.execute("CREATE TABLE author (" + + " id INTEGER PRIMARY KEY AUTOINCREMENT," + + " name TEXT NOT NULL," + + " country TEXT)"); + statement.execute("CREATE INDEX author_name_idx ON author (name)"); + } + + Flyway flyway = flyway(); + flyway.baseline(); + + MigrateResult result = flyway.migrate(); + + assertThat(result.migrationsExecuted) + .as("V1 is skipped by the baseline; V2, V3 and the repeatable view run") + .isEqualTo(3); + assertThat(userTables()).contains("author", "book", "flyway_schema_history"); + assertThat(scalarInt("SELECT count(*) FROM author")).isEqualTo(2); + + MigrationInfo baseline = flyway.info().applied()[0]; + assertThat(baseline.getVersion().toString()).isEqualTo("1"); + assertThat(baseline.getType().name()).isEqualTo("BASELINE"); + } +} 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 new file mode 100644 index 0000000..b55b9b9 --- /dev/null +++ b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/flyway/FlywaySqldSupport.java @@ -0,0 +1,231 @@ +package io.github.magnusp.libsql.jdbc.flyway; + +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +import javax.sql.DataSource; +import java.io.PrintWriter; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.SQLFeatureNotSupportedException; +import java.sql.Statement; +import java.time.Duration; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.logging.Logger; + +/** + * A throwaway sqld container for the Flyway integration tests. + * + *
Flyway owns the schema here, so unlike the jOOQ support class there is no
+ * out-of-band bootstrap. The reset helpers exist only to isolate tests: every
+ * test starts from an empty database.
+ */
+@Testcontainers
+abstract class FlywaySqldSupport {
+
+ private static final int PORT = 8080;
+ private static final String LIBSQL_IMAGE = requireLibsqlImage();
+
+ @Container
+ static final GenericContainer> LIBSQL = new GenericContainer<>(DockerImageName.parse(LIBSQL_IMAGE))
+ .withExposedPorts(PORT)
+ .withEnv("SQLD_NODE", "primary")
+ .withEnv("SQLD_DISABLE_INTELLIGENT_THROTTLING", "true")
+ .withEnv("SQLD_CONNECTION_CREATION_TIMEOUT_SEC", "30")
+ .waitingFor(Wait.forHttp("/v2/pipeline")
+ .forPort(PORT)
+ .forStatusCode(405)
+ .withStartupTimeout(Duration.ofSeconds(60)));
+
+ static String jdbcUrl() {
+ return "jdbc:libsql:http://" + LIBSQL.getHost() + ":" + LIBSQL.getMappedPort(PORT);
+ }
+
+ static String httpUrl() {
+ return "http://" + LIBSQL.getHost() + ":" + LIBSQL.getMappedPort(PORT);
+ }
+
+ static DataSource dataSource() {
+ return new DriverManagerDataSource(jdbcUrl());
+ }
+
+ static Connection connect() throws SQLException {
+ return DriverManager.getConnection(jdbcUrl());
+ }
+
+ static void resetDatabase() throws SQLException {
+ try (Connection connection = connect(); Statement statement = connection.createStatement()) {
+ for (String view : names(statement, "view")) {
+ statement.execute("DROP VIEW IF EXISTS \"" + view + "\"");
+ }
+ // A PRAGMA does not survive into the next auto-commit statement, so the
+ // drop order has to respect the foreign-key graph instead of disabling
+ // checks.
+ for (String table : dropOrder(statement, names(statement, "table"))) {
+ statement.execute("DROP TABLE IF EXISTS \"" + table + "\"");
+ }
+ }
+ }
+
+ static List