Проблема с правильным использованием удаления потерянных файлов

  • Я использую Spring Boot 2.7.18 с Java 17 и Maven.
  • Пожалуйста, относитесь к этому исключительно в образовательных целях (не кричите на меня что фильтрация и обновление должны работать не так, я это знаю).
  • Я создал этот простой пример после прочтения статьи о «Лучший способ справиться с спящим режимом MultipleBagFetchException» и споткнулся по поводу проблемы с правильным удалением сирот, отсюда и вопрос.

У меня есть следующие объекты:

@Entity
@Getter
@Setter
@Table(name = "post")
@SequenceGenerator(name = "post_seq", sequenceName = "post_id_seq", allocationSize = 1)
public class Post {

    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "post_seq")
    Long id;

    @ToString.Exclude
    @EqualsAndHashCode.Exclude
    @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true)
    List<Comment> comments;

    @ToString.Exclude
    @EqualsAndHashCode.Exclude
    @OneToMany(fetch = FetchType.EAGER, mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true)
    List<Metadata> metadata;

    @Enumerated(EnumType.STRING)
    PostStatus status;
}

и

@Entity
@Getter
@Setter
@Table(name = "metadata")
@SequenceGenerator(name = "post_metadata_seq", sequenceName = "post_metadata_id_seq", allocationSize = 1)
public class Metadata {

    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "post_metadata_seq")
    Long id;

    @ManyToOne
    @JoinColumn(name = "post_id")
    Post post;

    @Column(name = "\"key\"")
    String key;

    @Column(name = "\"value\"")
    String value;
}

и

@Entity
@Getter
@Setter
@Table(name = "comment")
@SequenceGenerator(name = "post_comment_seq", sequenceName = "post_comment_id_seq", allocationSize = 1)
public class Comment {

    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "post_comment_seq")
    Long id;

    @ManyToOne
    @JoinColumn(name = "post_id")
    Post post;

    String text;
}

и соответствующие DTO:

@Builder(toBuilder = true)
@Getter
@Jacksonized
public class PostDTO {
    Long id;
    MetadataDTO metadata;
    List<CommentDTO> comments;
    PostStatus status;
}

и

@ToString
@Value(staticConstructor = "of")
public class MetadataDTO {

    Map<String, String> metadata;

    @JsonCreator
    @Builder(toBuilder = true)
    public MetadataDTO(@JsonProperty("metadata") final Map<String, String> metadata) {
        this.metadata = Optional.ofNullable(metadata)
                .map(HashMap::new)
                .map(Collections::unmodifiableMap)
                .orElse(Map.of());
    }
}

услуга:

@Service
@RequiredArgsConstructor
public class PostService {

    public final PersistablePostMapper persistablePostMapper;
    public final PostRepository postRepository;
    public final EntityManager entityManager;

    @Transactional
    public PostDTO saveAll(final PostDTO postDTO) throws BadRequestException {
        String referenceId = postDTO.getMetadata().getMetadata().get("reference_id");
        List<Long> alreadyClosedPostIds = findAllByReferenceId(Long.valueOf(referenceId)).stream()
                .filter(p -> PostStatus.CLOSED.equals(p.getStatus()))
                .map(PostDTO::getId)
                .toList();
        if (alreadyClosedPostIds.contains(postDTO.getId())) {
            throw new BadRequestException();
        }

        return save(postDTO);
    }

    public List<PostDTO> findAllByReferenceId(final Long referenceId) {
        List<Post> posts = entityManager.createQuery("""
                        select distinct p
                        from Post p
                        left join fetch p.metadata m
                        where m.key=:key and m.value=:value""", Post.class)
                .setParameter("key", "reference_id")
                .setParameter("value", String.valueOf(referenceId))
                .setHint(QueryHints.PASS_DISTINCT_THROUGH, false)
                .getResultList();

        posts = entityManager.createQuery("""
                                select distinct p
                                from Post p
                                left join fetch p.comments l
                                where p in :posts"""
                        , Post.class)
                .setParameter("posts", posts)
                .setHint(QueryHints.PASS_DISTINCT_THROUGH, false)
                .getResultList();
        return posts.stream().map(persistablePostMapper::mapToPost).collect(Collectors.toList());
    }

    public PostDTO save(final PostDTO postDTO) {
        Post persistablePost = persistablePostMapper.mapToPersistablePost(postDTO);
        Post savedPersistablePost = postRepository.save(persistablePost);

        return persistablePostMapper.mapToPost(savedPersistablePost);
    }
}

контроллер:

@RequiredArgsConstructor
@RestController
@RequestMapping("/api/posts")
public class PostController {

    private final PostService postService;

    @PostMapping
    PostDTO createOrUpdatePosts(@RequestBody final PostDTO postDTO) throws BadRequestException {
        return postService.saveAll(postDTO);
    }
}

и тест:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles(profiles = {"test"})
@AutoConfigureMockMvc
class PostControllerTest {

    @Autowired
    protected MockMvc mockMvc;

    @Autowired
    protected ObjectMapper objectMapper;

    @Autowired
    protected PostService postService;

    private static final TypeReference<PostDTO> POST_TYPE_REFERENCE = new TypeReference<>() {
    };

    @Test
    void shouldUpdatePostComments() throws Exception {
        //given
        CommentDTO comment1 = CommentDTO.builder()
                .text("test1")
                .build();
        CommentDTO comment2 = CommentDTO.builder()
                .text("test2")
                .build();
        List<CommentDTO> commentsBeforeUpdate = List.of(comment1);
        List<CommentDTO> commentsAfterUpdate = List.of(comment1, comment2);
        PostDTO postWithOneComment = PostDTO.builder()
                .status(PostStatus.OPEN)
                .metadata(MetadataDTO.builder()
                        .metadata(Map.of(
                                "reference_id", "100",
                                "origin", "test"))
                        .build())
                .comments(commentsBeforeUpdate)
                .build();
        PostDTO savedPost = postService.save(postWithOneComment);

        List<PostDTO> postBeforeUpdate = postService.findAllByReferenceId(100L);

        //when
        MockHttpServletResponse response = mockMvc.perform(post("/api/posts")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(savedPost.toBuilder()
                                .comments(commentsAfterUpdate)
                                .build())))
                .andReturn().getResponse();

        //then
        assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value());
        PostDTO returnedPost = objectMapper.readValue(response.getContentAsString(), POST_TYPE_REFERENCE);
        PostDTO postAfterUpdate = postService.findAllByReferenceId(100L).get(0);

        assertThat(returnedPost).isEqualTo(postAfterUpdate);
        assertThat(postBeforeUpdate.size()).isEqualTo(1);
        assertThat(postBeforeUpdate.get(0).getComments()).isEqualTo(commentsBeforeUpdate);
        assertThat(postAfterUpdate.getComments()).isEqualTo(commentsAfterUpdate);
    }
}

  • Проблема в том, что тест не проходит по следующим причинам: java.lang.IllegalStateException: Duplicate key origin (attempted merging values test and test)
  • Сгенерированные запросы SQL показывают, что удалена только одна строка и только одни метаданные (тот, у которого есть «reference_id», а не тот, у которого есть «origin»).
  • Это происходит, вероятно, потому, что metadata в Post имеет orphanRemoval = true и по какой-то причине только «reference_id» удаляется и «вставляется» снова (из-за cascade.ALL).
  • Это означает, что во время запроса «обновления» в приведенном выше тесте у нас есть три метаданных в сообщении: 2x с ключом «origin» (старый и новый, поскольку orphanremoval его не удалил) и 1x с ключом «reference_id». (старый заменен на новый).
  • Это вызывает проблему: posts.stream().map(persistablePostMapper::mapToPost).collect(Collectors.toList()); где сопоставляется метаданные:

трассировка стека в точках Duplicate key exception здесь (до toMap):

default MetadataDTO mapToMetadataDTO(final List<Metadata> persistableMetadata) {
    Map<String, String> metadata = persistableMetadata
            .stream()
            .filter(content -> content.getKey() != null && content.getValue() != null)
            .collect(Collectors.toMap(Metadata::getKey, Metadata::getValue));
    return MetadataDTO.builder()
            .metadata(metadata)
            .build();
}

Может ли кто-нибудь объяснить мне, почему удаление потерянных файлов не воссоздает/удаляет всю коллекцию метаданных, а только «reference_id»? Почему метаданные с ключом «происхождение» также не воссоздаются?

Проблема возникает из-за того, что параметр orphanRemoval = true в списке метаданных в сущности Post работает неправильно. Когда вы обновляете метаданные, Hibernate пытается удалить старые записи и добавить новые. Однако он не удаляет все старые записи должным образом, что приводит к появлению дубликатов.

Vinod Baste 28.06.2024 06:24
Пользовательский скаляр GraphQL
Пользовательский скаляр GraphQL
Листовые узлы системы типов GraphQL называются скалярами. Достигнув скалярного типа, невозможно спуститься дальше по иерархии типов. Скалярный тип...
Как вычислять биты и понимать побитовые операторы в Java - объяснение с примерами
Как вычислять биты и понимать побитовые операторы в Java - объяснение с примерами
В компьютерном программировании биты играют важнейшую роль в представлении и манипулировании данными на двоичном уровне. Побитовые операции...
Поднятие тревоги для долго выполняющихся методов в Spring Boot
Поднятие тревоги для долго выполняющихся методов в Spring Boot
Приходилось ли вам сталкиваться с требованиями, в которых вас могли попросить поднять тревогу или выдать ошибку, когда метод Java занимает больше...
Полный курс Java для разработчиков веб-сайтов и приложений
Полный курс Java для разработчиков веб-сайтов и приложений
Получите сертификат Java Web и Application Developer, используя наш курс.
3
1
161
1
Перейти к ответу Данный вопрос помечен как решенный

Ответы 1

Ответ принят как подходящий

В вашем случае проблема возникает из-за того, что существующие метаданные не удаляются должным образом, несмотря на то, что атрибут orphanRemoval = true установлен в поле метаданных сущности Post.

Hibernate orphanRemoval удаляет только те объекты, которые были разыменованы. Поэтому вам необходимо явно удалить сущности, на которые больше нет ссылок, из существующей коллекции метаданных.

Есть два способа решения этой проблемы.


Первый метод — добавить метод установки для поля metadata в сущности Post и включить логику в remove the existing metadata when setting a new metadata list.

public void setMetadata(List<Metadata> metadata) {
    if (this.metadata == null) {
        this.metadata = new ArrayList<>();
    }
    this.metadata.clear();
    if (metadata != null) {
        this.metadata.addAll(metadata);
    }
}

Второй метод — удалить все существующие метаданные и заменить их новыми метаданными в методе save файла PostService.

public PostDTO save(final PostDTO postDTO) {
    Post persistablePost = persistablePostMapper.mapToPersistablePost(postDTO);
    
    if (persistablePost.getId() != null) {
        Post existingPost = postRepository.findById(persistablePost.getId()).orElseThrow();
        existingPost.getMetadata().clear();
        existingPost.getMetadata().addAll(persistablePost.getMetadata());
        persistablePost = existingPost;
    }
    
    Post savedPersistablePost = postRepository.save(persistablePost);
    return persistablePostMapper.mapToPost(savedPersistablePost);
}

Думаю эта ссылка будет вам полезна.

Хорошего дня!

Почему вы используете второй if в методе setMetadata, потому что вы инициализируете поле this.metadata раньше, если оно имеет значение null. Я хочу сказать, что это if всегда будет правдой

saddam.ya 28.06.2024 18:22

в первом случае, если он использует this.metadata, то это относится к Post#metadata, во втором он использует metadata без (this), поэтому он относится к аргументу метода. Поправьте меня, если я ошибаюсь. Кевин, большое спасибо, я ценю это.

user3529850 28.06.2024 21:18

Ваше наблюдение верно :) В setMetadata method of the Post entity this.metadata относится к полю метаданных внутри самой сущности Post. В save method of PostServicepersistablePost.getMetadata() относится к метаданным нового сохраняемого объекта Post, а existingPost.getMetadata() относится к метаданным существующего объекта Post, полученного из репозитория.

Kevin 29.06.2024 04:50

Другие вопросы по теме