Data Classes equals() and hashCode() for use with JPA

JPA has several dependencies on equals() and hashCode(), see JBoss: Equals and HashCode.

I was looking for a way to marry the practically usable features of data classes (copy method, toString, componentN methods) with the equals() and hashCode() demands from JPA. After several rounds of de-compiling Kotlin to Java I wonder if JPA had any consideration in designing data classes, because one has to be quite cautious when using them.

In the end the only practical solution I could come up with, was to create base classes that provide final implementations of equals() and hashCode() for entity data classes in a parent class.

To prevent duplicate Java field generation of version and id in DbEntity the JPA annotations must be placed at the parent class getters (Kotlin has no fields), whereas the JPA annotations of the data classes are created on Java field level (AccessType.FIELD on the base class).

Is this still correct usage of data classes or should one abstain to use them with JPA in general?

JPA Base Classes for Data Classes

/**
 * Base class for all JPA entities with an id and a version property.
 */
@Access(AccessType.FIELD)
@MappedSuperclass
abstract class DbEntity : Serializable {
    abstract var id: Long?
        @Access(AccessType.PROPERTY)
        @Id
        @GeneratedValue(strategy = GenerationType.AUTO)
        get

    abstract var version: LocalDateTime?
        @Access(AccessType.PROPERTY)
        @Version
        get
}

/**
 * Final equals(), hashCode() from java.lang.Object for sub classes that are data classes.
 */
abstract class IdentityDbEntity : DbEntity() {
    override final fun hashCode() = super.hashCode()
    override final fun equals(other: Any?) = super.equals(other)
}

/**
 * Equality is checked via a business key that may be comprised of several elements.
 * Each of these elements must implement equals() and hashCode() correctly for it to work.
 */
abstract class BusinessKeyDbEntity : DbEntity() {
    protected abstract val businessKey: List<Any>

    override final fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (other === null || other !is BusinessKeyDbEntity) return false
        if (this::class !== other::class) return false
        return businessKey == other.businessKey
    }

    override final fun hashCode(): Int {
        return businessKey.hashCode()
    }
}

Example

/**
 * Customers can have identical names and multiple credit cards.
 */
@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
) : IdentityDbEntity()

/**
 * 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
) : BusinessKeyDbEntity() {
    override val businessKey: List<Any>
        get() = listOf(cardIssuer, number)
}

enum class CreditCardCompany {
    VISA, MASTERCARD, AMERICAN_EXPRESS
}

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
}

Thanks for the really useful discussion, JPA is definitely a dark art and I’m getting the feeling that mixing up autogeneration and JPA are an accident waiting to happen generally.

I like your solution though, have you considered turning it into a library? It seems totally generic?