The jpa lifecyle hook method and Spring Data auditing only store the creation and last modification info of an Entity, but all the modification history are not tracked.
Hibernate Envers fills the blank table. Since Hibernate 3.5, Envers is part of Hibernate core project.
Configure Hibernate Envers in project is very simple, add hibernate-envers as a project dependency.
org.hibernate hibernate-envers
It is done. No need extra Event listeners configuration as the early version.
Hibernate Envers provides a simple @Audited
annotation, you can place it on an Entity class or property of an Entity.
@Audited private String description;
When an @Audited
annotation is placed on a property, this property can be tracked.
@Entity @Audited public class Signup implements Serializable {...}
If annotated @Audited
with an Entity class, all properties of this Entity will be tracked.
Hibernate will generate extra tables for the audited Entities.
By default, an table name ended with _AUD will be generated for the related Entity.
For example, Conference_AUD will be generated for entity Conference.
The audit table copies all audited fields from the entity table, and adds two fields, REVTYPE and REV, and the value of REVTYPE could be add, mod, del.
Besides these, an extra table named REVINFO will be generated by default, it includes two important fields, REV and REVTSTMP, it records the timestamp of every revision.
When you do some queries on the audited info of an entity, it will select the _AUD table join the REVINFO table.
The Envers events will be fired when the transaction is synchronized. This will cause an issue when use @Transactional
annotation on a method.
@Test // @Transactional public void retrieveConference() { final Conference conference1 = newConference(); Conference conf1 = transactionTemplate .execute(new TransactionCallback () { @Override public Conference doInTransaction(TransactionStatus arg0) { conference1.setSlug("test-jud"); conference1.setName("Test JUD"); conference1.getAddress().setCountry("US"); Conference reference = conferenceRepository .save(conference1); em.flush(); return reference; } }); // modifying description assertTrue(null != conf1.getId()); final Conference conference2 = conferenceRepository .findBySlug("test-jud"); log.debug("@conference @" + conference2); assertTrue(null != conference2); final Conference conf2 = transactionTemplate .execute(new TransactionCallback () { @Override public Conference doInTransaction(TransactionStatus arg0) { conference2.setDescription("changing description..."); Conference result = conferenceRepository .save(conference2); em.flush(); return result; } }); log.debug("@conf2 @" + conf2); // //modifying slug // conference.setSlug("test-jud-slug"); // conference= conferenceRepository.save(conference); // em.flush(); transactionTemplate.execute(new TransactionCallback () { @Override public Conference doInTransaction(TransactionStatus arg0) { AuditReader reader = AuditReaderFactory.get(em); List revisions = reader.getRevisions(Conference.class, conf2.getId()); assertTrue(!revisions.isEmpty()); log.debug("@rev numbers@" + revisions); Conference rev1 = reader.find(Conference.class, conf2.getId(), 2); log.debug("@rev 1@" + rev1); assertTrue(rev1.getSlug().equals("test-jud")); return null; } }); }
In this test, I use TransactionTemplate
to make the transaction happens immediately.
Currently the REVINFO does not tracked the auditor of the certain revision, it is easy to customize the RevisionEntity to implement it.
Create a generic entity, add annotation @ResivionEntity
.
@Entity @RevisionEntity(ConferenceRevisionListener.class) public class ConferenceRevisionEntity { @Id @GeneratedValue @RevisionNumber private int id; @RevisionTimestamp private long timestamp; @ManyToOne @JoinColumn(name="auditor_id") private User auditor; }
A @RevisionNumber
annotated property and a @RevisionTimestamp
annotated property are required. Hibernate provides a @MappedSuperclass
DefaultRevisionEntity
class, you can extend it directly.
@Entity @RevisionEntity(ConferenceRevisionListener.class) public class ConferenceRevisionEntity extends DefaultRevisionEntity{ @ManyToOne @JoinColumn(name="auditor_id") private User auditor; }
Create a custom RevisionListener
class.
public class ConferenceRevisionListener implements RevisionListener { @Override public void newRevision(Object revisionEntity) { ConferenceRevisionEntity entity=(ConferenceRevisionEntity) revisionEntity; entity.setAuditor(SecurityUtils.getCurrentUser()); } }
When create a new revision, set the auditor info. In the real project, it could be principal info from Spring Security.
Now you can query the ConferenceRevisionEntity
like a generic Entity, and get know who have modified the entity for some certain revisions.
transactionTemplate.execute(new TransactionCallbackWithoutResult() { @Override protected void doInTransactionWithoutResult(TransactionStatus status) { AuditReader reader = AuditReaderFactory.get(em); List revisions = reader.getRevisions(Conference.class, conf2.getId()); assertTrue(!revisions.isEmpty()); log.debug("@rev numbers@" + revisions); ConferenceRevisionEntity entity=em.find(ConferenceRevisionEntity.class, revisions.get(0)); log.debug("@rev 1@" + entity); assertTrue(entity.getAuditor().getId().equals(user.getId())); } });
You can track which audited properties are modified at the certain revision.
There are tow options to enable tracking of property-level modification.
Add the following configuration in the Hiberante/JPA configuration to enable it globally, and modification of all properties annotated with @Audited will be tracked.
org.hibernate.envers.global_with_modified_flag true
Or specify a withModifiedFlag attribute of @Audited
, for example @Audited(withModifiedFlag=true)
, which will track modification of all properties when annotate it on an entity class, or only track the specified property if annotate it on a property of an entity class.
Hibernate Envers will generate an extra _MOD field for every audited field in the _AUD table, which provides a flag for the audited field to identify it is modified at the certain revision.
@NotNull @Audited(withModifiedFlag=true) private String description; @NotNull @Audited(withModifiedFlag=true) private String slug;
Only change the description of the Conference
, and verify the modification of description and slug.
transactionTemplate.execute(new TransactionCallbackWithoutResult() { @Override protected void doInTransactionWithoutResult(TransactionStatus status) { AuditReader reader = AuditReaderFactory.get(em); List revisions = reader.getRevisions(Conference.class, conf2.getId()); assertTrue(!revisions.isEmpty()); log.debug("@rev numbers@" + revisions); List list = reader .createQuery() .forEntitiesAtRevision(Conference.class, revisions.get(0)) .add(AuditEntity.id().eq(conf2.getId())) .add(AuditEntity.property("description").hasChanged()) .getResultList(); log.debug("@description list changed@" + list.size()); assertTrue(!list.isEmpty()); List slugList = reader .createQuery() .forEntitiesAtRevision(Conference.class, revisions.get(0)) .add(AuditEntity.id().eq(conf2.getId())) .add(AuditEntity.property("slug").hasChanged()) .getResultList(); log.debug("@slugList 1@" + slugList.size()); assertTrue(!slugList.isEmpty()); list = reader .createQuery() .forEntitiesAtRevision(Conference.class, revisions.get(1)) .add(AuditEntity.id().eq(conf2.getId())) .add(AuditEntity.property("description").hasChanged()) .getResultList(); log.debug("@description list changed@" + list.size()); assertTrue(!list.isEmpty()); slugList = reader .createQuery() .forEntitiesAtRevision(Conference.class, revisions.get(1)) .add(AuditEntity.id().eq(conf2.getId())) .add(AuditEntity.property("slug").hasChanged()) .getResultList(); log.debug("@slugList 1@" + slugList.size()); assertTrue(slugList.isEmpty()); } });
There are several ways to track the modification of the entity class.
set org.hibernate.envers.track_entities_changed_in_revision to true in Hibernate/JPA configuration. Hibernate will generate a REVCHANGES to record the change of the entity name.
Create a custom revision entity that extends org.hibernate.envers.DefaultTrackingModifiedEntitiesRevisionEntity
class.
@Entity @RevisionEntity public class ExtendedRevisionEntity extends DefaultTrackingModifiedEntitiesRevisionEntity { ... }
In your custom revision entity, create a Set property, annotate it with @org.hibernate.envers.ModifiedEntityNames
annotation.
@Entity @RevisionEntity public class ConferenceTrackingRevisionEntity { ... @ElementCollection @JoinTable(name = "REVCHANGES", joinColumns = @JoinColumn(name = "REV")) @Column(name = "ENTITYNAME") @ModifiedEntityNames private Set modifiedEntityNames; ... }
There is an incubator project named Spring Data Envers under Spring Data which extends Spring Data JPA, and integrate Hibernate Envers with Spring Data JPA.
Add spring-data-envers as your project dependency.
org.springframework.data spring-data-envers 0.2.0.BUILD-SNAPSHOT
Specify the a factory-class attribute in * , you have to use it to enable Spring Data Envers.
There is an extra ResivionRepository
interface from Spring Data Commons provides you the capability of querying the entity revision info.
public interface RevisionRepository > { Revision findLastChangeRevision(ID id); Revisions findRevisions(ID id); Page > findRevisions(ID id, Pageable pageable); }
Make your repository extend it.
@Repository public interface SignupRepository extends RevisionRepository , JpaRepository { Signup findByConference(Conference conference); Signup findById(Long id); }
Have a try now.
@Test // @Transactional public void retrieveSignupRevision() { final Signup signup = newSignup(); final Signup signup2 = transactionTemplate .execute(new TransactionCallback () { @Override public Signup doInTransaction(TransactionStatus arg0) { signupRepository.save(signup); em.flush(); return signup; } }); // modifying description assertTrue(null != signup2.getId()); log.debug("@Signup @" + signup2); assertTrue(null != signup2); transactionTemplate.execute(new TransactionCallbackWithoutResult() { @Override protected void doInTransactionWithoutResult(TransactionStatus status) { Revisions revision = signupRepository .findRevisions(signup2.getId()); assertTrue(!revision.getContent().isEmpty()); Revision lastRevision = signupRepository .findLastChangeRevision(signup2.getId()); assertTrue(lastRevision.getRevisionNumber()==1); } }); }
The codes are hosted on my github.com account.
https://github.com/hantsy/spring-sandbox