perf(doctor): fetch-join specialties in clinic doctor list (H6)

findByClinicWithFilters left-joined specialties only for filtering, so
toListArray() lazy-loaded them per doctor (N+1). addSelect them and switch the
result fetch to Paginator(fetchJoinCollection: true) so LIMIT still paginates by
doctor.

Test infra: ApiTestCase::countQueries() (via doctrine.debug_data_holder).
Regression: tests/Doctor/ClinicDoctorListNPlusOneTest asserts the query count
does not grow with doctor count (4→10 without the fix).

Also relaxed AppointmentExpiryServiceTest's exact-count assertion (it counts all
stale pendings in the shared db_test, which accumulates) — logged test-isolation
debt as E6.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-06-28 19:24:49 +03:30
co-authored by Claude Opus 4.8
parent 4cf6873900
commit 6b12f3ddb9
5 changed files with 92 additions and 8 deletions
+2 -1
View File
@@ -42,7 +42,7 @@ _None outstanding._
| ✅H3 | `Payment.referenceId` not unique → same gateway callback credited twice | src/Payment/Entity/Payment.php:61-62 | db-unique | **DONE** — unique index on `reference_id` (NULL until success → no collision) + callback pre-check rejects replays. `tests/Payment/PaymentCallbackAmountTest::testReplayedGatewayReferenceIsRejected` |
| ✅H4 | `FinancialBreakdown.payment` onDelete CASCADE on non-nullable FK → deleting a Payment destroys ledger rows; should be RESTRICT | src/Settlement/Entity/FinancialBreakdown.php:28-30 | db-ondelete | **DONE** — onDelete RESTRICT + migration. `tests/Settlement/FinancialBreakdownIntegrityTest` |
| ✅H5 | Insurance pricing/coverage modeled as raw int FKs (no FK/onDelete) → orphan rows on delete | src/Insurance/Entity/EntityInsurancePricing.php · TenantInsurance.php · TenantServiceCoverage.php | db-ondelete | **DONE (entity-owner path)**`entity_id` is polymorphic (doctor\|clinic) so no DB FK is possible; added `TenantInsuranceCleanupService::purgeForEntity()` wired into doctor + clinic DELETE (purges tenant_insurances + pricing + coverage). `tests/Insurance/TenantInsuranceCleanupTest`. **Residual (→ M20-adjacent):** orphans when an *insurance category* itself is deleted (`insurance_id` ref) or a *service_item* is deleted (`service_item_id` ref) — different deletion paths, lower freq. |
| H6 | N+1: clinic doctors list lazy-loads `specialties` per doctor | src/Clinic/Controller/ClinicController.php:326 · src/Doctor/Repository/DoctorRepository.php:49 | perf-nplus1 | SQL profiler on clinic doctors list → 1 query/doctor for specialties |
| H6 | N+1: clinic doctors list lazy-loads `specialties` per doctor | src/Doctor/Repository/DoctorRepository.php (findByClinicWithFilters) | perf-nplus1 | **DONE**`addSelect('s')` + `Paginator(fetchJoinCollection:true)`. Added `ApiTestCase::countQueries()` helper. `tests/Doctor/ClinicDoctorListNPlusOneTest` (query count constant vs doctor count; verified 4→10 without fix). |
| H7 | N+1: comments list lazy-loads `likes``user`, `replies` (recursive), author realName | src/Rating/Controller/RatingController.php:278,352 | perf-nplus1 | Profiler GET comments → query count scales w/ comments |
| H8 | N+1: insurance service-coverage `serviceItemRepo->find()` per row in array_map | src/Insurance/Controller/InsuranceController.php:410 | perf-nplus1 | GET service-coverage → 1 find()/row |
| H9 | Unbounded list: `listClaims` loads ALL tenant claims, no LIMIT/pagination | src/Billing/Controller/BillingController.php:173 · ClaimRepository.php:64 | perf-pagination | GET claims high volume → must paginate |
@@ -106,6 +106,7 @@ _None outstanding._
| E3 | **Fat controllers** — AdminApiController (1938 LOC), MyAppointmentsController booking, RepresentationActionController (835), DoctorController/ClinicController | extract per-domain Services | SOLID refactor; lower urgency than security/db. |
| E4 | **CI** — no `.github/workflows`; add phpunit + phpstan + migrate-on-empty-db | devops | prompt var §DevOps |
| E5 | **phpstan baseline dirty** — 41 pre-existing errors across the codebase (D9 only repaired the config so it *runs*). Audit fixes must not add new ones; cleaning the 41 is its own task. | devops | `ddev exec php vendor/bin/phpstan analyse` → 41 errors (e.g. SlotCalculatorService.php:233, SubscriptionController.php:33) |
| E6 | **No test DB isolation**`ApiTestCase` doesn't reset/rollback `db_test` between tests/runs, so rows accumulate; count/time-based assertions are fragile (hit twice this session). Add per-test transaction rollback or a DB reset. | test | tests rely on random keys + relaxed assertions as a workaround |
---
+10 -6
View File
@@ -161,13 +161,17 @@ class DoctorRepository extends ServiceEntityRepository
->setParameter('active', (bool) $filters['active']);
}
$qb->orderBy('d.doctorRate', $sort);
// Hydrate specialties in the same query so toListArray() doesn't lazy-load
// them per doctor (N+1). fetchJoinCollection keeps LIMIT paginating by
// doctor, not by joined rows.
$qb->addSelect('s')
->orderBy('d.doctorRate', $sort)
->setFirstResult(($page - 1) * $limit)
->setMaxResults($limit);
$total = (new Paginator($qb))->count();
$results = $qb->setFirstResult(($page - 1) * $limit)
->setMaxResults($limit)
->getQuery()
->getResult();
$paginator = new Paginator($qb, fetchJoinCollection: true);
$total = count($paginator);
$results = iterator_to_array($paginator);
return [
'items' => $results,
+13
View File
@@ -71,4 +71,17 @@ abstract class ApiTestCase extends WebTestCase
{
return $this->client->getResponse()->getStatusCode();
}
/**
* Count the SQL queries executed while running $fn. Used to assert that a
* list endpoint's query count does not grow with the number of rows (N+1).
*/
protected function countQueries(callable $fn): int
{
$holder = static::getContainer()->get('doctrine.debug_data_holder');
$holder->reset();
$fn();
return array_sum(array_map('count', $holder->getData()));
}
}
@@ -41,7 +41,9 @@ class AppointmentExpiryServiceTest extends ApiTestCase
$service = static::getContainer()->get(AppointmentExpiryService::class);
$count = $service->expireStale();
$this->assertSame(5, $count);
// At least our 5 — db_test is shared and may hold other stale pendings
// from earlier tests/runs; the per-row checks below verify our own 5.
$this->assertGreaterThanOrEqual(5, $count);
$this->em->clear();
foreach ($appointments as [$appt, $payment]) {
@@ -0,0 +1,64 @@
<?php
namespace App\Tests\Doctor;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor;
use App\Specialty\Entity\Specialty;
use App\Tests\ApiTestCase;
/**
* The clinic doctor-list endpoint must hydrate each doctor's specialties in the
* list query, not lazily per doctor. The query count must therefore be constant
* regardless of how many doctors the clinic has.
*/
class ClinicDoctorListNPlusOneTest extends ApiTestCase
{
private function makeClinicWithDoctors(int $doctorCount): Clinic
{
$clinic = new Clinic($this->createUser(['ROLE_CLINIC']));
$this->em->persist($clinic);
for ($i = 0; $i < $doctorCount; $i++) {
$doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), "دکتر $i");
$slug = 'sp-' . bin2hex(random_bytes(6));
$specialty = new Specialty('تخصص', $slug);
$this->em->persist($specialty);
$doctor->getSpecialties()->add($specialty);
$this->em->persist($doctor);
$clinic->getDoctors()->add($doctor);
}
$this->em->flush();
return $clinic;
}
public function testQueryCountDoesNotGrowWithDoctorCount(): void
{
// Keep one kernel/container across both requests so the shared query
// logger (doctrine.debug_data_holder) stays consistent.
$this->client->disableReboot();
$small = $this->makeClinicWithDoctors(2);
$large = $this->makeClinicWithDoctors(6);
$qSmall = $this->countQueries(fn () => $this->client->request(
'GET', '/api/v1/clinic/doctor-list/' . $small->getUuid()
));
$this->assertSame(200, $this->responseCode());
$qLarge = $this->countQueries(fn () => $this->client->request(
'GET', '/api/v1/clinic/doctor-list/' . $large->getUuid()
));
$this->assertSame(200, $this->responseCode());
// No N+1: 4 extra doctors must not add ~4 extra queries. A tiny slack
// absorbs one-off warmup variance; the N+1 signal (≈ +doctorCount) is
// far larger than the slack.
$this->assertLessThanOrEqual($qSmall + 1, $qLarge, "N+1: query count grew from $qSmall to $qLarge with more doctors");
// Correctness preserved: specialties are present in the payload.
$body = json_decode($this->client->getResponse()->getContent(), true);
$this->assertNotEmpty($body['data']['data'][0]['specialties']);
}
}