MME-13 Fix hibernate delete problems with better equals/hashcode and easier ids.

This commit is contained in:
Jesse Brault 2026-02-12 15:46:38 -06:00
parent 6eead31193
commit fd08a4df13
10 changed files with 205 additions and 108 deletions

View File

@ -1,6 +1,12 @@
package app.mealsmadeeasy.api.recipe; package app.mealsmadeeasy.api.recipe;
import app.mealsmadeeasy.api.PostgresTestsExtension; import app.mealsmadeeasy.api.PostgresTestsExtension;
import app.mealsmadeeasy.api.image.Image;
import app.mealsmadeeasy.api.image.ImageRepository;
import app.mealsmadeeasy.api.recipe.comment.RecipeComment;
import app.mealsmadeeasy.api.recipe.comment.RecipeCommentRepository;
import app.mealsmadeeasy.api.recipe.star.RecipeStar;
import app.mealsmadeeasy.api.recipe.star.RecipeStarRepository;
import app.mealsmadeeasy.api.user.User; import app.mealsmadeeasy.api.user.User;
import app.mealsmadeeasy.api.user.UserRepository; import app.mealsmadeeasy.api.user.UserRepository;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@ -16,6 +22,7 @@ import java.util.Set;
import java.util.UUID; import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest @SpringBootTest
@ExtendWith(PostgresTestsExtension.class) @ExtendWith(PostgresTestsExtension.class)
@ -27,6 +34,15 @@ public class RecipeRepositoryTests {
@Autowired @Autowired
private UserRepository userRepository; private UserRepository userRepository;
@Autowired
private RecipeStarRepository recipeStarRepository;
@Autowired
private RecipeCommentRepository recipeCommentRepository;
@Autowired
private ImageRepository imageRepository;
private User seedUser() { private User seedUser() {
final String uuid = UUID.randomUUID().toString(); final String uuid = UUID.randomUUID().toString();
final User draft = User.getDefaultDraft(); final User draft = User.getDefaultDraft();
@ -36,6 +52,15 @@ public class RecipeRepositoryTests {
return this.userRepository.save(draft); return this.userRepository.save(draft);
} }
private Recipe seedRecipe(User owner) {
final Recipe recipe = new Recipe();
recipe.setOwner(owner);
recipe.setTitle("Test Recipe");
recipe.setSlug(UUID.randomUUID().toString());
recipe.setRawText("Hello, World!");
return this.recipeRepository.save(recipe);
}
@Test @Test
public void findsAllPublicRecipes() { public void findsAllPublicRecipes() {
final Recipe publicRecipe = new Recipe(); final Recipe publicRecipe = new Recipe();
@ -101,4 +126,59 @@ public class RecipeRepositoryTests {
assertThat(viewable.size()).isEqualTo(0); assertThat(viewable.size()).isEqualTo(0);
} }
@Test
public void deleteRecipeWithStar() {
final User user = this.seedUser();
final Recipe recipe = this.seedRecipe(user);
final RecipeStar star = new RecipeStar();
star.setOwner(user);
star.setRecipe(recipe);
recipe.getStars().add(star);
this.recipeStarRepository.save(star);
assertDoesNotThrow(() -> this.recipeRepository.delete(recipe));
}
@Test
public void deleteRecipeWithComment() {
final User user = this.seedUser();
final Recipe recipe = this.seedRecipe(user);
final RecipeComment recipeComment = new RecipeComment();
recipeComment.setRecipe(recipe);
recipeComment.setOwner(user);
recipeComment.setRawText("Hello, World!");
recipe.getComments().add(recipeComment);
this.recipeCommentRepository.save(recipeComment);
assertDoesNotThrow(() -> this.recipeRepository.delete(recipe));
}
@Test
public void deleteRecipeWithViewer() {
final User user = this.seedUser();
final Recipe recipe = this.seedRecipe(user);
recipe.getViewers().add(user);
this.recipeRepository.save(recipe);
assertDoesNotThrow(() -> this.recipeRepository.delete(recipe));
}
@Test
public void deleteRecipeWithMainImage() {
final User user = this.seedUser();
final Recipe recipe = this.seedRecipe(user);
final Image mainImage = new Image();
mainImage.setUserFilename(UUID.randomUUID().toString());
mainImage.setMimeType("image/jpeg");
mainImage.setObjectName(UUID.randomUUID().toString());
mainImage.setOwner(user);
final Image savedMainImage = this.imageRepository.save(mainImage);
recipe.setSlug(UUID.randomUUID().toString());
recipe.setTitle("Hello, World!");
recipe.setRawText("# Hello, World!");
recipe.setMainImage(savedMainImage);
this.recipeRepository.save(recipe);
assertDoesNotThrow(() -> this.recipeRepository.delete(recipe));
}
} }

View File

@ -54,10 +54,8 @@ public class RecipeStarRepositoryTests {
final Recipe recipe = this.getTestRecipe(owner); final Recipe recipe = this.getTestRecipe(owner);
final RecipeStar starDraft = new RecipeStar(); final RecipeStar starDraft = new RecipeStar();
final RecipeStarId starId = new RecipeStarId(); starDraft.setRecipe(recipe);
starId.setRecipeId(recipe.getId()); starDraft.setOwner(owner);
starId.setOwnerId(owner.getId());
starDraft.setId(starId);
this.recipeStarRepository.save(starDraft); this.recipeStarRepository.save(starDraft);
assertThat( assertThat(

View File

@ -5,15 +5,20 @@ import app.mealsmadeeasy.api.recipe.comment.RecipeComment;
import app.mealsmadeeasy.api.recipe.star.RecipeStar; import app.mealsmadeeasy.api.recipe.star.RecipeStar;
import app.mealsmadeeasy.api.user.User; import app.mealsmadeeasy.api.user.User;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.Data; import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.Nullable;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.util.HashSet; import java.util.HashSet;
import java.util.Objects;
import java.util.Set; import java.util.Set;
@Entity @Entity
@Data @Getter
@Setter
@ToString
public class Recipe { public class Recipe {
@Id @Id
@ -87,4 +92,20 @@ public class Recipe {
this.modified = OffsetDateTime.now(); this.modified = OffsetDateTime.now();
} }
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o instanceof Recipe other) {
return Objects.equals(this.owner.getUsername(), other.owner.getUsername())
&& Objects.equals(this.slug, other.slug);
} else {
return false;
}
}
@Override
public int hashCode() {
return Objects.hash(this.owner.getUsername(), this.slug);
}
} }

View File

@ -2,30 +2,58 @@ package app.mealsmadeeasy.api.recipe.star;
import app.mealsmadeeasy.api.recipe.Recipe; import app.mealsmadeeasy.api.recipe.Recipe;
import app.mealsmadeeasy.api.user.User; import app.mealsmadeeasy.api.user.User;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.Data; import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.util.Objects;
@Entity @Entity
@Table(name = "recipe_star") @Table(name = "recipe_star")
@Data @Getter
@Setter
@ToString
public final class RecipeStar { public final class RecipeStar {
@EmbeddedId @Id
private RecipeStarId id; @GeneratedValue(strategy = GenerationType.IDENTITY)
@Basic(optional = false)
@Column(updatable = false)
@JsonIgnore
private Long id;
@ManyToOne @ManyToOne
@MapsId("ownerId")
@JoinColumn(name = "owner_id", nullable = false, updatable = false) @JoinColumn(name = "owner_id", nullable = false, updatable = false)
@JsonIgnore
private User owner; private User owner;
@ManyToOne @ManyToOne
@MapsId("recipeId")
@JoinColumn(name = "recipe_id", nullable = false, updatable = false) @JoinColumn(name = "recipe_id", nullable = false, updatable = false)
@JsonIgnore
private Recipe recipe; private Recipe recipe;
@Column(nullable = false, updatable = false) @Basic(optional = false)
@Column(updatable = false)
private OffsetDateTime timestamp = OffsetDateTime.now(); private OffsetDateTime timestamp = OffsetDateTime.now();
@PrePersist
public void prePersist() {
this.timestamp = OffsetDateTime.now();
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o instanceof RecipeStar other) {
return Objects.equals(this.recipe.getOwner().getUsername(), other.getOwner().getUsername())
&& Objects.equals(this.recipe.getSlug(), other.getRecipe().getSlug())
&& Objects.equals(this.owner.getUsername(), other.getOwner().getUsername());
} else {
return false;
}
}
} }

