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:
@@ -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']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user