Skip to content

Commit

Permalink
chore: add audit trail
Browse files Browse the repository at this point in the history
using hibernate-envers
add V3__7_revision_info_schema.sql
add V4__7_customer_audit_schema.sql
add V5__7_customer_address_audit_schema.sql

resolves: #7
  • Loading branch information
alvinmarshall committed Jul 28, 2024
1 parent 93dfade commit 71fb9ea
Show file tree
Hide file tree
Showing 14 changed files with 313 additions and 4 deletions.
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.flywaydb:flyway-core'
implementation 'org.flywaydb:flyway-database-postgresql'
implementation 'org.springframework.data:spring-data-envers'
compileOnly 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-docker-compose'
runtimeOnly 'org.postgresql:postgresql'
Expand Down
4 changes: 4 additions & 0 deletions src/main/java/com/cheise_proj/auditing/Address.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.envers.AuditTable;
import org.hibernate.envers.Audited;

@Entity
@Table(name = "customer_address")
Expand All @@ -12,6 +14,8 @@
@NoArgsConstructor
@ToString
@Builder
@AuditTable(value = "customer_address_audit")
@Audited
class Address {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
Expand Down
22 changes: 22 additions & 0 deletions src/main/java/com/cheise_proj/auditing/AuditConfiguration.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.cheise_proj.auditing;

import jakarta.persistence.EntityManagerFactory;
import org.hibernate.envers.AuditReader;
import org.hibernate.envers.AuditReaderFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class AuditConfiguration {

private final EntityManagerFactory entityManagerFactory;

AuditConfiguration(EntityManagerFactory entityManagerFactory) {
this.entityManagerFactory = entityManagerFactory;
}

@Bean
AuditReader auditReader() {
return AuditReaderFactory.get(entityManagerFactory.createEntityManager());
}
}
61 changes: 61 additions & 0 deletions src/main/java/com/cheise_proj/auditing/AuditRevisionEntity.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package com.cheise_proj.auditing;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.envers.RevisionEntity;
import org.hibernate.envers.RevisionNumber;
import org.hibernate.envers.RevisionTimestamp;

import java.util.*;

@Entity
@Table(name = "revision_info")
@RevisionEntity(AuditRevisionListener.class)
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Setter
@JsonIgnoreProperties({"hibernateLazyInitializer", "handler"})
@Builder
class AuditRevisionEntity {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "revision_seq")
@SequenceGenerator(
name = "revision_seq",
sequenceName = "seq_revision_id"
)
@RevisionNumber
private int id;

@Column(name = "revision_date")
@Temporal(TemporalType.TIMESTAMP)
@RevisionTimestamp
private Date date;

@Column(name = "user_name")
private String userName;

static List<Map<String, Object>> toRevisionResults(List<Object> results) {
List<Map<String, Object>> revisions = new ArrayList<>();

for (Object result : results) {
if (result instanceof Object[] resultArray) {
Map<String, Object> revisionData = new HashMap<>();
revisionData.put("entity", resultArray[0]);
revisionData.put("revision", resultArray[1]);
revisionData.put("revisionType", resultArray[2]);
if (resultArray.length > 3) {
revisionData.put("changes", resultArray[3]);
}
revisions.add(revisionData);
}
else if (result instanceof Object resultMap) {
Map<String, Object> revisionData = new HashMap<>();
revisionData.put("revision", resultMap);
revisions.add(revisionData);
}
}
return revisions;
}
}
13 changes: 13 additions & 0 deletions src/main/java/com/cheise_proj/auditing/AuditRevisionListener.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.cheise_proj.auditing;

import org.hibernate.envers.RevisionListener;

