From 0906e1940ee9361a9d85666197863a1f26f7ea1e Mon Sep 17 00:00:00 2001 From: Barney Boisvert Date: Sun, 5 May 2024 14:35:29 -0700 Subject: [PATCH 1/4] use dtype for PlanItem's discriminator col (like Ingredient) --- src/main/java/com/brennaswitzer/cookbook/domain/PlanItem.java | 2 +- src/main/resources/db/changelog/gobrennas-2024.sql | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/brennaswitzer/cookbook/domain/PlanItem.java b/src/main/java/com/brennaswitzer/cookbook/domain/PlanItem.java index 02876d87..2ea7bae7 100644 --- a/src/main/java/com/brennaswitzer/cookbook/domain/PlanItem.java +++ b/src/main/java/com/brennaswitzer/cookbook/domain/PlanItem.java @@ -40,7 +40,7 @@ @SuppressWarnings("WeakerAccess") @Entity @Inheritance(strategy = InheritanceType.SINGLE_TABLE) -@DiscriminatorColumn(name = "_type") +@DiscriminatorColumn @DiscriminatorValue("item") public class PlanItem extends BaseEntity implements Named, MutableItem { diff --git a/src/main/resources/db/changelog/gobrennas-2024.sql b/src/main/resources/db/changelog/gobrennas-2024.sql index 9f1cc878..7eb870a3 100644 --- a/src/main/resources/db/changelog/gobrennas-2024.sql +++ b/src/main/resources/db/changelog/gobrennas-2024.sql @@ -375,3 +375,7 @@ SELECT id FROM ingredient WHERE dtype = 'PantryItem' ON CONFLICT DO NOTHING; + +--changeset barneyb:use-dtype-for-plan-items-discriminator +alter table plan_item + rename column _type to dtype; From bdd84ed677b0d1c5d0b99506ba45ac44876fa6e6 Mon Sep 17 00:00:00 2001 From: Barney Boisvert Date: Sun, 5 May 2024 14:18:20 -0700 Subject: [PATCH 2/4] set up envers for auditing hibernate entities --- pom.xml | 4 ++ .../domain/envers/BfsRevisionEntity.java | 44 +++++++++++++++++++ .../domain/envers/BfsRevisionListener.java | 39 ++++++++++++++++ .../cookbook/util/UserPrincipalAccess.java | 4 ++ src/main/resources/application.yml | 9 ++++ .../resources/db/changelog/gobrennas-2024.sql | 10 +++++ 6 files changed, 110 insertions(+) create mode 100644 src/main/java/com/brennaswitzer/cookbook/domain/envers/BfsRevisionEntity.java create mode 100644 src/main/java/com/brennaswitzer/cookbook/domain/envers/BfsRevisionListener.java diff --git a/pom.xml b/pom.xml index f8c5c3d7..bcf55d8c 100644 --- a/pom.xml +++ b/pom.xml @@ -196,6 +196,10 @@ ical4j 3.2.12 + + org.springframework.data + spring-data-envers + org.mockito mockito-junit-jupiter diff --git a/src/main/java/com/brennaswitzer/cookbook/domain/envers/BfsRevisionEntity.java b/src/main/java/com/brennaswitzer/cookbook/domain/envers/BfsRevisionEntity.java new file mode 100644 index 00000000..46045663 --- /dev/null +++ b/src/main/java/com/brennaswitzer/cookbook/domain/envers/BfsRevisionEntity.java @@ -0,0 +1,44 @@ +package com.brennaswitzer.cookbook.domain.envers; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.SequenceGenerator; +import jakarta.persistence.Table; +import lombok.Data; +import org.hibernate.envers.RevisionEntity; +import org.hibernate.envers.RevisionNumber; +import org.hibernate.envers.RevisionTimestamp; + +import java.time.Instant; + +@Entity +@Table(name = "AUD__REVINFO") // two underscores so it sorts first +@RevisionEntity(BfsRevisionListener.class) +@SequenceGenerator( + name = "aud_seq", + sequenceName = "aud_seq" +) +@Data +public class BfsRevisionEntity { + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "aud_seq") + @Column(name = "REV") + @RevisionNumber + private long id; + + @Column(name = "REV_TSTMP") + @RevisionTimestamp + private long timestamp; + + public Instant getInstant() { + return Instant.ofEpochMilli(timestamp); + } + + @Column(name = "REV_USERNAME") + private String username; + +} diff --git a/src/main/java/com/brennaswitzer/cookbook/domain/envers/BfsRevisionListener.java b/src/main/java/com/brennaswitzer/cookbook/domain/envers/BfsRevisionListener.java new file mode 100644 index 00000000..09c830f7 --- /dev/null +++ b/src/main/java/com/brennaswitzer/cookbook/domain/envers/BfsRevisionListener.java @@ -0,0 +1,39 @@ +package com.brennaswitzer.cookbook.domain.envers; + +import com.brennaswitzer.cookbook.util.UserPrincipalAccess; +import org.hibernate.envers.RevisionListener; +import org.springframework.context.ApplicationContext; + +/** + * Envers uses this to populate the custom {@link BfsRevisionEntity}. Due to + * partial Spring magic, this type is instantiated by the bean factory, but + * isn't quite a "real" bean with full wiring support. So just capture the + * {@link ApplicationContext} during construction, and then obtain the + * {@link UserPrincipalAccess} from it upon first need. This also happens to + * avoid a cyclic dependency, as this listener is needed for Hibernate to start, + * but Hibernate is needed by our implementation. + */ +public class BfsRevisionListener implements RevisionListener { + + private final ApplicationContext appCtx; + private UserPrincipalAccess principalAccess; + + public BfsRevisionListener(ApplicationContext appCtx) { + this.appCtx = appCtx; + } + + private UserPrincipalAccess getPrincipalAccess() { + if (principalAccess == null) { + principalAccess = appCtx.getBean(UserPrincipalAccess.class); + } + return principalAccess; + } + + @Override + public void newRevision(Object revisionEntity) { + if (revisionEntity instanceof BfsRevisionEntity rev) { + rev.setUsername(getPrincipalAccess().getUsername()); + } + } + +} diff --git a/src/main/java/com/brennaswitzer/cookbook/util/UserPrincipalAccess.java b/src/main/java/com/brennaswitzer/cookbook/util/UserPrincipalAccess.java index 0e893dcd..de7961b3 100644 --- a/src/main/java/com/brennaswitzer/cookbook/util/UserPrincipalAccess.java +++ b/src/main/java/com/brennaswitzer/cookbook/util/UserPrincipalAccess.java @@ -9,6 +9,10 @@ default Long getId() { return getUserPrincipal().getId(); } + default String getUsername() { + return getUserPrincipal().getUsername(); + } + UserPrincipal getUserPrincipal(); default User getUser() { diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index ed7b5cd2..7956b51c 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -29,6 +29,15 @@ spring: jdbc: lob: non_contextual_creation: true + org: + hibernate: + envers: + audit_table_prefix: "AUD_" + audit_table_suffix: "" + audit_strategy: org.hibernate.envers.strategy.ValidityAuditStrategy + audit_strategy_validity_store_revend_timestamp: true + audit_strategy_validity_revend_timestamp_numeric: true + cascade_delete_revision: true open-in-view: true # the default; just suppress the warning security: oauth2: diff --git a/src/main/resources/db/changelog/gobrennas-2024.sql b/src/main/resources/db/changelog/gobrennas-2024.sql index 7eb870a3..e63ac180 100644 --- a/src/main/resources/db/changelog/gobrennas-2024.sql +++ b/src/main/resources/db/changelog/gobrennas-2024.sql @@ -379,3 +379,13 @@ ON CONFLICT DO NOTHING; --changeset barneyb:use-dtype-for-plan-items-discriminator alter table plan_item rename column _type to dtype; + +--changeset barneyb:envers-auditing-setup +create sequence aud_seq start with 1 increment by 50; +create table aud__revinfo +( + rev bigint not null default nextval('aud_seq'), + rev_tstmp bigint not null default extract(epoch from now()) * 1000, + rev_username varchar, + primary key (rev) +); From 3e67f5710f6628badfd930fd4f0266cef6fee510 Mon Sep 17 00:00:00 2001 From: Barney Boisvert Date: Sun, 5 May 2024 15:46:26 -0700 Subject: [PATCH 3/4] set up auditing of core planned recipe data --- .../cookbook/domain/Ingredient.java | 2 + .../cookbook/domain/PantryItem.java | 5 + .../cookbook/domain/PlanBucket.java | 15 +-- .../cookbook/domain/PlanItem.java | 5 + .../brennaswitzer/cookbook/domain/Recipe.java | 26 ++--- .../repositories/IngredientRepository.java | 3 +- .../repositories/PlanBucketRepository.java | 3 +- .../repositories/PlanItemRepository.java | 3 +- .../resources/db/changelog/gobrennas-2024.sql | 109 ++++++++++++++++++ 9 files changed, 145 insertions(+), 26 deletions(-) diff --git a/src/main/java/com/brennaswitzer/cookbook/domain/Ingredient.java b/src/main/java/com/brennaswitzer/cookbook/domain/Ingredient.java index ab2f3d45..ed5c5b6f 100644 --- a/src/main/java/com/brennaswitzer/cookbook/domain/Ingredient.java +++ b/src/main/java/com/brennaswitzer/cookbook/domain/Ingredient.java @@ -13,6 +13,7 @@ import lombok.Getter; import lombok.Setter; import org.hibernate.annotations.BatchSize; +import org.hibernate.envers.Audited; import java.util.Comparator; import java.util.HashSet; @@ -42,6 +43,7 @@ public abstract class Ingredient extends BaseEntity implements Named, Labeled { @Getter @Setter + @Audited private String name; @ElementCollection diff --git a/src/main/java/com/brennaswitzer/cookbook/domain/PantryItem.java b/src/main/java/com/brennaswitzer/cookbook/domain/PantryItem.java index fe2156fa..54330d1f 100644 --- a/src/main/java/com/brennaswitzer/cookbook/domain/PantryItem.java +++ b/src/main/java/com/brennaswitzer/cookbook/domain/PantryItem.java @@ -12,6 +12,8 @@ import lombok.Setter; import org.hibernate.annotations.BatchSize; import org.hibernate.collection.spi.PersistentSet; +import org.hibernate.envers.Audited; +import org.hibernate.envers.NotAudited; import org.springframework.util.Assert; import java.util.Collections; @@ -24,6 +26,7 @@ @Entity @DiscriminatorValue("PantryItem") @JsonTypeName("PantryItem") +@Audited public class PantryItem extends Ingredient { public static final Comparator BY_STORE_ORDER = (a, b) -> { @@ -37,12 +40,14 @@ public class PantryItem extends Ingredient { }; // todo: make this user specific + @NotAudited private int storeOrder = 0; @ElementCollection @BatchSize(size = 50) @Column(name = "synonym") @Setter(AccessLevel.PRIVATE) + @NotAudited private Set synonyms; /** diff --git a/src/main/java/com/brennaswitzer/cookbook/domain/PlanBucket.java b/src/main/java/com/brennaswitzer/cookbook/domain/PlanBucket.java index 7056d741..6188ce54 100644 --- a/src/main/java/com/brennaswitzer/cookbook/domain/PlanBucket.java +++ b/src/main/java/com/brennaswitzer/cookbook/domain/PlanBucket.java @@ -11,6 +11,7 @@ import jakarta.validation.constraints.NotNull; import lombok.Getter; import lombok.Setter; +import org.hibernate.envers.Audited; import java.time.LocalDate; import java.util.Collection; @@ -19,29 +20,23 @@ @Table(name = "plan_bucket", uniqueConstraints = { @UniqueConstraint(columnNames = { "plan_id", "name" }) }) +@Getter +@Setter public class PlanBucket extends BaseEntity { @NotNull - @Getter - @Setter @ManyToOne(fetch = FetchType.LAZY) private Plan plan; - @Getter - @Setter + @Audited private String name; - @Getter - @Setter + @Audited private LocalDate date; - @Getter - @Setter @OneToMany(mappedBy = "bucket") private Collection items; - @Getter - @Setter @Column(name = "mod_count") private int modCount; diff --git a/src/main/java/com/brennaswitzer/cookbook/domain/PlanItem.java b/src/main/java/com/brennaswitzer/cookbook/domain/PlanItem.java index 2ea7bae7..16518b65 100644 --- a/src/main/java/com/brennaswitzer/cookbook/domain/PlanItem.java +++ b/src/main/java/com/brennaswitzer/cookbook/domain/PlanItem.java @@ -17,6 +17,7 @@ import lombok.val; import org.hibernate.Hibernate; import org.hibernate.annotations.BatchSize; +import org.hibernate.envers.Audited; import java.util.ArrayList; import java.util.Collection; @@ -73,6 +74,7 @@ public class PlanItem extends BaseEntity implements Named, MutableItem { @NotNull @Getter @Setter + @Audited private String name; @Getter @@ -82,6 +84,7 @@ public class PlanItem extends BaseEntity implements Named, MutableItem { @Column(name = "status_id") @Getter @Setter + @Audited private PlanItemStatus status = PlanItemStatus.NEEDED; @Embedded @@ -125,11 +128,13 @@ public class PlanItem extends BaseEntity implements Named, MutableItem { fetch = FetchType.LAZY) @Getter @Setter + @Audited private Ingredient ingredient; @Getter @Setter @ManyToOne(fetch = FetchType.LAZY) + @Audited private PlanBucket bucket; @Getter diff --git a/src/main/java/com/brennaswitzer/cookbook/domain/Recipe.java b/src/main/java/com/brennaswitzer/cookbook/domain/Recipe.java index c657d0d2..51c77bdf 100644 --- a/src/main/java/com/brennaswitzer/cookbook/domain/Recipe.java +++ b/src/main/java/com/brennaswitzer/cookbook/domain/Recipe.java @@ -16,41 +16,45 @@ import jakarta.persistence.PreUpdate; import lombok.Getter; import lombok.Setter; +import org.hibernate.envers.Audited; +import org.hibernate.envers.NotAudited; import java.util.Collection; import java.util.LinkedList; import java.util.List; +@Setter @Entity @DiscriminatorValue("Recipe") @JsonTypeName("Recipe") +@Audited public class Recipe extends Ingredient implements AggregateIngredient, Owned { // this will gracefully store the same way as an @Embedded Acl will @ManyToOne(fetch = FetchType.LAZY) + @NotAudited private User owner; // these will gracefully emulate AccessControlled's owner property @JsonIgnore // but hide it from the client :) public User getOwner() { return owner; } - public void setOwner(User owner) { this.owner = owner; } // end access control emulation @Getter - @Setter + @NotAudited private String externalUrl; @Getter - @Setter + @NotAudited private String directions; @Getter - @Setter + @NotAudited private Integer yield; @Getter - @Setter + @NotAudited private Integer calories; @Embedded @@ -61,6 +65,7 @@ public class Recipe extends Ingredient implements AggregateIngredient, Owned { @AttributeOverride(name = "focusTop", column = @Column(name = "photo_focus_top")), @AttributeOverride(name = "focusLeft", column = @Column(name = "photo_focus_left")) }) + @NotAudited private Photo photo; public Photo getPhoto() { return getPhoto(false); @@ -71,9 +76,7 @@ public Photo getPhoto(boolean create) { } return this.photo; } - public void setPhoto(Photo photo) { - this.photo = photo; - } + public void setPhoto(S3File file) { getPhoto(true).setFile(file); } @@ -90,11 +93,12 @@ public boolean hasPhoto() { * Time is stored in milliseconds */ @Getter - @Setter + @NotAudited private Integer totalTime; @ElementCollection @OrderBy("_idx, raw") + @NotAudited private List ingredients; public Recipe() { @@ -108,10 +112,6 @@ public List getIngredients() { return this.ingredients; } - public void setIngredients(List ingredients) { - this.ingredients = ingredients; - } - @Override public void addIngredient(Quantity quantity, Ingredient ingredient, String preparation) { ensureIngredients(); diff --git a/src/main/java/com/brennaswitzer/cookbook/repositories/IngredientRepository.java b/src/main/java/com/brennaswitzer/cookbook/repositories/IngredientRepository.java index 77192c88..720179dd 100644 --- a/src/main/java/com/brennaswitzer/cookbook/repositories/IngredientRepository.java +++ b/src/main/java/com/brennaswitzer/cookbook/repositories/IngredientRepository.java @@ -1,7 +1,8 @@ package com.brennaswitzer.cookbook.repositories; import com.brennaswitzer.cookbook.domain.Ingredient; +import org.springframework.data.repository.history.RevisionRepository; -public interface IngredientRepository extends BaseEntityRepository { +public interface IngredientRepository extends BaseEntityRepository, RevisionRepository { } diff --git a/src/main/java/com/brennaswitzer/cookbook/repositories/PlanBucketRepository.java b/src/main/java/com/brennaswitzer/cookbook/repositories/PlanBucketRepository.java index 46495242..9f44f4d4 100644 --- a/src/main/java/com/brennaswitzer/cookbook/repositories/PlanBucketRepository.java +++ b/src/main/java/com/brennaswitzer/cookbook/repositories/PlanBucketRepository.java @@ -1,10 +1,11 @@ package com.brennaswitzer.cookbook.repositories; import com.brennaswitzer.cookbook.domain.PlanBucket; +import org.springframework.data.repository.history.RevisionRepository; import java.util.stream.Stream; -public interface PlanBucketRepository extends BaseEntityRepository { +public interface PlanBucketRepository extends BaseEntityRepository, RevisionRepository { Stream streamAllByPlanIdAndDateIsNotNull(Long id); diff --git a/src/main/java/com/brennaswitzer/cookbook/repositories/PlanItemRepository.java b/src/main/java/com/brennaswitzer/cookbook/repositories/PlanItemRepository.java index 6d4d1320..6d39e43c 100644 --- a/src/main/java/com/brennaswitzer/cookbook/repositories/PlanItemRepository.java +++ b/src/main/java/com/brennaswitzer/cookbook/repositories/PlanItemRepository.java @@ -5,10 +5,11 @@ import com.brennaswitzer.cookbook.domain.PlanItemStatus; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.history.RevisionRepository; import java.time.Instant; -public interface PlanItemRepository extends BaseEntityRepository { +public interface PlanItemRepository extends BaseEntityRepository, RevisionRepository { int countByStatusNot(PlanItemStatus status); diff --git a/src/main/resources/db/changelog/gobrennas-2024.sql b/src/main/resources/db/changelog/gobrennas-2024.sql index e63ac180..aeec3d9e 100644 --- a/src/main/resources/db/changelog/gobrennas-2024.sql +++ b/src/main/resources/db/changelog/gobrennas-2024.sql @@ -389,3 +389,112 @@ create table aud__revinfo rev_username varchar, primary key (rev) ); + +--changeset barneyb:add-auditing-for-planned-recipes-and-initialize-history +create table aud_ingredient +( + id bigint not null, + rev bigint not null, + revtype smallint, + revend bigint, + revend_tstmp bigint, + -- data columns + dtype varchar not null, + name varchar, + constraint pk_aud_ingredient primary key (id, rev), + constraint fk_aud_ingredient_rev + foreign key (rev) + references aud__revinfo + on delete cascade, + constraint fk_aud_ingredient_revend + foreign key (revend) + references aud__revinfo + on delete cascade +); + +insert into aud__revinfo (rev_username) +values ('system'); + +insert into aud_ingredient +select id + , (select max(rev) from aud__revinfo) + , 0 -- add + , null + , null + , dtype + , name +from ingredient; + +create table aud_plan_bucket +( + id bigint not null, + rev bigint not null, + revtype smallint, + revend bigint, + revend_tstmp bigint, + -- data columns + name varchar, + date date, + constraint pk_aud_plan_bucket primary key (id, rev), + constraint fk_aud_plan_bucket_rev + foreign key (rev) + references aud__revinfo + on delete cascade, + constraint fk_aud_plan_bucket_revend + foreign key (revend) + references aud__revinfo + on delete cascade +); + +insert into aud__revinfo (rev_username) +values ('system'); + +insert into aud_plan_bucket +select id + , (select max(rev) from aud__revinfo) + , 0 -- add + , null + , null + , name + , date +from plan_bucket; + +create table aud_plan_item +( + id bigint not null, + rev bigint not null, + revtype smallint, + revend bigint, + revend_tstmp bigint, + -- data columns + dtype varchar not null, + name varchar, + status_id bigint, + ingredient_id bigint, + bucket_id bigint, + constraint pk_aud_plan_item primary key (id, rev), + constraint fk_aud_plan_item_rev + foreign key (rev) + references aud__revinfo + on delete cascade, + constraint fk_aud_plan_item_revend + foreign key (revend) + references aud__revinfo + on delete cascade +); + +insert into aud__revinfo (rev_username) +values ('system'); + +insert into aud_plan_item +select id + , (select max(rev) from aud__revinfo) + , 0 -- add + , null + , null + , dtype + , name + , status_id + , ingredient_id + , bucket_id +from plan_item; From ef717261a78a32c74662fb9fee82775a56116bcc Mon Sep 17 00:00:00 2001 From: Barney Boisvert Date: Sun, 5 May 2024 20:01:10 -0700 Subject: [PATCH 4/4] don't audit plan history --- src/main/java/com/brennaswitzer/cookbook/domain/Recipe.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/brennaswitzer/cookbook/domain/Recipe.java b/src/main/java/com/brennaswitzer/cookbook/domain/Recipe.java index 2ba99337..3ab254b7 100644 --- a/src/main/java/com/brennaswitzer/cookbook/domain/Recipe.java +++ b/src/main/java/com/brennaswitzer/cookbook/domain/Recipe.java @@ -15,7 +15,6 @@ import jakarta.persistence.OrderBy; import jakarta.persistence.PrePersist; import jakarta.persistence.PreUpdate; -import lombok.AccessLevel; import lombok.Getter; import lombok.Setter; import org.hibernate.envers.Audited; @@ -106,6 +105,7 @@ public boolean hasPhoto() { @Getter @OneToMany(mappedBy = "recipe") + @NotAudited private Collection planHistory; public Recipe() {