Move person-delete FK detach to database-level ON DELETE (#684) #736
@@ -189,18 +189,15 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
|||||||
List<Person> findCorrespondentsWithFilter(@Param("personId") UUID personId, @Param("q") String q);
|
List<Person> findCorrespondentsWithFilter(@Param("personId") UUID personId, @Param("q") String q);
|
||||||
|
|
||||||
// --- Merge helpers (native SQL to bypass JPA entity layer) ---
|
// --- Merge helpers (native SQL to bypass JPA entity layer) ---
|
||||||
|
// clearAutomatically + flushAutomatically keep the L1 cache from desyncing: these bulk
|
||||||
|
// updates run beneath Hibernate, and mergePersons follows them with a deleteById whose
|
||||||
|
// ON DELETE CASCADE (V71) also fires beneath the session.
|
||||||
|
|
||||||
@Modifying
|
@Modifying(clearAutomatically = true, flushAutomatically = true)
|
||||||
@Query(value = "UPDATE documents SET sender_id = :target WHERE sender_id = :source", nativeQuery = true)
|
@Query(value = "UPDATE documents SET sender_id = :target WHERE sender_id = :source", nativeQuery = true)
|
||||||
void reassignSender(@Param("source") UUID source, @Param("target") UUID target);
|
void reassignSender(@Param("source") UUID source, @Param("target") UUID target);
|
||||||
|
|
||||||
// Used by deletePerson: detach a deleted person from documents they sent, so the hard
|
@Modifying(clearAutomatically = true, flushAutomatically = true)
|
||||||
// delete cannot orphan a documents.sender_id FK (the column is nullable).
|
|
||||||
@Modifying
|
|
||||||
@Query(value = "UPDATE documents SET sender_id = NULL WHERE sender_id = :source", nativeQuery = true)
|
|
||||||
void reassignSenderToNull(@Param("source") UUID source);
|
|
||||||
|
|
||||||
@Modifying
|
|
||||||
@Query(value = """
|
@Query(value = """
|
||||||
INSERT INTO document_receivers (document_id, person_id)
|
INSERT INTO document_receivers (document_id, person_id)
|
||||||
SELECT document_id, :target FROM document_receivers
|
SELECT document_id, :target FROM document_receivers
|
||||||
@@ -210,8 +207,4 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
|||||||
)
|
)
|
||||||
""", nativeQuery = true)
|
""", nativeQuery = true)
|
||||||
void insertMissingReceiverReference(@Param("source") UUID source, @Param("target") UUID target);
|
void insertMissingReceiverReference(@Param("source") UUID source, @Param("target") UUID target);
|
||||||
|
}
|
||||||
@Modifying
|
|
||||||
@Query(value = "DELETE FROM document_receivers WHERE person_id = :source", nativeQuery = true)
|
|
||||||
void deleteReceiverReferences(@Param("source") UUID source);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -68,15 +68,13 @@ public class PersonService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hard-deletes a person used by triage. Detaches the person from any documents they
|
* Hard-deletes a person used by triage. Referential integrity is enforced by the database
|
||||||
* sent (nulls sender_id) and from any received-document references first, so the delete
|
* (V71's {@code ON DELETE} constraints: sender_id {@code SET NULL}, receiver and @-mention
|
||||||
* cannot orphan an FK and fail with a 500.
|
* rows {@code CASCADE}), so the service stays thin — it only verifies existence then deletes.
|
||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public void deletePerson(UUID id) {
|
public void deletePerson(UUID id) {
|
||||||
getById(id);
|
getById(id);
|
||||||
personRepository.reassignSenderToNull(id);
|
|
||||||
personRepository.deleteReceiverReferences(id);
|
|
||||||
personRepository.deleteById(id);
|
personRepository.deleteById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,6 +293,12 @@ public class PersonService {
|
|||||||
return personRepository.save(person);
|
return personRepository.save(person);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merges the source person into the target, then deletes the source. Sender references move
|
||||||
|
* to the target; receiver references the target lacks are inserted. The source's leftover
|
||||||
|
* receiver join rows are not deleted explicitly — they cascade-drop via V71's
|
||||||
|
* {@code ON DELETE CASCADE} on {@code document_receivers.person_id} when the source is deleted.
|
||||||
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public void mergePersons(UUID sourceId, UUID targetId) {
|
public void mergePersons(UUID sourceId, UUID targetId) {
|
||||||
if (sourceId.equals(targetId)) {
|
if (sourceId.equals(targetId)) {
|
||||||
@@ -311,9 +315,7 @@ public class PersonService {
|
|||||||
// Add target as receiver where source is receiver but target is not yet
|
// Add target as receiver where source is receiver but target is not yet
|
||||||
personRepository.insertMissingReceiverReference(sourceId, targetId);
|
personRepository.insertMissingReceiverReference(sourceId, targetId);
|
||||||
|
|
||||||
// Remove all remaining source receiver references (duplicates already handled)
|
// Source's remaining receiver rows cascade-drop via V71's ON DELETE CASCADE.
|
||||||
personRepository.deleteReceiverReferences(sourceId);
|
|
||||||
|
|
||||||
personRepository.deleteById(sourceId);
|
personRepository.deleteById(sourceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
-- Move person-delete referential integrity from application code into the database (#684).
|
||||||
|
--
|
||||||
|
-- Before this migration, PersonService.deletePerson nulled documents.sender_id and removed
|
||||||
|
-- document_receivers rows in Java before deleting the person, because the two V1 FKs into
|
||||||
|
-- persons had no ON DELETE behaviour. Any other delete path (a future endpoint, a manual
|
||||||
|
-- psql, a batch job) could still orphan rows or 500. This migration makes the database the
|
||||||
|
-- single source of truth so a person delete is safe from every path.
|
||||||
|
--
|
||||||
|
-- Cascade boundary: the cascade stays STRICTLY at the join/reference layer and NEVER reaches
|
||||||
|
-- documents rows — a cascade into documents would destroy historical letters. sender_id is
|
||||||
|
-- SET NULL (documents.senderText preserves the raw textual attribution); the receiver join
|
||||||
|
-- row and the @-mention sidecar row are dropped.
|
||||||
|
--
|
||||||
|
-- No NOT VALID + VALIDATE two-step: these tables are small (thousands of rows → sub-second
|
||||||
|
-- ACCESS EXCLUSIVE lock). Do NOT copy this drop-and-recreate pattern onto a large table.
|
||||||
|
--
|
||||||
|
-- Not audit-logged: a DB ON DELETE cascade runs below AuditService — a known, accepted trade.
|
||||||
|
-- The person-delete action itself is still logged at the service layer.
|
||||||
|
|
||||||
|
-- documents.sender_id → ON DELETE SET NULL (deleted sender clears the link; the document survives).
|
||||||
|
ALTER TABLE public.documents
|
||||||
|
DROP CONSTRAINT fkl5xhww7es3b4um01vmly4y18m,
|
||||||
|
ADD CONSTRAINT fkl5xhww7es3b4um01vmly4y18m
|
||||||
|
FOREIGN KEY (sender_id) REFERENCES public.persons(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
-- document_receivers.person_id → ON DELETE CASCADE (drop the join row), the symmetric
|
||||||
|
-- completion of V14, which added the same to the document_id side of this table.
|
||||||
|
ALTER TABLE public.document_receivers
|
||||||
|
DROP CONSTRAINT fkcg7r68qvosqricx1betgrlt7s,
|
||||||
|
ADD CONSTRAINT fkcg7r68qvosqricx1betgrlt7s
|
||||||
|
FOREIGN KEY (person_id) REFERENCES public.persons(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
-- Soft reference fix: transcription_block_mentioned_persons.person_id was a UUID with no FK
|
||||||
|
-- (V56), so deleting a person left dangling mention rows. Give it a real FK with CASCADE.
|
||||||
|
-- This reverses V56's deliberate "no FK on person_id" choice — that comment is now historical
|
||||||
|
-- but is intentionally left untouched, because editing an already-applied migration changes its
|
||||||
|
-- Flyway checksum and would fail validateOnMigrate in prod. ADR-032 is the authoritative record.
|
||||||
|
-- Clean up pre-existing orphans first — production likely holds dangling rows because the old
|
||||||
|
-- deletePerson never cleaned mention rows, and the ADD CONSTRAINT validation scan fails on them.
|
||||||
|
-- A DO block with RAISE NOTICE surfaces the purge count: Flyway runs each statement via JDBC
|
||||||
|
-- and discards a trailing SELECT's result set, so a "SELECT count(*)" would log nothing.
|
||||||
|
DO $$
|
||||||
|
DECLARE removed int;
|
||||||
|
BEGIN
|
||||||
|
DELETE FROM transcription_block_mentioned_persons m
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM persons p WHERE p.id = m.person_id);
|
||||||
|
GET DIAGNOSTICS removed = ROW_COUNT;
|
||||||
|
RAISE NOTICE 'V71 orphaned_mention_rows_removed=%', removed;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
ALTER TABLE public.transcription_block_mentioned_persons
|
||||||
|
ADD CONSTRAINT fk_tbmp_person
|
||||||
|
FOREIGN KEY (person_id) REFERENCES public.persons(id) ON DELETE CASCADE;
|
||||||
@@ -12,6 +12,8 @@ import org.raddatz.familienarchiv.document.annotation.DocumentAnnotation;
|
|||||||
import org.raddatz.familienarchiv.document.DocumentStatus;
|
import org.raddatz.familienarchiv.document.DocumentStatus;
|
||||||
import org.raddatz.familienarchiv.document.transcription.PersonMention;
|
import org.raddatz.familienarchiv.document.transcription.PersonMention;
|
||||||
import org.raddatz.familienarchiv.document.transcription.TranscriptionBlock;
|
import org.raddatz.familienarchiv.document.transcription.TranscriptionBlock;
|
||||||
|
import org.raddatz.familienarchiv.person.Person;
|
||||||
|
import org.raddatz.familienarchiv.person.PersonRepository;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
|
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
|
||||||
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
|
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
|
||||||
@@ -30,6 +32,7 @@ class TranscriptionBlockMentionsRepositoryTest {
|
|||||||
@Autowired TranscriptionBlockRepository blockRepository;
|
@Autowired TranscriptionBlockRepository blockRepository;
|
||||||
@Autowired DocumentRepository documentRepository;
|
@Autowired DocumentRepository documentRepository;
|
||||||
@Autowired AnnotationRepository annotationRepository;
|
@Autowired AnnotationRepository annotationRepository;
|
||||||
|
@Autowired PersonRepository personRepository;
|
||||||
@Autowired EntityManager em;
|
@Autowired EntityManager em;
|
||||||
|
|
||||||
private UUID documentId;
|
private UUID documentId;
|
||||||
@@ -55,8 +58,9 @@ class TranscriptionBlockMentionsRepositoryTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void mentionedPersons_roundTripsTwoEntries() {
|
void mentionedPersons_roundTripsTwoEntries() {
|
||||||
UUID auguste = UUID.randomUUID();
|
// person_id is a real FK since V71 — the mentioned persons must exist.
|
||||||
UUID hermann = UUID.randomUUID();
|
UUID auguste = personRepository.save(Person.builder().firstName("Auguste").lastName("Raddatz").build()).getId();
|
||||||
|
UUID hermann = personRepository.save(Person.builder().firstName("Hermann").lastName("Müller").build()).getId();
|
||||||
|
|
||||||
TranscriptionBlock saved = blockRepository.saveAndFlush(TranscriptionBlock.builder()
|
TranscriptionBlock saved = blockRepository.saveAndFlush(TranscriptionBlock.builder()
|
||||||
.annotationId(annotationId)
|
.annotationId(annotationId)
|
||||||
@@ -97,8 +101,9 @@ class TranscriptionBlockMentionsRepositoryTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void findByPersonIdWithMentionsFetched_returnsOnlyBlocksReferencingPerson_withMentionsLoaded() {
|
void findByPersonIdWithMentionsFetched_returnsOnlyBlocksReferencingPerson_withMentionsLoaded() {
|
||||||
UUID augusteId = UUID.randomUUID();
|
// person_id is a real FK since V71 — the mentioned persons must exist.
|
||||||
UUID hermannId = UUID.randomUUID();
|
UUID augusteId = personRepository.save(Person.builder().firstName("Auguste").lastName("Raddatz").build()).getId();
|
||||||
|
UUID hermannId = personRepository.save(Person.builder().firstName("Hermann").lastName("Müller").build()).getId();
|
||||||
|
|
||||||
blockRepository.saveAndFlush(TranscriptionBlock.builder()
|
blockRepository.saveAndFlush(TranscriptionBlock.builder()
|
||||||
.annotationId(annotationId).documentId(documentId)
|
.annotationId(annotationId).documentId(documentId)
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import jakarta.persistence.PersistenceContext;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
@@ -366,30 +367,6 @@ class PersonRepositoryTest {
|
|||||||
assertThat(result).hasSize(1);
|
assertThat(result).hasSize(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── deleteReceiverReferences ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void deleteReceiverReferences_removesPersonFromAllDocumentReceivers() {
|
|
||||||
Person toDelete = personRepository.save(Person.builder().firstName("Weg").lastName("Person").build());
|
|
||||||
Person sender = personRepository.save(Person.builder().firstName("Send").lastName("Er").build());
|
|
||||||
|
|
||||||
Document doc1 = documentRepository.save(Document.builder()
|
|
||||||
.title("Brief 1").originalFilename("b1.pdf")
|
|
||||||
.status(DocumentStatus.UPLOADED)
|
|
||||||
.sender(sender).receivers(Set.of(toDelete)).build());
|
|
||||||
Document doc2 = documentRepository.save(Document.builder()
|
|
||||||
.title("Brief 2").originalFilename("b2.pdf")
|
|
||||||
.status(DocumentStatus.UPLOADED)
|
|
||||||
.sender(sender).receivers(Set.of(toDelete)).build());
|
|
||||||
|
|
||||||
personRepository.deleteReceiverReferences(toDelete.getId());
|
|
||||||
entityManager.flush();
|
|
||||||
entityManager.clear();
|
|
||||||
|
|
||||||
assertThat(documentRepository.findById(doc1.getId()).orElseThrow().getReceivers()).isEmpty();
|
|
||||||
assertThat(documentRepository.findById(doc2.getId()).orElseThrow().getReceivers()).isEmpty();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── searchByName with aliases ───────────────────────────────────────────
|
// ─── searchByName with aliases ───────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -707,4 +684,146 @@ class PersonRepositoryTest {
|
|||||||
assertThat(found).isPresent();
|
assertThat(found).isPresent();
|
||||||
assertThat(found.get().getGeneration()).isNull();
|
assertThat(found.get().getGeneration()).isNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── #684: ON DELETE integrity enforced at the database layer ──────────────
|
||||||
|
// A raw deleteById (bypassing PersonService) must keep referential integrity:
|
||||||
|
// documents.sender_id → SET NULL, document_receivers.person_id → CASCADE, and the
|
||||||
|
// transcription_block_mentioned_persons soft reference → CASCADE. These run against
|
||||||
|
// real Postgres because the FK ON DELETE behaviour never fires on H2.
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteById_personSenderOfAReceiverOfB_nullsSender_dropsReceiverRow_bothDocumentsSurvive() {
|
||||||
|
Person target = personRepository.save(Person.builder().firstName("Weg").lastName("Person").build());
|
||||||
|
Person bystander = personRepository.save(Person.builder().firstName("Bleibt").lastName("Hier").build());
|
||||||
|
|
||||||
|
Document sent = documentRepository.save(Document.builder()
|
||||||
|
.title("Gesendet").originalFilename("sent.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED).sender(target).build());
|
||||||
|
Document received = documentRepository.save(Document.builder()
|
||||||
|
.title("Empfangen").originalFilename("received.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED).sender(bystander)
|
||||||
|
.receivers(Set.of(target)).build());
|
||||||
|
entityManager.flush();
|
||||||
|
entityManager.clear();
|
||||||
|
|
||||||
|
personRepository.deleteById(target.getId());
|
||||||
|
entityManager.flush();
|
||||||
|
entityManager.clear();
|
||||||
|
|
||||||
|
assertThat(personRepository.findById(target.getId())).isEmpty();
|
||||||
|
|
||||||
|
Document reloadedSent = documentRepository.findById(sent.getId()).orElseThrow();
|
||||||
|
assertThat(reloadedSent.getSender()).isNull(); // AC-1: SET NULL
|
||||||
|
|
||||||
|
Document reloadedReceived = documentRepository.findById(received.getId()).orElseThrow();
|
||||||
|
assertThat(reloadedReceived.getReceivers())
|
||||||
|
.noneMatch(p -> p.getId().equals(target.getId())); // AC-2: CASCADE drops the join row
|
||||||
|
|
||||||
|
// Cascade-boundary guard (Nora, non-negotiable): the cascade stops at the join/reference
|
||||||
|
// layer — both documents themselves survive. Guards against a future migration turning
|
||||||
|
// documents.sender_id SET NULL into CASCADE and destroying historical letters.
|
||||||
|
assertThat(documentRepository.findById(sent.getId())).isPresent();
|
||||||
|
assertThat(documentRepository.findById(received.getId())).isPresent();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteById_receiverWithCoReceiver_dropsOnlyDeletedPersonsJoinRow() {
|
||||||
|
Person target = personRepository.save(Person.builder().firstName("Weg").lastName("Person").build());
|
||||||
|
Person coReceiver = personRepository.save(Person.builder().firstName("Mit").lastName("Empfänger").build());
|
||||||
|
Person sender = personRepository.save(Person.builder().firstName("Send").lastName("Er").build());
|
||||||
|
|
||||||
|
Document doc = documentRepository.save(Document.builder()
|
||||||
|
.title("Brief").originalFilename("brief.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED).sender(sender)
|
||||||
|
.receivers(Set.of(target, coReceiver)).build());
|
||||||
|
entityManager.flush();
|
||||||
|
entityManager.clear();
|
||||||
|
|
||||||
|
personRepository.deleteById(target.getId());
|
||||||
|
entityManager.flush();
|
||||||
|
entityManager.clear();
|
||||||
|
|
||||||
|
Document reloaded = documentRepository.findById(doc.getId()).orElseThrow();
|
||||||
|
assertThat(reloaded.getReceivers()).extracting(Person::getId)
|
||||||
|
.containsExactly(coReceiver.getId()); // co-receiver untouched
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteById_personIsSenderAndReceiverOfSameDocument_documentSurvives_senderNull_receiverDropped() {
|
||||||
|
// AC-8: the trickier same-document interaction the cross-document cases don't exercise.
|
||||||
|
Person target = personRepository.save(Person.builder().firstName("Beides").lastName("Person").build());
|
||||||
|
Person coReceiver = personRepository.save(Person.builder().firstName("Mit").lastName("Empfänger").build());
|
||||||
|
|
||||||
|
Document doc = documentRepository.save(Document.builder()
|
||||||
|
.title("Selbstbrief").originalFilename("self.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED).sender(target)
|
||||||
|
.receivers(Set.of(target, coReceiver)).build());
|
||||||
|
entityManager.flush();
|
||||||
|
entityManager.clear();
|
||||||
|
|
||||||
|
personRepository.deleteById(target.getId());
|
||||||
|
entityManager.flush();
|
||||||
|
entityManager.clear();
|
||||||
|
|
||||||
|
Document reloaded = documentRepository.findById(doc.getId()).orElseThrow();
|
||||||
|
assertThat(reloaded.getSender()).isNull();
|
||||||
|
assertThat(reloaded.getReceivers()).extracting(Person::getId)
|
||||||
|
.containsExactly(coReceiver.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteById_mentionedPerson_dropsMentionRow_blockTextSurvives() {
|
||||||
|
// AC-3: the @-mention sidecar is a CASCADE soft reference, but the literal "@Name" lives
|
||||||
|
// in transcription_blocks.text and must stay visible as plain text after the person goes.
|
||||||
|
Person mentioned = personRepository.save(Person.builder().firstName("Auguste").lastName("Raddatz").build());
|
||||||
|
Person survivor = personRepository.save(Person.builder().firstName("Clara").lastName("Cram").build());
|
||||||
|
Document doc = documentRepository.save(Document.builder()
|
||||||
|
.title("Brief").originalFilename("brief.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED).build());
|
||||||
|
entityManager.flush();
|
||||||
|
|
||||||
|
UUID annotationId = UUID.randomUUID();
|
||||||
|
UUID blockId = UUID.randomUUID();
|
||||||
|
entityManager.createNativeQuery(
|
||||||
|
"INSERT INTO document_annotations (id, document_id, page_number, x, y, width, height, color) "
|
||||||
|
+ "VALUES (?1, ?2, 1, 0.1, 0.2, 0.3, 0.1, '#fff')")
|
||||||
|
.setParameter(1, annotationId).setParameter(2, doc.getId()).executeUpdate();
|
||||||
|
entityManager.createNativeQuery(
|
||||||
|
"INSERT INTO transcription_blocks (id, annotation_id, document_id, text) VALUES (?1, ?2, ?3, ?4)")
|
||||||
|
.setParameter(1, blockId).setParameter(2, annotationId).setParameter(3, doc.getId())
|
||||||
|
.setParameter(4, "Brief an @Auguste Raddatz und @Clara Cram").executeUpdate();
|
||||||
|
// Two mention rows on the same block: the deleted person and an innocent bystander.
|
||||||
|
entityManager.createNativeQuery(
|
||||||
|
"INSERT INTO transcription_block_mentioned_persons (block_id, person_id, display_name) "
|
||||||
|
+ "VALUES (?1, ?2, ?3)")
|
||||||
|
.setParameter(1, blockId).setParameter(2, mentioned.getId())
|
||||||
|
.setParameter(3, "Auguste Raddatz").executeUpdate();
|
||||||
|
entityManager.createNativeQuery(
|
||||||
|
"INSERT INTO transcription_block_mentioned_persons (block_id, person_id, display_name) "
|
||||||
|
+ "VALUES (?1, ?2, ?3)")
|
||||||
|
.setParameter(1, blockId).setParameter(2, survivor.getId())
|
||||||
|
.setParameter(3, "Clara Cram").executeUpdate();
|
||||||
|
entityManager.flush();
|
||||||
|
entityManager.clear();
|
||||||
|
|
||||||
|
personRepository.deleteById(mentioned.getId());
|
||||||
|
entityManager.flush();
|
||||||
|
entityManager.clear();
|
||||||
|
|
||||||
|
Number mentionRows = (Number) entityManager.createNativeQuery(
|
||||||
|
"SELECT count(*) FROM transcription_block_mentioned_persons WHERE person_id = ?1")
|
||||||
|
.setParameter(1, mentioned.getId()).getSingleResult();
|
||||||
|
assertThat(mentionRows.longValue()).isZero();
|
||||||
|
|
||||||
|
// The cascade is scoped to the deleted person — the bystander's mention row is untouched.
|
||||||
|
Number survivorRows = (Number) entityManager.createNativeQuery(
|
||||||
|
"SELECT count(*) FROM transcription_block_mentioned_persons WHERE person_id = ?1")
|
||||||
|
.setParameter(1, survivor.getId()).getSingleResult();
|
||||||
|
assertThat(survivorRows.longValue()).isEqualTo(1);
|
||||||
|
|
||||||
|
String text = (String) entityManager.createNativeQuery(
|
||||||
|
"SELECT text FROM transcription_blocks WHERE id = ?1")
|
||||||
|
.setParameter(1, blockId).getSingleResult();
|
||||||
|
assertThat(text).isEqualTo("Brief an @Auguste Raddatz und @Clara Cram");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -180,9 +180,9 @@ class PersonServiceIntegrationTest {
|
|||||||
@Test
|
@Test
|
||||||
void deletePerson_detachesSentAndReceivedReferences_beforeDelete_noOrphan() {
|
void deletePerson_detachesSentAndReceivedReferences_beforeDelete_noOrphan() {
|
||||||
// A person referenced as BOTH a document sender and a document receiver must delete
|
// A person referenced as BOTH a document sender and a document receiver must delete
|
||||||
// cleanly: deletePerson nulls the sender_id FK and removes the receiver join row first
|
// cleanly via the service path: deletePerson just calls deleteById, and V71's ON DELETE
|
||||||
// (reassignSenderToNull → deleteReceiverReferences → deleteById), so no FK orphan and
|
// constraints null the sender_id FK and drop the receiver join row, so there is no FK
|
||||||
// the documents themselves survive.
|
// orphan and the documents themselves survive.
|
||||||
Person target = personRepository.save(Person.builder()
|
Person target = personRepository.save(Person.builder()
|
||||||
.firstName("Weg").lastName("Person").provisional(true).build());
|
.firstName("Weg").lastName("Person").provisional(true).build());
|
||||||
Person bystander = personRepository.save(Person.builder()
|
Person bystander = personRepository.save(Person.builder()
|
||||||
@@ -196,16 +196,16 @@ class PersonServiceIntegrationTest {
|
|||||||
.status(DocumentStatus.UPLOADED).sender(bystander)
|
.status(DocumentStatus.UPLOADED).sender(bystander)
|
||||||
.receivers(new java.util.HashSet<>(Set.of(target))).build());
|
.receivers(new java.util.HashSet<>(Set.of(target))).build());
|
||||||
|
|
||||||
// Persist the fixture and detach everything so the native @Modifying deletes operate on
|
// Persist the fixture and detach everything so the delete operates on the database
|
||||||
// the database directly without the persistence context holding stale references that
|
// directly without the persistence context holding stale references.
|
||||||
// would re-flush a now-deleted person as a transient association.
|
|
||||||
entityManager.flush();
|
entityManager.flush();
|
||||||
entityManager.clear();
|
entityManager.clear();
|
||||||
|
|
||||||
personService.deletePerson(target.getId());
|
personService.deletePerson(target.getId());
|
||||||
|
|
||||||
// Native @Modifying queries bypass the persistence context — clear it so the asserting
|
// The ON DELETE cascade fires beneath Hibernate — flush the delete and clear the L1
|
||||||
// reads observe the post-delete database state, not stale managed entities.
|
// cache so the asserting reads observe the post-delete database state, not stale
|
||||||
|
// managed entities still holding the dropped sender/receiver associations.
|
||||||
entityManager.flush();
|
entityManager.flush();
|
||||||
entityManager.clear();
|
entityManager.clear();
|
||||||
|
|
||||||
@@ -220,4 +220,38 @@ class PersonServiceIntegrationTest {
|
|||||||
// The other person and the documents themselves survive the delete.
|
// The other person and the documents themselves survive the delete.
|
||||||
assertThat(personRepository.findById(bystander.getId())).isPresent();
|
assertThat(personRepository.findById(bystander.getId())).isPresent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void mergePersons_targetInheritsReferences_sourceJoinRowCascadeDrops_noFkError() {
|
||||||
|
// AC-7: merging a source who is sender of A and receiver of B into a target leaves the
|
||||||
|
// target as sender of A and receiver of B, drops the source's leftover receiver row via
|
||||||
|
// V71's ON DELETE CASCADE (no explicit delete, no FK error), and co-receivers are intact.
|
||||||
|
Person source = personRepository.save(Person.builder().firstName("Anna").lastName("Alt").build());
|
||||||
|
Person target = personRepository.save(Person.builder().firstName("Anna").lastName("Neu").build());
|
||||||
|
Person coReceiver = personRepository.save(Person.builder().firstName("Mit").lastName("Empfänger").build());
|
||||||
|
Person sender = personRepository.save(Person.builder().firstName("Send").lastName("Er").build());
|
||||||
|
|
||||||
|
Document docA = documentRepository.save(Document.builder()
|
||||||
|
.title("Von Anna").originalFilename("a.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED).sender(source).build());
|
||||||
|
Document docB = documentRepository.save(Document.builder()
|
||||||
|
.title("An Anna").originalFilename("b.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED).sender(sender)
|
||||||
|
.receivers(new java.util.HashSet<>(Set.of(source, coReceiver))).build());
|
||||||
|
entityManager.flush();
|
||||||
|
entityManager.clear();
|
||||||
|
|
||||||
|
personService.mergePersons(source.getId(), target.getId());
|
||||||
|
entityManager.flush();
|
||||||
|
entityManager.clear();
|
||||||
|
|
||||||
|
assertThat(personRepository.findById(source.getId())).isEmpty();
|
||||||
|
|
||||||
|
Document reloadedA = documentRepository.findById(docA.getId()).orElseThrow();
|
||||||
|
assertThat(reloadedA.getSender().getId()).isEqualTo(target.getId());
|
||||||
|
|
||||||
|
Document reloadedB = documentRepository.findById(docB.getId()).orElseThrow();
|
||||||
|
assertThat(reloadedB.getReceivers()).extracting(Person::getId)
|
||||||
|
.containsExactlyInAnyOrder(target.getId(), coReceiver.getId());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import static org.mockito.ArgumentMatchers.any;
|
|||||||
import static org.mockito.ArgumentMatchers.argThat;
|
import static org.mockito.ArgumentMatchers.argThat;
|
||||||
import static org.mockito.Mockito.never;
|
import static org.mockito.Mockito.never;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.verifyNoMoreInteractions;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
@ExtendWith(MockitoExtension.class)
|
@ExtendWith(MockitoExtension.class)
|
||||||
@@ -147,9 +148,11 @@ class PersonServiceTest {
|
|||||||
|
|
||||||
personService.deletePerson(id);
|
personService.deletePerson(id);
|
||||||
|
|
||||||
verify(personRepository).reassignSenderToNull(id);
|
// Integrity is enforced by V71's ON DELETE constraints — the service only checks
|
||||||
verify(personRepository).deleteReceiverReferences(id);
|
// existence then deletes; it no longer detaches sender/receiver references itself.
|
||||||
|
verify(personRepository).findById(id);
|
||||||
verify(personRepository).deleteById(id);
|
verify(personRepository).deleteById(id);
|
||||||
|
verifyNoMoreInteractions(personRepository);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -700,10 +703,14 @@ class PersonServiceTest {
|
|||||||
|
|
||||||
personService.mergePersons(sourceId, targetId);
|
personService.mergePersons(sourceId, targetId);
|
||||||
|
|
||||||
|
verify(personRepository).findById(sourceId);
|
||||||
|
verify(personRepository).findById(targetId);
|
||||||
verify(personRepository).reassignSender(sourceId, targetId);
|
verify(personRepository).reassignSender(sourceId, targetId);
|
||||||
verify(personRepository).insertMissingReceiverReference(sourceId, targetId);
|
verify(personRepository).insertMissingReceiverReference(sourceId, targetId);
|
||||||
verify(personRepository).deleteReceiverReferences(sourceId);
|
|
||||||
verify(personRepository).deleteById(sourceId);
|
verify(personRepository).deleteById(sourceId);
|
||||||
|
// The source's leftover receiver rows cascade-drop via V71's ON DELETE CASCADE on
|
||||||
|
// deleteById — merge no longer deletes them explicitly.
|
||||||
|
verifyNoMoreInteractions(personRepository);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── getAliases ─────────────────────────────────────────────────────────
|
// ─── getAliases ─────────────────────────────────────────────────────────
|
||||||
|
|||||||
64
docs/adr/032-person-delete-db-level-integrity.md
Normal file
64
docs/adr/032-person-delete-db-level-integrity.md
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
# ADR-032 — Person-delete referential integrity lives in the database, and the cascade never reaches `documents`
|
||||||
|
|
||||||
|
**Date:** 2026-06-06
|
||||||
|
**Status:** Accepted
|
||||||
|
**Issue:** #684 (move person-delete FK detach to database-level `ON DELETE`)
|
||||||
|
**Milestone:** —
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Deleting a `Person` had to detach the two FKs into `persons` that lacked any `ON DELETE`
|
||||||
|
behaviour: `documents.sender_id` and `document_receivers.person_id` (both from V1).
|
||||||
|
`PersonService.deletePerson` and `mergePersons` did this in Java — nulling the sender and
|
||||||
|
deleting receiver join rows before `deleteById` — so the integrity guarantee lived in
|
||||||
|
application code. Any other delete path (a future endpoint, a manual `psql`, a batch job)
|
||||||
|
could still orphan rows or fail with an FK-violation 500.
|
||||||
|
|
||||||
|
A related soft reference made it worse: `transcription_block_mentioned_persons.person_id`
|
||||||
|
was a UUID column with **no FK** (V56, a deliberate "no FK" choice), so a person delete left
|
||||||
|
dangling `@`-mention rows. The literal `@DisplayName` lives in `transcription_blocks.text`,
|
||||||
|
so only the *link* was ever at stake — not the visible name.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Move person-delete integrity into the database (migration V71) and thin the service to a
|
||||||
|
plain `deleteById`:
|
||||||
|
|
||||||
|
- `documents.sender_id` → `ON DELETE SET NULL` (`documents.senderText` preserves the raw
|
||||||
|
textual attribution, so nulling the link loses no historical record).
|
||||||
|
- `document_receivers.person_id` → `ON DELETE CASCADE` (the symmetric completion of V14,
|
||||||
|
which gave the `document_id` side the same).
|
||||||
|
- `transcription_block_mentioned_persons.person_id` → a real FK with `ON DELETE CASCADE`,
|
||||||
|
reversing V56's "no FK" decision. The read renderer already degrades a `@DisplayName` with
|
||||||
|
no sidecar row to plain escaped text, so removing the link is invisible to the reader.
|
||||||
|
|
||||||
|
**Cascade-boundary invariant:** the cascade stays strictly at the join/reference layer and
|
||||||
|
**never reaches `documents` rows** — a cascade into `documents` would destroy historical
|
||||||
|
letters. This is pinned by a non-negotiable document-survival assertion in
|
||||||
|
`PersonRepositoryTest`.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- A person delete is safe from every path, not just `PersonService`. The service and merge
|
||||||
|
stay thin (`deleteById` + the cascade); `reassignSenderToNull` and `deleteReceiverReferences`
|
||||||
|
are deleted.
|
||||||
|
- This *fixes* the pre-existing dead-link-on-deleted-person case — it is not a purely
|
||||||
|
invisible refactor. Note the read renderer strips the `@` prefix when it emits a live
|
||||||
|
mention link, but the degraded (deleted-person) path leaves the literal `@Name` in the
|
||||||
|
block text as-is — the reader sees `@Auguste Raddatz` as plain text, never a dead link.
|
||||||
|
- DB cascades run below `AuditService`, so the row-level cleanup is intentionally not
|
||||||
|
audit-logged; the person-delete action itself is still logged at the service layer.
|
||||||
|
- The V71 FK validation requires cleaning pre-existing orphan mention rows first; the
|
||||||
|
migration does this in a `DO` block that logs the purge count via `RAISE NOTICE`.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
- **Keep integrity in Java** — rejected; it only protects the one code path and re-breaks the
|
||||||
|
moment a second delete path appears.
|
||||||
|
- **Cascade `documents.sender_id`** — rejected; it would delete historical letters when a
|
||||||
|
sender is removed. `SET NULL` keeps the letter and its `senderText`.
|
||||||
|
- **Leave the mention sidecar FK-less (honour V56)** — rejected; the "no FK" rationale was
|
||||||
|
stale, the name survives in the block text regardless, and the FK removes the orphan-row
|
||||||
|
class of bug.
|
||||||
@@ -260,7 +260,7 @@ package "Transcription" {
|
|||||||
|
|
||||||
entity transcription_block_mentioned_persons {
|
entity transcription_block_mentioned_persons {
|
||||||
block_id : UUID <<FK>>
|
block_id : UUID <<FK>>
|
||||||
person_id : UUID NOT NULL
|
person_id : UUID NOT NULL <<FK>>
|
||||||
--
|
--
|
||||||
display_name : VARCHAR(200) NOT NULL
|
display_name : VARCHAR(200) NOT NULL
|
||||||
}
|
}
|
||||||
@@ -386,9 +386,9 @@ invite_token_group_ids }o--|| invite_tokens : invite_token_id
|
|||||||
invite_token_group_ids }o--|| user_groups : group_id
|
invite_token_group_ids }o--|| user_groups : group_id
|
||||||
|
|
||||||
' Document relationships
|
' Document relationships
|
||||||
documents }o--o| persons : sender_id
|
documents }o--o| persons : sender_id (ON DELETE SET NULL)
|
||||||
document_receivers }o--|| documents : document_id
|
document_receivers }o--|| documents : document_id
|
||||||
document_receivers }o--|| persons : person_id
|
document_receivers }o--|| persons : person_id (ON DELETE CASCADE)
|
||||||
document_tags }o--|| documents : document_id
|
document_tags }o--|| documents : document_id
|
||||||
document_tags }o--|| tag : tag_id
|
document_tags }o--|| tag : tag_id
|
||||||
document_versions }o--|| documents : document_id
|
document_versions }o--|| documents : document_id
|
||||||
@@ -420,6 +420,7 @@ transcription_blocks }o--o| app_users : updated_by
|
|||||||
transcription_block_versions }o--|| transcription_blocks : block_id
|
transcription_block_versions }o--|| transcription_blocks : block_id
|
||||||
transcription_block_versions }o--o| app_users : changed_by
|
transcription_block_versions }o--o| app_users : changed_by
|
||||||
transcription_block_mentioned_persons }o--|| transcription_blocks : block_id
|
transcription_block_mentioned_persons }o--|| transcription_blocks : block_id
|
||||||
|
transcription_block_mentioned_persons }o--|| persons : person_id (ON DELETE CASCADE)
|
||||||
|
|
||||||
' OCR relationships
|
' OCR relationships
|
||||||
ocr_job_documents }o--|| ocr_jobs : job_id
|
ocr_job_documents }o--|| ocr_jobs : job_id
|
||||||
|
|||||||
@@ -79,9 +79,9 @@ invite_token_group_ids }o--|| invite_tokens : invite_token_id
|
|||||||
invite_token_group_ids }o--|| user_groups : group_id
|
invite_token_group_ids }o--|| user_groups : group_id
|
||||||
|
|
||||||
' Document relationships
|
' Document relationships
|
||||||
documents }o--o| persons : sender_id
|
documents }o--o| persons : sender_id (ON DELETE SET NULL)
|
||||||
document_receivers }o--|| documents : document_id
|
document_receivers }o--|| documents : document_id
|
||||||
document_receivers }o--|| persons : person_id
|
document_receivers }o--|| persons : person_id (ON DELETE CASCADE)
|
||||||
document_tags }o--|| documents : document_id
|
document_tags }o--|| documents : document_id
|
||||||
document_tags }o--|| tag : tag_id
|
document_tags }o--|| tag : tag_id
|
||||||
document_versions }o--|| documents : document_id
|
document_versions }o--|| documents : document_id
|
||||||
@@ -113,6 +113,7 @@ transcription_blocks }o--o| app_users : updated_by
|
|||||||
transcription_block_versions }o--|| transcription_blocks : block_id
|
transcription_block_versions }o--|| transcription_blocks : block_id
|
||||||
transcription_block_versions }o--o| app_users : changed_by
|
transcription_block_versions }o--o| app_users : changed_by
|
||||||
transcription_block_mentioned_persons }o--|| transcription_blocks : block_id
|
transcription_block_mentioned_persons }o--|| transcription_blocks : block_id
|
||||||
|
transcription_block_mentioned_persons }o--|| persons : person_id (ON DELETE CASCADE)
|
||||||
|
|
||||||
' OCR relationships
|
' OCR relationships
|
||||||
ocr_job_documents }o--|| ocr_jobs : job_id
|
ocr_job_documents }o--|| ocr_jobs : job_id
|
||||||
|
|||||||
@@ -307,9 +307,21 @@ describe('renderTranscriptionBody', () => {
|
|||||||
expect(result).not.toMatch(/>O"Brien<\/a>/);
|
expect(result).not.toMatch(/>O"Brien<\/a>/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders nothing when mentionedPersons is undefined-empty and no @ triggers', () => {
|
it('renders a deleted-person @mention as plain text with no dead link (graceful degradation)', () => {
|
||||||
const result = renderTranscriptionBody('Plain old transcription text.', []);
|
// AC-6 (#684): when a mentioned person is deleted, V71's ON DELETE CASCADE removes the
|
||||||
expect(result).toBe('Plain old transcription text.');
|
// sidecar row, so the displayName reaches the renderer with an empty mentionedPersons
|
||||||
|
// array. The reader must still see the name as plain text — never a dead <a>, a
|
||||||
|
// person-mention class, a data-person-id, or an href. This locks the degradation
|
||||||
|
// contract so a future renderer refactor cannot silently reintroduce a dead link.
|
||||||
|
const result = renderTranscriptionBody('Brief an @Auguste Raddatz vom Mai', []);
|
||||||
|
// (a) the reader still sees the readable name and the surrounding sentence verbatim
|
||||||
|
expect(result).toContain('Auguste Raddatz');
|
||||||
|
expect(result).toBe('Brief an @Auguste Raddatz vom Mai');
|
||||||
|
// (b) none of the anchor artifacts leak through
|
||||||
|
expect(result).not.toContain('<a');
|
||||||
|
expect(result).not.toContain('person-mention');
|
||||||
|
expect(result).not.toContain('data-person-id');
|
||||||
|
expect(result).not.toContain('href');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('skips substitution when personId is not a UUID (defense in depth)', () => {
|
it('skips substitution when personId is not a UUID (defense in depth)', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user