HowTo ChangeLog with JPA
2010/07/31 19 Comments
The problem
In almost all projects which have some kind of administrative functionality, there is a need to keep track of changes done to the data.
If we take a car rental store as example, it is good to know who reserved a car and who changed a reservation.
Our entity looks like the following (only the important parts of classes are shown here)
@Entity
public class CarReservation {
@Id, @Version ..
private Car car;
private @Temporal.. Date reservedFrom;
private @Temporal.. Date reservedUntil;
private Customer reservedFor;
.. getters and setters
}
Now let’s consider that the customer calls our shop and likes to move his reservation to a slightly different date. The clerk officer opens the reservation and changes it accordingly. Too bad that he edited the wrong reservation and saved it. Even if he recognises his error quickly, he has no chance to revert the wrong reservation back to the correct values, because they didn’t got stored in the system.
The solution
JPA provides a nice way to receive lifecycle callbacks JPA2 JavaDoc.
We need to somehow get the old values at the time we store the changes in our entity so we can create a ‘diff’. Since there always exists exactly 1 instance of a DB entry per EntityManager, we don’t have a chance to get those old values at save time. There exists a dirty trick with opening a new EntityManager inside the save operation, but that doesn’t perform well. The way I get my hands on the old values is to store them in a Map at load time by utilising @PostLoad via a EntityListener.
The single parts involved in the magic:
- The TrackChangesListener which listens to @PostLoad and @PreUpdate JPA events
- A new @TrackChanges annotation which marks entity fields as being subject to be tracked
- A new @ChangeLog annotation which mark the one String @Lob field to stores the changeLog
- An Editor component which ThreadLocally stores the oldValues and knows which entities are currently being edited (for performance reasons). It also knows the current user so we can also store who edited the entry.
How our Car entity looks after we applied the changelog functionality:
@Entity @EntityListeners({TrackChangesListener.class}) public class CarReservation { @Id, @Version .. private @TrackChanges Car car; private @TrackChanges @Temporal.. Date reservedFrom; private @TrackChanges @Temporal.. Date reservedUntil; private @TrackChanges Customer reservedFor; private @ChangeLog @Lob String changeLog; .. getters and setters } And here comes the code: First the annotations:@Target(ElementType.FIELD) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface TrackChanges { }
and@Target(ElementType.FIELD) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface ChangeLog { }
And here comes the most important part, the EntityListener:public class TrackChangesListener { @PostLoad public void loadOldData(Object he) { if (!Editor.isEdited(he)) { return; } Map oldData = new HashMap(); collectOldValues(oldData, "", he); Editor.setOldData(he, oldData); } private void collectOldValues(Map oldData, String prefix, Object he) { List historisedFields = getHistorisedFields(he); for (Field field : historisedFields) { field.setAccessible(true); try { if (field.getAnnotation(Embedded.class) != null) { Object value = field.get(he); if (value != null) { // recurse into the Embedded field collectOldValues(oldData, prefix + field.getName() + ".", value); } } else { oldData.put(prefix + field.getName(), field.get(he)); } } catch (IllegalAccessException e) { throw new RuntimeException(e); } } } @PreUpdate public void updateChangeLog(Object he) { if (!Editor.isEdited(he)) { return; } try { Field changeLogField = getChangeLogField(he); Validate.notNull(changeLogField, "could not find @ChangeLog field in Class " + he.getClass().getName()); StringBuilder changeLog = new StringBuilder(); String changeLogValue = (String) changeLogField.get(he); if (changeLogValue != null) { changeLog.append(changeLogValue).append("\n\n"); } changeLog.append("changedBy " ).append(Editor.getUser()); changeLog.append(" on ").append((new Date()).toGMTString()).append('\n'); boolean changed = false; changed = collectChanges(changeLog, Editor.getOldData(he), "", he); // update the changeLog field if a trackable change was detected if (changed) { changeLogField.set(he, changeLog.toString()); } } catch (IllegalAccessException e) { throw new RuntimeException(e); } } private Field getChangeLogField(Object he) { Field[] fields = he.getClass().getDeclaredFields(); for (Field field : fields) { if (field.getAnnotation(ChangeLog.class) != null) { field.setAccessible(true); return field; } } return null; } private boolean collectChanges(StringBuilder changeLog, Map oldData, String prefix, Object he) { boolean changed = false; List historisedFields = getHistorisedFields(he); for (Field field : historisedFields) { field.setAccessible(true); String fieldName = field.getName(); try { if (field.getAnnotation(Embedded.class) != null) { Object value = field.get(he); if (value != null) { // recurse into the Embedded field changed |= collectChanges(changeLog, oldData, prefix + field.getName() + ".", value); } } else { Object newValue = field.get(he); Object oldValue = oldData.get(prefix + fieldName); changed |= addChangeLine(changeLog, prefix + fieldName, oldValue, newValue); } } catch (IllegalAccessException e) { throw new RuntimeException(e); } } return changed; } private boolean addChangeLine(StringBuilder changeLog, String fieldName, Object oldValue, Object newValue) { if (oldValue == null && newValue == null) { return false; } if (oldValue != null && oldValue.equals(newValue)) { // no change return false; } String changeLine = fieldName + " old: [" + (oldValue != null ? oldValue : "") + "] new: [" + (newValue != null ? newValue : "") + "]\n"; changeLog.append(changeLine); return true; } private List getHistorisedFields(Object he) { Field[] fields = he.getClass().getDeclaredFields(); List histFields = new ArrayList(); for (Field field : fields) { if (field.getAnnotation(TrackChanges.class) != null) { histFields.add(field); } } return histFields; } }
This is just what I was looking for. Thanks. Can you post the code of the Editor class, specifically how it keeps track of objects being edited ? Thanks in advance.
Hi Juan!
Sorry for only replying so late. The Editor class is pretty straight forward, just stores a Set of Entities which are ‘edited’ in a ThreadLocal and their Map
http://pastie.org/3147915
And about EditInfo, mentioned in line 14 of Editor class?
Ok, I Find it (line 198), but I cannot find the UserSettings and BaseEntity classes. Are they from some library?
Very interesting paper !! It will inspire me soon. Don’t you have improvement untill ?
I think change tracking is really a lack on JPA2. Why not give access to entityManger within the interceptors ?
Thanks
Bruno
Txs for your comment Bruno. Guess you mean having the EM in the entity listeners?
In that case I’m not a fan of it as it makes it hard to avoid ‘cycles’. The EntityListener already gets invoked during a kind of atomic EM action. Changing the EM now would create a tons of problems. You also cannot change a transaction while the very transaction gets committed.
I would only suggest using an EM in an EntityListener if the EM is for a completely different connection (other database or configuration).
Btw, for OpenJPA there is now a native solution for maintaining a changelog.
LieGrue,
strub
Nice article! We have a relatively equal approach. Our problem is that, we can not access the EJBContext inside the EntityListener under WebSphere Application Server. So your article inspired us to use an Object like Editor in your case and store the username in ThreadLocal fields. But we are not 100% sure if this is a good solid solution. Do you think we will have the right user in every case?
ThreadLocal should be perfectly fine. Most of the important EE container parts are based on it as well.
But what do you do when the entity gets deleted from the database? You’d want to retain a log afterwards.
I usually do not hard-delete anything from the database when it comes to business applications. Those records simply have a ‘deleted’ flag.
Also I do not say that this is the only solution any probably not even the best one. But it works with standard JPA and for some scenarios it is quite fine.
This is exactly what I was searching for!
Unfortunately it doesn’t work for me. The @PreUpdate method is triggered, but then, for some reason, the DeferredChangeDetectionPolicy class of EclipseLink doesn’t see these changes.
I’ve read that this happened back with EclipseLink 1.x because they didn’t check for updates in @PreUpdate, but I’m using EclipseLink 2.x and still it doesn’t work 😦
Do you have any ideas concerning this, struberg?
It seems to be related woth the use of the reflection API, because, if I update the field directly using the set method, it works
Hi!
That might happen because Eclipselink internally is pretty similar to how OpenJPA does the JPA magic. I’ve written this up in an old blog post:
Basically during classloading the bytecode of your entity class totally gets changed. Thus the real JPA ‘magic’ in Eclipselink properly only works if you use the setter and getter methods.
If you use build-time enhancement, then you can even run a java decompiler on your enhanced classes and take a look what Eclipselink generates in the end.
LieGrue,
strub
What if my entity classes doesnt have common super class (BaseEntity)… How do I use this program in that case? Please provide code of BaseEntity class.
Hello.
Where exacly Editor.cleanupThread() supposed to be called?
It feels list w/o calling it server will run out memory quite soon.
I suggest you clean the ThreadLocal simply in a finally block on the level where you did set it up. Another option would be to clean it in a Servlet request listener. But that wouldnt work for async threads or batches.
Hm. That would mean audit code to spread around the project, even sneaking to the servlet layer.
Seems it is better to somehow attach old state to the entity itself in @PostLoad interceptor and dump changes in @PostUpdate.
Or other idea. What do you think, is it fine to make somewhat like default ejb interceptor (or servlet filter) which would clean up ThreadLocal?
Hello,
Thx for the article, but I have 1 question.
Where comes the Validate.notNull from? (in the TrackChangeListener)
I’m busy with changing the code to Java 1.7 complient, and this is the only fault I have left.
thx in advance.
chill
Guess I used the one from apache-commons-lang3:
https://commons.apache.org/proper/commons-lang/javadocs/api-3.1/org/apache/commons/lang3/Validate.html
maven coordinates are:
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.4</version>
</dependency>