View File

@ -1,17 +0,0 @@
package app.mealsmadeeasy.api.recipe.star;
import jakarta.persistence.Column;
import jakarta.persistence.Embeddable;
import lombok.Data;
@Embeddable
@Data
public class RecipeStarId {
@Column(name = "owner_id", nullable = false)
private Integer ownerId;
@Column(name = "recipe_id", nullable = false)
private Integer recipeId;
}

View File

@ -2,22 +2,18 @@ package app.mealsmadeeasy.api.recipe.star;
import jakarta.transaction.Transactional; import jakarta.transaction.Transactional;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.Query;
import java.util.Optional; import java.util.Optional;
public interface RecipeStarRepository extends JpaRepository<RecipeStar, Long> { public interface RecipeStarRepository extends JpaRepository<RecipeStar, Long> {
@Query("SELECT star FROM RecipeStar star WHERE star.id.recipeId = ?1 AND star.id.ownerId = ?2")
Optional<RecipeStar> findByRecipeIdAndOwnerId(Integer recipeId, Integer ownerId); Optional<RecipeStar> findByRecipeIdAndOwnerId(Integer recipeId, Integer ownerId);
@Query("SELECT count(rs) > 0 FROM RecipeStar rs, Recipe r WHERE r.owner.username = ?1 AND r.slug = ?2 AND r.id = rs.id.recipeId AND rs.id.ownerId = ?3") @Query("SELECT count(rs) > 0 FROM RecipeStar rs, Recipe r WHERE r.owner.username = ?1 AND r.slug = ?2 AND r.id = rs.recipe.id AND rs.owner.id = ?3")
boolean isStarer(String ownerUsername, String slug, Integer viewerId); boolean isStarer(String ownerUsername, String slug, Integer viewerId);
@Modifying
@Transactional @Transactional
@Query("DELETE FROM RecipeStar star WHERE star.id.recipeId = ?1 AND star.id.ownerId = ?2")
void deleteByRecipeIdAndOwnerId(Integer recipeId, Integer ownerId); void deleteByRecipeIdAndOwnerId(Integer recipeId, Integer ownerId);
} }

