Over a million developers have joined DZone.
{{announcement.body}}
{{announcement.title}}

Generic JPA Entity History

DZone's Guide to

Generic JPA Entity History

Want to build a changelog without adding redundant tables? Check out this DIY validation manager that will help you track your history.

· Database Zone ·
Free Resource

Discover Tarantool's unique features which include powerful stored procedures, SQL support, smart cache, and the speed of 1 million ACID transactions on a single CPU core!

I've always had an issue trying to keep a changelog for my entities on a database. In my early projects, I did this just by adding redundant tables to add this information, basically doubling the number of tables in the original database design.

Since then, I've been looking for a better way.

Today I want to share what I'm brewing as part of Validation Manager, the beginning of a dynamic History system. Below, you'll see the basic design.

Database Design

Basically, a History entity storing version (major.mid.minor), modification time, modification reason, and modifier. Each history has a series of fields from certain field types.

In the entities, there is some plumbing to do. Entities need to extend Versionable, a mapped superclass.

package com.validation.manager.core.db.mapped;

import javax.persistence.Basic;
import javax.persistence.Column;
import javax.persistence.DiscriminatorColumn;
import javax.persistence.Inheritance;
import javax.persistence.InheritanceType;
import javax.persistence.MappedSuperclass;

@MappedSuperclass
@Inheritance(strategy = InheritanceType.JOINED)
@DiscriminatorColumn(name = "VERSIONABLE_TYPE")
public abstract class Versionable extends AuditedObject {

    @Column(name = "dirty")
    @Basic(optional = false)
    private boolean dirty = false;

    public boolean getDirty() {
        return this.dirty;
    }

    /**
     * @param dirty the dirty to set
     */
    public void setDirty(boolean dirty) {
        this.dirty = dirty;
    }

    @Override
    public void update(AuditedObject target, AuditedObject source) {
        ((Versionable) target).setDirty(((Versionable) source).getDirty());
        super.update(target, source);
    }
}


And the AuditedObject class.

package com.validation.manager.core.db.mapped;

import com.validation.manager.core.db.History;
import com.validation.manager.core.db.VmUser;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Objects;

public abstract class AuditedObject
        implements Comparable<AuditedObject>, Serializable {

    private Integer majorVersion = 0;
    private Integer midVersion = 0;
    private Integer minorVersion = 1;
    private String reason;
    private VmUser modifierId;
    private Date modificationTime;

    public Integer getMajorVersion() {
        return this.majorVersion;
    }

    public void setMajorVersion(Integer majorVersion) {
        this.majorVersion = majorVersion;
    }

    public Integer getMidVersion() {
        return this.midVersion;
    }

    public void setMidVersion(Integer midVersion) {
        this.midVersion = midVersion;
    }

    public Integer getMinorVersion() {
        return this.minorVersion;
    }

    public void setMinorVersion(Integer minorVersion) {
        this.minorVersion = minorVersion;
    }

    public VmUser getModifierId() {
        return modifierId;
    }

    public void setModifierId(VmUser modifierId) {
        this.modifierId = modifierId;
    }

    public String getReason() {
        return this.reason;
    }

    public void setReason(String reason) {
        this.reason = reason;
    }

    public Date getModificationTime() {
        return this.modificationTime;
    }

    public void setModificationTime(Date modificationTime) {
        this.modificationTime = modificationTime;
    }

    /**
     * Get history for this entity.
     *
     * @return history
     */
    public abstract List<History> getHistoryList();

    /**
     * Set history for this entity
     *
     * @param historyList
     */
    public abstract void setHistoryList(List<History> historyList);

    /**
     * Update the fields.
     *
     * @param target target object
     * @param source source object
     */
    public void update(AuditedObject target,
            AuditedObject source) {
        target.setReason(source.getReason());
        target.setModificationTime(source.getModificationTime());
        target.setModifierId(source.getModifierId());
        target.setMajorVersion(source.getMajorVersion());
        target.setMidVersion(source.getMidVersion());
        target.setMinorVersion(source.getMinorVersion());
        target.setModifierId(source.getModifierId());
        target.setReason(source.getReason());
        target.setHistoryList(source.getHistoryList());
    }

    @Override
    public int compareTo(AuditedObject o) {
        if (!Objects.equals(getMajorVersion(),
                o.getMajorVersion())) {
            return getMajorVersion() - o.getMajorVersion();
        }//Same major version
        else if (!Objects.equals(getMidVersion(),
                o.getMidVersion())) {
            return getMidVersion() - o.getMidVersion();
        } //Same mid version
        else if (!Objects.equals(getMinorVersion(),
                o.getMinorVersion())) {
            return getMinorVersion() - o.getMinorVersion();
        }
        //Everything the same
        return 0;
    }

    /**
     * Add history to this entity.
     *
     * @param history History to add.
     */
    public void addHistory(History history) {
        if (getHistoryList() == null) {
            setHistoryList(new ArrayList<>());
        }
        getHistoryList().add(history);
    }

    /**
     * Increase major version.
     */
    public void increaseMajorVersion() {
        setMajorVersion(getMajorVersion() + 1);
        setMidVersion(0);
        setMinorVersion(0);
    }

    /**
     * Increase major version.
     */
    public void increaseMidVersion() {
        setMidVersion(getMidVersion() + 1);
        setMinorVersion(0);
    }

    /**
     * Increase minor is done by default when updating a record.
     */
}


