I’m replying to myself, in the hope that someone may find this useful.
I came to the conclusion that it is:
- more convenient to always override equals() and hashCode() in the base entity class
- easier to use reflection to provide the business key and cache it in a companion object
- sometimes still useful to have a dataEquals() and dataHashCode() methods for comparison of all data fields
This setup forces data classes to implement the id and version properties which makes them available as componentN functions, in copy() and in toString(). By putting id and version at the end of the constructor argument list the copy() function and destructuring declarations become more convenient to use.
Some Remarks
An annotation to exclude properties from generated equals() / hashCode() methods would be faster, though. This is somewhat related to https://youtrack.jetbrains.com/issue/KT-4503, https://youtrack.jetbrains.com/issue/KT-12991 and https://youtrack.jetbrains.com/issue/KT-8466.
With entities it is easily possible to generate a circular reference via ManyToOne and OneToMany mappings in toString(), which will lead to https://youtrack.jetbrains.com/issue/KT-16244. Another annotation to exclude a property from the generated toString() method could help to prevent that.
JPA Base Class for Data Classes
internal inline fun <reified T> propertyEquals(first: T, second: Any?,
properties: Collection<KProperty1<out T, Any?>>): Boolean {
if (first === second) return true
if (second === null || second !is T) return false
return properties.map {
@Suppress("UNCHECKED_CAST")
it as KProperty1<T, Any?>
}.all { it.get(first) == it.get(second) }
}
internal fun <T> propertyHashCode(obj: T, properties: Collection<KProperty1<out T, Any?>>): Int =
properties.map {
@Suppress("UNCHECKED_CAST")
it as KProperty1<T, Any?>
}.map { it.get(obj) }.hashCode()
/**
* Base class for all JPA entities with id and version fields that are not used for object equality.
*/
@Access(AccessType.FIELD)
@MappedSuperclass
abstract class DbEntity : Serializable {
@get:[Access(AccessType.PROPERTY) Id GeneratedValue(strategy = GenerationType.AUTO)]
abstract var id: Long?
@get:[Access(AccessType.PROPERTY) Version]
abstract var version: LocalDateTime?
override final fun equals(other: Any?): Boolean =
if (equalityProperties.isEmpty()) super.equals(other) else propertyEquals(this, other, equalityProperties)
override final fun hashCode(): Int =
if (equalityProperties.isEmpty()) super.hashCode() else propertyHashCode(this, equalityProperties)
fun dataEquals(other: Any?): Boolean = propertyEquals(this, other, dataProperties)
fun dataHashCode(): Int = propertyHashCode(this, dataProperties)
private val equalityProperties: Collection<KProperty1<out DbEntity, Any?>>
get() = equalityPropertiesByType.computeIfAbsent(this::class, { _ -> equalityProperties() })
private val dataProperties: Collection<KProperty1<out DbEntity, Any?>>
get() = dataPropertiesByType.computeIfAbsent(this::class, { _ -> dataProperties() })
companion object {
private val equalityPropertiesByType:
MutableMap<KClass<out DbEntity>, Collection<KProperty1<out DbEntity, Any?>>> = mutableMapOf()
private val dataPropertiesByType:
MutableMap<KClass<out DbEntity>, Collection<KProperty1<out DbEntity, Any?>>> = mutableMapOf()
}
/**
* Return a list of equality properties that are used for equals and hashCode.
* The default is to return all declared public properties that are neither id nor version.
*/
protected open fun equalityProperties(): Collection<KProperty1<out DbEntity, Any?>> =
dataProperties().filter { it.name != "id" && it.name != "version" }
/**
* Return all declared public properties.
*/
private fun dataProperties(): Collection<KProperty1<out DbEntity, Any?>> =
this::class.declaredMemberProperties.filter { it.visibility === KVisibility.PUBLIC }
}
Example Usage
/**
* Customers can have identical names and multiple credit cards. (Uses all fields in equals() and hashCode()).
*/
@Entity
data class Customer(
@Column(length = 30, nullable = false) var firstName: String,
@Column(length = 30, nullable = false) var lastName: String,
@OneToMany(cascade = arrayOf(CascadeType.ALL)) val creditCards: MutableSet<CreditCard> = mutableSetOf(),
override var id: Long? = null,
override var version: LocalDateTime? = null
) : DbEntity()
/**
* A credit card is identified by per credit card issuer and number.
*/
@Entity
@Table(uniqueConstraints = arrayOf(UniqueConstraint(columnNames = arrayOf("cardIssuer", "number"))))
data class CreditCard(
@Column(length = 30, nullable = false) @Enumerated(EnumType.STRING) var cardIssuer: CreditCardCompany,
@Size(min = 16, max = 16) @Column(length = 16, nullable = false) var number: String,
@Size(min = 3, max = 3) @Column(length = 3, nullable = false) var verificationNumber: String,
override var id: Long? = null,
override var version: LocalDateTime? = null
) : DbEntity() {
override fun equalityProperties(): Collection<KProperty1<out DbEntity, Any?>> =
listOf(CreditCard::cardIssuer, CreditCard::number)
}
enum class CreditCardCompany {
VISA, MASTERCARD, AMERICAN_EXPRESS
}