diff --git a/hibernate-envers/src/main/java/org/hibernate/envers/query/AuditQueryCreator.java b/hibernate-envers/src/main/java/org/hibernate/envers/query/AuditQueryCreator.java index 236efa91f2..ee079f9a85 100644 --- a/hibernate-envers/src/main/java/org/hibernate/envers/query/AuditQueryCreator.java +++ b/hibernate-envers/src/main/java/org/hibernate/envers/query/AuditQueryCreator.java @@ -173,6 +173,7 @@ public class AuditQueryCreator { c, selectEntitiesOnly, selectDeletedEntities, + false, false ); } @@ -214,6 +215,7 @@ public class AuditQueryCreator { entityName, selectEntitiesOnly, selectDeletedEntities, + false, false ); } @@ -239,7 +241,8 @@ public class AuditQueryCreator { clazz, false, selectDeletedEntities, - true + true, + false ); } @@ -266,6 +269,75 @@ public class AuditQueryCreator { entityName, false, selectDeletedEntities, + true, + false + ); + } + + /** + * Creates a query that selects the revisions at which the given entity was modified. Unless a + * projection is set, the result will be a list of 4-element arrays, containing the following: + *
    + *
  1. The entity instance
  2. + *
  3. Revision entity, corresponding to the revision where the entity was modified. If no custom + * revision entity is used, this will be an instance of {@link org.hibernate.envers.DefaultRevisionEntity}.
  4. + *
  5. The revision type, an enum of class {@link org.hibernate.envers.RevisionType}.
  6. + *
  7. The names of the properties changed in this revision
  8. + *
+ * Additional criterion may be specified to filter the result set. + * + * @param clazz Class of the entities for which to query. + * @param selectDeletedEntities If true, the result set will include revisions where entities were deleted. + * + * @return the audit query + * + * @since 5.3 + */ + @Incubating + public AuditQuery forRevisionsOfEntityWithChanges(Class clazz, boolean selectDeletedEntities) { + clazz = getTargetClassIfProxied( clazz ); + return new RevisionsOfEntityQuery( + enversService, + auditReaderImplementor, + clazz, + false, + selectDeletedEntities, + false, + true + ); + } + + /** + * Creates a query that selects the revisions at which the given entity was modified. Unless a + * projection is set, the result will be a list of 4-element arrays, containing the following: + *
    + *
  1. The entity instance
  2. + *
  3. Revision entity, corresponding to the revision where the entity was modified. If no custom + * revision entity is used, this will be an instance of {@link org.hibernate.envers.DefaultRevisionEntity}.
  4. + *
  5. The revision type, an enum of class {@link org.hibernate.envers.RevisionType}.
  6. + *
  7. The names of the properties changed in this revision
  8. + *
+ * Additional criterion may be specified to filter the result set. + * + * @param clazz Class of the entities for which to query. + * @param entityName Name of the entity (if it can't be guessed basing on the {@code clazz}). + * @param selectDeletedEntities If true, the result set will include revisions where entities were deleted. + * + * @return the audit query + * + * @since 5.3 + */ + @Incubating + public AuditQuery forRevisionsOfEntityWithChanges(Class clazz, String entityName, boolean selectDeletedEntities) { + clazz = getTargetClassIfProxied( clazz ); + return new RevisionsOfEntityQuery( + enversService, + auditReaderImplementor, + clazz, + entityName, + false, + selectDeletedEntities, + false, true ); } diff --git a/hibernate-envers/src/main/java/org/hibernate/envers/query/internal/impl/AbstractAuditQuery.java b/hibernate-envers/src/main/java/org/hibernate/envers/query/internal/impl/AbstractAuditQuery.java index e2e963d6ae..f26b9a741e 100644 --- a/hibernate-envers/src/main/java/org/hibernate/envers/query/internal/impl/AbstractAuditQuery.java +++ b/hibernate-envers/src/main/java/org/hibernate/envers/query/internal/impl/AbstractAuditQuery.java @@ -22,6 +22,7 @@ import org.hibernate.LockOptions; import org.hibernate.envers.boot.internal.EnversService; import org.hibernate.envers.exception.AuditException; import org.hibernate.envers.exception.NotAuditedException; +import org.hibernate.envers.internal.entities.EntityConfiguration; import org.hibernate.envers.internal.entities.EntityInstantiator; import org.hibernate.envers.internal.reader.AuditReaderImplementor; import org.hibernate.envers.internal.tools.query.QueryBuilder; @@ -341,4 +342,13 @@ public abstract class AbstractAuditQuery implements AuditQueryImplementor { } return result; } + + protected EntityConfiguration getEntityConfiguration() { + return enversService.getEntitiesConfigurations().get( entityName ); + } + + protected String getEntityName() { + // todo: can this be replaced by a call to getEntittyConfiguration#getEntityClassName()? + return entityName; + } } diff --git a/hibernate-envers/src/main/java/org/hibernate/envers/query/internal/impl/RevisionsOfEntityQuery.java b/hibernate-envers/src/main/java/org/hibernate/envers/query/internal/impl/RevisionsOfEntityQuery.java index af5cf0da54..78f9f24dd6 100644 --- a/hibernate-envers/src/main/java/org/hibernate/envers/query/internal/impl/RevisionsOfEntityQuery.java +++ b/hibernate-envers/src/main/java/org/hibernate/envers/query/internal/impl/RevisionsOfEntityQuery.java @@ -7,8 +7,11 @@ package org.hibernate.envers.query.internal.impl; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; +import java.util.Locale; import java.util.Map; +import java.util.Set; import java.util.stream.Collectors; import javax.persistence.criteria.JoinType; @@ -17,6 +20,8 @@ import org.hibernate.envers.RevisionType; import org.hibernate.envers.boot.internal.EnversService; import org.hibernate.envers.configuration.internal.AuditEntitiesConfiguration; import org.hibernate.envers.exception.AuditException; +import org.hibernate.envers.internal.entities.PropertyData; +import org.hibernate.envers.internal.entities.mapper.ExtendedPropertyMapper; import org.hibernate.envers.internal.entities.mapper.relation.query.QueryConstants; import org.hibernate.envers.internal.reader.AuditReaderImplementor; import org.hibernate.envers.query.AuditAssociationQuery; @@ -32,6 +37,7 @@ public class RevisionsOfEntityQuery extends AbstractAuditQuery { private final boolean selectEntitiesOnly; private final boolean selectDeletedEntities; private final boolean selectRevisionInfoOnly; + private final boolean includePropertyChanges; public RevisionsOfEntityQuery( EnversService enversService, @@ -39,12 +45,14 @@ public class RevisionsOfEntityQuery extends AbstractAuditQuery { Class cls, boolean selectEntitiesOnly, boolean selectDeletedEntities, - boolean selectRevisionInfoOnly) { + boolean selectRevisionInfoOnly, + boolean includePropertyChanges) { super( enversService, versionsReader, cls ); this.selectEntitiesOnly = selectEntitiesOnly; this.selectDeletedEntities = selectDeletedEntities; this.selectRevisionInfoOnly = selectRevisionInfoOnly && !selectEntitiesOnly; + this.includePropertyChanges = includePropertyChanges; } public RevisionsOfEntityQuery( @@ -53,12 +61,14 @@ public class RevisionsOfEntityQuery extends AbstractAuditQuery { Class cls, String entityName, boolean selectEntitiesOnly, boolean selectDeletedEntities, - boolean selectRevisionInfoOnly) { + boolean selectRevisionInfoOnly, + boolean includePropertyChanges) { super( enversService, versionsReader, cls, entityName ); this.selectEntitiesOnly = selectEntitiesOnly; this.selectDeletedEntities = selectDeletedEntities; this.selectRevisionInfoOnly = selectRevisionInfoOnly && !selectEntitiesOnly; + this.includePropertyChanges = includePropertyChanges; } private Number getRevisionNumber(Map versionsEntity) { @@ -123,49 +133,7 @@ public class RevisionsOfEntityQuery extends AbstractAuditQuery { ); } - List queryResult = buildAndExecuteQuery(); - if ( hasProjection() ) { - return queryResult; - } - else if ( selectRevisionInfoOnly ) { - return queryResult.stream().map( e -> ( (Object[]) e )[1] ).collect( Collectors.toList() ); - } - else { - List entities = new ArrayList(); - String revisionTypePropertyName = verEntCfg.getRevisionTypePropName(); - - for ( Object resultRow : queryResult ) { - Map versionsEntity; - Object revisionData; - - if ( selectEntitiesOnly ) { - versionsEntity = (Map) resultRow; - revisionData = null; - } - else { - Object[] arrayResultRow = (Object[]) resultRow; - versionsEntity = (Map) arrayResultRow[0]; - revisionData = arrayResultRow[1]; - } - - Number revision = getRevisionNumber( versionsEntity ); - - Object entity = entityInstantiator.createInstanceFromVersionsEntity( - entityName, - versionsEntity, - revision - ); - - if ( !selectEntitiesOnly ) { - entities.add( new Object[] {entity, revisionData, versionsEntity.get( revisionTypePropertyName )} ); - } - else { - entities.add( entity ); - } - } - - return entities; - } + return getQueryResults(); } @Override @@ -173,4 +141,87 @@ public class RevisionsOfEntityQuery extends AbstractAuditQuery { throw new UnsupportedOperationException( "Not yet implemented for revisions of entity queries" ); } + private boolean isEntityUsingModifiedFlags() { + // todo: merge HHH-8973 ModifiedFlagMapperSupport into 6.0 to get this behavior by default + final ExtendedPropertyMapper propertyMapper = getEntityConfiguration().getPropertyMapper(); + for ( PropertyData propertyData : propertyMapper.getProperties().keySet() ) { + if ( propertyData.isUsingModifiedFlag() ) { + return true; + } + } + return false; + } + + private Set getChangedPropertyNames(Map dataMap, Object revisionType) { + final Set changedPropertyNames = new HashSet<>(); + // we're only interested in changed properties on modification rows. + if ( revisionType == RevisionType.MOD ) { + final String modifiedFlagSuffix = enversService.getGlobalConfiguration().getModifiedFlagSuffix(); + for ( Map.Entry entry : dataMap.entrySet() ) { + final String key = entry.getKey(); + if ( key.endsWith( modifiedFlagSuffix ) ) { + if ( entry.getValue() != null && Boolean.parseBoolean( entry.getValue().toString() ) ) { + changedPropertyNames.add( key.substring( 0, key.length() - modifiedFlagSuffix.length() ) ); + } + } + } + } + return changedPropertyNames; + } + + private List getQueryResults() { + List queryResults = buildAndExecuteQuery(); + if ( hasProjection() ) { + return queryResults; + } + else if ( selectRevisionInfoOnly ) { + return queryResults.stream().map( e -> ( (Object[]) e )[1] ).collect( Collectors.toList() ); + } + else { + List entities = new ArrayList(); + if ( selectEntitiesOnly ) { + for ( Object row : queryResults ) { + final Map versionsEntity = (Map) row; + entities.add( getQueryResultRowValue( versionsEntity, null, getEntityName() ) ); + } + } + else { + for ( Object row : queryResults ) { + final Object[] rowArray = (Object[]) row; + final Map versionsEntity = (Map) rowArray[ 0 ]; + final Object revisionData = rowArray[ 1 ]; + entities.add( getQueryResultRowValue( versionsEntity, revisionData, getEntityName() ) ); + } + } + return entities; + } + } + + private Object getQueryResultRowValue(Map versionsData, Object revisionData, String entityName) { + final Number revision = getRevisionNumber( versionsData ); + + final Object entity = entityInstantiator.createInstanceFromVersionsEntity( entityName, versionsData, revision ); + if ( selectEntitiesOnly ) { + return entity; + } + + final String revisionTypePropertyName = enversService.getAuditEntitiesConfiguration().getRevisionTypePropName(); + Object revisionType = versionsData.get( revisionTypePropertyName ); + if ( !includePropertyChanges ) { + return new Object[] { entity, revisionData, revisionType }; + } + + if ( !isEntityUsingModifiedFlags() ) { + throw new AuditException( + String.format( + Locale.ROOT, + "The specified entity [%s] does not support or use modified flags.", + getEntityConfiguration().getEntityClassName() + ) + ); + } + + final Set changedPropertyNames = getChangedPropertyNames( versionsData, revisionType ); + return new Object[] { entity, revisionData, revisionType, changedPropertyNames }; + } } diff --git a/hibernate-envers/src/test/java/org/hibernate/envers/test/integration/query/AbstractEntityWithChangesQueryTest.java b/hibernate-envers/src/test/java/org/hibernate/envers/test/integration/query/AbstractEntityWithChangesQueryTest.java new file mode 100644 index 0000000000..4a25ef85e4 --- /dev/null +++ b/hibernate-envers/src/test/java/org/hibernate/envers/test/integration/query/AbstractEntityWithChangesQueryTest.java @@ -0,0 +1,244 @@ +/* + * Hibernate, Relational Persistence for Idiomatic Java + * + * License: GNU Lesser General Public License (LGPL), version 2.1 or later. + * See the lgpl.txt file in the root directory or . + */ +package org.hibernate.envers.test.integration.query; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.Id; + +import org.hibernate.envers.Audited; +import org.hibernate.envers.RevisionType; +import org.hibernate.envers.configuration.EnversSettings; +import org.hibernate.envers.query.AuditEntity; +import org.hibernate.envers.test.BaseEnversJPAFunctionalTestCase; +import org.hibernate.envers.test.Priority; +import org.hibernate.envers.test.tools.TestTools; +import org.junit.Test; + +import org.hibernate.testing.TestForIssue; + +import static org.hibernate.testing.transaction.TransactionUtil.doInJPA; +import static org.junit.Assert.assertEquals; + +/** + * @author Chris Cranford + */ +@TestForIssue( jiraKey = "HHH-8058" ) +public abstract class AbstractEntityWithChangesQueryTest extends BaseEnversJPAFunctionalTestCase { + private Integer simpleId; + + @Override + protected Class[] getAnnotatedClasses() { + return new Class[] { Simple.class }; + } + + @Test + @Priority(10) + public void initData() { + // Revision 1 + simpleId = doInJPA( this::entityManagerFactory, entityManager -> { + final Simple simple = new Simple(); + simple.setName( "Name" ); + simple.setValue( 25 ); + entityManager.persist( simple ); + return simple.getId(); + } ); + + // Revision 2 + doInJPA( this::entityManagerFactory, entityManager -> { + final Simple simple = entityManager.find( Simple.class, simpleId ); + simple.setName( "Name-Modified2" ); + entityManager.merge( simple ); + } ); + + // Revision 3 + doInJPA( this::entityManagerFactory, entityManager -> { + final Simple simple = entityManager.find( Simple.class, simpleId ); + simple.setName( "Name-Modified3" ); + simple.setValue( 100 ); + entityManager.merge( simple ); + } ); + + // Revision 4 + doInJPA( this::entityManagerFactory, entityManager -> { + final Simple simple = entityManager.find( Simple.class, simpleId ); + entityManager.remove( simple ); + } ); + } + + @Test + public void testRevisionCount() { + assertEquals( Arrays.asList( 1, 2, 3, 4 ), getAuditReader().getRevisions( Simple.class, simpleId ) ); + } + + @Test + public void testEntityRevisionsWithChangesQueryNoDeletions() { + List results = getAuditReader().createQuery() + .forRevisionsOfEntityWithChanges( Simple.class, false ) + .add( AuditEntity.id().eq( simpleId ) ) + .getResultList(); + compareResults( getExpectedResults( false ), results ); + } + + @Test + public void testEntityRevisionsWithChangesQuery() { + List results = getAuditReader().createQuery() + .forRevisionsOfEntityWithChanges( Simple.class, true ) + .add( AuditEntity.id().eq( simpleId ) ) + .getResultList(); + compareResults( getExpectedResults( true ), results ); + } + + private void compareResults(List expectedResults, List results) { + assertEquals( expectedResults.size(), results.size() ); + for ( int i = 0; i < results.size(); ++i ) { + final Object[] row = (Object[]) results.get( i ); + final Object[] expectedRow = expectedResults.get( i ); + // the query returns 4, index 1 has the revision entity which we don't test here + assertEquals( 4, row.length ); + // because we don't test the revision entity, we adjust indexes between the two arrays + assertEquals( expectedRow[ 0 ], row[ 0 ] ); + assertEquals( expectedRow[ 1 ], row[ 2 ] ); + assertEquals( expectedRow[ 2 ], row[ 3 ] ); + } + } + + protected List getExpectedResults(boolean includeDeletions) { + + String deleteName = null; + Integer deleteValue = null; + if ( getConfig().get( EnversSettings.STORE_DATA_AT_DELETE ) == Boolean.TRUE ) { + deleteName = "Name-Modified3"; + deleteValue = 100; + } + + final List results = new ArrayList<>(); + + results.add( + new Object[] { + new Simple( simpleId, "Name", 25 ), + RevisionType.ADD, + Collections.emptySet() + } + ); + + results.add( + new Object[] { + new Simple( simpleId, "Name-Modified2", 25 ), + RevisionType.MOD, + TestTools.makeSet( "name" ) + } + ); + + results.add( + new Object[] { + new Simple( simpleId, "Name-Modified3", 100 ), + RevisionType.MOD, + TestTools.makeSet( "name", "value" ) + } + ); + + if ( includeDeletions ) { + results.add( + new Object[] { + new Simple( simpleId, deleteName, deleteValue ), + RevisionType.DEL, + Collections.emptySet() + } + ); + } + + System.out.println( "Generated " + results.size() + " results." ); + return results; + } + + @Audited + @Entity(name = "Simple") + public static class Simple { + @Id + @GeneratedValue + private Integer id; + private String name; + private Integer value; + + Simple() { + + } + + Simple(Integer id, String name, Integer value) { + this.id = id; + this.name = name; + this.value = value; + } + + public Integer getId() { + return id; + } + + public void setId(Integer id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Integer getValue() { + return value; + } + + public void setValue(Integer value) { + this.value = value; + } + + @Override + public boolean equals(Object o) { + if ( this == o ) { + return true; + } + if ( o == null || getClass() != o.getClass() ) { + return false; + } + + Simple simple = (Simple) o; + + if ( getId() != null ? !getId().equals( simple.getId() ) : simple.getId() != null ) { + return false; + } + if ( getName() != null ? !getName().equals( simple.getName() ) : simple.getName() != null ) { + return false; + } + return getValue() != null ? getValue().equals( simple.getValue() ) : simple.getValue() == null; + } + + @Override + public int hashCode() { + int result = getId() != null ? getId().hashCode() : 0; + result = 31 * result + ( getName() != null ? getName().hashCode() : 0 ); + result = 31 * result + ( getValue() != null ? getValue().hashCode() : 0 ); + return result; + } + + @Override + public String toString() { + return "Simple{" + + "id=" + id + + ", name='" + name + '\'' + + ", value=" + value + + '}'; + } + } +} diff --git a/hibernate-envers/src/test/java/org/hibernate/envers/test/integration/query/EntityWithChangesQueryNoModifiedFlagTest.java b/hibernate-envers/src/test/java/org/hibernate/envers/test/integration/query/EntityWithChangesQueryNoModifiedFlagTest.java new file mode 100644 index 0000000000..1e11b430f9 --- /dev/null +++ b/hibernate-envers/src/test/java/org/hibernate/envers/test/integration/query/EntityWithChangesQueryNoModifiedFlagTest.java @@ -0,0 +1,43 @@ +/* + * Hibernate, Relational Persistence for Idiomatic Java + * + * License: GNU Lesser General Public License (LGPL), version 2.1 or later. + * See the lgpl.txt file in the root directory or . + */ +package org.hibernate.envers.test.integration.query; + +import org.hibernate.envers.exception.AuditException; +import org.junit.Test; + +import org.hibernate.testing.TestForIssue; + +import static org.hibernate.testing.junit4.ExtraAssertions.assertTyping; +import static org.junit.Assert.fail; + +/** + * @author Chris Cranford + */ +@TestForIssue( jiraKey = "HHH-8058" ) +public class EntityWithChangesQueryNoModifiedFlagTest extends AbstractEntityWithChangesQueryTest { + @Test + public void testEntityRevisionsWithChangesQueryNoDeletions() { + try { + super.testEntityRevisionsWithChangesQueryNoDeletions(); + fail( "This should have failed with AuditException since test case doesn't enable modifiedFlag" ); + } + catch ( Exception e ) { + assertTyping( AuditException.class, e ); + } + } + + @Test + public void testEntityRevisionsWithChangesQuery() { + try { + super.testEntityRevisionsWithChangesQuery(); + fail( "This should have failed with AuditException since test case doesn't enable modifiedFlag" ); + } + catch ( Exception e ) { + assertTyping( AuditException.class, e ); + } + } +} diff --git a/hibernate-envers/src/test/java/org/hibernate/envers/test/integration/query/EntityWithChangesQueryStoreDeletionDataTest.java b/hibernate-envers/src/test/java/org/hibernate/envers/test/integration/query/EntityWithChangesQueryStoreDeletionDataTest.java new file mode 100644 index 0000000000..7ecda5783e --- /dev/null +++ b/hibernate-envers/src/test/java/org/hibernate/envers/test/integration/query/EntityWithChangesQueryStoreDeletionDataTest.java @@ -0,0 +1,26 @@ +/* + * Hibernate, Relational Persistence for Idiomatic Java + * + * License: GNU Lesser General Public License (LGPL), version 2.1 or later. + * See the lgpl.txt file in the root directory or . + */ +package org.hibernate.envers.test.integration.query; + +import java.util.Map; + +import org.hibernate.envers.configuration.EnversSettings; + +import org.hibernate.testing.TestForIssue; + +/** + * @author Chris Cranford + */ +@TestForIssue( jiraKey = "HHH-8058" ) +public class EntityWithChangesQueryStoreDeletionDataTest extends AbstractEntityWithChangesQueryTest { + @Override + protected void addConfigOptions(Map options) { + options.put( EnversSettings.GLOBAL_WITH_MODIFIED_FLAG, Boolean.TRUE ); + options.put( EnversSettings.STORE_DATA_AT_DELETE, Boolean.TRUE ); + super.addConfigOptions( options ); + } +} diff --git a/hibernate-envers/src/test/java/org/hibernate/envers/test/integration/query/EntityWithChangesQueryTest.java b/hibernate-envers/src/test/java/org/hibernate/envers/test/integration/query/EntityWithChangesQueryTest.java new file mode 100644 index 0000000000..b014d94e7b --- /dev/null +++ b/hibernate-envers/src/test/java/org/hibernate/envers/test/integration/query/EntityWithChangesQueryTest.java @@ -0,0 +1,25 @@ +/* + * Hibernate, Relational Persistence for Idiomatic Java + * + * License: GNU Lesser General Public License (LGPL), version 2.1 or later. + * See the lgpl.txt file in the root directory or . + */ +package org.hibernate.envers.test.integration.query; + +import java.util.Map; + +import org.hibernate.envers.configuration.EnversSettings; + +import org.hibernate.testing.TestForIssue; + +/** + * @author Chris Cranford + */ +@TestForIssue( jiraKey = "HHH-8058" ) +public class EntityWithChangesQueryTest extends AbstractEntityWithChangesQueryTest { + @Override + protected void addConfigOptions(Map options) { + super.addConfigOptions( options ); + options.put( EnversSettings.GLOBAL_WITH_MODIFIED_FLAG, Boolean.TRUE ); + } +}