DZone
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
Refcards Trend Reports Events Over 2 million developers have joined DZone. Join Today! Thanks for visiting DZone today,
Edit Profile Manage Email Subscriptions Moderation Admin Console How to Post to DZone Article Submission Guidelines
View Profile
Sign Out
Refcards
Trend Reports
Events
Zones
Culture and Methodologies Agile Career Development Methodologies Team Management
Data Engineering AI/ML Big Data Data Databases IoT
Software Design and Architecture Cloud Architecture Containers Integration Microservices Performance Security
Coding Frameworks Java JavaScript Languages Tools
Testing, Deployment, and Maintenance Deployment DevOps and CI/CD Maintenance Monitoring and Observability Testing, Tools, and Frameworks
Culture and Methodologies
Agile Career Development Methodologies Team Management
Data Engineering
AI/ML Big Data Data Databases IoT
Software Design and Architecture
Cloud Architecture Containers Integration Microservices Performance Security
Coding
Frameworks Java JavaScript Languages Tools
Testing, Deployment, and Maintenance
Deployment DevOps and CI/CD Maintenance Monitoring and Observability Testing, Tools, and Frameworks
  1. DZone
  2. Data Engineering
  3. Databases
  4. 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 · Tutorial
Like (6)
Save
Tweet
Share
15.84K 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

  • Using QuestDB to Collect Infrastructure Metrics
  • PostgreSQL: Bulk Loading Data With Node.js and Sequelize
  • What Java Version Are You Running? Let’s Take a Look Under the Hood of the JDK!
  • Distributed SQL: An Alternative to Database Sharding

Comments

Partner Resources

X

ABOUT US

  • About DZone
  • Send feedback
  • Careers
  • Sitemap

ADVERTISE

  • Advertise with DZone

CONTRIBUTE ON DZONE

  • Article Submission Guidelines
  • 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: