diff --git a/libsql-jdbc/pom.xml b/libsql-jdbc/pom.xml index 8ab30f9..9c5499c 100644 --- a/libsql-jdbc/pom.xml +++ b/libsql-jdbc/pom.xml @@ -32,6 +32,12 @@ assertj-core test + + org.liquibase + liquibase-core + ${liquibase.version} + test + org.jooq jooq diff --git a/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/liquibase/LiquibaseIT.java b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/liquibase/LiquibaseIT.java new file mode 100644 index 0000000..ea47a85 --- /dev/null +++ b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/liquibase/LiquibaseIT.java @@ -0,0 +1,258 @@ +package io.github.magnusp.libsql.jdbc.liquibase; + +import liquibase.Contexts; +import liquibase.LabelExpression; +import liquibase.Liquibase; +import liquibase.database.Database; +import liquibase.database.DatabaseFactory; +import liquibase.database.core.SQLiteDatabase; +import liquibase.database.core.UnsupportedDatabase; +import liquibase.database.jvm.JdbcConnection; +import liquibase.resource.ClassLoaderResourceAccessor; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.sql.Timestamp; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Drives the in-tree driver through Liquibase's update, rollback, sync and + * snapshot paths against a real sqld container. + * + *

Liquibase selects its SQLite implementation with + * {@code "SQLite".equalsIgnoreCase(getDatabaseProductName())}. This driver + * reports {@code "SQLite (libSQL)"}, so the tests that need Liquibase's SQLite + * SQL build a {@link SQLiteDatabase} directly. The gap is pinned in + * {@link #databaseFactoryFallsBackToUnsupportedDatabase()}; when the product + * name becomes exactly {@code SQLite}, switch {@link #liquibase} back to + * {@link DatabaseFactory#findCorrectDatabaseImplementation} and delete the pin. + */ +class LiquibaseIT extends LiquibaseSqldSupport { + + @Test + void updateAppliesChangelogAndIsIdempotent() throws Exception { + try (Connection conn = DriverManager.getConnection(jdbcUrl())) { + Liquibase liquibase = liquibase(conn, "liquibase/changelog-update.xml"); + + liquibase.validate(); + liquibase.update(new Contexts()); + liquibase.update(new Contexts()); + + assertThat(tableExists(conn, "lb_person")).isTrue(); + assertThat(columnExists(conn, "lb_person", "email")).isTrue(); + assertThat(count(conn, "SELECT COUNT(*) FROM lb_person")).isEqualTo(1); + assertThat(count(conn, "SELECT COUNT(*) FROM databasechangelog WHERE id IN ('update-1', 'update-2')")) + .isEqualTo(2); + assertThat(queryString(conn, "SELECT email FROM lb_person")).isEqualTo("ada@example.com"); + } + } + + @Test + void alterTableChangesRoundTripThroughLiquibaseSnapshots() throws Exception { + try (Connection conn = DriverManager.getConnection(jdbcUrl())) { + Liquibase liquibase = liquibase(conn, "liquibase/changelog-alter.xml"); + + liquibase.update(new Contexts()); + + assertThat(columnNames(conn, "lb_alter_person")) + .as("renameColumn rebuilds the table from a Liquibase snapshot") + .containsExactly("id", "given_name", "nickname", "email"); + assertThat(indexNames(conn, "lb_alter_person")) + .contains("idx_lb_alter_given", "uq_lb_alter_email"); + assertThat(uniqueIndexNames(conn, "lb_alter_person")).contains("uq_lb_alter_email"); + } + } + + @Test + void foreignKeyCreatedByLiquibaseIsVisibleThroughMetadata() throws Exception { + try (Connection conn = DriverManager.getConnection(jdbcUrl())) { + Liquibase liquibase = liquibase(conn, "liquibase/changelog-fk.xml"); + + liquibase.update(new Contexts()); + + try (ResultSet rs = conn.getMetaData().getImportedKeys(null, null, "lb_fk_child")) { + assertThat(rs.next()).isTrue(); + assertThat(rs.getString("PKTABLE_NAME")).isEqualTo("lb_fk_parent"); + assertThat(rs.getString("FKCOLUMN_NAME")).isEqualTo("parent_id"); + assertThat(rs.getString("FK_NAME")).isEqualTo("fk_lb_child_parent"); + assertThat(rs.getInt("DELETE_RULE")).isEqualTo(DatabaseMetaData.importedKeyCascade); + } + } + } + + @Test + void rollbackCountRemovesChangesetsInReverse() throws Exception { + try (Connection conn = DriverManager.getConnection(jdbcUrl())) { + Liquibase liquibase = liquibase(conn, "liquibase/changelog-rollback.xml"); + + liquibase.update(new Contexts()); + assertThat(tableExists(conn, "lb_rb_widget")).isTrue(); + assertThat(indexNames(conn, "lb_rb_widget")).contains("idx_lb_rb_label"); + + liquibase.rollback(1, new Contexts(), new LabelExpression()); + assertThat(tableExists(conn, "lb_rb_widget")).isTrue(); + assertThat(indexNames(conn, "lb_rb_widget")).doesNotContain("idx_lb_rb_label"); + assertThat(count(conn, "SELECT COUNT(*) FROM databasechangelog WHERE id = 'rollback-2'")) + .isZero(); + + liquibase.rollback(1, new Contexts(), new LabelExpression()); + assertThat(tableExists(conn, "lb_rb_widget")).isFalse(); + assertThat(count(conn, "SELECT COUNT(*) FROM databasechangelog WHERE id = 'rollback-1'")) + .isZero(); + } + } + + @Test + void dataTypesRoundTripThroughLiquibaseInsert() throws Exception { + try (Connection conn = DriverManager.getConnection(jdbcUrl())) { + Liquibase liquibase = liquibase(conn, "liquibase/changelog-types.xml"); + + liquibase.update(new Contexts()); + + try (Statement s = conn.createStatement(); + ResultSet rs = s.executeQuery("SELECT flag, amount, ratio, counter, happened_at," + + " happened_on, happened_time, notes FROM lb_types WHERE id = 1")) { + assertThat(rs.next()).isTrue(); + assertThat(rs.getBoolean("flag")).isTrue(); + assertThat(rs.getBigDecimal("amount")).isEqualByComparingTo(new BigDecimal("1234.50")); + assertThat(rs.getDouble("ratio")).isEqualTo(0.25d); + assertThat(rs.getLong("counter")).isEqualTo(9007199254740993L); + assertThat(rs.getTimestamp("happened_at")).isEqualTo(Timestamp.valueOf("2024-01-02 03:04:05")); + assertThat(rs.getDate("happened_on").toLocalDate().toString()).isEqualTo("2024-01-02"); + assertThat(rs.getString("notes")).isEqualTo("hello clob"); + } + } + } + + @Test + void changeLogSyncMarksChangesAsRunWithoutExecutingThem() throws Exception { + try (Connection conn = DriverManager.getConnection(jdbcUrl())) { + Liquibase liquibase = liquibase(conn, "liquibase/changelog-sync.xml"); + + liquibase.changeLogSync(new Contexts()); + + assertThat(tableExists(conn, "lb_sync_table")).isFalse(); + assertThat(count(conn, "SELECT COUNT(*) FROM databasechangelog WHERE id = 'sync-1'")) + .isEqualTo(1); + } + } + + /** + * Pins the driver gap that stops Liquibase from selecting its SQLite + * dialect when an application lets Liquibase inspect the connection. + * + *

