perf(settlement,rating): paginate 3 unbounded list endpoints (M10-M12)

- M10 GET /settlement: was unbounded; add page/limit + countByUser + data.meta.
- M11 GET /admin/comments/pending: paginate findPending + countPending.
- M12 GET /comments/{doctor}: paginate the fetch-joined roots query via
  Paginator(fetchJoinCollection) + countApprovedRootsByDoctor.

All keep the existing { data: { data: [...] } } envelope and add data.meta
(backward compatible). Default limit 50 / max 100.

Regressions: SettlementListPaginationTest, CommentPaginationTest (both fail
without the limits). Also de-flaked SendCodeMobileRateLimitTest (randomised the
IP block so the persistent per-IP limiter buckets don't accumulate across runs).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-06-28 20:36:41 +03:30
co-authored by Claude Opus 4.8
parent 447482c2ea
commit 61ac775175
10 changed files with 175 additions and 24 deletions
+30 -8
View File
@@ -5,6 +5,7 @@ namespace App\Rating\Repository;
use App\Doctor\Entity\Doctor;
use App\Rating\Entity\Comment;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\Tools\Pagination\Paginator;
use Doctrine\Persistence\ManagerRegistry;
class CommentRepository extends ServiceEntityRepository
@@ -19,15 +20,29 @@ class CommentRepository extends ServiceEntityRepository
return $this->findBy(['doctor' => $doctor, 'status' => Comment::STATUS_APPROVED], ['createdAt' => 'DESC']);
}
public function countApprovedRootsByDoctor(Doctor $doctor): int
{
return (int) $this->createQueryBuilder('c')
->select('COUNT(c.id)')
->where('c.doctor = :doctor')
->andWhere('c.status = :status')
->andWhere('c.parent IS NULL')
->setParameter('doctor', $doctor)
->setParameter('status', Comment::STATUS_APPROVED)
->getQuery()
->getSingleScalarResult();
}
/** @return Comment[] approved root comments (no parent) for a doctor */
public function findApprovedRootsByDoctor(Doctor $doctor): array
public function findApprovedRootsByDoctor(Doctor $doctor, int $limit = 50, int $offset = 0): array
{
// Two fetch-join passes (not the product, to avoid a likes×replies
// cartesian) hydrate everything toArray() touches, so serialization
// triggers no per-comment lazy loads (N+1): pass 1 = roots + author +
// likes, pass 2 = replies + their author + likes, merged into the same
// managed root entities.
$roots = $this->createQueryBuilder('c')
// managed root entities. Paginator(fetchJoinCollection) keeps the LIMIT
// paginating by root comment, not by joined like rows.
$qb = $this->createQueryBuilder('c')
->addSelect('u', 'l')
->leftJoin('c.user', 'u')
->leftJoin('c.likes', 'l')
@@ -37,8 +52,10 @@ class CommentRepository extends ServiceEntityRepository
->setParameter('doctor', $doctor)
->setParameter('status', Comment::STATUS_APPROVED)
->orderBy('c.createdAt', 'DESC')
->getQuery()
->getResult();
->setFirstResult($offset)
->setMaxResults($limit);
$roots = iterator_to_array(new Paginator($qb, fetchJoinCollection: true));
if ($roots !== []) {
// Also initialise each reply's own replies collection (empty in a
@@ -58,10 +75,15 @@ class CommentRepository extends ServiceEntityRepository
return $roots;
}
/** @return Comment[] */
public function findPending(): array
public function countPending(): int
{
return $this->findBy(['status' => Comment::STATUS_PENDING], ['createdAt' => 'ASC']);
return $this->count(['status' => Comment::STATUS_PENDING]);
}
/** @return Comment[] */
public function findPending(int $limit = 50, int $offset = 0): array
{
return $this->findBy(['status' => Comment::STATUS_PENDING], ['createdAt' => 'ASC'], $limit, $offset);
}
public function save(Comment $e, bool $flush = true): void { $this->getEntityManager()->persist($e); if ($flush) $this->getEntityManager()->flush(); }