DZone
Database Zone
Thanks for visiting DZone today,
Edit Profile
  • Manage Email Subscriptions
  • How to Post to DZone
  • Article Submission Guidelines
Sign Out View Profile
  • Post an Article
  • Manage My Drafts
Over 2 million developers have joined DZone.
Log In / Join
  • Refcardz
  • Trend Reports
  • Webinars
  • Zones
  • |
    • Agile
    • AI
    • Big Data
    • Cloud
    • Database
    • DevOps
    • Integration
    • IoT
    • Java
    • Microservices
    • Open Source
    • Performance
    • Security
    • Web Dev
DZone > Database Zone > Generic JPA Entity History

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.

Javier  Ortiz user avatar by
Javier Ortiz
·
May. 05, 17 · Database Zone · Tutorial
Like (6)
Save
Tweet
15.51K Views

Join the DZone community and get the full member experience.

Join For Free

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.

Database History (command)

Published at DZone with permission of Javier Ortiz. See the original article here.

Opinions expressed by DZone contributors are their own.

Popular on DZone

  • How to Submit a Post to DZone
  • 12 Modern CSS Techniques For Older CSS Problems
  • What I Miss in Java, the Perspective of a Kotlin Developer
  • 8 Must-Have Project Reports You Can Use Today

Comments

Database Partner Resources

X

ABOUT US

  • About DZone
  • Send feedback
  • Careers
  • Sitemap

ADVERTISE

  • Advertise with DZone

CONTRIBUTE ON DZONE

  • Article Submission Guidelines
  • MVB Program
  • Become a Contributor
  • Visit the Writers' Zone

LEGAL

  • Terms of Service
  • Privacy Policy

CONTACT US

  • 600 Park Offices Drive
  • Suite 300
  • Durham, NC 27709
  • support@dzone.com
  • +1 (919) 678-0300

Let's be friends:

DZone.com is powered by 

AnswerHub logo