View File

@ -1,15 +1,68 @@
package app.mealsmadeeasy.api.recipe.star; package app.mealsmadeeasy.api.recipe.star;
import app.mealsmadeeasy.api.recipe.Recipe;
import app.mealsmadeeasy.api.recipe.RecipeRepository;
import app.mealsmadeeasy.api.recipe.RecipeService;
import app.mealsmadeeasy.api.user.User; import app.mealsmadeeasy.api.user.User;
import app.mealsmadeeasy.api.user.UserRepository;
import app.mealsmadeeasy.api.util.NoSuchEntityWithIdException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.Optional; import java.util.Optional;
public interface RecipeStarService { @Service
RecipeStar create(Integer recipeId, Integer ownerId); @RequiredArgsConstructor
RecipeStar create(String recipeOwnerUsername, String recipeSlug, User starer); public class RecipeStarService {
Optional<RecipeStar> find(String recipeOwnerUsername, String recipeSlug, User starer); private final RecipeStarRepository recipeStarRepository;
private final UserRepository userRepository;
private final RecipeRepository recipeRepository;
private final RecipeService recipeService;
public RecipeStar create(Recipe recipe, User owner) {
final RecipeStar recipeStar = new RecipeStar();
recipeStar.setOwner(owner);
recipeStar.setRecipe(recipe);
return this.recipeStarRepository.save(recipeStar);
}
@Deprecated
public RecipeStar create(Integer recipeId, Integer ownerId) {
return this.recipeStarRepository.findByRecipeIdAndOwnerId(recipeId, ownerId)
.orElseGet(() -> {
final User owner = this.userRepository.findById((long) ownerId).orElseThrow(
() -> new NoSuchEntityWithIdException(User.class, ownerId)
);
final Recipe recipe = this.recipeRepository.findById(recipeId).orElseThrow(
() -> new NoSuchEntityWithIdException(Recipe.class, recipeId)
);
return this.create(recipe, owner);
});
}
@Deprecated
public RecipeStar create(String recipeOwnerUsername, String recipeSlug, User starer) {
final Recipe recipe = this.recipeService.getByUsernameAndSlug(recipeOwnerUsername, recipeSlug, starer);
final Optional<RecipeStar> existing = this.recipeStarRepository.findByRecipeIdAndOwnerId(
recipe.getId(),
starer.getId()
);
return existing.orElseGet(() -> this.create(recipe, starer));
}
public Optional<RecipeStar> find(String recipeOwnerUsername, String recipeSlug, User starer) {
final Recipe recipe = this.recipeService.getByUsernameAndSlug(recipeOwnerUsername, recipeSlug, starer);
return this.recipeStarRepository.findByRecipeIdAndOwnerId(recipe.getId(), starer.getId());
}
public void delete(Integer recipeId, Integer ownerId) {
this.recipeStarRepository.deleteByRecipeIdAndOwnerId(recipeId, ownerId);
}
public void delete(String recipeOwnerUsername, String recipeSlug, User starer) {
final Recipe recipe = this.recipeService.getByUsernameAndSlug(recipeOwnerUsername, recipeSlug, starer);
this.delete(recipe.getId(), starer.getId());
}
void delete(Integer recipeId, Integer ownerId);
void delete(String recipeOwnerUsername, String recipeSlug, User starer);
} }

View File

@ -1,63 +0,0 @@
package app.mealsmadeeasy.api.recipe.star;
import app.mealsmadeeasy.api.recipe.Recipe;
import app.mealsmadeeasy.api.recipe.RecipeService;
import app.mealsmadeeasy.api.user.User;
import org.springframework.stereotype.Service;
import java.time.OffsetDateTime;
import java.util.Optional;
@Service
public class RecipeStarServiceImpl implements RecipeStarService {
private final RecipeStarRepository recipeStarRepository;
private final RecipeService recipeService;
public RecipeStarServiceImpl(RecipeStarRepository recipeStarRepository, RecipeService recipeService) {
this.recipeStarRepository = recipeStarRepository;
this.recipeService = recipeService;
}
@Override
public RecipeStar create(Integer recipeId, Integer ownerId) {
final RecipeStar draft = new RecipeStar();
final RecipeStarId id = new RecipeStarId();
id.setRecipeId(recipeId);
id.setOwnerId(ownerId);
draft.setId(id);
draft.setTimestamp(OffsetDateTime.now());
return this.recipeStarRepository.save(draft);
}
@Override
public RecipeStar create(String recipeOwnerUsername, String recipeSlug, User starer) {
final Recipe recipe = this.recipeService.getByUsernameAndSlug(recipeOwnerUsername, recipeSlug, starer);
final Optional<RecipeStar> existing = this.recipeStarRepository.findByRecipeIdAndOwnerId(
recipe.getId(),
starer.getId()
);
if (existing.isPresent()) {
return existing.get();
}
return this.create(recipe.getId(), starer.getId());
}
@Override
public Optional<RecipeStar> find(String recipeOwnerUsername, String recipeSlug, User starer) {
final Recipe recipe = this.recipeService.getByUsernameAndSlug(recipeOwnerUsername, recipeSlug, starer);
return this.recipeStarRepository.findByRecipeIdAndOwnerId(recipe.getId(), starer.getId());
}
@Override
public void delete(Integer recipeId, Integer ownerId) {
this.recipeStarRepository.deleteByRecipeIdAndOwnerId(recipeId, ownerId);
}
@Override
public void delete(String recipeOwnerUsername, String recipeSlug, User starer) {
final Recipe recipe = this.recipeService.getByUsernameAndSlug(recipeOwnerUsername, recipeSlug, starer);
this.delete(recipe.getId(), starer.getId());
}
}

View File

@ -0,0 +1,3 @@
ALTER TABLE recipe_star ADD COLUMN id INT GENERATED ALWAYS AS IDENTITY;
ALTER TABLE recipe_star DROP CONSTRAINT recipe_star_pkey;
ALTER TABLE recipe_star ADD PRIMARY KEY (id);

View File

@ -2,12 +2,11 @@ package app.mealsmadeeasy.api.recipe;
import app.mealsmadeeasy.api.matchers.ContainsItemsMatcher; import app.mealsmadeeasy.api.matchers.ContainsItemsMatcher;
import app.mealsmadeeasy.api.recipe.star.RecipeStar; import app.mealsmadeeasy.api.recipe.star.RecipeStar;
import app.mealsmadeeasy.api.recipe.star.RecipeStarId;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
public class ContainsRecipeStarsMatcher extends ContainsItemsMatcher<RecipeStar, RecipeStar, RecipeStarId> { public class ContainsRecipeStarsMatcher extends ContainsItemsMatcher<RecipeStar, RecipeStar, Long> {
public static ContainsRecipeStarsMatcher containsStars(RecipeStar... expected) { public static ContainsRecipeStarsMatcher containsStars(RecipeStar... expected) {
return new ContainsRecipeStarsMatcher(expected); return new ContainsRecipeStarsMatcher(expected);
@ -19,8 +18,7 @@ public class ContainsRecipeStarsMatcher extends ContainsItemsMatcher<RecipeStar,
o -> o instanceof RecipeStar, o -> o instanceof RecipeStar,
RecipeStar::getId, RecipeStar::getId,
RecipeStar::getId, RecipeStar::getId,
(id0, id1) -> Objects.equals(id0.getRecipeId(), id1.getRecipeId()) Objects::equals
&& Objects.equals(id0.getOwnerId(), id1.getOwnerId())
); );
} }