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:
- To ensure we truly read from the database, and not the entity manager's in-memory cache, flush the object and clear the cache.
- As saving also updates the entity's id field, save a copy, so our original is untouched.
- Be careful to use
saveAndFlushon the Spring repository, rather thanentityManager.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:
- The generated
equalstakes id into account, but we are only interested in the data values, not the database administrivia. - 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:
Post a Comment