Fix N+1 query problem in MemberService (Closes #200)
Database Portability Tests / Unit Tests (PlatformHelper) (push) Failing after 40s
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
Database Portability Tests / Unit Tests (PlatformHelper) (push) Failing after 40s
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
- MemberMapper: 8 new *WithRelations() methods that fetch members with addresses, phones, and emails in a single query using LEFT JOINs - MemberMapper: addJoinClauses() and fetchWithRelations() private helpers that handle JOIN duplication (one member × multiple sub-entities) - MemberService: refactored findAll, findByFamily, findByStatus, search, findByBirthdayThisMonth, findWithUnpaidFees, findFiltered, fullTextSearch to delegate to joined mapper methods - MemberService: added arrayToMember() and arrayToAddress() helpers so buildMatchContext() works with flat-array results from fullTextSearch - MemberServiceTest: updated all existing tests to mock new method names and return flat-array format with nested sub-entities - MemberServiceTest: added 10 new tests covering joined methods, backward compatibility, and correct shape of returned data - Moved issue-200 plan from open/ to done/
This commit is contained in:
+1788
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -21,7 +21,7 @@
|
||||
]
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^10.0",
|
||||
"phpunit/phpunit": "^10.5",
|
||||
"nextcloud/ocp": "^28",
|
||||
"doctrine/dbal": "^3.0",
|
||||
"sabre/vobject": "^4.5"
|
||||
|
||||
Executable
BIN
Binary file not shown.
@@ -232,6 +232,47 @@ class MemberMapper extends QBMapper {
|
||||
return $this->findEntities($qb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-text search across members, addresses, and notes with all
|
||||
* sub-entities in a single query.
|
||||
*
|
||||
* Searches: vorname, nachname, notizen, zusatz_notizen, and joined
|
||||
* address fields. Returns members with nested addresses, phones, and
|
||||
* emails.
|
||||
*
|
||||
* Part of Issue #33.
|
||||
*
|
||||
* @return array<int, array> Flat member arrays with nested sub-entities
|
||||
* @throws Exception
|
||||
*/
|
||||
public function fullTextSearchWithRelations(string $query, int $limit = 20): array {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$searchPattern = '%' . $this->db->escapeLikeParameter($query) . '%';
|
||||
|
||||
$qb->select('m.*')
|
||||
->from($this->getTableName(), 'm');
|
||||
|
||||
$this->addJoinClauses($qb);
|
||||
|
||||
$qb->where($qb->expr()->isNull('m.deleted_at'))
|
||||
->andWhere(
|
||||
$qb->expr()->orX(
|
||||
$qb->expr()->iLike('m.vorname', $qb->createNamedParameter($searchPattern)),
|
||||
$qb->expr()->iLike('m.nachname', $qb->createNamedParameter($searchPattern)),
|
||||
$qb->expr()->iLike('m.notizen', $qb->createNamedParameter($searchPattern)),
|
||||
$qb->expr()->iLike('m.zusatz_notizen', $qb->createNamedParameter($searchPattern)),
|
||||
$qb->expr()->iLike('a.strasse', $qb->createNamedParameter($searchPattern)),
|
||||
$qb->expr()->iLike('a.plz', $qb->createNamedParameter($searchPattern)),
|
||||
$qb->expr()->iLike('a.ort', $qb->createNamedParameter($searchPattern))
|
||||
)
|
||||
)
|
||||
->orderBy('m.nachname', 'ASC')
|
||||
->addOrderBy('m.vorname', 'ASC')
|
||||
->setMaxResults($limit);
|
||||
|
||||
return $this->fetchWithRelations($qb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a member by exact Vorname, Nachname, and Geburtsdatum.
|
||||
* Used for duplicate detection during import.
|
||||
@@ -330,4 +371,348 @@ class MemberMapper extends QBMapper {
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
// ── Joined fetch methods (N+1 avoidance) ────────────────────────
|
||||
|
||||
/**
|
||||
* Find all members with their addresses, phones, and emails in a
|
||||
* single query using LEFT JOINs.
|
||||
*
|
||||
* Returns an array of associative arrays, each representing one
|
||||
* member with nested 'addresses', 'phones', and 'emails' arrays.
|
||||
*
|
||||
* This replaces the N+1 pattern (1 query for members + 3 queries
|
||||
* per member for sub-entities) with a single query regardless of
|
||||
* the number of members.
|
||||
*
|
||||
* @param int|null $limit Pagination limit
|
||||
* @param int|null $offset Pagination offset
|
||||
* @return array<int, array> Flat member arrays with nested sub-entities
|
||||
* @throws Exception
|
||||
*/
|
||||
public function findAllWithRelations(?int $limit = null, ?int $offset = null): array {
|
||||
return $this->fetchWithRelations(
|
||||
$this->buildBaseQuery(false, $limit, $offset)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find members by family ID with all sub-entities in a single query.
|
||||
*
|
||||
* @param int $familyId Family ID
|
||||
* @return array<int, array> Flat member arrays with nested sub-entities
|
||||
* @throws Exception
|
||||
*/
|
||||
public function findByFamilyWithRelations(int $familyId): array {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')
|
||||
->from($this->getTableName(), 'm');
|
||||
|
||||
$this->addJoinClauses($qb);
|
||||
|
||||
$qb->where($qb->expr()->eq('m.family_id', $qb->createNamedParameter($familyId, IQueryBuilder::PARAM_INT)))
|
||||
->andWhere($qb->expr()->isNull('m.deleted_at'))
|
||||
->orderBy('m.nachname', 'ASC')
|
||||
->addOrderBy('m.vorname', 'ASC');
|
||||
|
||||
return $this->fetchWithRelations($qb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find members by status with all sub-entities in a single query.
|
||||
*
|
||||
* @param string $status Status to filter by
|
||||
* @return array<int, array> Flat member arrays with nested sub-entities
|
||||
* @throws Exception
|
||||
*/
|
||||
public function findByStatusWithRelations(string $status): array {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')
|
||||
->from($this->getTableName(), 'm');
|
||||
|
||||
$this->addJoinClauses($qb);
|
||||
|
||||
$qb->where($qb->expr()->eq('m.status', $qb->createNamedParameter($status)))
|
||||
->orderBy('m.nachname', 'ASC')
|
||||
->addOrderBy('m.vorname', 'ASC');
|
||||
|
||||
return $this->fetchWithRelations($qb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search members by name with all sub-entities in a single query.
|
||||
*
|
||||
* @param string $query Search query (searches Vorname and Nachname)
|
||||
* @return array<int, array> Flat member arrays with nested sub-entities
|
||||
* @throws Exception
|
||||
*/
|
||||
public function searchWithRelations(string $query): array {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')
|
||||
->from($this->getTableName(), 'm');
|
||||
|
||||
$this->addJoinClauses($qb);
|
||||
|
||||
$searchPattern = '%' . $this->db->escapeLikeParameter($query) . '%';
|
||||
$qb->where($qb->expr()->isNull('m.deleted_at'))
|
||||
->andWhere(
|
||||
$qb->expr()->orX(
|
||||
$qb->expr()->iLike('m.vorname', $qb->createNamedParameter($searchPattern)),
|
||||
$qb->expr()->iLike('m.nachname', $qb->createNamedParameter($searchPattern))
|
||||
)
|
||||
)
|
||||
->orderBy('m.nachname', 'ASC')
|
||||
->addOrderBy('m.vorname', 'ASC');
|
||||
|
||||
return $this->fetchWithRelations($qb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find members with a birthday in the given month with all
|
||||
* sub-entities in a single query.
|
||||
*
|
||||
* @param int $month Month (1-12)
|
||||
* @return array<int, array> Flat member arrays with nested sub-entities
|
||||
* @throws Exception
|
||||
*/
|
||||
public function findByBirthdayMonthWithRelations(int $month): array {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')
|
||||
->from($this->getTableName(), 'm');
|
||||
|
||||
$this->addJoinClauses($qb);
|
||||
|
||||
$monthExpr = PlatformHelper::getMonthExpression($this->db, 'm.geburtsdatum');
|
||||
$qb->where($qb->expr()->isNull('m.deleted_at'))
|
||||
->andWhere(
|
||||
$qb->expr()->eq(
|
||||
$qb->createFunction($monthExpr),
|
||||
$qb->createNamedParameter($month, IQueryBuilder::PARAM_INT)
|
||||
)
|
||||
)
|
||||
->orderBy('m.geburtsdatum', 'ASC');
|
||||
|
||||
return $this->fetchWithRelations($qb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find members who have unpaid fee records for a given year with
|
||||
* all sub-entities in a single query.
|
||||
*
|
||||
* @param int $year Year to filter by
|
||||
* @return array<int, array> Flat member arrays with nested sub-entities
|
||||
* @throws Exception
|
||||
*/
|
||||
public function findWithUnpaidFeesWithRelations(int $year): array {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('m.*')
|
||||
->from($this->getTableName(), 'm')
|
||||
->innerJoin('m', 'mv_fee_records', 'f', $qb->expr()->eq('m.id', 'f.member_id'));
|
||||
|
||||
$this->addJoinClauses($qb);
|
||||
|
||||
$qb->where($qb->expr()->isNull('m.deleted_at'))
|
||||
->andWhere($qb->expr()->eq('f.year', $qb->createNamedParameter($year, IQueryBuilder::PARAM_INT)))
|
||||
->andWhere($qb->expr()->eq('f.paid', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL)))
|
||||
->orderBy('m.nachname', 'ASC')
|
||||
->addOrderBy('m.vorname', 'ASC');
|
||||
|
||||
return $this->fetchWithRelations($qb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find members matching a combination of filters with all
|
||||
* sub-entities in a single query.
|
||||
*
|
||||
* @param string|null $status Filter by status (aktiv/inaktiv)
|
||||
* @param string|null $rolle Filter by rolle (mitglied/erziehungsberechtigter)
|
||||
* @param bool $birthdayThisMonth Only members with birthday in current month
|
||||
* @param bool $unpaidFees Only members with unpaid fee records for current year
|
||||
* @return array<int, array> Flat member arrays with nested sub-entities
|
||||
* @throws Exception
|
||||
*/
|
||||
public function findFilteredWithRelations(
|
||||
?string $status = null,
|
||||
?string $rolle = null,
|
||||
bool $birthdayThisMonth = false,
|
||||
bool $unpaidFees = false
|
||||
): array {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('m.*')
|
||||
->from($this->getTableName(), 'm');
|
||||
|
||||
$this->addJoinClauses($qb);
|
||||
|
||||
$qb->where($qb->expr()->isNull('m.deleted_at'));
|
||||
|
||||
if ($unpaidFees) {
|
||||
$year = (int)(new \DateTime())->format('Y');
|
||||
$qb->innerJoin('m', 'mv_fee_records', 'f', $qb->expr()->eq('m.id', 'f.member_id'))
|
||||
->andWhere($qb->expr()->eq('f.year', $qb->createNamedParameter($year, IQueryBuilder::PARAM_INT)))
|
||||
->andWhere($qb->expr()->eq('f.paid', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL)));
|
||||
}
|
||||
|
||||
if ($status !== null) {
|
||||
$qb->andWhere($qb->expr()->eq('m.status', $qb->createNamedParameter($status)));
|
||||
}
|
||||
|
||||
if ($rolle !== null) {
|
||||
$qb->andWhere($qb->expr()->eq('m.rolle', $qb->createNamedParameter($rolle)));
|
||||
}
|
||||
|
||||
if ($birthdayThisMonth) {
|
||||
$currentMonth = (int)(new \DateTime())->format('m');
|
||||
$monthExpr = PlatformHelper::getMonthExpression($this->db, 'm.geburtsdatum');
|
||||
$qb->andWhere(
|
||||
$qb->expr()->eq(
|
||||
$qb->createFunction($monthExpr),
|
||||
$qb->createNamedParameter($currentMonth, IQueryBuilder::PARAM_INT)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
$qb->orderBy('m.nachname', 'ASC')
|
||||
->addOrderBy('m.vorname', 'ASC');
|
||||
|
||||
return $this->fetchWithRelations($qb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the base query for findAllWithRelations.
|
||||
*/
|
||||
private function buildBaseQuery(
|
||||
bool $includeDeleted,
|
||||
?int $limit,
|
||||
?int $offset
|
||||
): \OCP\DB\QueryBuilder\IQueryBuilder {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')
|
||||
->from($this->getTableName(), 'm');
|
||||
|
||||
$this->addJoinClauses($qb);
|
||||
|
||||
if (!$includeDeleted) {
|
||||
$qb->where($qb->expr()->isNull('m.deleted_at'));
|
||||
}
|
||||
|
||||
$qb->orderBy('m.nachname', 'ASC')
|
||||
->addOrderBy('m.vorname', 'ASC');
|
||||
|
||||
if ($limit !== null) {
|
||||
$qb->setMaxResults($limit);
|
||||
}
|
||||
if ($offset !== null) {
|
||||
$qb->setFirstResult($offset);
|
||||
}
|
||||
|
||||
return $qb;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the LEFT JOIN clauses for addresses, phones, and emails
|
||||
* to the given query builder.
|
||||
*/
|
||||
private function addJoinClauses(\OCP\DB\QueryBuilder\IQueryBuilder $qb): void {
|
||||
$qb->leftJoin('m', 'mv_addresses', 'a', $qb->expr()->eq('m.id', 'a.member_id'))
|
||||
->leftJoin('m', 'mv_phones', 'p', $qb->expr()->eq('m.id', 'p.member_id'))
|
||||
->leftJoin('m', 'mv_emails', 'e', $qb->expr()->eq('m.id', 'e.member_id'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a query with joined sub-entities and parse the flat
|
||||
* result set into nested member arrays.
|
||||
*
|
||||
* The query must SELECT all columns from the members table plus
|
||||
* columns from mv_addresses, mv_phones, and mv_emails. Column
|
||||
* name collision is handled by prefixing joined columns with
|
||||
* aliases (a.*, p.*, e.*) which Doctrine maps as
|
||||
* a_strasse, p_number_e164, etc.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
private function fetchWithRelations(\OCP\DB\QueryBuilder\IQueryBuilder $qb): array {
|
||||
$result = $qb->executeQuery();
|
||||
|
||||
/** @var array<int, array> $grouped */
|
||||
$grouped = [];
|
||||
|
||||
while ($row = $result->fetch()) {
|
||||
$id = (int)$row['id'];
|
||||
if (!isset($grouped[$id])) {
|
||||
// Build the base member record from the member columns.
|
||||
$grouped[$id] = [
|
||||
'id' => $id,
|
||||
'vorname' => $row['vorname'] ?? '',
|
||||
'nachname' => $row['nachname'] ?? '',
|
||||
'geburtsdatum' => $row['geburtsdatum'] ?? null,
|
||||
'geschlecht' => $row['geschlecht'] ?? null,
|
||||
'rolle' => $row['rolle'] ?? 'mitglied',
|
||||
'stufeId' => isset($row['stufe_id']) ? (int)$row['stufe_id'] : null,
|
||||
'eintritt' => $row['eintritt'] ?? '',
|
||||
'austritt' => $row['austritt'] ?? null,
|
||||
'status' => $row['status'] ?? 'aktiv',
|
||||
'allergienEncrypted' => $row['allergien_encrypted'] ?? null,
|
||||
'notizen' => $row['notizen'] ?? null,
|
||||
'zusatzNotizen' => $row['zusatz_notizen'] ?? null,
|
||||
'kvTyp' => $row['kv_typ'] ?? null,
|
||||
'kvName' => $row['kv_name'] ?? null,
|
||||
'familyId' => isset($row['family_id']) ? (int)$row['family_id'] : null,
|
||||
'frozenFeeRate' => $row['frozen_fee_rate'] ?? null,
|
||||
'calendarEventUri' => $row['calendar_event_uri'] ?? null,
|
||||
'contactVcardUri' => $row['contact_vcard_uri'] ?? null,
|
||||
'createdAt' => $row['created_at'] ?? '',
|
||||
'updatedAt' => $row['updated_at'] ?? '',
|
||||
'deletedAt' => $row['deleted_at'] ?? null,
|
||||
'einwilligungDatum' => $row['einwilligung_datum'] ?? null,
|
||||
'juleicaNummer' => $row['juleica_nummer'] ?? null,
|
||||
'juleicaAblaufdatum' => $row['juleica_ablaufdatum'] ?? null,
|
||||
'addresses' => [],
|
||||
'phones' => [],
|
||||
'emails' => [],
|
||||
];
|
||||
}
|
||||
|
||||
// Parse sub-entities from the joined columns.
|
||||
// A row with NULLs for all address columns means no address
|
||||
// is joined for this row — we must not create an empty Address.
|
||||
$addrId = $row['a_id'] ?? null;
|
||||
if ($addrId !== null && $addrId !== '') {
|
||||
$grouped[$id]['addresses'][] = [
|
||||
'id' => (int)$addrId,
|
||||
'memberId' => (int)$row['a_member_id'],
|
||||
'label' => $row['a_label'] ?? null,
|
||||
'strasse' => $row['a_strasse'] ?? '',
|
||||
'plz' => $row['a_plz'] ?? '',
|
||||
'ort' => $row['a_ort'] ?? '',
|
||||
'land' => $row['a_land'] ?? 'Deutschland',
|
||||
'isPrimary' => (bool)$row['a_is_primary'],
|
||||
];
|
||||
}
|
||||
|
||||
$phoneId = $row['p_id'] ?? null;
|
||||
if ($phoneId !== null && $phoneId !== '') {
|
||||
$grouped[$id]['phones'][] = [
|
||||
'id' => (int)$phoneId,
|
||||
'memberId' => (int)$row['p_member_id'],
|
||||
'label' => $row['p_label'] ?? null,
|
||||
'numberE164' => $row['p_number_e164'] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
$emailId = $row['e_id'] ?? null;
|
||||
if ($emailId !== null && $emailId !== '') {
|
||||
$grouped[$id]['emails'][] = [
|
||||
'id' => (int)$emailId,
|
||||
'memberId' => (int)$row['e_member_id'],
|
||||
'label' => $row['e_label'] ?? null,
|
||||
'email' => $row['e_email'] ?? '',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$result->closeCursor();
|
||||
|
||||
// Re-index to a sequential 0-based array while preserving order.
|
||||
return array_values($grouped);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,77 +186,56 @@ class MemberService {
|
||||
/**
|
||||
* Find all members with optional pagination.
|
||||
*
|
||||
* Uses a single query with LEFT JOINs to avoid the N+1 problem.
|
||||
*
|
||||
* @return array[]
|
||||
* @throws Exception
|
||||
*/
|
||||
public function findAll(?int $limit = null, ?int $offset = null): array {
|
||||
$members = $this->memberMapper->findAll($limit, $offset);
|
||||
return array_map(function (Member $member) {
|
||||
$id = $member->getId();
|
||||
$addresses = $this->addressMapper->findByMemberId($id);
|
||||
$phones = $this->phoneMapper->findByMemberId($id);
|
||||
$emails = $this->emailMapper->findByMemberId($id);
|
||||
return $this->buildMemberResponse($member, $addresses, $phones, $emails);
|
||||
}, $members);
|
||||
return $this->memberMapper->findAllWithRelations($limit, $offset);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find members by family ID.
|
||||
*
|
||||
* Uses a single query with LEFT JOINs to avoid the N+1 problem.
|
||||
*
|
||||
* @return array[]
|
||||
* @throws Exception
|
||||
*/
|
||||
public function findByFamily(int $familyId): array {
|
||||
$members = $this->memberMapper->findByFamily($familyId);
|
||||
return array_map(function (Member $member) {
|
||||
return $this->buildMemberResponse(
|
||||
$member,
|
||||
$this->addressMapper->findByMemberId($member->getId()),
|
||||
$this->phoneMapper->findByMemberId($member->getId()),
|
||||
$this->emailMapper->findByMemberId($member->getId())
|
||||
);
|
||||
}, $members);
|
||||
return $this->memberMapper->findByFamilyWithRelations($familyId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find members by status.
|
||||
*
|
||||
* Uses a single query with LEFT JOINs to avoid the N+1 problem.
|
||||
*
|
||||
* @return array[]
|
||||
* @throws Exception
|
||||
*/
|
||||
public function findByStatus(string $status): array {
|
||||
$members = $this->memberMapper->findByStatus($status);
|
||||
return array_map(function (Member $member) {
|
||||
return $this->buildMemberResponse(
|
||||
$member,
|
||||
$this->addressMapper->findByMemberId($member->getId()),
|
||||
$this->phoneMapper->findByMemberId($member->getId()),
|
||||
$this->emailMapper->findByMemberId($member->getId())
|
||||
);
|
||||
}, $members);
|
||||
return $this->memberMapper->findByStatusWithRelations($status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search members by name.
|
||||
*
|
||||
* Uses a single query with LEFT JOINs to avoid the N+1 problem.
|
||||
*
|
||||
* @return array[]
|
||||
* @throws Exception
|
||||
*/
|
||||
public function search(string $query): array {
|
||||
$members = $this->memberMapper->search($query);
|
||||
return array_map(function (Member $member) {
|
||||
return $this->buildMemberResponse(
|
||||
$member,
|
||||
$this->addressMapper->findByMemberId($member->getId()),
|
||||
$this->phoneMapper->findByMemberId($member->getId()),
|
||||
$this->emailMapper->findByMemberId($member->getId())
|
||||
);
|
||||
}, $members);
|
||||
return $this->memberMapper->searchWithRelations($query);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find members with a birthday in the current month.
|
||||
*
|
||||
* Uses a single query with LEFT JOINs to avoid the N+1 problem.
|
||||
*
|
||||
* Part of Issue #34.
|
||||
*
|
||||
* @return array[]
|
||||
@@ -264,20 +243,14 @@ class MemberService {
|
||||
*/
|
||||
public function findByBirthdayThisMonth(): array {
|
||||
$currentMonth = (int)(new DateTime())->format('m');
|
||||
$members = $this->memberMapper->findByBirthdayMonth($currentMonth);
|
||||
return array_map(function (Member $member) {
|
||||
return $this->buildMemberResponse(
|
||||
$member,
|
||||
$this->addressMapper->findByMemberId($member->getId()),
|
||||
$this->phoneMapper->findByMemberId($member->getId()),
|
||||
$this->emailMapper->findByMemberId($member->getId())
|
||||
);
|
||||
}, $members);
|
||||
return $this->memberMapper->findByBirthdayMonthWithRelations($currentMonth);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find members with unpaid fee records for the current year.
|
||||
*
|
||||
* Uses a single query with LEFT JOINs to avoid the N+1 problem.
|
||||
*
|
||||
* Part of Issue #34.
|
||||
*
|
||||
* @return array[]
|
||||
@@ -285,20 +258,14 @@ class MemberService {
|
||||
*/
|
||||
public function findWithUnpaidFees(): array {
|
||||
$currentYear = (int)(new DateTime())->format('Y');
|
||||
$members = $this->memberMapper->findWithUnpaidFees($currentYear);
|
||||
return array_map(function (Member $member) {
|
||||
return $this->buildMemberResponse(
|
||||
$member,
|
||||
$this->addressMapper->findByMemberId($member->getId()),
|
||||
$this->phoneMapper->findByMemberId($member->getId()),
|
||||
$this->emailMapper->findByMemberId($member->getId())
|
||||
);
|
||||
}, $members);
|
||||
return $this->memberMapper->findWithUnpaidFeesWithRelations($currentYear);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find members matching a combination of filters (additive/AND logic).
|
||||
*
|
||||
* Uses a single query with LEFT JOINs to avoid the N+1 problem.
|
||||
*
|
||||
* @return array[]
|
||||
* @throws Exception
|
||||
*/
|
||||
@@ -308,43 +275,88 @@ class MemberService {
|
||||
bool $birthdayThisMonth = false,
|
||||
bool $unpaidFees = false
|
||||
): array {
|
||||
$members = $this->memberMapper->findFiltered($status, $rolle, $birthdayThisMonth, $unpaidFees);
|
||||
return array_map(function (Member $member) {
|
||||
return $this->buildMemberResponse(
|
||||
$member,
|
||||
$this->addressMapper->findByMemberId($member->getId()),
|
||||
$this->phoneMapper->findByMemberId($member->getId()),
|
||||
$this->emailMapper->findByMemberId($member->getId())
|
||||
);
|
||||
}, $members);
|
||||
return $this->memberMapper->findFilteredWithRelations($status, $rolle, $birthdayThisMonth, $unpaidFees);
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-text search across member names, addresses, and notes.
|
||||
* Returns results with match context for display in search dropdown.
|
||||
*
|
||||
* Uses a single query with LEFT JOINs to avoid the N+1 problem.
|
||||
*
|
||||
* Part of Issue #33.
|
||||
*
|
||||
* @return array[] Members with sub-entities and matchContext field
|
||||
* @throws Exception
|
||||
*/
|
||||
public function fullTextSearch(string $query, int $limit = 20): array {
|
||||
$members = $this->memberMapper->fullTextSearch($query, $limit);
|
||||
$rawResults = $this->memberMapper->fullTextSearchWithRelations($query, $limit);
|
||||
$lowQuery = mb_strtolower($query);
|
||||
|
||||
return array_map(function (Member $member) use ($lowQuery) {
|
||||
$id = $member->getId();
|
||||
$addresses = $this->addressMapper->findByMemberId($id);
|
||||
$phones = $this->phoneMapper->findByMemberId($id);
|
||||
$emails = $this->emailMapper->findByMemberId($id);
|
||||
|
||||
$result = $this->buildMemberResponse($member, $addresses, $phones, $emails);
|
||||
return array_map(function (array $row) use ($lowQuery): array {
|
||||
// buildMatchContext expects an Address[] — the raw result
|
||||
// stores addresses as arrays. Build minimal Address objects
|
||||
// so the existing logic works without rewriting the method.
|
||||
$addresses = array_map(
|
||||
fn(array $a): Address => $this->arrayToAddress($a),
|
||||
$row['addresses'] ?? []
|
||||
);
|
||||
|
||||
// Determine match context — which field matched
|
||||
$result['matchContext'] = $this->buildMatchContext($member, $addresses, $lowQuery);
|
||||
$row['matchContext'] = $this->buildMatchContext(
|
||||
$this->arrayToMember($row),
|
||||
$addresses,
|
||||
$lowQuery
|
||||
);
|
||||
|
||||
return $result;
|
||||
}, $members);
|
||||
return $row;
|
||||
}, $rawResults);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an associative array (from fetchWithRelations) to a Member entity.
|
||||
*/
|
||||
private function arrayToMember(array $row): Member {
|
||||
$m = new Member();
|
||||
$m->setId($row['id']);
|
||||
$m->setVorname($row['vorname']);
|
||||
$m->setNachname($row['nachname']);
|
||||
$m->setGeburtsdatum($row['geburtsdatum']);
|
||||
$m->setGeschlecht($row['geschlecht']);
|
||||
$m->setRolle($row['rolle']);
|
||||
$m->setStufeId($row['stufeId']);
|
||||
$m->setEintritt($row['eintritt']);
|
||||
$m->setAustritt($row['austritt']);
|
||||
$m->setStatus($row['status']);
|
||||
$m->setAllergienEncrypted($row['allergienEncrypted']);
|
||||
$m->setNotizen($row['notizen']);
|
||||
$m->setZusatzNotizen($row['zusatzNotizen']);
|
||||
$m->setKvTyp($row['kvTyp']);
|
||||
$m->setKvName($row['kvName']);
|
||||
$m->setFamilyId($row['familyId']);
|
||||
$m->setFrozenFeeRate($row['frozenFeeRate']);
|
||||
$m->setCalendarEventUri($row['calendarEventUri']);
|
||||
$m->setContactVcardUri($row['contactVcardUri']);
|
||||
$m->setEinwilligungDatum($row['einwilligungDatum']);
|
||||
$m->setJuleicaNummer($row['juleicaNummer']);
|
||||
$m->setJuleicaAblaufdatum($row['juleicaAblaufdatum']);
|
||||
return $m;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an associative array (from fetchWithRelations) to an Address entity.
|
||||
*/
|
||||
private function arrayToAddress(array $row): Address {
|
||||
$a = new Address();
|
||||
$a->setId($row['id']);
|
||||
$a->setMemberId($row['memberId']);
|
||||
$a->setLabel($row['label']);
|
||||
$a->setStrasse($row['strasse']);
|
||||
$a->setPlz($row['plz']);
|
||||
$a->setOrt($row['ort']);
|
||||
$a->setLand($row['land']);
|
||||
$a->setIsPrimary($row['isPrimary']);
|
||||
return $a;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -186,10 +186,10 @@ class MemberServiceTest extends TestCase {
|
||||
$member1 = $this->createMember(1, 'Max');
|
||||
$member2 = $this->createMember(2, 'Anna', 'Schmidt');
|
||||
|
||||
$this->memberMapper->method('findAll')->willReturn([$member1, $member2]);
|
||||
$this->addressMapper->method('findByMemberId')->willReturn([]);
|
||||
$this->phoneMapper->method('findByMemberId')->willReturn([]);
|
||||
$this->emailMapper->method('findByMemberId')->willReturn([]);
|
||||
$this->memberMapper->method('findAllWithRelations')->willReturn([
|
||||
$member1->jsonSerialize() + ['addresses' => [], 'phones' => [], 'emails' => []],
|
||||
$member2->jsonSerialize() + ['addresses' => [], 'phones' => [], 'emails' => []],
|
||||
]);
|
||||
|
||||
$result = $this->service->findAll();
|
||||
|
||||
@@ -200,7 +200,7 @@ class MemberServiceTest extends TestCase {
|
||||
|
||||
public function testFindAllWithPagination(): void {
|
||||
$this->memberMapper->expects($this->once())
|
||||
->method('findAll')
|
||||
->method('findAllWithRelations')
|
||||
->with(10, 5)
|
||||
->willReturn([]);
|
||||
|
||||
@@ -208,7 +208,7 @@ class MemberServiceTest extends TestCase {
|
||||
}
|
||||
|
||||
public function testFindAllReturnsEmptyArray(): void {
|
||||
$this->memberMapper->method('findAll')->willReturn([]);
|
||||
$this->memberMapper->method('findAllWithRelations')->willReturn([]);
|
||||
|
||||
$result = $this->service->findAll();
|
||||
$this->assertSame([], $result);
|
||||
@@ -225,9 +225,6 @@ class MemberServiceTest extends TestCase {
|
||||
$m->setId(1);
|
||||
return $m;
|
||||
});
|
||||
$this->addressMapper->method('findByMemberId')->willReturn([]);
|
||||
$this->phoneMapper->method('findByMemberId')->willReturn([]);
|
||||
$this->emailMapper->method('findByMemberId')->willReturn([]);
|
||||
|
||||
$this->auditService->expects($this->once())->method('logCreate');
|
||||
|
||||
@@ -900,10 +897,9 @@ class MemberServiceTest extends TestCase {
|
||||
|
||||
public function testSearchReturnsMatchingMembers(): void {
|
||||
$member = $this->createMember(1);
|
||||
$this->memberMapper->method('search')->with('Max')->willReturn([$member]);
|
||||
$this->addressMapper->method('findByMemberId')->willReturn([]);
|
||||
$this->phoneMapper->method('findByMemberId')->willReturn([]);
|
||||
$this->emailMapper->method('findByMemberId')->willReturn([]);
|
||||
$this->memberMapper->method('searchWithRelations')->with('Max')->willReturn([
|
||||
$member->jsonSerialize() + ['addresses' => [], 'phones' => [], 'emails' => []],
|
||||
]);
|
||||
|
||||
$result = $this->service->search('Max');
|
||||
|
||||
@@ -912,7 +908,7 @@ class MemberServiceTest extends TestCase {
|
||||
}
|
||||
|
||||
public function testSearchReturnsEmptyForNoMatch(): void {
|
||||
$this->memberMapper->method('search')->willReturn([]);
|
||||
$this->memberMapper->method('searchWithRelations')->willReturn([]);
|
||||
|
||||
$result = $this->service->search('Nonexistent');
|
||||
$this->assertSame([], $result);
|
||||
@@ -922,14 +918,15 @@ class MemberServiceTest extends TestCase {
|
||||
|
||||
public function testFullTextSearchReturnsResultsWithMatchContext(): void {
|
||||
$member = $this->createMember(1, 'Max', 'Mustermann');
|
||||
$address = $this->createAddress(1, 1);
|
||||
$memberData = $member->jsonSerialize() + [
|
||||
'addresses' => [['id' => 1, 'memberId' => 1, 'label' => null, 'strasse' => 'Musterstr. 1', 'plz' => '12345', 'ort' => 'Berlin', 'land' => 'Deutschland', 'isPrimary' => true]],
|
||||
'phones' => [],
|
||||
'emails' => [],
|
||||
];
|
||||
|
||||
$this->memberMapper->method('fullTextSearch')
|
||||
$this->memberMapper->method('fullTextSearchWithRelations')
|
||||
->with('Max', 20)
|
||||
->willReturn([$member]);
|
||||
$this->addressMapper->method('findByMemberId')->willReturn([$address]);
|
||||
$this->phoneMapper->method('findByMemberId')->willReturn([]);
|
||||
$this->emailMapper->method('findByMemberId')->willReturn([]);
|
||||
->willReturn([$memberData]);
|
||||
|
||||
$result = $this->service->fullTextSearch('Max');
|
||||
|
||||
@@ -940,13 +937,14 @@ class MemberServiceTest extends TestCase {
|
||||
|
||||
public function testFullTextSearchMatchesAddress(): void {
|
||||
$member = $this->createMember(1, 'Max', 'Mustermann');
|
||||
$address = $this->createAddress(1, 1);
|
||||
$memberData = $member->jsonSerialize() + [
|
||||
'addresses' => [['id' => 1, 'memberId' => 1, 'label' => null, 'strasse' => 'Musterstrasse 1', 'plz' => '12345', 'ort' => 'Musterstadt', 'land' => 'Deutschland', 'isPrimary' => true]],
|
||||
'phones' => [],
|
||||
'emails' => [],
|
||||
];
|
||||
|
||||
$this->memberMapper->method('fullTextSearch')
|
||||
->willReturn([$member]);
|
||||
$this->addressMapper->method('findByMemberId')->willReturn([$address]);
|
||||
$this->phoneMapper->method('findByMemberId')->willReturn([]);
|
||||
$this->emailMapper->method('findByMemberId')->willReturn([]);
|
||||
$this->memberMapper->method('fullTextSearchWithRelations')
|
||||
->willReturn([$memberData]);
|
||||
|
||||
$result = $this->service->fullTextSearch('Musterstrasse');
|
||||
|
||||
@@ -957,11 +955,13 @@ class MemberServiceTest extends TestCase {
|
||||
public function testFullTextSearchMatchesNotizen(): void {
|
||||
$member = $this->createMember(1, 'Max', 'Mustermann');
|
||||
$member->setNotizen('Hat einen besonderen Wunsch fuer Training');
|
||||
$memberData = $member->jsonSerialize() + [
|
||||
'addresses' => [],
|
||||
'phones' => [],
|
||||
'emails' => [],
|
||||
];
|
||||
|
||||
$this->memberMapper->method('fullTextSearch')->willReturn([$member]);
|
||||
$this->addressMapper->method('findByMemberId')->willReturn([]);
|
||||
$this->phoneMapper->method('findByMemberId')->willReturn([]);
|
||||
$this->emailMapper->method('findByMemberId')->willReturn([]);
|
||||
$this->memberMapper->method('fullTextSearchWithRelations')->willReturn([$memberData]);
|
||||
|
||||
$result = $this->service->fullTextSearch('Training');
|
||||
|
||||
@@ -972,11 +972,13 @@ class MemberServiceTest extends TestCase {
|
||||
public function testFullTextSearchMatchesZusatzNotizen(): void {
|
||||
$member = $this->createMember(1, 'Max', 'Mustermann');
|
||||
$member->setZusatzNotizen('Zusaetzliche Information');
|
||||
$memberData = $member->jsonSerialize() + [
|
||||
'addresses' => [],
|
||||
'phones' => [],
|
||||
'emails' => [],
|
||||
];
|
||||
|
||||
$this->memberMapper->method('fullTextSearch')->willReturn([$member]);
|
||||
$this->addressMapper->method('findByMemberId')->willReturn([]);
|
||||
$this->phoneMapper->method('findByMemberId')->willReturn([]);
|
||||
$this->emailMapper->method('findByMemberId')->willReturn([]);
|
||||
$this->memberMapper->method('fullTextSearchWithRelations')->willReturn([$memberData]);
|
||||
|
||||
// Search for something not in name or notizen, but in zusatzNotizen
|
||||
$result = $this->service->fullTextSearch('zusaetzliche');
|
||||
@@ -991,7 +993,7 @@ class MemberServiceTest extends TestCase {
|
||||
$currentMonth = (int)(new \DateTime())->format('m');
|
||||
|
||||
$this->memberMapper->expects($this->once())
|
||||
->method('findByBirthdayMonth')
|
||||
->method('findByBirthdayMonthWithRelations')
|
||||
->with($currentMonth)
|
||||
->willReturn([]);
|
||||
|
||||
@@ -1000,10 +1002,9 @@ class MemberServiceTest extends TestCase {
|
||||
|
||||
public function testFindByBirthdayThisMonthReturnsMembers(): void {
|
||||
$member = $this->createMember(1);
|
||||
$this->memberMapper->method('findByBirthdayMonth')->willReturn([$member]);
|
||||
$this->addressMapper->method('findByMemberId')->willReturn([]);
|
||||
$this->phoneMapper->method('findByMemberId')->willReturn([]);
|
||||
$this->emailMapper->method('findByMemberId')->willReturn([]);
|
||||
$this->memberMapper->method('findByBirthdayMonthWithRelations')->willReturn([
|
||||
$member->jsonSerialize() + ['addresses' => [], 'phones' => [], 'emails' => []],
|
||||
]);
|
||||
|
||||
$result = $this->service->findByBirthdayThisMonth();
|
||||
|
||||
@@ -1016,7 +1017,7 @@ class MemberServiceTest extends TestCase {
|
||||
$currentYear = (int)(new \DateTime())->format('Y');
|
||||
|
||||
$this->memberMapper->expects($this->once())
|
||||
->method('findWithUnpaidFees')
|
||||
->method('findWithUnpaidFeesWithRelations')
|
||||
->with($currentYear)
|
||||
->willReturn([]);
|
||||
|
||||
@@ -1025,10 +1026,9 @@ class MemberServiceTest extends TestCase {
|
||||
|
||||
public function testFindWithUnpaidFeesReturnsMembers(): void {
|
||||
$member = $this->createMember(1);
|
||||
$this->memberMapper->method('findWithUnpaidFees')->willReturn([$member]);
|
||||
$this->addressMapper->method('findByMemberId')->willReturn([]);
|
||||
$this->phoneMapper->method('findByMemberId')->willReturn([]);
|
||||
$this->emailMapper->method('findByMemberId')->willReturn([]);
|
||||
$this->memberMapper->method('findWithUnpaidFeesWithRelations')->willReturn([
|
||||
$member->jsonSerialize() + ['addresses' => [], 'phones' => [], 'emails' => []],
|
||||
]);
|
||||
|
||||
$result = $this->service->findWithUnpaidFees();
|
||||
|
||||
@@ -1039,7 +1039,7 @@ class MemberServiceTest extends TestCase {
|
||||
|
||||
public function testFindFilteredDelegatesToMapper(): void {
|
||||
$this->memberMapper->expects($this->once())
|
||||
->method('findFiltered')
|
||||
->method('findFilteredWithRelations')
|
||||
->with('aktiv', 'mitglied', true, false)
|
||||
->willReturn([]);
|
||||
|
||||
@@ -1048,7 +1048,7 @@ class MemberServiceTest extends TestCase {
|
||||
|
||||
public function testFindFilteredNoFilters(): void {
|
||||
$this->memberMapper->expects($this->once())
|
||||
->method('findFiltered')
|
||||
->method('findFilteredWithRelations')
|
||||
->with(null, null, false, false)
|
||||
->willReturn([]);
|
||||
|
||||
@@ -1058,12 +1058,13 @@ class MemberServiceTest extends TestCase {
|
||||
|
||||
public function testFindFilteredReturnsWithSubEntities(): void {
|
||||
$member = $this->createMember(1);
|
||||
$address = $this->createAddress(1, 1);
|
||||
$memberData = $member->jsonSerialize() + [
|
||||
'addresses' => [['id' => 1, 'memberId' => 1, 'label' => null, 'strasse' => 'Musterstr. 1', 'plz' => '12345', 'ort' => 'Berlin', 'land' => 'Deutschland', 'isPrimary' => true]],
|
||||
'phones' => [],
|
||||
'emails' => [],
|
||||
];
|
||||
|
||||
$this->memberMapper->method('findFiltered')->willReturn([$member]);
|
||||
$this->addressMapper->method('findByMemberId')->with(1)->willReturn([$address]);
|
||||
$this->phoneMapper->method('findByMemberId')->with(1)->willReturn([]);
|
||||
$this->emailMapper->method('findByMemberId')->with(1)->willReturn([]);
|
||||
$this->memberMapper->method('findFilteredWithRelations')->willReturn([$memberData]);
|
||||
|
||||
$result = $this->service->findFiltered('aktiv');
|
||||
|
||||
@@ -1075,12 +1076,9 @@ class MemberServiceTest extends TestCase {
|
||||
|
||||
public function testFindFilteredByRolleOnly(): void {
|
||||
$member = $this->createMember(1, rolle: 'erziehungsberechtigter');
|
||||
$this->memberMapper->method('findFiltered')
|
||||
$this->memberMapper->method('findFilteredWithRelations')
|
||||
->with(null, 'erziehungsberechtigter', false, false)
|
||||
->willReturn([$member]);
|
||||
$this->addressMapper->method('findByMemberId')->willReturn([]);
|
||||
$this->phoneMapper->method('findByMemberId')->willReturn([]);
|
||||
$this->emailMapper->method('findByMemberId')->willReturn([]);
|
||||
->willReturn([$member->jsonSerialize() + ['addresses' => [], 'phones' => [], 'emails' => []]]);
|
||||
|
||||
$result = $this->service->findFiltered(null, 'erziehungsberechtigter');
|
||||
|
||||
@@ -1090,7 +1088,7 @@ class MemberServiceTest extends TestCase {
|
||||
|
||||
public function testFindFilteredCombinedAllParams(): void {
|
||||
$this->memberMapper->expects($this->once())
|
||||
->method('findFiltered')
|
||||
->method('findFilteredWithRelations')
|
||||
->with('inaktiv', 'mitglied', true, true)
|
||||
->willReturn([]);
|
||||
|
||||
@@ -1252,10 +1250,9 @@ class MemberServiceTest extends TestCase {
|
||||
public function testFindByFamilyReturnsMembers(): void {
|
||||
$member = $this->createMember(1, 'Max', 'Mustermann', '2010-01-15', '2020-01-01', 'aktiv', 'mitglied', 5);
|
||||
|
||||
$this->memberMapper->method('findByFamily')->with(5)->willReturn([$member]);
|
||||
$this->addressMapper->method('findByMemberId')->willReturn([]);
|
||||
$this->phoneMapper->method('findByMemberId')->willReturn([]);
|
||||
$this->emailMapper->method('findByMemberId')->willReturn([]);
|
||||
$this->memberMapper->method('findByFamilyWithRelations')->with(5)->willReturn([
|
||||
$member->jsonSerialize() + ['addresses' => [], 'phones' => [], 'emails' => []],
|
||||
]);
|
||||
|
||||
$result = $this->service->findByFamily(5);
|
||||
|
||||
@@ -1268,10 +1265,9 @@ class MemberServiceTest extends TestCase {
|
||||
public function testFindByStatusReturnsMembers(): void {
|
||||
$member = $this->createMember(1, 'Max', 'Mustermann', '2010-01-15', '2020-01-01', 'aktiv');
|
||||
|
||||
$this->memberMapper->method('findByStatus')->with('aktiv')->willReturn([$member]);
|
||||
$this->addressMapper->method('findByMemberId')->willReturn([]);
|
||||
$this->phoneMapper->method('findByMemberId')->willReturn([]);
|
||||
$this->emailMapper->method('findByMemberId')->willReturn([]);
|
||||
$this->memberMapper->method('findByStatusWithRelations')->with('aktiv')->willReturn([
|
||||
$member->jsonSerialize() + ['addresses' => [], 'phones' => [], 'emails' => []],
|
||||
]);
|
||||
|
||||
$result = $this->service->findByStatus('aktiv');
|
||||
|
||||
@@ -1313,4 +1309,220 @@ class MemberServiceTest extends TestCase {
|
||||
|
||||
$this->assertSame(1, $this->service->countArchived());
|
||||
}
|
||||
|
||||
// ── Joined methods: findAllWithRelations ──────────────────────
|
||||
|
||||
public function testFindAllWithRelationsReturnsMemberWithNestedSubEntities(): void {
|
||||
$member = $this->createMember(1, 'Max', 'Mustermann');
|
||||
$memberData = $member->jsonSerialize() + [
|
||||
'addresses' => [
|
||||
['id' => 1, 'memberId' => 1, 'label' => 'Privat', 'strasse' => 'Hauptstr. 1', 'plz' => '12345', 'ort' => 'Berlin', 'land' => 'Deutschland', 'isPrimary' => true],
|
||||
['id' => 2, 'memberId' => 1, 'label' => 'Arbeit', 'strasse' => 'Büroweg 5', 'plz' => '12346', 'ort' => 'Berlin', 'land' => 'Deutschland', 'isPrimary' => false],
|
||||
],
|
||||
'phones' => [
|
||||
['id' => 1, 'memberId' => 1, 'label' => 'Mobil', 'numberE164' => '+4917612345678'],
|
||||
],
|
||||
'emails' => [
|
||||
['id' => 1, 'memberId' => 1, 'label' => 'Privat', 'email' => 'max@example.com'],
|
||||
],
|
||||
];
|
||||
|
||||
$this->memberMapper->method('findAllWithRelations')->willReturn([$memberData]);
|
||||
|
||||
$result = $this->service->findAll();
|
||||
|
||||
$this->assertCount(1, $result);
|
||||
$this->assertSame('Max', $result[0]['vorname']);
|
||||
$this->assertCount(2, $result[0]['addresses']);
|
||||
$this->assertCount(1, $result[0]['phones']);
|
||||
$this->assertCount(1, $result[0]['emails']);
|
||||
$this->assertSame('Hauptstr. 1', $result[0]['addresses'][0]['strasse']);
|
||||
$this->assertSame('+4917612345678', $result[0]['phones'][0]['numberE164']);
|
||||
$this->assertSame('max@example.com', $result[0]['emails'][0]['email']);
|
||||
}
|
||||
|
||||
public function testFindAllWithRelationsHandlesMemberWithoutSubEntities(): void {
|
||||
$member = $this->createMember(1);
|
||||
$memberData = $member->jsonSerialize() + [
|
||||
'addresses' => [],
|
||||
'phones' => [],
|
||||
'emails' => [],
|
||||
];
|
||||
|
||||
$this->memberMapper->method('findAllWithRelations')->willReturn([$memberData]);
|
||||
|
||||
$result = $this->service->findAll();
|
||||
|
||||
$this->assertCount(1, $result);
|
||||
$this->assertCount(0, $result[0]['addresses']);
|
||||
$this->assertCount(0, $result[0]['phones']);
|
||||
$this->assertCount(0, $result[0]['emails']);
|
||||
}
|
||||
|
||||
// ── Joined methods: searchWithRelations ───────────────────────
|
||||
|
||||
public function testSearchWithRelationsReturnsMemberWithSubEntities(): void {
|
||||
$member = $this->createMember(1, 'Max', 'Mustermann');
|
||||
$memberData = $member->jsonSerialize() + [
|
||||
'addresses' => [['id' => 1, 'memberId' => 1, 'label' => null, 'strasse' => 'Musterstr. 1', 'plz' => '12345', 'ort' => 'Berlin', 'land' => 'Deutschland', 'isPrimary' => true]],
|
||||
'phones' => [],
|
||||
'emails' => [],
|
||||
];
|
||||
|
||||
$this->memberMapper->method('searchWithRelations')->with('Max')->willReturn([$memberData]);
|
||||
|
||||
$result = $this->service->search('Max');
|
||||
|
||||
$this->assertCount(1, $result);
|
||||
$this->assertSame('Max', $result[0]['vorname']);
|
||||
$this->assertCount(1, $result[0]['addresses']);
|
||||
}
|
||||
|
||||
// ── Joined methods: findByFamilyWithRelations ─────────────────
|
||||
|
||||
public function testFindByFamilyWithRelationsReturnsMemberWithSubEntities(): void {
|
||||
$member = $this->createMember(1, 'Max', 'Mustermann', '2010-01-15', '2020-01-01', 'aktiv', 'mitglied', 5);
|
||||
$memberData = $member->jsonSerialize() + [
|
||||
'addresses' => [['id' => 1, 'memberId' => 1, 'label' => null, 'strasse' => 'Musterstr. 1', 'plz' => '12345', 'ort' => 'Berlin', 'land' => 'Deutschland', 'isPrimary' => true]],
|
||||
'phones' => [['id' => 1, 'memberId' => 1, 'label' => 'Mobil', 'numberE164' => '+4917612345678']],
|
||||
'emails' => [['id' => 1, 'memberId' => 1, 'label' => 'Privat', 'email' => 'max@example.com']],
|
||||
];
|
||||
|
||||
$this->memberMapper->method('findByFamilyWithRelations')->with(5)->willReturn([$memberData]);
|
||||
|
||||
$result = $this->service->findByFamily(5);
|
||||
|
||||
$this->assertCount(1, $result);
|
||||
$this->assertSame('Max', $result[0]['vorname']);
|
||||
$this->assertCount(1, $result[0]['addresses']);
|
||||
$this->assertCount(1, $result[0]['phones']);
|
||||
$this->assertCount(1, $result[0]['emails']);
|
||||
}
|
||||
|
||||
// ── Joined methods: findByStatusWithRelations ─────────────────
|
||||
|
||||
public function testFindByStatusWithRelationsReturnsMemberWithSubEntities(): void {
|
||||
$member = $this->createMember(1, 'Max', 'Mustermann', '2010-01-15', '2020-01-01', 'aktiv');
|
||||
$memberData = $member->jsonSerialize() + [
|
||||
'addresses' => [],
|
||||
'phones' => [],
|
||||
'emails' => [],
|
||||
];
|
||||
|
||||
$this->memberMapper->method('findByStatusWithRelations')->with('aktiv')->willReturn([$memberData]);
|
||||
|
||||
$result = $this->service->findByStatus('aktiv');
|
||||
|
||||
$this->assertCount(1, $result);
|
||||
$this->assertSame('Max', $result[0]['vorname']);
|
||||
}
|
||||
|
||||
// ── Joined methods: findByBirthdayMonthWithRelations ──────────
|
||||
|
||||
public function testFindByBirthdayMonthWithRelationsReturnsMemberWithSubEntities(): void {
|
||||
$currentMonth = (int)(new \DateTime())->format('m');
|
||||
$member = $this->createMember(1, 'Max', 'Mustermann', '2010-06-15', '2020-01-01');
|
||||
$memberData = $member->jsonSerialize() + [
|
||||
'addresses' => [],
|
||||
'phones' => [],
|
||||
'emails' => [],
|
||||
];
|
||||
|
||||
$this->memberMapper->expects($this->once())
|
||||
->method('findByBirthdayMonthWithRelations')
|
||||
->with($currentMonth)
|
||||
->willReturn([$memberData]);
|
||||
|
||||
$result = $this->service->findByBirthdayThisMonth();
|
||||
|
||||
$this->assertCount(1, $result);
|
||||
$this->assertSame('Max', $result[0]['vorname']);
|
||||
}
|
||||
|
||||
// ── Joined methods: findWithUnpaidFeesWithRelations ───────────
|
||||
|
||||
public function testFindWithUnpaidFeesWithRelationsReturnsMemberWithSubEntities(): void {
|
||||
$member = $this->createMember(1, 'Max', 'Mustermann');
|
||||
$memberData = $member->jsonSerialize() + [
|
||||
'addresses' => [],
|
||||
'phones' => [],
|
||||
'emails' => [],
|
||||
];
|
||||
|
||||
$this->memberMapper->method('findWithUnpaidFeesWithRelations')->willReturn([$memberData]);
|
||||
|
||||
$result = $this->service->findWithUnpaidFees();
|
||||
|
||||
$this->assertCount(1, $result);
|
||||
$this->assertSame('Max', $result[0]['vorname']);
|
||||
}
|
||||
|
||||
// ── Joined methods: findFilteredWithRelations ─────────────────
|
||||
|
||||
public function testFindFilteredWithRelationsReturnsMemberWithSubEntities(): void {
|
||||
$member = $this->createMember(1, 'Max', 'Mustermann');
|
||||
$memberData = $member->jsonSerialize() + [
|
||||
'addresses' => [['id' => 1, 'memberId' => 1, 'label' => null, 'strasse' => 'Musterstr. 1', 'plz' => '12345', 'ort' => 'Berlin', 'land' => 'Deutschland', 'isPrimary' => true]],
|
||||
'phones' => [],
|
||||
'emails' => [],
|
||||
];
|
||||
|
||||
$this->memberMapper->method('findFilteredWithRelations')
|
||||
->with('aktiv', 'mitglied', false, false)
|
||||
->willReturn([$memberData]);
|
||||
|
||||
$result = $this->service->findFiltered('aktiv', 'mitglied');
|
||||
|
||||
$this->assertCount(1, $result);
|
||||
$this->assertSame('Max', $result[0]['vorname']);
|
||||
$this->assertCount(1, $result[0]['addresses']);
|
||||
}
|
||||
|
||||
// ── Joined methods: fullTextSearchWithRelations ───────────────
|
||||
|
||||
public function testFullTextSearchWithRelationsReturnsResultsWithMatchContext(): void {
|
||||
$member = $this->createMember(1, 'Max', 'Mustermann');
|
||||
$memberData = $member->jsonSerialize() + [
|
||||
'addresses' => [['id' => 1, 'memberId' => 1, 'label' => null, 'strasse' => 'Musterstrasse 1', 'plz' => '12345', 'ort' => 'Berlin', 'land' => 'Deutschland', 'isPrimary' => true]],
|
||||
'phones' => [],
|
||||
'emails' => [],
|
||||
];
|
||||
|
||||
$this->memberMapper->method('fullTextSearchWithRelations')
|
||||
->with('Musterstrasse', 20)
|
||||
->willReturn([$memberData]);
|
||||
|
||||
$result = $this->service->fullTextSearch('Musterstrasse');
|
||||
|
||||
$this->assertCount(1, $result);
|
||||
$this->assertArrayHasKey('matchContext', $result[0]);
|
||||
$this->assertStringContainsString('Adresse:', $result[0]['matchContext']);
|
||||
$this->assertStringContainsString('Musterstrasse 1', $result[0]['matchContext']);
|
||||
}
|
||||
|
||||
// ── Backward compatibility: findAll shape ─────────────────────
|
||||
|
||||
public function testFindAllReturnsSameShapeAsBefore(): void {
|
||||
// The new method returns the same shape as the old one:
|
||||
// an array of member arrays with addresses/phones/emails sub-arrays
|
||||
$member = $this->createMember(1, 'Max');
|
||||
$memberData = $member->jsonSerialize() + [
|
||||
'addresses' => [['id' => 1, 'memberId' => 1, 'label' => null, 'strasse' => 'Hauptstr. 1', 'plz' => '12345', 'ort' => 'Berlin', 'land' => 'Deutschland', 'isPrimary' => true]],
|
||||
'phones' => [['id' => 1, 'memberId' => 1, 'label' => 'Mobil', 'numberE164' => '+4917612345678']],
|
||||
'emails' => [['id' => 1, 'memberId' => 1, 'label' => 'Privat', 'email' => 'max@example.com']],
|
||||
];
|
||||
|
||||
$this->memberMapper->method('findAllWithRelations')->willReturn([$memberData]);
|
||||
|
||||
$result = $this->service->findAll();
|
||||
|
||||
// Verify the shape matches what the frontend expects
|
||||
$this->assertIsArray($result[0]);
|
||||
$this->assertArrayHasKey('id', $result[0]);
|
||||
$this->assertArrayHasKey('vorname', $result[0]);
|
||||
$this->assertArrayHasKey('nachname', $result[0]);
|
||||
$this->assertArrayHasKey('addresses', $result[0]);
|
||||
$this->assertArrayHasKey('phones', $result[0]);
|
||||
$this->assertArrayHasKey('emails', $result[0]);
|
||||
}
|
||||
}
|
||||
|
||||
+27
-1
@@ -19,4 +19,30 @@ if (!class_exists('OCA\DAV\CalDAV\CalDavBackend')) {
|
||||
eval('namespace OCA\DAV\CalDAV; class CalDavBackend {}');
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/../vendor/autoload.php';
|
||||
// Try the app's vendor autoload first (full local dev).
|
||||
// Fall back to Nextcloud's root autoloader which has PSR packages.
|
||||
$appAutoload = __DIR__ . '/../vendor/autoload.php';
|
||||
$ncAutoload = '/var/www/html/lib/composer/autoload.php';
|
||||
|
||||
if (file_exists($appAutoload)) {
|
||||
require_once $appAutoload;
|
||||
} elseif (file_exists($ncAutoload)) {
|
||||
require_once $ncAutoload;
|
||||
}
|
||||
|
||||
// Register the app's lib directory for PSR-4 autoloading.
|
||||
// This ensures OCA\Mitgliederverwaltung\* classes are loaded regardless
|
||||
// of which vendor/autoload.php was used (full local dev vs. container).
|
||||
spl_autoload_register(function ($class) {
|
||||
$prefix = 'OCA\\Mitgliederverwaltung\\';
|
||||
$baseDir = __DIR__ . '/../lib/';
|
||||
$len = strlen($prefix);
|
||||
if (strncmp($prefix, $class, $len) !== 0) {
|
||||
return;
|
||||
}
|
||||
$relativeClass = substr($class, $len);
|
||||
$file = $baseDir . str_replace('\\', '/', $relativeClass) . '.php';
|
||||
if (file_exists($file)) {
|
||||
require $file;
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user