Files
Mitgliederverwaltung/lib/Db/MemberMapper.php
T
shahondin1624 ccda3ee570
Database Portability Tests / Unit Tests (PlatformHelper) (pull_request) Failing after 42s
Database Portability Tests / Integration (mysql) (pull_request) Has been skipped
Database Portability Tests / Integration (postgres) (pull_request) Has been skipped
Database Portability Tests / Integration (sqlite) (pull_request) Has been skipped
Database Portability Tests / Verify no MySQL-specific SQL (pull_request) Successful in 5s
Fix findArchived() filtering all members in memory (Closes #202)
Replace in-memory array_filter with a dedicated SQL query
WHERE deleted_at IS NOT NULL in MemberMapper::findArchived().

This fixes broken pagination (limit/offset now apply to archived
members only), eliminates unnecessary data transfer, and follows
the same pattern as the countArchived() fix from #201.
2026-04-29 21:13:32 +02:00

770 lines
28 KiB
PHP

<?php
declare(strict_types=1);
namespace OCA\Mitgliederverwaltung\Db;
use OCA\Mitgliederverwaltung\Db\PlatformHelper;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Db\MultipleObjectsReturnedException;
use OCP\AppFramework\Db\QBMapper;
use OCP\DB\Exception;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;
/**
* Mapper for the oc_mv_members table.
*
* @extends QBMapper<Member>
*/
class MemberMapper extends QBMapper {
public function __construct(IDBConnection $db) {
parent::__construct($db, 'mv_members', Member::class);
}
/**
* Find a single member by ID.
*
* @throws DoesNotExistException
* @throws MultipleObjectsReturnedException
* @throws Exception
*/
public function findById(int $id): Member {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT)));
return $this->findEntity($qb);
}
/**
* Find all members with optional pagination.
* Excludes soft-deleted members by default.
*
* @return Member[]
* @throws Exception
*/
public function findAll(?int $limit = null, ?int $offset = null, bool $includeDeleted = false): array {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName());
if (!$includeDeleted) {
$qb->andWhere($qb->expr()->isNull('deleted_at'));
}
$qb->orderBy('nachname', 'ASC')
->addOrderBy('vorname', 'ASC');
if ($limit !== null) {
$qb->setMaxResults($limit);
}
if ($offset !== null) {
$qb->setFirstResult($offset);
}
return $this->findEntities($qb);
}
/**
* Find all members belonging to a given family.
*
* @return Member[]
* @throws Exception
*/
public function findByFamily(int $familyId): array {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
->where($qb->expr()->eq('family_id', $qb->createNamedParameter($familyId, IQueryBuilder::PARAM_INT)))
->andWhere($qb->expr()->isNull('deleted_at'))
->orderBy('nachname', 'ASC')
->addOrderBy('vorname', 'ASC');
return $this->findEntities($qb);
}
/**
* Find all members with a given status.
*
* @return Member[]
* @throws Exception
*/
public function findByStatus(string $status): array {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
->where($qb->expr()->eq('status', $qb->createNamedParameter($status)))
->orderBy('nachname', 'ASC')
->addOrderBy('vorname', 'ASC');
return $this->findEntities($qb);
}
/**
* Find all members in a given Stufe.
*
* @return Member[]
* @throws Exception
*/
public function findByStufe(int $stufeId): array {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
->where($qb->expr()->eq('stufe_id', $qb->createNamedParameter($stufeId, IQueryBuilder::PARAM_INT)))
->andWhere($qb->expr()->isNull('deleted_at'))
->orderBy('nachname', 'ASC')
->addOrderBy('vorname', 'ASC');
return $this->findEntities($qb);
}
/**
* Search members by name (Vorname or Nachname, case-insensitive).
*
* @return Member[]
* @throws Exception
*/
public function search(string $query): array {
$qb = $this->db->getQueryBuilder();
$searchPattern = '%' . $this->db->escapeLikeParameter($query) . '%';
$qb->select('*')
->from($this->getTableName())
->where($qb->expr()->isNull('deleted_at'))
->andWhere(
$qb->expr()->orX(
$qb->expr()->iLike('vorname', $qb->createNamedParameter($searchPattern)),
$qb->expr()->iLike('nachname', $qb->createNamedParameter($searchPattern))
)
)
->orderBy('nachname', 'ASC')
->addOrderBy('vorname', 'ASC');
return $this->findEntities($qb);
}
/**
* Find members with a birthday in the given month.
* Uses platform-aware month extraction instead of LIKE on date columns.
*
* Part of Issue #34, updated for Issue #192 (database portability).
*
* @return Member[]
* @throws Exception
*/
public function findByBirthdayMonth(int $month): array {
$qb = $this->db->getQueryBuilder();
$monthExpr = PlatformHelper::getMonthExpression($this->db, 'geburtsdatum');
$qb->select('*')
->from($this->getTableName())
->where($qb->expr()->isNull('deleted_at'))
->andWhere(
$qb->expr()->eq(
$qb->createFunction($monthExpr),
$qb->createNamedParameter($month, IQueryBuilder::PARAM_INT)
)
)
->orderBy('geburtsdatum', 'ASC');
return $this->findEntities($qb);
}
/**
* Find members who have unpaid fee records for a given year.
* Uses a subquery join on mv_fee_records.
*
* Part of Issue #34.
*
* @return Member[]
* @throws Exception
*/
public function findWithUnpaidFees(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'))
->where($qb->expr()->isNull('m.deleted_at'))
->andWhere($qb->expr()->eq('f.year', $qb->createNamedParameter($year, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT)))
->andWhere($qb->expr()->eq('f.paid', $qb->createNamedParameter(false, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL)))
->orderBy('m.nachname', 'ASC')
->addOrderBy('m.vorname', 'ASC');
return $this->findEntities($qb);
}
/**
* Full-text search across members, addresses, and notes.
* Searches: vorname, nachname, notizen, zusatz_notizen, and joined address fields.
*
* Part of Issue #33.
*
* @return Member[]
* @throws Exception
*/
public function fullTextSearch(string $query, int $limit = 20): array {
$qb = $this->db->getQueryBuilder();
$searchPattern = '%' . $this->db->escapeLikeParameter($query) . '%';
$qb->selectDistinct('m.*')
->from($this->getTableName(), 'm')
->leftJoin('m', 'mv_addresses', 'a', $qb->expr()->eq('m.id', 'a.member_id'))
->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->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.
*
* Part of Issue #58.
*
* @return Member|null The matching member, or null if not found
* @throws Exception
*/
public function findByNameAndBirthdate(string $vorname, string $nachname, string $geburtsdatum): ?Member {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
->where($qb->expr()->eq('vorname', $qb->createNamedParameter($vorname)))
->andWhere($qb->expr()->eq('nachname', $qb->createNamedParameter($nachname)))
->andWhere($qb->expr()->eq('geburtsdatum', $qb->createNamedParameter($geburtsdatum)))
->andWhere($qb->expr()->isNull('deleted_at'))
->setMaxResults(1);
try {
return $this->findEntity($qb);
} catch (DoesNotExistException $e) {
return null;
}
}
/**
* Find members matching a combination of filters.
* All filter parameters are optional and additive (AND logic).
*
* @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 Member[]
* @throws Exception
*/
public function findFiltered(
?string $status = null,
?string $rolle = null,
bool $birthdayThisMonth = false,
bool $unpaidFees = false
): array {
$qb = $this->db->getQueryBuilder();
$qb->select('m.*')
->from($this->getTableName(), 'm')
->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->findEntities($qb);
}
/**
* Count all non-deleted members.
*
* @throws Exception
*/
public function countAll(): int {
$qb = $this->db->getQueryBuilder();
$qb->select($qb->createFunction('COUNT(*)'))
->from($this->getTableName())
->where($qb->expr()->isNull('deleted_at'));
$result = $qb->executeQuery();
$count = (int)$result->fetchOne();
$result->closeCursor();
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;
}
/**
* 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) ────────────────────────
/**
* 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);
}
}