HHH-8058 - Enable querying entity revisions with property change indicators.

(backport from wip/6.0)
This commit is contained in:
Chris Cranford 2018-02-21 13:50:59 -05:00
parent 79354bab9f
commit 664c652a25
7 changed files with 517 additions and 46 deletions

View File

@ -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:
* <ol>
* <li>The entity instance</li>
* <li>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}.</li>
* <li>The revision type, an enum of class {@link org.hibernate.envers.RevisionType}.</li>
* <li>The names of the properties changed in this revision</li>
* </ol>
* 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:
* <ol>
* <li>The entity instance</li>
* <li>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}.</li>
* <li>The revision type, an enum of class {@link org.hibernate.envers.RevisionType}.</li>
* <li>The names of the properties changed in this revision</li>
* </ol>
* 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
);
}

View File

@ -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;
}
}

View File

@ -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<Object> 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<String> getChangedPropertyNames(Map<String, Object> dataMap, Object revisionType) {
final Set<String> 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<String, Object> 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<String> changedPropertyNames = getChangedPropertyNames( versionsData, revisionType );
return new Object[] { entity, revisionData, revisionType, changedPropertyNames };
}
}

View File

@ -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 <http://www.gnu.org/licenses/lgpl-2.1.html>.
*/
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<Object[]> 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<Object[]> 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<Object[]> 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 +
'}';
}
}
}

View File

@ -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 <http://www.gnu.org/licenses/lgpl-2.1.html>.
*/
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 );
}
}
}

View File

@ -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 <http://www.gnu.org/licenses/lgpl-2.1.html>.
*/
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 );
}
}

View File

@ -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 <http://www.gnu.org/licenses/lgpl-2.1.html>.
*/
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 );
}
}