Featured Image with Sidebar

How to retrieve DTOs from your Envers Audit Logs

By Thorben Janssen


Hibernate Envers is a popular library that automates the creation of an audit log. As I’ve shown in previous articles, it logs all write operations performed using your entity classes in an audit log and provides a flexible API to query data from that log. Based on these articles, I got recently asked if Hibernate Envers provides any support for DTO projections.

This is an interesting question if you want to implement more complex use cases than just showing the latest changes on a specific business object. DTO projections provide the benefit that you can adjust them to each use case’s specific needs and that you avoid the overhead of managed entity objects. This makes them the optimal projection for all read-only operations.

Envers’ query API is very flexible. You can use it to define complex queries that work on the data valid at a certain point in time or on all changes performed on a business object. You can also define different kinds of projections.

Unfortunately, I didn’t find any direct support for DTO projections. But the query API is flexible enough to define a use case specific projection that includes scalar values from different entities. In the next step, you can then instantiate your DTO objects. Let’s take a look at an example.

Setting Up the Example Data

In this article, I will use a chess game with 2 players as an example:

Both classes are simple entity classes that I annotated with Envers @Audited annotation. Besides that, there isn’t anything special about these classes. The primary keys get generated by Hibernate’s default sequence and I rely on the default mapping for the other basic attributes.

@Entity
@Audited
public class ChessGame {
    
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    private Long id;

    private LocalDate date;

    private int round;

    @ManyToOne(fetch = FetchType.LAZY)
    private ChessPlayer playerWhite;
    
    @ManyToOne(fetch = FetchType.LAZY)
    private ChessPlayer playerBlack;
	
    ...
}
@Entity
@Audited
public class ChessPlayer {

    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    private Long id;

    private String firstName;
    
    private String lastName;

    private LocalDate birthDate;

    @OneToMany(mappedBy = "playerWhite")
    private Set<ChessGame> gamesWhite;

    @OneToMany(mappedBy = "playerBlack")
    private Set<ChessGame> gamesBlack;
	
    ...
}

Between these 2 entity classes, I modeled 2 bidirectional many-to-one/one-to-many associations to persist who played the white and who the black pieces. When doing that, you should make sure to set the FetchType of the @ManyToOne association to lazy to avoid performance problems.

Let’s use these 2 entities to fetch a ChessGameDto projection from the audit log that only includes the date and the tournament’s round when the game was played and the first and last name of both players.

public class ChessGameDto {

    private LocalDate date;
    private int round;
    private String playerWhiteFullName;
    private String playerBlackFullName;

    public ChessGameDto(LocalDate date, int round, String playerWhiteFullName, String playerBlackFullName) {
        this.date = date;
        this.round = round;
        this.playerWhiteFullName = playerWhiteFullName;
        this.playerBlackFullName = playerBlackFullName;
    }
}

Fetch a DTO Projection using Hibernate Envers

As explained in my article about Hibernate Envers’ query API, you can look at your audit log from a horizontal or vertical perspective, define custom projections and create complex WHERE clauses. I use all of that in the following code snippet to get the id of the ChessGame, the round and date when it was played, and the first and last name of both players.

And don’t worry, the code snippet might look complex, but I will explain all of it in the following paragraphs.

// Build a query with a scalar projection
AuditReader auditReader = AuditReaderFactory.get(em);
Object[] game = (Object[]) auditReader.createQuery()
		.forEntitiesAtRevision(ChessGame.class, round1RevisionNumber)
		.add(AuditEntity.id().eq(chessGame.getId()))
		.addProjection(AuditEntity.property("round"))
		.addProjection(AuditEntity.property("date"))
		.traverseRelation("playerWhite", JoinType.INNER)
			.addProjection(AuditEntity.property("firstName"))
			.addProjection(AuditEntity.property("lastName"))
		.up()
		.traverseRelation("playerBlack", JoinType.INNER)
			.addProjection(AuditEntity.property("firstName"))
			.addProjection(AuditEntity.property("lastName"))
		.getSingleResult();

Let’s start at the first line. To define a query using Hibernate Envers’ query API, you need to get an instance of an AuditReader.

Using that reader, you can then define a horizontal or vertical query. In this example, I call the forEntitiesAtRevision method to create a horizontal query. It works on the data that was valid at a certain point in time. A typical use case is to retrieve the data that was valid after we drew the first round.

Then I call the add method to define the WHERE clause. In this example, it’s very simple. I want to get information about a specific game, so my WHERE clause only compares the audited record’s primary key value with the primary key of the game I’m searching for.

After this is done, I define the projection by calling the addProjection method for every entity attribute we want to retrieve. The interesting part here is the traversal of associations. You can do that using the methods traverseRelation and up.

By calling the traversRelation method, I traverse the playerWhite association from the ChessGame entity to the associated ChessPlayer entity. After doing that, I call the addProjection method twice and reference the firstName and lastName attribute of the ChessPlayer entity.

In the next step, I first need to navigate back to the ChessGame entity before I can traverse the association to the 2nd player. You can do that by calling the up method. I traverse one step back within your graph of entities. So, in this example, it navigates from the ChessPlayer back to the ChessGame. From the ChessGame we can then traverse the playerBlack association to the 2nd player who played the black pieces and add their name to the projection.

This query returns an Object[] with 6 fields that contain the round and date of the game, the first and last name of the player with the white pieces, and the first and last name of the player with the black pieces. In the next step, we can use this information to instantiate and initialize a ChessGameDto object.

// Map to a ChessGameDto object
ChessGameDto chessGameDto = new ChessGameDto();
chessGameDto.setRound((Integer) game[0]);
chessGameDto.setDate((LocalDate) game[1]);

String playerWhiteFullName = game[2] + " " + game[3];
chessGameDto.setPlayerWhiteFullName(playerWhiteFullName);

String playerBlackFullName = game[4] + " " + game[5];
chessGameDto.setPlayerBlackFullName(playerBlackFullName);

Conclusion

DTO projections are the perfect fit for read operations. They enable you to model a data structure that’s the perfect fit for the business operations you want to perform, and they require less overhead than an entity projection.

Unfortunately, Hibernate Envers doesn’t provide any direct support for DTO projections. But its query API is flexible and powerful enough to define scalar projections containing attributes from multiple entity objects.

The scalar projection is returned as an Object[]. You could, of course, use it directly in your business code. But code that works on an Object[] is hard to read and maintain. I, therefore, recommend mapping the Object[] immediately to a DTO object.

About the author

Thorben is an independent consultant, international speaker, and trainer specialized in solving Java persistence problems with JPA and Hibernate.
He is also the author of Amazon’s bestselling book Hibernate Tips - More than 70 solutions to common Hibernate problems.

Books and Courses

Coaching and Consulting

Tools

Leave a Reply

Your email address will not be published. Required fields are marked

This site uses Akismet to reduce spam. Learn how your comment data is processed.

{"email":"Email address invalid","url":"Website address invalid","required":"Required field missing"}