diff --git a/build.gradle b/build.gradle index 6869fe0..23a060a 100644 --- a/build.gradle +++ b/build.gradle @@ -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' diff --git a/src/main/java/com/cheise_proj/auditing/Address.java b/src/main/java/com/cheise_proj/auditing/Address.java index dc2eead..438f550 100644 --- a/src/main/java/com/cheise_proj/auditing/Address.java +++ b/src/main/java/com/cheise_proj/auditing/Address.java @@ -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") @@ -12,6 +14,8 @@ @NoArgsConstructor @ToString @Builder +@AuditTable(value = "customer_address_audit") +@Audited class Address { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/src/main/java/com/cheise_proj/auditing/AuditConfiguration.java b/src/main/java/com/cheise_proj/auditing/AuditConfiguration.java new file mode 100644 index 0000000..ee1ad83 --- /dev/null +++ b/src/main/java/com/cheise_proj/auditing/AuditConfiguration.java @@ -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()); + } +} \ No newline at end of file diff --git a/src/main/java/com/cheise_proj/auditing/AuditRevisionEntity.java b/src/main/java/com/cheise_proj/auditing/AuditRevisionEntity.java new file mode 100644 index 0000000..be98545 --- /dev/null +++ b/src/main/java/com/cheise_proj/auditing/AuditRevisionEntity.java @@ -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> toRevisionResults(List results) { + List> revisions = new ArrayList<>(); + + for (Object result : results) { + if (result instanceof Object[] resultArray) { + Map 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 revisionData = new HashMap<>(); + revisionData.put("revision", resultMap); + revisions.add(revisionData); + } + } + return revisions; + } +} \ No newline at end of file diff --git a/src/main/java/com/cheise_proj/auditing/AuditRevisionListener.java b/src/main/java/com/cheise_proj/auditing/AuditRevisionListener.java new file mode 100644 index 0000000..036ade4 --- /dev/null +++ b/src/main/java/com/cheise_proj/auditing/AuditRevisionListener.java @@ -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); + } +} \ No newline at end of file diff --git a/src/main/java/com/cheise_proj/auditing/Customer.java b/src/main/java/com/cheise_proj/auditing/Customer.java index b767b00..991b287 100644 --- a/src/main/java/com/cheise_proj/auditing/Customer.java +++ b/src/main/java/com/cheise_proj/auditing/Customer.java @@ -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; @@ -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) diff --git a/src/main/java/com/cheise_proj/auditing/CustomerController.java b/src/main/java/com/cheise_proj/auditing/CustomerController.java index ce169c9..0e4bee2 100644 --- a/src/main/java/com/cheise_proj/auditing/CustomerController.java +++ b/src/main/java/com/cheise_proj/auditing/CustomerController.java @@ -56,4 +56,12 @@ ResponseEntity deleteCustomer(@PathVariable("id") Long id) { return ResponseEntity.noContent().build(); } + @GetMapping(path = "/{id}/revisions") + ResponseEntity>> getRevisions( + @PathVariable(name = "id") String customerId, + @RequestParam(required = false, defaultValue = "false") boolean fetch + ) { + List> results = customerService.getRevisions(Long.valueOf(customerId), fetch); + return ResponseEntity.ok(results); + } } diff --git a/src/main/java/com/cheise_proj/auditing/CustomerService.java b/src/main/java/com/cheise_proj/auditing/CustomerService.java index 88af38b..30db5e8 100644 --- a/src/main/java/com/cheise_proj/auditing/CustomerService.java +++ b/src/main/java/com/cheise_proj/auditing/CustomerService.java @@ -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) { @@ -47,4 +56,17 @@ void deleteCustomer(Long id) { customerRepository.deleteById(id); } + List> 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 results = auditQuery.getResultList(); + return AuditRevisionEntity.toRevisionResults(results); + } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 7a16f7a..bc70aa2 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -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 + + + + + diff --git a/src/main/resources/db/migration/V3__7_revision_info_schema.sql b/src/main/resources/db/migration/V3__7_revision_info_schema.sql new file mode 100644 index 0000000..52077a2 --- /dev/null +++ b/src/main/resources/db/migration/V3__7_revision_info_schema.sql @@ -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) +); \ No newline at end of file diff --git a/src/main/resources/db/migration/V4__7_customer_audit_schema.sql b/src/main/resources/db/migration/V4__7_customer_audit_schema.sql new file mode 100644 index 0000000..2259328 --- /dev/null +++ b/src/main/resources/db/migration/V4__7_customer_audit_schema.sql @@ -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); \ No newline at end of file diff --git a/src/main/resources/db/migration/V5__7_customer_address_audit_schema.sql b/src/main/resources/db/migration/V5__7_customer_address_audit_schema.sql new file mode 100644 index 0000000..0419aee --- /dev/null +++ b/src/main/resources/db/migration/V5__7_customer_address_audit_schema.sql @@ -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); \ No newline at end of file diff --git a/src/test/java/com/cheise_proj/auditing/CustomerControllerIT.java b/src/test/java/com/cheise_proj/auditing/CustomerControllerIT.java index 14404b8..39886f3 100644 --- a/src/test/java/com/cheise_proj/auditing/CustomerControllerIT.java +++ b/src/test/java/com/cheise_proj/auditing/CustomerControllerIT.java @@ -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())); + } + } \ No newline at end of file diff --git a/src/test/java/com/cheise_proj/auditing/CustomerServiceTest.java b/src/test/java/com/cheise_proj/auditing/CustomerServiceTest.java index 27aace7..da7f5b0 100644 --- a/src/test/java/com/cheise_proj/auditing/CustomerServiceTest.java +++ b/src/test/java/com/cheise_proj/auditing/CustomerServiceTest.java @@ -1,6 +1,11 @@ package com.cheise_proj.auditing; import jakarta.persistence.EntityNotFoundException; +import org.hibernate.envers.AuditReader; +import org.hibernate.envers.RevisionType; +import org.hibernate.envers.query.AuditQuery; +import org.hibernate.envers.query.AuditQueryCreator; +import org.hibernate.envers.query.criteria.AuditCriterion; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; @@ -12,9 +17,7 @@ import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; -import java.util.List; -import java.util.Optional; -import java.util.Set; +import java.util.*; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -25,6 +28,12 @@ class CustomerServiceTest { private CustomerService sut; @Mock private CustomerRepository customerRepository; + @Mock + private AuditReader auditReader; + @Mock + private AuditQuery auditQuery; + @Mock + private AuditQueryCreator auditQueryCreator; @Captor private ArgumentCaptor customerArgumentCaptor; @@ -185,4 +194,48 @@ void deleteCustomer() { sut.deleteCustomer(1L); Mockito.verify(customerRepository, Mockito.atMostOnce()).deleteById(ArgumentMatchers.anyLong()); } + + @Test + void testGetRevisionsWithoutChanges() { + Long customerId = 1L; + boolean fetchChanges = false; + + Mockito.when(auditReader.createQuery()).thenReturn(auditQueryCreator); + Mockito.when(auditReader.createQuery().forRevisionsOfEntity(Customer.class, true)).thenReturn(auditQuery); + Mockito.when(auditQuery.add(ArgumentMatchers.any(AuditCriterion.class))).thenReturn(auditQuery); + AuditRevisionEntity revision = AuditRevisionEntity.builder().userName("Test").date(new Date()).build(); + List mockResults = List.of(revision, revision); + Mockito.when(auditQuery.getResultList()).thenReturn(mockResults); + + List> revisions = sut.getRevisions(customerId, fetchChanges); + + assertEquals(2, revisions.size()); + assertNotNull(revisions.getFirst().get("revision")); + } + + @Test + void testGetRevisionsWithChanges() { + Long customerId = 1L; + boolean fetchChanges = true; + + Mockito.when(auditReader.createQuery()).thenReturn(auditQueryCreator); + Mockito.when(auditReader.createQuery().forRevisionsOfEntityWithChanges(Customer.class, true)).thenReturn(auditQuery); + Mockito.when(auditQuery.add(ArgumentMatchers.any(AuditCriterion.class))).thenReturn(auditQuery); + Customer customerRev1 = Customer.builder().id(1L).firstName("Troy").lastName("Hahn").emailAddress("troy.hahn@gmail.com").build(); + Customer customerRev2 = Customer.builder().id(1L).firstName("Theresia").lastName("Macejkovic").emailAddress("thres.mac@gmail.com").build(); + Set changes = Set.of("firstName", "lastName", "emailAddress"); + Object[] revision1 = new Object[]{customerRev1, AuditRevisionEntity.builder().userName("Test").build(), RevisionType.ADD}; + Object[] revision2 = new Object[]{customerRev2, AuditRevisionEntity.builder().userName("Test").build(), RevisionType.MOD, changes}; + List mockResults = List.of(revision1, revision2); + + Mockito.when(auditQuery.getResultList()).thenReturn(mockResults); + + List> revisions = sut.getRevisions(customerId, fetchChanges); + + assertEquals(2, revisions.size()); + assertNotNull(revisions.getLast().get("entity")); + assertNotNull(revisions.getLast().get("revision")); + assertEquals(RevisionType.MOD, revisions.getLast().get("revisionType")); + assertEquals(changes, revisions.getLast().get("changes")); + } } \ No newline at end of file