Liquibase's {@code SQLiteDatabase.isCorrectDatabaseImplementation} + * compares the product name with {@code equalsIgnoreCase("SQLite")}. The + * driver reports {@code "SQLite (libSQL)"}, so Liquibase silently falls + * back to {@link UnsupportedDatabase} and emits generic DDL such as + * {@code INT GENERATED BY DEFAULT AS IDENTITY}, which sqld rejects. + * Returning exactly {@code "SQLite"} and keeping the libSQL identity in + * {@code getDriverName()} would close the gap. + * + *

This assertion describes current behavior, not desired behavior. If + * it starts failing because the product name changed, remove this test and + * the explicit {@link SQLiteDatabase} workaround in {@link #liquibase}. + */ + @Test + void databaseFactoryFallsBackToUnsupportedDatabase() throws Exception { + try (Connection conn = DriverManager.getConnection(jdbcUrl())) { + assertThat(conn.getMetaData().getDatabaseProductName()).isEqualTo("SQLite (libSQL)"); + + Database database = DatabaseFactory.getInstance() + .findCorrectDatabaseImplementation(new JdbcConnection(conn)); + + assertThat(database).isInstanceOf(UnsupportedDatabase.class); + } + } + + /** + * Pins the second bootstrapping gap: Liquibase resolves a JDBC driver class + * from the URL before it opens a connection, and its SQLite implementation + * only claims the {@code jdbc:sqlite:} prefix. A {@code jdbc:libsql:} URL + * therefore needs an explicit driver class (or a {@code DataSource}). + * + *

This assertion describes current behavior. It is not a driver bug on + * its own, but it is the reason a Liquibase configuration needs + * {@code driver=io.github.magnusp.libsql.jdbc.LibsqlDriver} in addition to + * the URL. + */ + @Test + void databaseFactoryCannotResolveALibsqlUrlToADriver() { + assertThatThrownBy(() -> DatabaseFactory.getInstance() + .openDatabase(jdbcUrl(), null, null, null, new ClassLoaderResourceAccessor())) + .hasMessageContaining("Driver class was not specified") + .hasMessageContaining("jdbc:libsql:"); + } + + private static Liquibase liquibase(Connection conn, String changelog) throws Exception { + Database database = new SQLiteDatabase(); + database.setConnection(new JdbcConnection(conn)); + return new Liquibase(changelog, new ClassLoaderResourceAccessor(), database); + } + + private static boolean tableExists(Connection conn, String name) throws SQLException { + try (ResultSet rs = conn.getMetaData().getTables(null, null, name, new String[] {"TABLE"})) { + return rs.next(); + } + } + + private static boolean columnExists(Connection conn, String table, String column) throws SQLException { + try (ResultSet rs = conn.getMetaData().getColumns(null, null, table, column)) { + return rs.next(); + } + } + + private static List columnNames(Connection conn, String table) throws SQLException { + List names = new ArrayList<>(); + try (ResultSet rs = conn.getMetaData().getColumns(null, null, table, null)) { + while (rs.next()) { + names.add(rs.getString("COLUMN_NAME")); + } + } + return names; + } + + private static List indexNames(Connection conn, String table) throws SQLException { + List names = new ArrayList<>(); + try (ResultSet rs = conn.getMetaData().getIndexInfo(null, null, table, false, false)) { + while (rs.next()) { + names.add(rs.getString("INDEX_NAME")); + } + } + return names; + } + + private static List uniqueIndexNames(Connection conn, String table) throws SQLException { + List names = new ArrayList<>(); + try (ResultSet rs = conn.getMetaData().getIndexInfo(null, null, table, true, false)) { + while (rs.next()) { + names.add(rs.getString("INDEX_NAME")); + } + } + return names; + } + + private static int count(Connection conn, String sql) throws SQLException { + try (Statement s = conn.createStatement(); ResultSet rs = s.executeQuery(sql)) { + assertThat(rs.next()).isTrue(); + return rs.getInt(1); + } + } + + private static String queryString(Connection conn, String sql) throws SQLException { + try (Statement s = conn.createStatement(); ResultSet rs = s.executeQuery(sql)) { + assertThat(rs.next()).isTrue(); + return rs.getString(1); + } + } +} diff --git a/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/liquibase/LiquibaseSqldSupport.java b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/liquibase/LiquibaseSqldSupport.java new file mode 100644 index 0000000..3f606ac --- /dev/null +++ b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/liquibase/LiquibaseSqldSupport.java @@ -0,0 +1,49 @@ +package io.github.magnusp.libsql.jdbc.liquibase; + +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 java.time.Duration; + +/** + * A throwaway sqld container for the Liquibase integration tests. + * + *

Every test uses its own table and changeset names, so the tests can share + * one database without dropping each other's objects. Liquibase's own + * {@code databasechangelog} bookkeeping is filtered by changeset id where a + * test asserts on it. + */ +@Testcontainers +abstract class LiquibaseSqldSupport { + + 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); + } + + private static String requireLibsqlImage() { + String image = System.getProperty("libsql.image"); + if (image == null || image.isBlank()) { + throw new IllegalStateException( + "libsql.image is not set. Run the integration tests with Maven so the" + + " pinned sqld image from the root pom is used."); + } + return image; + } +} diff --git a/libsql-jdbc/src/test/resources/liquibase/changelog-alter.xml b/libsql-jdbc/src/test/resources/liquibase/changelog-alter.xml new file mode 100644 index 0000000..0da41f9 --- /dev/null +++ b/libsql-jdbc/src/test/resources/liquibase/changelog-alter.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/libsql-jdbc/src/test/resources/liquibase/changelog-fk.xml b/libsql-jdbc/src/test/resources/liquibase/changelog-fk.xml new file mode 100644 index 0000000..5909f35 --- /dev/null +++ b/libsql-jdbc/src/test/resources/liquibase/changelog-fk.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/libsql-jdbc/src/test/resources/liquibase/changelog-rollback.xml b/libsql-jdbc/src/test/resources/liquibase/changelog-rollback.xml new file mode 100644 index 0000000..2e54485 --- /dev/null +++ b/libsql-jdbc/src/test/resources/liquibase/changelog-rollback.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/libsql-jdbc/src/test/resources/liquibase/changelog-sync.xml b/libsql-jdbc/src/test/resources/liquibase/changelog-sync.xml new file mode 100644 index 0000000..1b0fe56 --- /dev/null +++ b/libsql-jdbc/src/test/resources/liquibase/changelog-sync.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + diff --git a/libsql-jdbc/src/test/resources/liquibase/changelog-types.xml b/libsql-jdbc/src/test/resources/liquibase/changelog-types.xml new file mode 100644 index 0000000..1c40b80 --- /dev/null +++ b/libsql-jdbc/src/test/resources/liquibase/changelog-types.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/libsql-jdbc/src/test/resources/liquibase/changelog-update.xml b/libsql-jdbc/src/test/resources/liquibase/changelog-update.xml new file mode 100644 index 0000000..b3d600d --- /dev/null +++ b/libsql-jdbc/src/test/resources/liquibase/changelog-update.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + UPDATE lb_person SET email = 'ada@example.com' WHERE name = 'Ada' + + diff --git a/pom.xml b/pom.xml index 527c204..9a95983 100644 --- a/pom.xml +++ b/pom.xml @@ -61,6 +61,7 @@ 3.27.7 2.0.5 7.1.0 + 5.0.3 3.21.9 4.36.2