Those just expose interfaces/methods and database fields to the entities. Then we add some mapping between the classes. First, we add the following in the Entity:

@ManyToMany(mappedBy = "nameX")
private List < History > historyList;

...

@XmlTransient
@JsonIgnore
@Override
public List < History > getHistoryList() {
    return historyList;
}

@Override
public void setHistoryList(List < History > historyList) {
    this.historyList = historyList;
}


And update the History entity:

@JoinTable(name = "x_has_history", joinColumns = {
        @JoinColumn(name = "history_id", referencedColumnName = "id")
    },
    inverseJoinColumns = {
        @JoinColumn(name = "x_id", referencedColumnName = "id")
    })
@ManyToMany
private List < Requirement > nameX;


Then we annotate the fields we want to keep track of in the target entity by adding the Auditable annotation to the target field. For example:

    @Basic(optional = false)
    @NotNull
    @Lob
    @Size(max = 2147483647)
    @Column(name = "description")
    @Auditable
    private String description;


Here's the Annotation class, nothing fancy:

package com.validation.manager.core.api.history;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
 *
 * @author Javier A. Ortiz Bultron <javier.ortiz.78@gmail.com>
 */
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface Auditable {

}


Now that the plumbing is done, we need to inject the version automatically into the system. For that we add an Entity Listener:

package com.validation.manager.core.api.history;

import com.validation.manager.core.DataBaseManager;
import com.validation.manager.core.db.FieldType;
import com.validation.manager.core.db.History;
import com.validation.manager.core.db.HistoryField;
import com.validation.manager.core.db.mapped.Versionable;
import com.validation.manager.core.server.core.FieldTypeServer;
import com.validation.manager.core.server.core.HistoryFieldServer;
import com.validation.manager.core.server.core.HistoryServer;
import com.validation.manager.core.server.core.VMUserServer;
import java.lang.reflect.Field;
import java.util.Date;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.persistence.PrePersist;
import javax.persistence.PreUpdate;
import org.apache.commons.lang3.reflect.FieldUtils;
import org.eclipse.persistence.jpa.JpaEntityManager;
import org.eclipse.persistence.sessions.CopyGroup;

/**
 *
 * @author Javier Ortiz Bultron <javier.ortiz.78@gmail.com>
 */
public class VersionListener {

    private final static Logger LOG
            = Logger.getLogger(VersionListener.class.getSimpleName());

    @PrePersist
    public synchronized void onCreation(Object entity) {
        //Handle audit
        if (entity instanceof Versionable
                && DataBaseManager.isVersioningEnabled()) {
            try {
                Versionable v = (Versionable) entity;
                if (v.getReason() == null) {
                    v.setReason("audit.general.creation");
                }
                setHistory(v);
            }
            catch (Exception ex) {
                LOG.log(Level.SEVERE, null, ex);
            }
        }
    }

    @PreUpdate
    public synchronized void onChange(Object entity) {
        //Handle audit
        if (entity instanceof Versionable
                && DataBaseManager.isVersioningEnabled()) {
            try {
                Versionable v = (Versionable) entity;
                if (v.getReason() == null) {
                    v.setReason("audit.general.modified");
                }
                setHistory(v);
            }
            catch (Exception ex) {
                LOG.log(Level.SEVERE, null, ex);
            }
        }
    }

