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
saveAndFlush
on 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
equals
takes 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