class AuditRevisionListener implements RevisionListener {

@Override
public void newRevision(Object revisionEntity) {
String currentUser = "System";
AuditRevisionEntity audit = (AuditRevisionEntity) revisionEntity;
audit.setUserName(currentUser);
}
}
5 changes: 5 additions & 0 deletions src/main/java/com/cheise_proj/auditing/Customer.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import lombok.*;
import org.hibernate.envers.AuditTable;
import org.hibernate.envers.Audited;
import org.hibernate.envers.RelationTargetAuditMode;
import org.springframework.data.annotation.CreatedBy;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedBy;
Expand All @@ -24,6 +27,8 @@
@ToString
@Builder
@EntityListeners(AuditingEntityListener.class)
@Audited(targetAuditMode = RelationTargetAuditMode.NOT_AUDITED, withModifiedFlag = true)
@AuditTable(value = "customers_audit")
class Customer {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,12 @@ ResponseEntity<Object> deleteCustomer(@PathVariable("id") Long id) {
return ResponseEntity.noContent().build();
}

@GetMapping(path = "/{id}/revisions")
ResponseEntity<List<Map<String, Object>>> getRevisions(
@PathVariable(name = "id") String customerId,
@RequestParam(required = false, defaultValue = "false") boolean fetch
) {
List<Map<String, Object>> results = customerService.getRevisions(Long.valueOf(customerId), fetch);
return ResponseEntity.ok(results);
}
}
24 changes: 23 additions & 1 deletion src/main/java/com/cheise_proj/auditing/CustomerService.java
Original file line number Diff line number Diff line change
@@ -1,16 +1,25 @@
package com.cheise_proj.auditing;

import jakarta.persistence.EntityNotFoundException;
import org.hibernate.envers.AuditReader;
import org.hibernate.envers.query.AuditEntity;
import org.hibernate.envers.query.AuditQuery;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Map;

