(Hopefully) The Final Article About Equals and HashCode for JPA Entities With DB-Generated IDs
The article explains how to implement equals/hashCode for JPA entities. It covers common implementations and their potential issues. Find ready-to-use methods at the end.
Join the DZone community and get the full member experience.
Join For Freeequals()
and hashCode()
methods for JPA entities. While you can find a lot of implementations on the internet, it's crucial to understand the reasoning behind the chosen implementations to avoid potential issues. By reading the entire article, you will:
-
Gain insights about default
equals()
andhashCode()
implementations; -
Discover issues you might encounter using common
equals()
andhashCode()
implementations found on the internet; -
Learn a lot of interesting things about proxies in Hibernate.
equals()
and hashCode()
methods, and paste them into your code :).
Should We Override Equals and HashCode at All?
@Getter
@Setter
@Entity
@Table(name = "student")
public class Student {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id", nullable = false)
private Long id;
@Column(name = "name")
private String name;
@Column(name = "average_grade")
private Short averageGrade;
}
public interface StudentRepository extends JpaRepository<Student, Long> {
Set<Student> findByAverageGradeLessThan(Short averageGrade);
Set<Student> findByNameStartsWithIgnoreCase(String name);
}
@Service
public class StudentService {
private final StudentRepository studentRepository;
public StudentService(StudentRepository studentRepository) {
this.studentRepository = studentRepository;
}
@Transactional
public Set<Student> getStudentsWithNameStartsWith(String letter) {
return studentRepository.findByNameStartsWithIgnoreCase(letter);
}
@Transactional
public Set<Student> getStudentsWithAverageGradeLessThan(Short averageGrade) {
return studentRepository.findByAverageGradeLessThan(averageGrade);
}
}
So, the controller with the corresponding REST endpoint will look like this:
@RestController
@RequestMapping("/students")
public class StudentController {
private final StudentService studentService;
public StudentController(StudentService studentService) {
this.studentService = studentService;
}
@GetMapping(value = "/name-and-grade")
public Set<Student> getStudentsWithNameStartsWithAndGradeLessThan(
@RequestParam(name = "prefix") String prefix,
@RequestParam(name = "grade") Short grade) {
Set<Student> students = studentService.getStudentsWithNameStartsWith(prefix);
students.retainAll(studentService.getStudentsWithAverageGradeLessThan(grade));
return students;
}
}
Please note that we have disabled the "open session in view." Firstly, long database sessions have a negative impact on throughput, thereby affecting the performance of the application. Secondly, we do not have control over additional queries, which can lead to the N+1 query problem. Lastly, all additional queries open transactions in auto-commit mode, causing transaction logs to be flushed with each statement, negatively impacting database performance once again. Therefore, we do not have a transaction open all the time, so we have to open it ourselves when needed.
spring.jpa.open-in-view=false
equals()
and hashCode()
methods. By default, the equals()
method checks for reference equality — whether the two objects are the same instance in memory. In our case, since the entities were fetched from different methods and separate transactions, they represent different objects in memory.
@Transactional
annotation above the endpoint. In this case, the entities will be fetched in a single transaction and stored in one location in memory. However, using the @Transactional
annotation in REST controllers is not recommended due to potential issues such as resource locking, inconsistent transaction boundaries, violation of separation of concerns, complex rollback scenarios, and so on. It is better to delegate transaction management to a service layer.
@Transactional
, and move all the logic there. Yes, that would be a good solution. But what if you don’t want to open a shared transaction for the two methods? Then, you should override the equals()
and hashCode()
methods for the Student
entity. In the overridden methods, you must ensure that the entities represent the same record in the database rather than the same location in memory.
equals()
and hashCode()
. If you don't do this, objects that are related to the same row in a database table might be considered unequal.
-
Avoid using entities within hash-based collections throughout the entire project;
- If you use hash-based collections, they should exist within the transaction where you retrieved the entities;
- Do not mix entities from different transactions into the same hash-based collection;
- Do not compare entities via
equals()
.
equals()
and hashCode()
in your entities, you’ll need to search for the best version on the internet.
Let Me Google/ChatGPT It for You
Stack Overflow and Vlad Mihalcea Implementations
equals()
methods in the provided implementations are identical. They use instanceof
to test whether the object is an instance of the specified type (class, subclass, or interface). However, transferring such an implementation to the base MappedSuperclass would lead to an incorrect child entities comparison.
Cat
and Dog
classes, which extend the Animal
class. The Animal
class contains both methods, and none of the child classes override them. The current implementation will return true
when comparing objects of the Cat
and Dog
classes with the same ID
. This defies logic and is not desirable behavior.
hashCode()
method are slightly different. Vlad Mihalcea's approach is more accurate than the one from the Stack Overflow. The Stack Overflow implementation can return the same hashCode()
for entities with the same id belonging to different classes. Hash-based collections compare hash codes to determine equality first and then check for object equality using the equals()
method. Therefore, the equals()
method will be used for all comparisons, including comparing objects belonging to different classes, since the hash codes will be equal (when ids are the same). So, it's better to assign unique hash code values to each class to improve performance.
ChatGPT and Thorben Janssen Implementations
hashCode()
method first. The method implementation proposed by ChatGPT as well as the StackOverflow implementation, returns the same hash for objects belonging to different classes in certain cases. We discovered previously why this approach is not the most optimal. The implementation proposed by Thorben Janssen returns the same hash code for all objects! This approach makes hashCode()
completely meaningless.
equals()
method, ChatGPT, and Thorben Janssen offer a more correct approach compared to previous implementations. Using getClass()
instead of instanceof
makes it possible to transfer the implementation to a base class without confusion when comparing two child classes. The getClass()
method returns distinct values for child classes (e.g., Cat
and Dog
, which extend Animal
), whereas instanceof
provides the same output no matter the class.
Results of Research
equals()
and hashCode()
methods.
Making Proxies Great Again
Hibernate offers a powerful proxy mechanism that effectively reduces load to a database by fetching data only when necessary. It is essential to understand that we may interact exclusively with proxy objects, solely with entities, or even with both simultaneously in specific scenarios.
Let's test the current implementations when a proxy object comes into play. For example, declare proxy and non-proxy objects belonging to the same database record. Then, add them to a HashSet and ensure that the size of the HashSet is equal to 1.
@Test
void equalsAndHashCodeTest() {
studentRepository.save(new Student());
Student student = studentRepository.findById(1L).orElseThrow();
Student proxy = studentRepository.getReferenceById(1L);
Set<Student> students = new HashSet<>();
students.add(student);
students.add(proxy);
Assertions.assertEquals(1, students.size());
}
The test failed. However, the reason for this was a LazyInitializationException
, not the collection's size.
Note: Later on, we'll use the term "initialization" to refer to accessing the database, loading field values, and setting it to the proxy.
In the considered implementations, the equals()
method directly accessed the id
field instead of using a getter method. Furthermore, none of the methods were declared as final
. When using Hibernate, a proxy object is initialized whenever a non-final method is called (getId()
is an exception to the rule). As a result, using non-final equals()
and hashCode()
methods can cause unintended proxy initialization and additional hits to the database.
To fix this, we need to access the id
through a getter method and declare the methods as final
:
Excellent! Now we can execute the test without an open transaction. Unfortunately, the test failed once again. This time the reason is the difference between actual and expected collection sizes:
When dealing with a proxy object, the getClass()
method returns a different value than the original class. Consequently, with the current hashCode()
implementation, we get different hash codes for proxy and non-proxy objects. Therefore, java placed them in different buckets inside a hash-based collection and didn’t even compare them for equality. That is why the collection size has become equal to 2.
We can use the Hibernate.getClass()
method to address this issue. The documentation for this method states, "Get the true, underlying class of a proxied entity". By using this method, we can ensure accurate hash codes generation and equality comparisons in scenarios involving proxies and non-proxy objects.
The test fails once more! This time, again, due to a LazyInitializationException
.
After adding the proxy to HashSet, the following call chain occurs:
The AbstractLazyInitializer.initialize()
method leads to a select query to the database. It looks like we missed something. Let's take another look at the Hibernate.getClass()
method documentation:
Get the true, underlying class of a proxied entity. This operation will initialize a proxy by side effect.
There it is! This side-effect is exactly what we have encountered here!
Before Hibernate version 5.6, we could use the HibernateProxyHelper
class and its getClassWithoutInitializingProxy()
method instead of Hibernate.getClass()
. Since Hibernate version 6, we need to use the following approach to avoid proxy initialization:
HibernateProxy.getHibernateLazyInitializer().getPersistentClass()
Consequently, the implementation of equals()
and hashCode()
methods will appear as follows:
And now the test is a breeze!
The Best Way To Implement Equals and Hashcode for JPA Entities...So Far
equals()
and hashCode()
seems to be the most correct and protected from various side effects.
equals()
and hashCode()
method implementations.
@Override
public final boolean equals(Object o) {
if (this == o) return true;
if (o == null) return false;
Class<?> oEffectiveClass = o instanceof HibernateProxy ? ((HibernateProxy) o).getHibernateLazyInitializer().getPersistentClass() : o.getClass();
Class<?> thisEffectiveClass = this instanceof HibernateProxy ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass() : this.getClass();
if (thisEffectiveClass != oEffectiveClass) return false;
Student student = (Student) o;
return getId() != null && Objects.equals(getId(), student.getId());
}
@Override
public final int hashCode() {
return this instanceof HibernateProxy
? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass().hashCode()
: getClass().hashCode();
}
With this implementation, you won't encounter problems when comparing:
-
Newly created objects not yet associated with the database;
- Proxy and non-proxy objects associated with the same database record;
- Two proxy objects associated with the same database record;
- Objects inherited from a class where the
equals()
andhashCode()
implementations are defined; - Two identical objects from different sessions (obtained from different transactions).
equals()
either implicitly or explicitly. Consequently, the performance of your application won't be affected.
Published at DZone with permission of Georgii Vlasov. See the original article here.
Opinions expressed by DZone contributors are their own.
Comments