Merge pull request 'Fix findArchived() filtering all members in memory (Closes #202)' (#202) from fix/find-archived-filtering-in-memory into main
Database Portability Tests / Unit Tests (PlatformHelper) (push) Failing after 37s
Database Portability Tests / Integration (mysql) (push) Has been skipped
Database Portability Tests / Integration (postgres) (push) Has been skipped
Database Portability Tests / Integration (sqlite) (push) Has been skipped
Database Portability Tests / Verify no MySQL-specific SQL (push) Successful in 5s

This commit was merged in pull request #202.
This commit is contained in:
2026-04-29 21:14:55 +02:00
4 changed files with 107 additions and 11 deletions
+28
View File
@@ -395,6 +395,34 @@ class MemberMapper extends QBMapper {
return $count;
}
/**
* Find all soft-deleted (archived) members with optional pagination.
*
* Uses a single SQL query with WHERE deleted_at IS NOT NULL instead
* of loading all members into memory and filtering in PHP.
*
* Part of Issue #202.
*
* @return Member[]
* @throws Exception
*/
public function findArchived(?int $limit = null, ?int $offset = null): array {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
->where($qb->expr()->isNotNull('deleted_at'))
->orderBy('deleted_at', 'DESC');
if ($limit !== null) {
$qb->setMaxResults($limit);
}
if ($offset !== null) {
$qb->setFirstResult($offset);
}
return $this->findEntities($qb);
}
// ── Joined fetch methods (N+1 avoidance) ────────────────────────
/**
+2 -5
View File
@@ -738,10 +738,7 @@ class MemberService {
* @throws Exception
*/
public function findArchived(?int $limit = null, ?int $offset = null): array {
$members = $this->memberMapper->findAll($limit, $offset, true);
// Filter to only soft-deleted members
$archived = array_filter($members, fn(Member $m) => $m->getDeletedAt() !== null);
$members = $this->memberMapper->findArchived($limit, $offset);
return array_values(array_map(function (Member $member) {
// Only return retained fields
@@ -755,7 +752,7 @@ class MemberService {
'deletedAt' => $member->getDeletedAt(),
'mitgliedsdauer' => $this->calculateMitgliedsdauer($member),
];
}, $archived));
}, $members));
}
/**
+25
View File
@@ -557,6 +557,31 @@ class MapperTest extends TestCase {
$this->assertSame(0, $count);
}
public function testMemberMapperFindArchivedNoParams(): void {
$row = $this->memberRow(1);
$row['deleted_at'] = '2026-01-01 00:00:00';
$this->configureResultRows([$row]);
$mapper = new MemberMapper($this->db);
$members = $mapper->findArchived();
$this->assertCount(1, $members);
}
public function testMemberMapperFindArchivedWithPagination(): void {
$row = $this->memberRow(1);
$row['deleted_at'] = '2026-01-01 00:00:00';
$this->configureResultRows([$row]);
$mapper = new MemberMapper($this->db);
$members = $mapper->findArchived(10, 0);
$this->assertCount(1, $members);
}
public function testMemberMapperFindArchivedEmpty(): void {
$this->configureResultRows([]);
$mapper = new MemberMapper($this->db);
$members = $mapper->findArchived();
$this->assertCount(0, $members);
}
// ══════════════════════════════════════════════════════════════════
// ── FamilyMapper ─────────────────────────────────────────────────
// ══════════════════════════════════════════════════════════════════
+52 -6
View File
@@ -1276,17 +1276,20 @@ class MemberServiceTest extends TestCase {
// ── findArchived() ──────────────────────────────────────────────
public function testFindArchivedReturnsOnlyDeletedMembers(): void {
$activeMember = $this->createMember(1);
$deletedMember = $this->createMember(2, 'Anna', 'Schmidt');
public function testFindArchivedDelegatesToMapper(): void {
// Issue #202: findArchived() now delegates to MemberMapper::findArchived()
// which uses a SQL WHERE deleted_at IS NOT NULL query instead of
// loading all members into memory and filtering in PHP.
$deletedMember = $this->createMember(1, 'Anna', 'Schmidt');
$deletedMember->setDeletedAt('2026-01-01 00:00:00');
$deletedMember->setStatus('geloescht');
$deletedMember->setEintritt('2018-01-01');
$deletedMember->setAustritt('2025-12-31');
$this->memberMapper->method('findAll')
->with(null, null, true)
->willReturn([$activeMember, $deletedMember]);
$this->memberMapper->expects($this->once())
->method('findArchived')
->with(null, null)
->willReturn([$deletedMember]);
$result = $this->service->findArchived();
@@ -1296,6 +1299,49 @@ class MemberServiceTest extends TestCase {
$this->assertArrayHasKey('deletedAt', $result[0]);
}
public function testFindArchivedPassesPaginationToMapper(): void {
$this->memberMapper->expects($this->once())
->method('findArchived')
->with(10, 20)
->willReturn([]);
$this->service->findArchived(10, 20);
}
public function testFindArchivedReturnsEmptyArrayWhenNoArchivedMembers(): void {
$this->memberMapper->method('findArchived')->willReturn([]);
$result = $this->service->findArchived();
$this->assertSame([], $result);
}
public function testFindArchivedReturnsOnlyRetainedFields(): void {
$deletedMember = $this->createMember(1, 'Anna', 'Schmidt');
$deletedMember->setDeletedAt('2026-01-01 00:00:00');
$deletedMember->setEintritt('2018-01-01');
$deletedMember->setAustritt('2025-12-31');
$this->memberMapper->method('findArchived')->willReturn([$deletedMember]);
$result = $this->service->findArchived();
$this->assertCount(1, $result);
$entry = $result[0];
// Retained fields should be present
$this->assertArrayHasKey('id', $entry);
$this->assertArrayHasKey('vorname', $entry);
$this->assertArrayHasKey('nachname', $entry);
$this->assertArrayHasKey('geburtsdatum', $entry);
$this->assertArrayHasKey('eintritt', $entry);
$this->assertArrayHasKey('austritt', $entry);
$this->assertArrayHasKey('deletedAt', $entry);
$this->assertArrayHasKey('mitgliedsdauer', $entry);
// Sensitive fields should NOT be present
$this->assertArrayNotHasKey('notizen', $entry);
$this->assertArrayNotHasKey('allergienEncrypted', $entry);
$this->assertArrayNotHasKey('kvTyp', $entry);
}
// ── countArchived() ──────────────────────────────────────────────
public function testCountArchivedReturnsCorrectCount(): void {