From 25efbccdd62ac509b341800ff0315e18d05d9902 Mon Sep 17 00:00:00 2001 From: Jochen Just Date: Fri, 15 Sep 2023 17:14:52 +0200 Subject: [PATCH] Example for Micronaut Data JPA --- README.adoc | 2 +- ...nt-micronaut.kotlin-conventions.gradle.kts | 1 + examples/data-jpa/README.adoc | 5 ++ examples/data-jpa/build.gradle.kts | 23 +++++ .../kotlin/example/data/jpa/model/Book.kt | 15 ++++ .../kotlin/example/data/jpa/model/Isbn.kt | 9 ++ .../data/jpa/model/NoArgConstructor.kt | 5 ++ .../kotlin/example/data/jpa/model/Title.kt | 8 ++ .../data/jpa/persistence/BookEntity.kt | 42 +++++++++ .../jpa/persistence/BookEntityRepository.kt | 13 +++ .../data/jpa/persistence/Converters.kt | 23 +++++ .../src/main/resources/application.properties | 6 ++ .../resources/db/migration/V1__schema.sql | 8 ++ .../persistence/BookEntityRepositoryTest.kt | 86 +++++++++++++++++++ settings.gradle.kts | 1 + 15 files changed, 246 insertions(+), 1 deletion(-) create mode 100644 examples/data-jpa/README.adoc create mode 100644 examples/data-jpa/build.gradle.kts create mode 100644 examples/data-jpa/src/main/kotlin/example/data/jpa/model/Book.kt create mode 100644 examples/data-jpa/src/main/kotlin/example/data/jpa/model/Isbn.kt create mode 100644 examples/data-jpa/src/main/kotlin/example/data/jpa/model/NoArgConstructor.kt create mode 100644 examples/data-jpa/src/main/kotlin/example/data/jpa/model/Title.kt create mode 100644 examples/data-jpa/src/main/kotlin/example/data/jpa/persistence/BookEntity.kt create mode 100644 examples/data-jpa/src/main/kotlin/example/data/jpa/persistence/BookEntityRepository.kt create mode 100644 examples/data-jpa/src/main/kotlin/example/data/jpa/persistence/Converters.kt create mode 100644 examples/data-jpa/src/main/resources/application.properties create mode 100644 examples/data-jpa/src/main/resources/db/migration/V1__schema.sql create mode 100644 examples/data-jpa/src/test/kotlin/example/data/jpa/persistence/BookEntityRepositoryTest.kt diff --git a/README.adoc b/README.adoc index 3037e4e..79a6efa 100644 --- a/README.adoc +++ b/README.adoc @@ -13,7 +13,7 @@ scale projects. ** link:examples/caching[Caching] ** link:examples/http-server[HTTP-Server] ** link:examples/graphql[GraphQL] -** Micronaut-Data (tbd) +** link:examples/data-jpa[Micronaut-Data] using JPA ** link:examples/rabbitmq[RabbitMQ] ** Kafka (tbd) ** Security (tbd) diff --git a/buildSrc/src/main/kotlin/cnt-micronaut.kotlin-conventions.gradle.kts b/buildSrc/src/main/kotlin/cnt-micronaut.kotlin-conventions.gradle.kts index 183f74a..3eae8a8 100644 --- a/buildSrc/src/main/kotlin/cnt-micronaut.kotlin-conventions.gradle.kts +++ b/buildSrc/src/main/kotlin/cnt-micronaut.kotlin-conventions.gradle.kts @@ -16,4 +16,5 @@ dependencies { implementation("org.jetbrains.kotlin:kotlin-reflect:${kotlinVersion}") implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:${kotlinVersion}") testImplementation("io.mockk:mockk") + testImplementation("io.kotest:kotest-assertions-core-jvm") } diff --git a/examples/data-jpa/README.adoc b/examples/data-jpa/README.adoc new file mode 100644 index 0000000..66c89cf --- /dev/null +++ b/examples/data-jpa/README.adoc @@ -0,0 +1,5 @@ += Micronaut: Data JDBC + +Showcase demonstrating how to test JPA repositories. + +This showcase uses https://github.com/micronaut-projects/micronaut-test-resources[Micronaut test resources]. Have a look at `build.gradle.kts` to see an easy and painless way to automatically provide a database during tests. diff --git a/examples/data-jpa/build.gradle.kts b/examples/data-jpa/build.gradle.kts new file mode 100644 index 0000000..d1aafb9 --- /dev/null +++ b/examples/data-jpa/build.gradle.kts @@ -0,0 +1,23 @@ +plugins { + id("cnt-micronaut.micronaut-conventions") + // this plugin automatically uses TestContainers to provide a postgres database for tests + id("io.micronaut.test-resources") + kotlin("plugin.jpa") version "1.8.22" + kotlin("plugin.noarg") version "1.8.22" +} + +dependencies { + ksp("io.micronaut.data:micronaut-data-processor") + implementation("io.micronaut.data:micronaut-data-hibernate-jpa") + implementation("io.micronaut.flyway:micronaut-flyway") + implementation("io.micronaut.sql:micronaut-jdbc-hikari") + runtimeOnly("org.postgresql:postgresql") +} + +application { + mainClass.set("example.micronaut.DataApplicationKt") +} + +noArg { + annotation("example.data.jpa.model.NoArgConstructor") +} diff --git a/examples/data-jpa/src/main/kotlin/example/data/jpa/model/Book.kt b/examples/data-jpa/src/main/kotlin/example/data/jpa/model/Book.kt new file mode 100644 index 0000000..e793924 --- /dev/null +++ b/examples/data-jpa/src/main/kotlin/example/data/jpa/model/Book.kt @@ -0,0 +1,15 @@ +package example.data.jpa.model + +import example.data.jpa.persistence.IsbnConverter +import example.data.jpa.persistence.TitleConverter +import jakarta.persistence.Convert +import jakarta.persistence.Embeddable + +@NoArgConstructor +@Embeddable +data class Book( + @field:Convert (converter = IsbnConverter::class) + val isbn: Isbn, + @field:Convert (converter = TitleConverter::class) + val title: Title +) diff --git a/examples/data-jpa/src/main/kotlin/example/data/jpa/model/Isbn.kt b/examples/data-jpa/src/main/kotlin/example/data/jpa/model/Isbn.kt new file mode 100644 index 0000000..82eb4ef --- /dev/null +++ b/examples/data-jpa/src/main/kotlin/example/data/jpa/model/Isbn.kt @@ -0,0 +1,9 @@ +package example.data.jpa.model + +private val pattern = Regex("([0-9]{3}(-)?)?[0-9]{10}") + +data class Isbn(val value: String) { + init { + require(value matches pattern) { "ISBN [$value] must match pattern [$pattern]!" } + } +} diff --git a/examples/data-jpa/src/main/kotlin/example/data/jpa/model/NoArgConstructor.kt b/examples/data-jpa/src/main/kotlin/example/data/jpa/model/NoArgConstructor.kt new file mode 100644 index 0000000..70a729a --- /dev/null +++ b/examples/data-jpa/src/main/kotlin/example/data/jpa/model/NoArgConstructor.kt @@ -0,0 +1,5 @@ +package example.data.jpa.model + +@Retention +@Target(AnnotationTarget.CLASS) +annotation class NoArgConstructor diff --git a/examples/data-jpa/src/main/kotlin/example/data/jpa/model/Title.kt b/examples/data-jpa/src/main/kotlin/example/data/jpa/model/Title.kt new file mode 100644 index 0000000..e327a45 --- /dev/null +++ b/examples/data-jpa/src/main/kotlin/example/data/jpa/model/Title.kt @@ -0,0 +1,8 @@ +package example.data.jpa.model + +data class Title(val value: String) { + init { + require(value.isNotBlank()) { "Titles must not be blank!" } + require(value.length <= 100) { "Titles longer than 100 characters are not allowed!" } + } +} diff --git a/examples/data-jpa/src/main/kotlin/example/data/jpa/persistence/BookEntity.kt b/examples/data-jpa/src/main/kotlin/example/data/jpa/persistence/BookEntity.kt new file mode 100644 index 0000000..486b4cc --- /dev/null +++ b/examples/data-jpa/src/main/kotlin/example/data/jpa/persistence/BookEntity.kt @@ -0,0 +1,42 @@ +package example.data.jpa.persistence + +import example.data.jpa.model.Book +import jakarta.persistence.Embedded +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.Table +import jakarta.persistence.Version +import java.util.UUID + +@Entity +@Table(name = "books") +class BookEntity( + @Id + val id: UUID, + @Embedded + var book: Book, + @Version + val version: Int = 0 +) { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as BookEntity + + if (id != other.id) return false + if (book != other.book) return false + if (version != other.version) return false + + return true + } + + override fun hashCode(): Int { + var result = id.hashCode() + result = 31 * result + book.hashCode() + result = 31 * result + version.hashCode() + return result + } +} + diff --git a/examples/data-jpa/src/main/kotlin/example/data/jpa/persistence/BookEntityRepository.kt b/examples/data-jpa/src/main/kotlin/example/data/jpa/persistence/BookEntityRepository.kt new file mode 100644 index 0000000..cb19481 --- /dev/null +++ b/examples/data-jpa/src/main/kotlin/example/data/jpa/persistence/BookEntityRepository.kt @@ -0,0 +1,13 @@ +package example.data.jpa.persistence + +import example.data.jpa.model.Title +import io.micronaut.data.annotation.Query +import io.micronaut.data.annotation.Repository +import io.micronaut.data.jpa.repository.JpaRepository +import java.util.UUID + +@Repository +interface BookEntityRepository : JpaRepository { + @Query("SELECT b FROM BookEntity b WHERE b.book.title = :title") + fun findByTitle(title: Title): List +} diff --git a/examples/data-jpa/src/main/kotlin/example/data/jpa/persistence/Converters.kt b/examples/data-jpa/src/main/kotlin/example/data/jpa/persistence/Converters.kt new file mode 100644 index 0000000..8586e17 --- /dev/null +++ b/examples/data-jpa/src/main/kotlin/example/data/jpa/persistence/Converters.kt @@ -0,0 +1,23 @@ +package example.data.jpa.persistence + +import example.data.jpa.model.Isbn +import example.data.jpa.model.Title +import jakarta.inject.Singleton +import jakarta.persistence.AttributeConverter +import jakarta.persistence.Converter + +@Singleton +@Converter +class TitleConverter : AttributeConverter { + override fun convertToDatabaseColumn(attribute: Title?) = attribute?.value + + override fun convertToEntityAttribute(dbData: String?) = dbData?.let(::Title) +} + +@Singleton +@Converter +class IsbnConverter : AttributeConverter { + override fun convertToDatabaseColumn(attribute: Isbn?) = attribute?.value + + override fun convertToEntityAttribute(dbData: String?) = dbData?.let(::Isbn) +} diff --git a/examples/data-jpa/src/main/resources/application.properties b/examples/data-jpa/src/main/resources/application.properties new file mode 100644 index 0000000..2cecf8a --- /dev/null +++ b/examples/data-jpa/src/main/resources/application.properties @@ -0,0 +1,6 @@ +micronaut.application.name=cnt-micronaut.data-jpa +datasources.default.db-type=postgres +datasources.default.dialect=POSTGRES +jpa.default.properties.hibernate.hbm2ddl.auto=none +datasources.default.driver-class-name=org.postgresql.Driver +flyway.datasources.default.enabled=true diff --git a/examples/data-jpa/src/main/resources/db/migration/V1__schema.sql b/examples/data-jpa/src/main/resources/db/migration/V1__schema.sql new file mode 100644 index 0000000..c6cca2f --- /dev/null +++ b/examples/data-jpa/src/main/resources/db/migration/V1__schema.sql @@ -0,0 +1,8 @@ +CREATE TABLE books +( + id uuid, + title text, + isbn varchar(13), + version bigint, + PRIMARY KEY (id) +) diff --git a/examples/data-jpa/src/test/kotlin/example/data/jpa/persistence/BookEntityRepositoryTest.kt b/examples/data-jpa/src/test/kotlin/example/data/jpa/persistence/BookEntityRepositoryTest.kt new file mode 100644 index 0000000..17326aa --- /dev/null +++ b/examples/data-jpa/src/test/kotlin/example/data/jpa/persistence/BookEntityRepositoryTest.kt @@ -0,0 +1,86 @@ +package example.data.jpa.persistence + +import example.data.jpa.model.Book +import example.data.jpa.model.Isbn +import example.data.jpa.model.Title +import io.kotest.assertions.throwables.shouldThrowExactly +import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder +import io.kotest.matchers.optional.shouldBePresent +import io.kotest.matchers.shouldBe +import io.micronaut.test.extensions.junit5.annotation.MicronautTest +import org.junit.jupiter.api.Disabled +import org.junit.jupiter.api.Test +import kotlin.random.Random.Default.nextInt +import java.util.UUID + +@MicronautTest +class BookEntityRepositoryTest( + private val cut: BookEntityRepository +) { + + @Test + fun `entity can be saved`() { + val entity = bookRecordEntity() + + val savedEntity = cut.save(entity) + + savedEntity.shouldBe(entity) + } + + @Test + fun `entity version is increased with every change`() { + // Micronaut never returns a new Object after save, but applies all changes in place + // @Version functionality is only triggered after a flush hence we call saveAndFlush + val entity = bookRecordEntity() + + val savedEntity1 = cut.saveAndFlush(entity) + savedEntity1.version.shouldBe(0) + + val savedEntity2 = cut.saveAndFlush(savedEntity1.changeTitle()) + savedEntity2.version.shouldBe(1) + + val savedEntity3 = cut.saveAndFlush(savedEntity2) + savedEntity3.version.shouldBe(1) + + val savedEntity4 = cut.saveAndFlush(savedEntity3.changeTitle()) + savedEntity4.version.shouldBe(2) + } + + @Test + @Disabled + // TODO Any idea whether how to test this? It is not easy, as Micronaut only has a single object + // with the same Identifier. + fun `entity can not be saved in lower than current version`() { + val entity = bookRecordEntity() + val savedEntity1 = cut.save(entity) + cut.save(savedEntity1.changeTitle()) + + shouldThrowExactly { cut.save(savedEntity1) } + } + + @Test + fun `entity can be found by id`() { + val savedEntity = cut.save(bookRecordEntity()) + + val foundEntity = cut.findById(savedEntity.id) + + foundEntity.shouldBePresent().shouldBe(savedEntity) + } + + @Test + fun `entity can be found by title`() { + val e1 = cut.save(bookRecordEntity("Clean Code")) + cut.save(bookRecordEntity("Clean Architecture")) + val e3 = cut.save(bookRecordEntity("Clean Code")) + + val foundEntities = cut.findByTitle(Title("Clean Code")) + + foundEntities.shouldContainExactlyInAnyOrder(e1, e3) + } + + private fun bookRecordEntity(title: String = "Clean Code") = + BookEntity(UUID.randomUUID(), Book(Isbn("9780123456789"), Title(title))) + + private fun BookEntity.changeTitle(): BookEntity = + apply { book = book.copy(title = Title("Change Title #${nextInt(1_000)}")) } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 821e642..e31b037 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -5,3 +5,4 @@ include("examples:http-server") include("examples:caching") include("examples:graphql") include("examples:rabbitmq") +include("examples:data-jpa")