    private synchronized void setHistory(Versionable v) throws Exception {
        //Only if an auditable field has been modified
        if (auditable(v)) {
            //Add history of creation
            HistoryServer hs = new HistoryServer();
            if (v.getModifierId() == null) {
                try {
                    //By default blame system
                    hs.setModifierId(new VMUserServer(1).getEntity());
                }
                catch (Exception ex) {
                    LOG.log(Level.SEVERE, null, ex);
                }
            }
            hs.setReason(v.getReason());
            if (v.getHistoryList() != null && !v.getHistoryList().isEmpty()) {
                History last = v.getHistoryList().get(v
                        .getHistoryList().size() - 1);
                if ((v.getMajorVersion() == 0 && v.getMidVersion() == 0) // It has default values
                        || last.getVersionMajor() == v.getMajorVersion() // Or it has a higher mid/major version assigned.
                        && last.getVersionMid() == v.getMidVersion()) {
                    //Make it one more than latest
                    hs.setVersionMinor(last.getVersionMinor() + 1);
                }
            }
            hs.setModificationTime(v.getModificationTime() == null
                    ? new Date() : v.getModificationTime());
            hs.write2DB();
            //Check the fields to be placed in history
            updateFields(hs, v);
        }
    }

    public Versionable cloneEntity(Versionable entity) {
        CopyGroup group = new CopyGroup();
        group.setShouldResetPrimaryKey(true);
        Versionable copy = (Versionable) DataBaseManager.getEntityManager()
                .unwrap(JpaEntityManager.class).copy(entity, group);
        return copy;
    }

    private synchronized void updateFields(HistoryServer hs, Versionable v)
            throws IllegalArgumentException, IllegalAccessException, Exception {
        for (Field field : FieldUtils.getFieldsListWithAnnotation(v.getClass(),
                Auditable.class)) {
            Class type = field.getType();
            String name = field.getName();
            FieldType ft = FieldTypeServer.findType(type.getSimpleName());
            if (ft == null) {
                FieldTypeServer fts = new FieldTypeServer();
                fts.setTypeName(type.getSimpleName());
                fts.write2DB();
                ft = fts.getEntity();
            }
            HistoryFieldServer hf
                    = new HistoryFieldServer(ft.getId(), hs.getId());
            hf.setFieldName(name);
            hf.setFieldType(ft);
            hf.setHistory(hs.getEntity());
            field.setAccessible(true);
            Object value = field.get(v);
            hf.setFieldValue(value == null ? "null" : value.toString());
            hf.write2DB();
            hs.getHistoryFieldList().add(hf.getEntity());
        }
        hs.write2DB();
        v.addHistory(hs.getEntity());
        DataBaseManager.getEntityManager().persist(v);
    }

    private synchronized boolean auditable(Versionable v) {
        History current;
        if (v.getHistoryList() != null && !v.getHistoryList().isEmpty()) {
            current = v.getHistoryList().get(v.getHistoryList().size() - 1);
        } else {
            //Check if the changed fields are auditable or not
            return true;
        }
        for (HistoryField hf : current.getHistoryFieldList()) {
            try {
                //Compare audit field vs. the record in history.
                Object o = FieldUtils.readField(FieldUtils.getField(v.getClass(),
                        hf.getFieldName(), true), v);
                if ((o == null && !hf.getFieldValue().equals("null"))
                        || (o != null && !o.equals(hf.getFieldValue()))) {
                    return true;
                }
            }
            catch (SecurityException | IllegalArgumentException | IllegalAccessException ex) {
                LOG.log(Level.SEVERE, null, ex);
            }
        }
        return false;
    }
}


Each time an Entity that extends Versionable and has Auditable annotated fields is created/modified, a new history entry is added, assuming the change was on the annotated fields. Other changes don't trigger this system.

I see the potential and what else could be done to make this a stand-alone system:

  • Add an annotation processor to add the fields mentioned above and create/update an orm.xml file.

  • Have as a separate artifact so it can be reused.

Discover Tarantool's unique features such as powerful stored procedures, SQL support, smart cache, and the speed of 1 million ACID transactions on a single CPU.

Topics:
database abstraction layer ,jpa 2.1 ,database ,changelog ,tutorial

Published at DZone with permission of

Opinions expressed by DZone contributors are their own.

{{ parent.title || parent.header.title}}

{{ parent.tldr }}

{{ parent.urlSource.name }}