HowTo ChangeLog with JPA

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;
    }

}

About struberg
I'm an Apache Software Foundation member and Java Champion blogging about Java, µC, TheASF, OpenWebBeans, Maven, MyFaces, CODI, GIT, OpenJPA, TomEE, DeltaSpike, ...

19 Responses to HowTo ChangeLog with JPA

  1. Juan Botiva says:

    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.

  2. struberg says:

    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

  3. Bruno says:

    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

  4. struberg says:

    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

  5. Kim says:

    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?

  6. Miha Vitorovič says:

    But what do you do when the entity gets deleted from the database? You’d want to retain a log afterwards.

    • struberg says:

      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.

  7. herreria says:

    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?

    • herreria says:

      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

      • struberg says:

        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:

        JPA Enhancement done right

        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

  8. grashmi13 says:

    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.

  9. Lauri says:

    Hello.

    Where exacly Editor.cleanupThread() supposed to be called?
    It feels list w/o calling it server will run out memory quite soon.

    • struberg says:

      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.

      • Lauri says:

        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?

  10. chillworld says:

    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

Leave a reply to struberg Cancel reply