Kotlin Data Classes With JPA
Want to learn more about implementing data classes with the Java Persistence API (JPA)? Check out this post to learn more about using data classes in Kotlin.
Join the DZone community and get the full member experience.
Join For FreeIntroduction
In Kotlin, data classes are handy and provide default implementation for equals()
, hashCode()
, copy()
, and toString()
. You get the implementation of these functions free of charge. For standard value classes, they are excellent, but you still need to understand what is going on particularly when using this feature within enterprise frameworks, such as Spring, sundry, and the widely-used Jakarta EE technologies, such JAX-RS or JPA. Because if you do not, you may get more or less than what you bargained for.
What Is a Data Class?
In a Kotlin data class, all properties that you declare in the primary constructor will be considered in the aforementioned functions.
Therefore, in the class:
data class Book(val isdn: String, val title: String)
Both properties isdn
and title
will be considered. This is good for toString()
and copy()
; the information provided is clearly helpful both to programmers and operations staff. For JPA, however, we need more-considered hashCode()
and equals()
implementations. In the above class in a JPA setting, title would presumably be a superfluous value in those two methods. If we modify the class for JPA under the consideration of the recommended equals()
and hashCode()
implementations (see the article by Hibernate guru Vlad Mihalcea here), it would look like this:
package test.model
import javax.persistence.Entity
import javax.persistence.Id
/**
* For JPA, we only relevant property for equals and hashCode is the isdn, so that
* is the only thing that goes into the primary constructor. This comes at the cost
* of having an insufficient copy and equals method. The benefit of a Kotlin data class
* is compromised.
*/
@Entity
data class JpaBook(@Id val isdn: String = "undefined") {
var title : String = ""
// note this unpretty hack to get immutability back
// since we cannot use this property in the primary constructor
private set
constructor(isdn: String, title: String) : this(isdn) {
this.title = title
}
}
We removed the title from the primary constructor, which is good for JPA, But, we broke toString()
in the process. We are missing desired information, namely the title of the book, which is information that could have proved quite helpful in debugging.
@Test
fun `demonstrate that toString is not what we actually want`() {
val isdn = "978-3-16-148410-0"
val title = "Wuthering Heights"
val book = JpaBook(isdn, title)
print(book)
// here we are asserting that toString is _not_ what we want.
assertThat(book.toString(), Is(not("Book(isdn=$isdn, title=$title)")))
}
Not only that, but we also broke the useful copy()
magic:
/**
* This is a Kotlin data class API exerciser, which illustrates
* how the interests of an entity class and a Kotlin data class conflict.
* The copy() function, in the case of a JpaBook, does not allow you to copy the title.
*/
@Test
fun `copy does not handle properties that are not in the primary constructor`() {
// Can copy both properties
val classicalKotlinDataClassBookFunctions = Book::class.functions
val functionNamesOfClassicalDataClassBook: MutableList<String> = mutableListOf()
classicalKotlinDataClassBookFunctions.mapTo(functionNamesOfClassicalDataClassBook) { it.toString() }
assertThat(functionNamesOfClassicalDataClassBook, hasItem("fun test.model.Book.copy(kotlin.String, kotlin.String): test.model.Book"))
// Cannot copy both properties
val jpaBookFunctions = JpaBook::class.functions
val functionNamesOfJpaBook: MutableList<String> = mutableListOf()
jpaBookFunctions.mapTo(functionNamesOfJpaBook) { it.toString() }
assertThat(functionNamesOfJpaBook, not(hasItem("fun test.model.Book.copy(kotlin.String, kotlin.String): test.model.Book")))
}
Finally, we have broken Kotlin support for immutability and have had to provide a workaround by making the setter private.
The issue is that, in a common JPA setting, equals()/hashCode() satisfy one need, toString() another, and copy() yet a third. But in Kotlin, data class implementation is all jumbled together. We need to unravel this coupling.
Business Keys
If you have the lucky situation in which say a single field satisfies what you need for hashCode()
and equals()
, such as a business ID, then you can declare this in the primary constructor. In Hibernate, one would annotate such a field with @NaturalId
. In order to achieve non-nullable fields, there are several approaches. First, you can implement secondary constructors with default values, or you can simply declare the values as properties. Doing so will, however, break immutability.
package test.model
import au.com.console.kassava.kotlinEquals
import org.hibernate.annotations.NaturalId
import java.util.*
import javax.persistence.*
/**
* Hibernate has a nifty @NaturalId annotation, and as such we must
* override equals and hashCode to use that and only that. If you are not familiar
* with NaturalId, it is essentially a business key, or what is also known as a
* friendly id.
* <p>
* The isdn field, which is marked as a NaturalId, is never null, and as such we can use it.
* <p>
* Note how we are not using id in equals or hashCode, and cannot have it in the constructor
* for that reason.
* <p>
* @author S Gertiser, created 2018-08-31.
*/
@Entity
@EntityListeners(UuidPopulatorPersistenceListener::class)
@Access(AccessType.FIELD)
data class BookWithNaturalId(
/** Friendly or business key */
@NaturalId
val isbn: String = "undefined",
val title: String = "undefined" ) : Identifiable<String> {
private var _id: String = "undefined"
@Id
@Access(AccessType.PROPERTY)
override fun getId(): String {
return _id
}
override fun setId(id: String) {
_id = id
}
// Here we optionally define properties for equals in a companion object.
// In this way, Kotlin will generate fewer KProperty classes,
// and we won't have array creation for every method call.
// More on this later ...
companion object {
private val equalsProperties = arrayOf(BookWithNaturalId::isbn)
}
override fun equals(other: Any?) = kotlinEquals(other = other, properties = equalsProperties)
/**
* isbn is always unique and is the only thing we need in hashCode.
*/
override fun hashCode(): Int {
return Objects.hash(isbn)
}
}
Solution
There is nothing stopping you from overriding any of the four methods declared to be the subject of attention for data classes. That can become onerous over time, not to mention it begins to detract from the readability and conciseness of our code, which was a primary motivator for JetBrains to invent the data class construct.
The open-source solution kassava can provide more flexibility for Kotlin data classes. The above examples used kassava to granularly define equals()
and toString()
where they necessarily diverged from standard data class primary constructor usage.
Conclusion
In the context of enterprise frameworks, such as JPA, Kotlin data classes are too generically defined as advisable for implicitly defining the properties used in the equals()
, hashCode()
, copy()
, and toString()
functions in most cases. By using a handy extension, we overcome these limitations. Kotlin data classes are certainly no worse than a standard class, in which the defaults are also rarely suitable. See the reference for full source code including extensive test cases.
Opinions expressed by DZone contributors are their own.
Comments