Sunday, July 29, 2018

Kotlin JPA patterns

Kotlin JPA pattern

The problem

Kotlin does many wonderful things for you. For example, the data classes create helpful constructors, and automatically implement equals and hashCode in a reasonable way.

Similarly, JPA works magic—expecially in the context of Spring Data.

So how do I test that my Kotlin entity is correctly annotated for JPA? The simplest thing would be a "round trip" test: create an entity, save it to a database, read it back, and confirm the object has the same values. Let's start with a simple entity, and the simplest possible test:

@Entity
data class Greeting(
        val content: String,
        @Id @GeneratedValue
        val: Int id = 0)
@DataJpaTest
@ExtendWith(SpringExtension::class)
internal class GreetingRepositoryIT(
        @Autowired val repository: GreetingRepository,
        @Autowired val entityManager: EntityManager) {
    @DisplayName("WHEN saving a greeting properly annotated")
    @Nested
    inner class Roundtrip {
        @Test
        fun `THEN is can be read back`() {
            val greeting = Greeting("Hello, world!")

            repository.saveAndFlush(greeting.copy())
            entityManager.clear()

            assertThat(repository.findOne(Example.of(greeting)).get())
                    .isEqualTo(greeting)
        }
    }
}

Some things to note:

  1. To ensure we truly read from the database, and not the entity manager's in-memory cache, flush the object and clear the cache.
  2. As saving also updates the entity's id field, save a copy, so our original is untouched.
  3. Be careful to use saveAndFlush on the Spring repository, rather than entityManager.flush(), which requires a transaction, and would add unneeded complexity to the test.

But this test fails! Why?

The unsaved entity (remember, we made a copy to keep the original pristine) does not have a value for id, and the entity read back does. Hence, the automatically generated equals method says the two objects differ because of id (null in the original vs some value from the database).

Further, the Spring Data QBE (QBE) search for our entity includes id in the search criteria. Even changing equals would not address this.

What to do?

The solution

It turns out we need to address two issues:

  1. The generated equals takes id into account, but we are only interested in the data values, not the database administrivia.
  2. The test look up in the database includes the SQL id column. Although we could try repository.getOne(saved.id), I'd prefer to keep using QBE, if the code is reasonable.

To address equals, we can rely on an interesting fact about Kotlin data classes: only default constructor parameters are used, not properties in the class body, when generating equals and hashCode. Hence, I write the entity like this, and equals does not include id, while JPA is stil happy as it relies on getter reflection:

@Entity
data class Greeting(
        val content: String) {
    @Id
    @GeneratedValue
    val id = 0
}

To address the test, we can ask QBE to ignore id when fetching our saved entity back from the database:

@DataJpaTest
@ExtendWith(SpringExtension::class)
internal class GreetingRepositoryIT(
        @Autowired val repository: GreetingRepository,
        @Autowired val entityManager: EntityManager) {
    @DisplayName("WHEN saving a greeting properly annotated")
    @Nested
    inner class Roundtrip {
        @Test
        fun `THEN is can be read back`() {
            val greeting = Greeting("Hello, world!")

            repository.saveAndFlush(greeting.copy())
            entityManager.clear()

            val matcher = ExampleMatcher.matching()
                    .withIgnoreNullValues()
                    .withIgnorePaths("id")
            val example = Example.of(greeting, matcher);

            assertThat(repository.findOne(example).get()).isEqualTo(greeting)
        }
    }
}

In a larger database, I'd look into providing an entity.asExample() to avoid duplicating ExampleMatcher in each test.

Java approach

The closest to Kotlin's data classes for JPA entities is Lombok's @Data annotation, together with @EqualsAndHashCode(exclude = "id") and @Builder(toBuilder = true), however the expressiveness is lower, and clutter higher.

The test would be largely the same modulo language, replacing greeting.copy() with greeting.toBuilder().build(). Alternatively, rather than a QBE matcher, one could write greeting.toBuilder().id(null).build().

This last fact leads to an alternative with Kotlin: include id in the data class' default constructor, and in the test compare the QBE result as findOne(example).get().copy(id = null) without a matcher.

Conclusion

What Kotlin JPA patterns have you discovered?

No comments: