diff --git a/backend/src/main/java/org/raddatz/familienarchiv/repository/TagRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/repository/TagRepository.java index 7e43d52d..c3e7bbef 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/repository/TagRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/repository/TagRepository.java @@ -1,11 +1,13 @@ package org.raddatz.familienarchiv.repository; +import java.util.Collection; import java.util.List; import java.util.Optional; import java.util.UUID; import org.raddatz.familienarchiv.model.Tag; 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.repository.query.Param; @@ -51,4 +53,67 @@ public interface TagRepository extends JpaRepository { SELECT id FROM descendants """, nativeQuery = true) List findDescendantIdsByName(@Param("name") String name); + + /** + * Returns the IDs of the tag with the given ID AND all of its descendants + * via a recursive CTE. Used for merge validation and subtree delete. + * Includes a depth guard of 50 levels to prevent runaway queries. + */ + @Query(value = """ + WITH RECURSIVE descendants AS ( + SELECT id, 0 AS depth FROM tag WHERE id = :tagId + UNION ALL + SELECT t.id, d.depth + 1 FROM tag t + JOIN descendants d ON t.parent_id = d.id + WHERE d.depth < 50 + ) + SELECT id FROM descendants + """, nativeQuery = true) + List findDescendantIds(@Param("tagId") UUID tagId); + + /** + * Reassigns document_tags rows from source to target, skipping rows where + * the target tag is already present (to avoid PK conflicts). + */ + @Modifying(clearAutomatically = true) + @Query(value = """ + UPDATE document_tags + SET tag_id = :targetId + WHERE tag_id = :sourceId + AND NOT EXISTS ( + SELECT 1 FROM document_tags d2 + WHERE d2.document_id = document_tags.document_id + AND d2.tag_id = :targetId + ) + """, nativeQuery = true) + void reassignDocumentTags(@Param("sourceId") UUID sourceId, @Param("targetId") UUID targetId); + + /** + * Removes all document_tags rows for the given tag. + */ + @Modifying(clearAutomatically = true) + @Query(value = "DELETE FROM document_tags WHERE tag_id = :tagId", nativeQuery = true) + void deleteDocumentTagsByTagId(@Param("tagId") UUID tagId); + + /** + * Removes all document_tags rows for the given collection of tag IDs. + * Caller must guard against an empty collection — PostgreSQL rejects IN (). + */ + @Modifying(clearAutomatically = true) + @Query(value = "DELETE FROM document_tags WHERE tag_id IN :ids", nativeQuery = true) + void deleteDocumentTagsByTagIds(@Param("ids") Collection ids); + + /** + * Re-parents all direct children of sourceId to targetId. + */ + @Modifying(clearAutomatically = true) + @Query(value = "UPDATE tag SET parent_id = :targetId WHERE parent_id = :sourceId", nativeQuery = true) + void reparentChildren(@Param("sourceId") UUID sourceId, @Param("targetId") UUID targetId); + + /** + * Returns (tag_id, count) pairs for all tags that appear in document_tags. + * Used to populate documentCount in the tag tree without N+1 queries. + */ + @Query(value = "SELECT tag_id, COUNT(*) FROM document_tags GROUP BY tag_id", nativeQuery = true) + List findDocumentCountsPerTag(); }