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
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.
770 lines
28 KiB
PHP
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);
|
|
}
|
|
}
|