diff --git a/libsql-jdbc/pom.xml b/libsql-jdbc/pom.xml index f4dbd2a..2dafd3d 100644 --- a/libsql-jdbc/pom.xml +++ b/libsql-jdbc/pom.xml @@ -38,6 +38,18 @@ ${flyway.version} test + + org.hibernate.orm + hibernate-core + ${hibernate.version} + test + + + org.hibernate.orm + hibernate-community-dialects + ${hibernate.version} + test + org.liquibase liquibase-core diff --git a/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/hibernate/Author.java b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/hibernate/Author.java new file mode 100644 index 0000000..c094635 --- /dev/null +++ b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/hibernate/Author.java @@ -0,0 +1,57 @@ +package io.github.magnusp.libsql.jdbc.hibernate; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.Version; + +@Entity +@Table(name = "author") +public class Author { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + @Column(nullable = false) + private String name; + + private String country; + + @Version + private long version; + + protected Author() {} + + public Author(String name, String country) { + this.name = name; + this.country = country; + } + + public Integer getId() { + return id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getCountry() { + return country; + } + + public void setCountry(String country) { + this.country = country; + } + + public long getVersion() { + return version; + } +} diff --git a/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/hibernate/Book.java b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/hibernate/Book.java new file mode 100644 index 0000000..085efae --- /dev/null +++ b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/hibernate/Book.java @@ -0,0 +1,83 @@ +package io.github.magnusp.libsql.jdbc.hibernate; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.Version; + +import java.math.BigDecimal; + +@Entity +@Table(name = "book") +public class Book { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "author_id", nullable = false) + private Author author; + + @Column(nullable = false) + private String title; + + private BigDecimal price; + + @Column(name = "in_print", nullable = false) + private boolean inPrint; + + @Version + private long version; + + protected Book() {} + + public Book(Author author, String title, BigDecimal price, boolean inPrint) { + this.author = author; + this.title = title; + this.price = price; + this.inPrint = inPrint; + } + + public Integer getId() { + return id; + } + + public Author getAuthor() { + return author; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public BigDecimal getPrice() { + return price; + } + + public void setPrice(BigDecimal price) { + this.price = price; + } + + public boolean isInPrint() { + return inPrint; + } + + public void setInPrint(boolean inPrint) { + this.inPrint = inPrint; + } + + public long getVersion() { + return version; + } +} diff --git a/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/hibernate/HibernateIT.java b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/hibernate/HibernateIT.java new file mode 100644 index 0000000..28079c5 --- /dev/null +++ b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/hibernate/HibernateIT.java @@ -0,0 +1,274 @@ +package io.github.magnusp.libsql.jdbc.hibernate; + +import jakarta.persistence.LockModeType; +import jakarta.persistence.OptimisticLockException; +import org.hibernate.Session; +import org.hibernate.SessionFactory; +import org.hibernate.StaleStateException; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.time.Instant; +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 Hibernate ORM, with a focus on optimistic + * locking. The probes assert what Hibernate needs from the driver rather than + * re-testing Hibernate itself. + */ +class HibernateIT extends HibernateSqldSupport { + + @Test + void persistsAndReloadsIdentityAndScalarTypes() { + Integer authorId = createAuthor("Ursula K. Le Guin", "US"); + Integer bookId = createBook(authorId, "A Wizard of Earthsea", "9.99", true); + + assertThat(authorId).as("identity generated key").isNotNull().isPositive(); + assertThat(bookId).as("identity generated key").isNotNull().isPositive(); + + read(session -> { + Author author = session.find(Author.class, authorId); + assertThat(author).isNotNull(); + assertThat(author.getName()).isEqualTo("Ursula K. Le Guin"); + assertThat(author.getCountry()).isEqualTo("US"); + assertThat(author.getVersion()).as("initial @Version").isZero(); + + Book book = session.find(Book.class, bookId); + assertThat(book).isNotNull(); + assertThat(book.getTitle()).isEqualTo("A Wizard of Earthsea"); + assertThat(book.getPrice()).isEqualByComparingTo("9.99"); + assertThat(book.isInPrint()).isTrue(); + assertThat(book.getAuthor().getId()).isEqualTo(authorId); + assertThat(book.getVersion()).as("initial @Version").isZero(); + return null; + }); + } + + @Test + void versionIncrementsOnUpdate() { + Integer authorId = createAuthor("Octavia E. Butler", "US"); + + inTransaction((Session session) -> { + session.find(Author.class, authorId).setCountry("USA"); + }); + + read(session -> { + Author author = session.find(Author.class, authorId); + assertThat(author.getCountry()).isEqualTo("USA"); + assertThat(author.getVersion()) + .as("Hibernate must bump @Version on a successful update") + .isEqualTo(1); + return null; + }); + } + + @Test + void versionedBookUpdateIncrementsVersion() { + Integer authorId = createAuthor("Book Version", "SE"); + Integer bookId = createBook(authorId, "First Edition", "9.99", true); + + inTransaction((Session session) -> { + Book book = session.find(Book.class, bookId); + book.setPrice(new BigDecimal("12.50")); + book.setInPrint(false); + }); + + read(session -> { + Book book = session.find(Book.class, bookId); + assertThat(book.getPrice()).isEqualByComparingTo("12.50"); + assertThat(book.isInPrint()).isFalse(); + assertThat(book.getVersion()) + .as("a versioned update through a foreign-key association must succeed") + .isEqualTo(1); + return null; + }); + } + + @Test + void staleMergeOfADetachedEntityFails() { + Integer authorId = createAuthor("Stale Merge", "US"); + + Author detached = read(session -> session.find(Author.class, authorId)); + assertThat(detached.getVersion()).isZero(); + + inTransaction((Session session) -> { + session.find(Author.class, authorId).setCountry("SE"); + }); + + detached.setCountry("NO"); + assertThatThrownBy(() -> inTransaction((Session session) -> { + session.merge(detached); + })) + .as("merge of a detached entity with an old @Version must fail") + .isInstanceOfAny(OptimisticLockException.class, StaleStateException.class); + + read(session -> { + Author author = session.find(Author.class, authorId); + assertThat(author.getCountry()).as("the stale merge must not land").isEqualTo("SE"); + assertThat(author.getVersion()).isEqualTo(1); + return null; + }); + } + + @Test + void staleDeleteFails() { + Integer authorId = createAuthor("Stale Delete", "US"); + Author stale = read(session -> session.find(Author.class, authorId)); + + inTransaction((Session session) -> { + session.remove(session.find(Author.class, authorId)); + }); + + assertThatThrownBy(() -> inTransaction((Session session) -> { + session.remove(stale); + })) + .as("the versioned DELETE matches no row; Hibernate needs the driver's" + + " update count to be exactly 0") + .isInstanceOfAny(OptimisticLockException.class, StaleStateException.class); + + read(session -> { + assertThat(session.find(Author.class, authorId)) + .as("the row stays deleted") + .isNull(); + return null; + }); + } + + @Test + void optimisticForceIncrementBumpsVersionWithoutAFieldChange() { + Integer authorId = createAuthor("Force Increment", "US"); + long before = read(session -> session.find(Author.class, authorId).getVersion()); + + inTransaction((Session session) -> { + session.find(Author.class, authorId, LockModeType.OPTIMISTIC_FORCE_INCREMENT); + }); + + read(session -> { + Author author = session.find(Author.class, authorId); + assertThat(author.getVersion()) + .as("explicit optimistic force-increment must go through the same" + + " versioned UPDATE path") + .isEqualTo(before + 1); + return null; + }); + } + + @Test + void hibernateSchemaValidationAcceptsTheDriverMetadata() { + // A second SessionFactory validates the tables the first one created. Hibernate + // reads them back through DatabaseMetaData, so this exercises the driver's + // metadata instead of Hibernate's own DDL. + try (SessionFactory validating = configuration() + .setProperty("hibernate.hbm2ddl.auto", "validate") + .buildSessionFactory()) { + assertThat(validating.isOpen()).isTrue(); + } + } + + @Test + void hqlJoinAndPaginationUseTheDriversLimitSupport() { + Integer authorId = createAuthor("Pagination Author", "SE"); + createBook(authorId, "Volume 1", "1.00", true); + createBook(authorId, "Volume 2", "2.00", true); + createBook(authorId, "Volume 3", "3.00", false); + + read(session -> { + List page = session + .createQuery("select b from Book b join fetch b.author" + + " where b.inPrint = true order by b.title", Book.class) + .setFirstResult(1) + .setMaxResults(2) + .getResultList(); + + assertThat(page) + .extracting(Book::getTitle) + .containsExactly("Volume 2"); + assertThat(page.getFirst().getAuthor().getName()).isEqualTo("Pagination Author"); + return null; + }); + } + + @Test + void batchInsertKeepsIdentityAndVersion() { + Integer authorId = createAuthor("Batch Author", "SE"); + + inTransaction((Session session) -> { + Author author = session.find(Author.class, authorId); + for (int i = 1; i <= 5; i++) { + session.persist(new Book(author, "Batch " + i, new BigDecimal(i + ".00"), true)); + } + }); + + read(session -> { + List books = session.createQuery( + "select b from Book b where b.author.id = :authorId order by b.title", + Book.class) + .setParameter("authorId", authorId) + .getResultList(); + assertThat(books).hasSize(5); + assertThat(books).allSatisfy(book -> { + assertThat(book.getId()).isNotNull().isPositive(); + assertThat(book.getVersion()).isZero(); + }); + return null; + }); + } + + @Test + void timestampVersionRoundTripsAndIncrementsOnUpdate() { + Integer noteId = inTransaction(session -> { + Note note = new Note("first"); + session.persist(note); + return note.getId(); + }); + + Instant firstVersion = read(session -> session.find(Note.class, noteId).getVersion()); + assertThat(firstVersion).as("Hibernate seeds the timestamp version on insert").isNotNull(); + + inTransaction((Session session) -> { + session.find(Note.class, noteId).setText("second"); + }); + + read(session -> { + Note note = session.find(Note.class, noteId); + assertThat(note.getText()).isEqualTo("second"); + assertThat(note.getVersion()) + .as("the versioned UPDATE must match the stored timestamp text exactly") + .isAfter(firstVersion); + return null; + }); + } + + @Test + void staleTimestampVersionMergeFails() { + Integer noteId = inTransaction(session -> { + Note note = new Note("original"); + session.persist(note); + return note.getId(); + }); + + Note stale = read(session -> session.find(Note.class, noteId)); + Instant staleVersion = stale.getVersion(); + + inTransaction((Session session) -> { + session.find(Note.class, noteId).setText("current"); + }); + + stale.setText("stale"); + assertThatThrownBy(() -> inTransaction((Session session) -> { + session.merge(stale); + })) + .as("the versioned UPDATE must match no row once the timestamp moved") + .isInstanceOfAny(OptimisticLockException.class, StaleStateException.class); + + read(session -> { + Note note = session.find(Note.class, noteId); + assertThat(note.getText()).isEqualTo("current"); + assertThat(note.getVersion()).isAfter(staleVersion); + return null; + }); + } +} diff --git a/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/hibernate/HibernateSqldSupport.java b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/hibernate/HibernateSqldSupport.java new file mode 100644 index 0000000..f31ecdd --- /dev/null +++ b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/hibernate/HibernateSqldSupport.java @@ -0,0 +1,174 @@ +package io.github.magnusp.libsql.jdbc.hibernate; + +import org.hibernate.Session; +import org.hibernate.SessionFactory; +import org.hibernate.Transaction; +import org.hibernate.cfg.Configuration; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +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.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; +import java.sql.Statement; +import java.time.Duration; +import java.util.function.Consumer; +import java.util.function.Function; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * A throwaway sqld container and Hibernate bootstrap for the ORM probes. + * + *

The schema is created through JDBC so the probes exercise Hibernate's runtime + * JDBC use rather than its SQLite DDL output. The validator test still reads the + * schema back through the driver's metadata. + * + *

Hibernate generates SQLite identities with a separate + * {@code select last_insert_rowid()}, so the probes keep auto-commit off. Issue #83 + * records why that query cannot see an insert from an earlier auto-commit statement. + */ +@Testcontainers +abstract class HibernateSqldSupport { + + 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 Configuration configuration() { + return new Configuration() + .addAnnotatedClass(Author.class) + .addAnnotatedClass(Book.class) + .addAnnotatedClass(Note.class) + .setProperty("jakarta.persistence.jdbc.url", jdbcUrl()) + .setProperty("hibernate.dialect", "org.hibernate.community.dialect.SQLiteDialect") + .setProperty("hibernate.connection.autocommit", "false") + .setProperty("hibernate.show_sql", "false") + .setProperty("hibernate.format_sql", "false") + .setProperty("hibernate.highlight_sql", "false"); + } + + static SessionFactory sessionFactory; + + @BeforeAll + static void createSessionFactory() throws SQLException { + createSchema(); + sessionFactory = configuration() + .setProperty("hibernate.hbm2ddl.auto", "none") + .buildSessionFactory(); + } + + private static void createSchema() throws SQLException { + try (Connection connection = DriverManager.getConnection(jdbcUrl()); + Statement statement = connection.createStatement()) { + statement.execute("create table if not exists author (" + + " id integer primary key autoincrement," + + " version bigint not null," + + " country varchar(255)," + + " name varchar(255) not null)"); + statement.execute("create table if not exists book (" + + " id integer primary key autoincrement," + + " author_id integer not null references author (id)," + + " version bigint not null," + + " in_print boolean not null," + + " price numeric(38, 2)," + + " title varchar(255) not null)"); + statement.execute("create table if not exists note (" + + " id integer primary key autoincrement," + + " version timestamp not null," + + " text varchar(255) not null)"); + } + } + + @AfterAll + static void closeSessionFactory() { + if (sessionFactory != null) { + sessionFactory.close(); + } + } + + @BeforeEach + void cleanDatabase() { + inTransaction(session -> { + session.createMutationQuery("delete from Note").executeUpdate(); + session.createMutationQuery("delete from Book").executeUpdate(); + session.createMutationQuery("delete from Author").executeUpdate(); + }); + } + + static void inTransaction(Consumer work) { + inTransaction(session -> { + work.accept(session); + return null; + }); + } + + static T inTransaction(Function work) { + try (Session session = sessionFactory.openSession()) { + Transaction transaction = session.beginTransaction(); + try { + T result = work.apply(session); + transaction.commit(); + return result; + } catch (RuntimeException e) { + if (transaction.isActive()) { + transaction.rollback(); + } + throw e; + } + } + } + + static T read(Function work) { + try (Session session = sessionFactory.openSession()) { + return work.apply(session); + } + } + + static Integer createAuthor(String name, String country) { + return inTransaction(session -> { + Author author = new Author(name, country); + session.persist(author); + return author.getId(); + }); + } + + static Integer createBook(Integer authorId, String title, String price, boolean inPrint) { + return inTransaction(session -> { + Author author = session.find(Author.class, authorId); + Book book = new Book(author, title, new java.math.BigDecimal(price), inPrint); + session.persist(book); + return book.getId(); + }); + } + + 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/java/io/github/magnusp/libsql/jdbc/hibernate/Note.java b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/hibernate/Note.java new file mode 100644 index 0000000..ae3c924 --- /dev/null +++ b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/hibernate/Note.java @@ -0,0 +1,48 @@ +package io.github.magnusp.libsql.jdbc.hibernate; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.Version; + +import java.time.Instant; + +@Entity +@Table(name = "note") +public class Note { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + @Column(nullable = false) + private String text; + + @Version + private Instant version; + + protected Note() {} + + public Note(String text) { + this.text = text; + } + + public Integer getId() { + return id; + } + + public String getText() { + return text; + } + + public void setText(String text) { + this.text = text; + } + + public Instant getVersion() { + return version; + } +} diff --git a/pom.xml b/pom.xml index 2b51152..70ef908 100644 --- a/pom.xml +++ b/pom.xml @@ -62,6 +62,7 @@ 2.0.5 7.1.0 13.12.0 + 7.4.13.Final 5.0.3 3.21.9 4.36.2