diff --git a/.plans/open/issue-201-count-archived-loads-all-members.md b/.plans/done/issue-201-count-archived-loads-all-members.md similarity index 100% rename from .plans/open/issue-201-count-archived-loads-all-members.md rename to .plans/done/issue-201-count-archived-loads-all-members.md diff --git a/lib/Db/MemberMapper.php b/lib/Db/MemberMapper.php index d9f5edb..3840a8b 100644 --- a/lib/Db/MemberMapper.php +++ b/lib/Db/MemberMapper.php @@ -372,6 +372,29 @@ class MemberMapper extends QBMapper { return $count; } + /** + * Count all soft-deleted (archived) members. + * + * Uses a single SQL COUNT query instead of loading all members + * into memory. + * + * Part of Issue #201. + * + * @throws Exception + */ + public function countArchived(): int { + $qb = $this->db->getQueryBuilder(); + $qb->select($qb->createFunction('COUNT(*)')) + ->from($this->getTableName()) + ->where($qb->expr()->isNotNull('deleted_at')); + + $result = $qb->executeQuery(); + $count = (int)$result->fetchOne(); + $result->closeCursor(); + + return $count; + } + // ── Joined fetch methods (N+1 avoidance) ──────────────────────── /** diff --git a/lib/Service/MemberService.php b/lib/Service/MemberService.php index 895d8f8..91eab68 100644 --- a/lib/Service/MemberService.php +++ b/lib/Service/MemberService.php @@ -761,11 +761,13 @@ class MemberService { /** * Count all soft-deleted (archived) members. * + * Part of Issue #201: uses a single SQL COUNT query via the mapper + * instead of loading all members into memory. + * * @throws Exception */ public function countArchived(): int { - $allMembers = $this->memberMapper->findAll(null, null, true); - return count(array_filter($allMembers, fn(Member $m) => $m->getDeletedAt() !== null)); + return $this->memberMapper->countArchived(); } /** diff --git a/tests/Unit/MapperTest.php b/tests/Unit/MapperTest.php index be5af8e..6531a9e 100644 --- a/tests/Unit/MapperTest.php +++ b/tests/Unit/MapperTest.php @@ -543,6 +543,20 @@ class MapperTest extends TestCase { $this->assertSame(0, $count); } + public function testMemberMapperCountArchived(): void { + $this->configureResultCount(7); + $mapper = new MemberMapper($this->db); + $count = $mapper->countArchived(); + $this->assertSame(7, $count); + } + + public function testMemberMapperCountArchivedZero(): void { + $this->configureResultCount(0); + $mapper = new MemberMapper($this->db); + $count = $mapper->countArchived(); + $this->assertSame(0, $count); + } + // ══════════════════════════════════════════════════════════════════ // ── FamilyMapper ───────────────────────────────────────────────── // ══════════════════════════════════════════════════════════════════ diff --git a/tests/Unit/MemberServiceTest.php b/tests/Unit/MemberServiceTest.php index 968eb9c..1458de1 100644 --- a/tests/Unit/MemberServiceTest.php +++ b/tests/Unit/MemberServiceTest.php @@ -1299,17 +1299,19 @@ class MemberServiceTest extends TestCase { // ── countArchived() ────────────────────────────────────────────── public function testCountArchivedReturnsCorrectCount(): void { - $activeMember = $this->createMember(1); - $deletedMember = $this->createMember(2); - $deletedMember->setDeletedAt('2026-01-01 00:00:00'); - - $this->memberMapper->method('findAll') - ->with(null, null, true) - ->willReturn([$activeMember, $deletedMember]); + // Issue #201: countArchived() now delegates to MemberMapper::countArchived() + // which uses a SQL COUNT query instead of loading all members into memory. + $this->memberMapper->method('countArchived')->willReturn(1); $this->assertSame(1, $this->service->countArchived()); } + public function testCountArchivedReturnsZeroWhenNoArchived(): void { + $this->memberMapper->method('countArchived')->willReturn(0); + + $this->assertSame(0, $this->service->countArchived()); + } + // ── Joined methods: findAllWithRelations ────────────────────── public function testFindAllWithRelationsReturnsMemberWithNestedSubEntities(): void {