JPA @PostLoad during readOnly (EclipseLink) queries
According to the JPA 2.1 specification, entity methods annotated with @PostLoad
are used as callbacks when the entity is loaded from the database.
More precisely, it happens when the entity is loaded into persistence context and when the refresh is invoked. However,
there is a misconception that the method will be called anytime we fetch data from the database.
Both the @ReadOnly
annotation set on the entity and the QueryHints.READ_ONLY
("eclipselink.read-only") hint, specific
to the EclipseLink implementation, allows us to bypass persistence context during query processing. This is an
interesting optimization option that reduces heap memory consumption when loading larger datasets. At the same time, it
allows us to utilize shared cache on the persistence unit level. Fancy when we do not need to modify the entity.
However, when the above feature is used, the @PostLoad
method is not invoked. To illustrate an example of a situation, I
will use a simple entity with such method:
package dev.termian.demo;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import org.eclipse.persistence.annotations.Customizer;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.PostLoad;
import javax.persistence.Table;
import javax.persistence.Transient;
@Getter
@Setter
@ToString(exclude = "id")
@Entity
@Table(name="users")
@Customizer(PostLoadOnReadOnlyDescriptorCustomizer.class)
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@Transient
private int index = 0;
@PostLoad
void postLoad() {
index++;
}
}
The test of the index
field value after loading from the database shows that the method was not called for the readOnly query:
package dev.termian.demo;
import org.eclipse.persistence.config.HintValues;
import org.eclipse.persistence.config.QueryHints;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
import javax.persistence.TypedQuery;
import javax.persistence.criteria.CriteriaBuilder;
import javax.persistence.criteria.CriteriaQuery;
import javax.transaction.Transactional;
import java.util.Arrays;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
@SpringBootTest
class DemoApplicationTests {
@PersistenceContext
EntityManager em;
@Test
void testPostLoadReadOnly() {
CriteriaBuilder builder = em.getCriteriaBuilder();
CriteriaQuery<User> criteria = builder.createQuery(User.class);
TypedQuery<User> query = em.createQuery(criteria);
query.setHint(QueryHints.READ_ONLY, HintValues.TRUE);
List<User> users = query.getResultList();
assertPostLoadExecutedOnce(users);
}
@Test
void testPostLoad() {
CriteriaBuilder builder = em.getCriteriaBuilder();
CriteriaQuery<User> criteria = builder.createQuery(User.class);
TypedQuery<User> query = em.createQuery(criteria);
List<User> users = query.getResultList();
assertPostLoadExecutedOnce(users);
}
private void assertPostLoadExecutedOnce(List<User> users) {
assertFalse(users.isEmpty());
for (User user : users) {
assertEquals(1, user.getIndex());
}
}
}
Due to the lack of target behavior by EL, we find open bug reports 336066
and 477063. In one of the reports, Jan
Vermeulen explains how the problem translates into the EclipseLink code.
In short, the @PostLoad
method is attached to
the EclipseLink clone and refresh events that occur just when an entity is loaded into the context. However, it is
not hooked up to the build event, which usually occurs outside the unit of work and could potentially solve the problem.
Wanting to extend the @PostLoad
method also beyond tje persistence context, we will have to get to the code that binds
the entity lifecycle methods to the EclipseLink events. The relevant operation occurs in the EntityListener
class during the persistence unit initialization.
At the same time, EL provides us with an API to further configure the persistence layer. Using the following
interfaces, we can do our bidding:
SessionCustomizer
defined at the persistence unit level;DescriptorCustomizer
defined at the entity level.
SessionCustomizer
The session initiation usually takes place just before the creation of the first entity manager (persistence context). One of the last steps during the initialization is the configuration. The interface provides us with only one customize method with a session parameter. From the session, we can read the initialized metadata in the form of entity descriptors (#1) detected in the previous stages of this process.
package dev.termian.demo;
import org.eclipse.persistence.config.SessionCustomizer;
import org.eclipse.persistence.descriptors.ClassDescriptor;
import org.eclipse.persistence.descriptors.DescriptorEvent;
import org.eclipse.persistence.descriptors.DescriptorEventAdapter;
import org.eclipse.persistence.descriptors.DescriptorEventListener;
import org.eclipse.persistence.internal.jpa.metadata.listeners.EntityListener;
import org.eclipse.persistence.sessions.Session;
import java.lang.reflect.Method;
import java.util.Collections;
import java.util.List;
import java.util.Map;
public class PostLoadOnReadOnlySessionCustomizer implements SessionCustomizer {
@Override
public void customize(Session session) {
for (ClassDescriptor classDescriptor : session.getDescriptors().values()) { // #1
DescriptorEventListener entityEventListener = classDescriptor.getEventManager()
.getEntityEventListener();
if (entityEventListener instanceof EntityListener) { // #2
setPostLoadToPostBuild((EntityListener<?>) entityEventListener);
}
}
}
private void setPostLoadToPostBuild(EntityListener<?> listener) {
Map<String, List<Method>> eventMethods = listener.getAllEventMethods(); // #3
List<Method> methods = eventMethods.getOrDefault(EntityListener.POST_CLONE,
Collections.emptyList()); // #4
if (!methods.isEmpty()) {
Method postLoad = methods.get(0);
listener.setPostBuildMethod(postLoad); // #5
System.out.println(postLoad + " bound to the EL postBuild");
}
}
}
The entity listener (#2) associated with the class previously discovered contains references to the associated lifecycle methods
of the entity (#3). Knowing that the EclipseLink POST_CLONE event (#4) is attached to the @PostLoad
method, we might as well
attach it to the POST_BUILD event (#5).
In the above solution, there is a situation where the postLoad method will be called twice (during build and clone). This can happen when objects are cloned in a unit of work (persistence unit). With the intermediate listener (#6), you can cover this case and conditionally (#7) delegate the postBuild to the postClone of the basic listener (#8):
public class PostLoadOnReadOnlySessionCustomizer implements SessionCustomizer {
//...
private void setPostLoadToPostBuild(EntityListener<?> listener, ClassDescriptor classDescriptor) {
Map<String, List<Method>> eventMethods = listener.getAllEventMethods();
List<Method> methods = eventMethods.getOrDefault(EntityListener.POST_CLONE, Collections.emptyList());
if (!methods.isEmpty()) {
Method postLoad = methods.get(0);
classDescriptor.getEventManager().addListener(new DescriptorEventAdapter() { // #6
@Override
public void postBuild(DescriptorEvent event) {
if (!event.getSession().isUnitOfWork()) { // #7
listener.postClone(event); // #8
}
}
});
System.out.println(postLoad + " bound to the EL postBuild");
}
}
}
We apply the configurator in the persistence.xml file (the proper namespace may differ between JPA versions 2.x
and 3.x). Define this by providing the package name of the class under the eclipselink.session.customizer
property:
<?xml version="1.0" encoding="UTF-8"?>
<persistence version="2.1" xmlns="http://xmlns.jcp.org/xml/ns/persistence"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/persistence
http://xmlns.jcp.org/xml/ns/persistence/persistence_2_1.xsd">
<persistence-unit name="my-pu" transaction-type="RESOURCE_LOCAL">
<!--...-->
<properties>
<!--...-->
<property name="eclipselink.session.customizer"
value="dev.termian.demo.PostLoadOnReadOnlySessionCustomizer"/>
</properties>
</persistence-unit>
</persistence>
DescriptorCustomizer
A similar extension can be implemented on the entity level. At the time of configuration, the standard
EntityListener
will not be initialized in this case (#9). However, nothing prevents you from moving the necessary parts
of the code (#2, #7, #9) to the event processing. In fact, we could refactor the example from point #8 in
the same way.
package dev.termian.demo;
import org.eclipse.persistence.config.DescriptorCustomizer;
import org.eclipse.persistence.descriptors.ClassDescriptor;
import org.eclipse.persistence.descriptors.DescriptorEvent;
import org.eclipse.persistence.descriptors.DescriptorEventAdapter;
import org.eclipse.persistence.descriptors.DescriptorEventListener;
import org.eclipse.persistence.internal.jpa.metadata.listeners.EntityListener;
import org.eclipse.persistence.sessions.Session;
import java.lang.reflect.Method;
import java.util.Collections;
import java.util.List;
import java.util.Map;
public class PostLoadOnReadOnlyDescriptorCustomizer extends DescriptorEventAdapter implements DescriptorCustomizer {
@Override
public void customize(ClassDescriptor descriptor) {
assert descriptor.getEventManager().getEntityEventListener() == null; // #9
descriptor.getEventManager().addListener(this);
}
@Override
public void postBuild(DescriptorEvent event) {
// ((User)event.getSource()).postLoad();
if (!event.getSession().isUnitOfWork()) {
DescriptorEventListener entityEventListener = event.getDescriptor().getEventManager().getEntityEventListener();
if (entityEventListener instanceof EntityListener) {
EntityListener<?> entityListener = (EntityListener<?>) entityEventListener;
entityListener.postClone(event);
}
}
}
}
The org.eclipse.persistence.annotations.Customizer
annotation can be used to apply the customizer i.e.
@Customizer(PostLoadOnReadOnlyDescriptorCustomizer.class)
. Alternatively, you can also do it through the
persistence descriptor or
through the EclipseLink ORM descriptor.
Keep in mind that an internal EclipseLink interface
org.eclipse.persistence.internal.jpa.metadata.listeners.EntityListener
was used in this sample (EL 2.7.4).
To remove this dependency, all you need to do is to figure out your own way to lookup the @PostLoad
annotated methods.
Note, however, that such methods can also be found in the parent classes marked with @MappedSuperclass
annotation.