@Service
class CustomerService {
private final CustomerRepository customerRepository;
private final AuditReader auditReader;


CustomerService(CustomerRepository customerRepository) {
CustomerService(CustomerRepository customerRepository, AuditReader auditReader) {
this.customerRepository = customerRepository;
this.auditReader = auditReader;
}

Customer createCustomer(CustomerDto.CreateCustomer customer) {
Expand Down Expand Up @@ -47,4 +56,17 @@ void deleteCustomer(Long id) {
customerRepository.deleteById(id);
}

List<Map<String, Object>> getRevisions(Long id, boolean fetchChanges) {
AuditQuery auditQuery;
if (fetchChanges) {
auditQuery = auditReader.createQuery()
.forRevisionsOfEntityWithChanges(Customer.class, true);
} else {
auditQuery = auditReader.createQuery()
.forRevisionsOfEntity(Customer.class, true);
}
auditQuery.add(AuditEntity.id().eq(id));
@SuppressWarnings("unchecked") List<Object> results = auditQuery.getResultList();
return AuditRevisionEntity.toRevisionResults(results);
}
}
13 changes: 13 additions & 0 deletions src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,16 @@ spring:
name: auditing
jpa:
open-in-view: false
hibernate:
ddl-auto: validate
id:
new_generator_mappings: false
properties:
hibernate:
envers:
audit_table_suffix: _AUDIT





Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
CREATE SEQUENCE IF NOT EXISTS seq_revision_id START WITH 1 INCREMENT BY 50;

CREATE TABLE revision_info
(
id INTEGER NOT NULL,
revision_date TIMESTAMP WITHOUT TIME ZONE,
user_name VARCHAR(255),
CONSTRAINT pk_revision_info PRIMARY KEY (id)
);
25 changes: 25 additions & 0 deletions src/main/resources/db/migration/V4__7_customer_audit_schema.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
CREATE TABLE customers_audit
(
rev INTEGER NOT NULL,
revtype SMALLINT,
id BIGINT NOT NULL,
first_name VARCHAR(100),
first_name_mod BOOLEAN,
last_name VARCHAR(100),
last_name_mod BOOLEAN,
email_address VARCHAR(255),
email_address_mod BOOLEAN,
addresses_mod BOOLEAN,
created_by VARCHAR(255),
created_by_mod BOOLEAN,
updated_by VARCHAR(255),
updated_by_mod BOOLEAN,
created_on TIMESTAMP WITHOUT TIME ZONE,
created_on_mod BOOLEAN,
updated_on TIMESTAMP WITHOUT TIME ZONE,
updated_on_mod BOOLEAN,
CONSTRAINT pk_customers_audit PRIMARY KEY (rev, id)
);

ALTER TABLE customers_audit
ADD CONSTRAINT FK_CUSTOMERS_AUDIT_ON_REV FOREIGN KEY (rev) REFERENCES revision_info (id);
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
CREATE TABLE customer_address_audit
(
rev INTEGER NOT NULL,
revtype SMALLINT,
id BIGINT NOT NULL,
street_address VARCHAR(255),
city VARCHAR(255),
state_code VARCHAR(255),
country VARCHAR(255),
zip_code VARCHAR(255),
customer_id BIGINT,
CONSTRAINT pk_customer_address_audit PRIMARY KEY (rev, id)
);

ALTER TABLE customer_address_audit
ADD CONSTRAINT FK_CUSTOMER_ADDRESS_AUDIT_ON_REV FOREIGN KEY (rev) REFERENCES revision_info (id);
57 changes: 57 additions & 0 deletions src/test/java/com/cheise_proj/auditing/CustomerControllerIT.java
Original file line number Diff line number Diff line change
Expand Up @@ -171,4 +171,61 @@ void deleteCustomer_returns_204() throws Exception {
).andExpectAll(MockMvcResultMatchers.status().isNoContent())
.andDo(result -> log.info("result: {}", result.getResponse().getContentAsString()));
}

@Test
void Customer_revisions_without_Changes_returns_200() throws Exception {
String customerLocation = (String) mockMvc.perform(MockMvcRequestBuilders.post("/customers")
.contentType(MediaType.APPLICATION_JSON)
.content(CustomerFixture.createCustomerWithAddress(objectMapper))

).andExpectAll(MockMvcResultMatchers.status().isCreated())
.andDo(result -> log.info("result: {}", result.getResponse().getHeaderValue("location")))
.andReturn().getResponse().getHeaderValue("location");

assert customerLocation != null;
mockMvc.perform(MockMvcRequestBuilders.put(customerLocation)
.contentType(MediaType.APPLICATION_JSON)
.content(CustomerFixture.updateCustomer(objectMapper))

).andExpectAll(MockMvcResultMatchers.status().isOk())
.andDo(result -> log.info("result: {}", result.getResponse().getContentAsString()));

mockMvc.perform(MockMvcRequestBuilders.get("%s/revisions".formatted(customerLocation))
.contentType(MediaType.APPLICATION_JSON)

).andExpectAll(
MockMvcResultMatchers.status().isOk(),
MockMvcResultMatchers.jsonPath("$.[0].revision").exists()
)
.andDo(result -> log.info("result: {}", result.getResponse().getContentAsString()));
}

@Test
void Customer_revisions_With_Changes_returns_200() throws Exception {
String customerLocation = (String) mockMvc.perform(MockMvcRequestBuilders.post("/customers")
.contentType(MediaType.APPLICATION_JSON)
.content(CustomerFixture.createCustomerWithAddress(objectMapper))

).andExpectAll(MockMvcResultMatchers.status().isCreated())
.andDo(result -> log.info("result: {}", result.getResponse().getHeaderValue("location")))
.andReturn().getResponse().getHeaderValue("location");

assert customerLocation != null;
mockMvc.perform(MockMvcRequestBuilders.put(customerLocation)
.contentType(MediaType.APPLICATION_JSON)
.content(CustomerFixture.updateCustomer(objectMapper))

).andExpectAll(MockMvcResultMatchers.status().isOk())
.andDo(result -> log.info("result: {}", result.getResponse().getContentAsString()));

mockMvc.perform(MockMvcRequestBuilders.get("%s/revisions?fetch=true".formatted(customerLocation))
.contentType(MediaType.APPLICATION_JSON)
).andExpectAll(
MockMvcResultMatchers.status().isOk(),
MockMvcResultMatchers.jsonPath("$.[0].revisionType").exists(),
MockMvcResultMatchers.jsonPath("$.[0].revision").exists()
)
.andDo(result -> log.info("result: {}", result.getResponse().getContentAsString()));
}

}
Loading

0 comments on commit 71fb9ea

Please sign in to comment.