From 91cfb64217c64416fd48b53dd365481d3f7adfd8 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Sun, 21 Jun 2026 16:41:59 +0330 Subject: [PATCH] fix: improve doctor search performance by optimizing query filters in findWithFilters method --- .../prompt/fix-doctor-search-performance.md | 178 ++++++++++++++++++ src/Doctor/Repository/DoctorRepository.php | 45 ++++- 2 files changed, 217 insertions(+), 6 deletions(-) create mode 100644 .claude/prompt/fix-doctor-search-performance.md diff --git a/.claude/prompt/fix-doctor-search-performance.md b/.claude/prompt/fix-doctor-search-performance.md new file mode 100644 index 00000000..360cdf85 --- /dev/null +++ b/.claude/prompt/fix-doctor-search-performance.md @@ -0,0 +1,178 @@ +# رفع افت شدید سرعت سرچ پزشک (regression کامیت city-via-clinic) + +## پروژه + +`clinicpro` (Backend — Doctrine DQL در `DoctorRepository`) + +## زمینه + +در کامیت `8b9130011ae370fa3bad152612e88ec350f99fc2` برای حل مشکل «پزشکی که آدرس مستقیم ندارد و از آدرس کلینیک استفاده می‌کند، در شهر کلینیک نمایش داده نمی‌شد»، به متد `findWithFilters()` در `DoctorRepository` دو `leftJoin` اضافه شد. این تغییر مشکل شهر را حل کرد اما **سرعت سرچ سایت عمومی (`nobat724_front` → `GET /api/v1/doctor`) را به‌شدت پایین آورد**. این متد قلب صفحه `/doctors` است و در هر تغییر فیلتر/شهر/صفحه فراخوانی می‌شود، پس کندی آن مستقیماً تجربه کاربر را خراب می‌کند. پرفورمنس برای این پروژه اولویت بالا دارد. + +## مشکل / هدف + +دو `leftJoin` اضافه‌شده باعث افت سرعت می‌شوند: + +```php +->leftJoin(Clinic::class, 'cl', Join::WITH, 'd MEMBER OF cl.doctors') +->leftJoin(DoctorAddress::class, 'ca', Join::WITH, 'ca.clinicId = cl.id AND ca.doctor IS NULL') +``` + +علت دقیق کندی: + +1. **`d MEMBER OF cl.doctors` در شرط JOIN** → Doctrine آن را به یک subquery همبسته (`EXISTS (SELECT ... FROM clinic_doctor ...)` per row) ترجمه می‌کند که روی **کل جدول `Clinic`** برای هر ردیف پزشک ارزیابی می‌شود. این JOIN بدون شرط محدودکننده روی `cl` است، یعنی عملاً یک نیمه‌cross-join. +2. این JOIN **همیشه** اجرا می‌شود — حتی وقتی هیچ فیلتر `state`/`city` ست نشده (یعنی اکثر سرچ‌ها که فقط `name` یا اصلاً فیلتری ندارند). هزینه را بی‌دلیل به همه‌ی کوئری‌ها تحمیل می‌کند. +3. `->distinct()` روی این محصول دکارتی بزرگ‌شده، به‌علاوه‌ی `Paginator` که برای شمارش `total` یک کوئری `COUNT(DISTINCT ...)` جداگانه می‌سازد، هزینه را دوبرابر می‌کند. + +**هدف:** پزشکِ بدون آدرس مستقیم که در کلینیک عضو است، همچنان در شهر/استان کلینیک نمایش داده شود (رفتار فعلی حفظ شود)، **اما** بدون افت سرعت — به‌خصوص کوئری‌های بدون فیلتر مکان باید به سرعت قبل از کامیت برگردند. + +## فایل‌های مرتبط + +| فایل | نقش | +|------|-----| +| `src/Doctor/Repository/DoctorRepository.php` | متد `findWithFilters()` — کوئری اصلی سرچ پزشک | +| `src/Doctor/Entity/DoctorAddress.php` | موجودیت آدرس؛ `clinicId` (FK خام int)، `province`/`city` (ManyToOne)، `doctor` (ManyToOne، برای آدرس کلینیک NULL است) | +| `src/Clinic/Entity/Clinic.php` | رابطه‌ی ManyToMany `doctors` (owner سمت کلینیک) | +| `docs/api/doctor.md` | مستندات endpoint — اگر رفتار/قرارداد عوض شد به‌روزرسانی شود | + +## وضعیت فعلی + +`src/Doctor/Repository/DoctorRepository.php` — متد `findWithFilters()`: + +```php +$qb = $this->createQueryBuilder('d') + ->leftJoin('d.specialties', 's') + ->leftJoin('d.provinces', 'pr') + ->leftJoin('d.cities', 'ci') + ->leftJoin(Clinic::class, 'cl', Join::WITH, 'd MEMBER OF cl.doctors') + ->leftJoin(DoctorAddress::class, 'ca', Join::WITH, 'ca.clinicId = cl.id AND ca.doctor IS NULL') + ->distinct(); + +if (!empty($filters['state'])) { + $qb->andWhere('pr.id = :state OR IDENTITY(ca.province) = :state') + ->setParameter('state', (int) $filters['state']); +} +if (!empty($filters['city'])) { + $qb->andWhere('ci.id = :city OR IDENTITY(ca.city) = :city') + ->setParameter('city', (int) $filters['city']); +} +``` + +نکته مهم درباره داده‌ها (از کامیت قبلی استخراج شده): +- `Clinic.cityId` همیشه `NULL` است؛ شهر/استان کلینیک در `DoctorAddress` با `clinic_id` پر و `doctor_id IS NULL` ذخیره می‌شود. +- `DoctorAddress.clinicId` یک ستون `int` خام است (نه رابطه به Clinic). آدرس کلینیک: `clinic_id = ` و `doctor_id IS NULL`. +- جدول واسط ManyToMany بین Clinic و Doctor: `clinic_doctor` (ستون‌های `clinic_id`, `doctor_id`). + +## وظایف + +### ۱. حذف JOIN گران `d MEMBER OF cl.doctors` و فیلترکردن مکان با subquery شرطی + +به‌جای دو JOIN دائمی، شرط «پزشک از طریق کلینیک به این شهر/استان متعلق است» را فقط زمانی اضافه کن که فیلتر `state` یا `city` واقعاً ست شده باشد، و آن را به‌صورت یک subquery هدفمند بنویس که از ایندکس استفاده می‌کند — نه یک JOIN روی کل جدول کلینیک. + +ایده: پزشک `d` در شهر `:city` نمایش داده شود اگر **یا** آدرس مستقیم خودش (`d.cities`/`d.provinces`) مطابقت کند، **یا** عضو کلینیکی باشد که آن کلینیک یک `DoctorAddress` با همان شهر دارد. + +راه‌حل پیشنهادی — حذف `cl`/`ca` از JOINهای ثابت، و افزودن شرط داخل بلوک فیلتر: + +```php +$qb = $this->createQueryBuilder('d') + ->leftJoin('d.specialties', 's') + ->leftJoin('d.provinces', 'pr') + ->leftJoin('d.cities', 'ci') + ->distinct(); + +if (!empty($filters['city'])) { + // پزشک مستقیماً در این شهر است، یا عضو کلینیکی است که آدرسش در این شهر است + $clinicCitySub = $this->getEntityManager()->createQueryBuilder() + ->select('1') + ->from(\App\Doctor\Entity\DoctorAddress::class, 'ca') + ->join(\App\Clinic\Entity\Clinic::class, 'cl', Join::WITH, 'ca.clinicId = cl.id') + ->where('ca.doctor IS NULL') + ->andWhere('IDENTITY(ca.city) = :city') + ->andWhere('d MEMBER OF cl.doctors') + ->getDQL(); + + $qb->andWhere(sprintf('ci.id = :city OR EXISTS (%s)', $clinicCitySub)) + ->setParameter('city', (int) $filters['city']); +} + +if (!empty($filters['state'])) { + $clinicStateSub = $this->getEntityManager()->createQueryBuilder() + ->select('1') + ->from(\App\Doctor\Entity\DoctorAddress::class, 'ca2') + ->join(\App\Clinic\Entity\Clinic::class, 'cl2', Join::WITH, 'ca2.clinicId = cl2.id') + ->where('ca2.doctor IS NULL') + ->andWhere('IDENTITY(ca2.province) = :state') + ->andWhere('d MEMBER OF cl2.doctors') + ->getDQL(); + + $qb->andWhere(sprintf('pr.id = :state OR EXISTS (%s)', $clinicStateSub)) + ->setParameter('state', (int) $filters['state']); +} +``` + +**نکته:** اگر در تست مشخص شد که Doctrine اجازه‌ی ارجاع به alias بیرونی `d` را داخل subquery (`d MEMBER OF cl.doctors`) نمی‌دهد (همان محدودیتی که در کامیت قبلی باعث شد از این روش به JOIN سوییچ شود)، آنگاه به‌جای آن از یک subquery مستقیم روی جدول واسط ManyToMany استفاده کن — پزشکانی که در شهر هدف کلینیک دارند را با یک کوئری جدا بگیر: + +```php +// جایگزین: لیست id پزشکانِ متعلق به کلینیک‌های این شهر را یک‌بار بگیر +$doctorIdsViaClinic = $this->getEntityManager()->createQueryBuilder() + ->select('IDENTITY(cd.doctor)') // یا join مناسب بسته به مدل ManyToMany + ->from(\App\Doctor\Entity\DoctorAddress::class, 'ca') + ->join(\App\Clinic\Entity\Clinic::class, 'cl', Join::WITH, 'ca.clinicId = cl.id') + ->join('cl.doctors', 'cd') + ->where('ca.doctor IS NULL') + ->andWhere('IDENTITY(ca.city) = :city') + ->setParameter('city', (int) $filters['city']) + ->getQuery()->getSingleColumnResult(); + +// سپس در کوئری اصلی: +$qb->andWhere('ci.id = :city OR d.id IN (:clinicDoctorIds)') + ->setParameter('city', (int) $filters['city']) + ->setParameter('clinicDoctorIds', $doctorIdsViaClinic ?: [0]); +``` + +این روش دوم تضمین می‌کند هیچ subquery همبسته‌ی per-row اجرا نشود؛ یک کوئری کوچک جداگانه + یک `IN (...)` ساده. اگر تعداد پزشکان کلینیکی زیاد نیست (که نیست)، این سریع‌ترین گزینه است. + +**هر کدام از دو روش که کار کرد و سریع بود را نگه دار؛ روش دوم (IN) را به‌عنوان پیش‌فرض امن ترجیح بده.** + +### ۲. اطمینان از اینکه کوئری بدون فیلتر مکان دست‌نخورده بماند + +بعد از تغییر، وقتی نه `state` و نه `city` ست نشده‌اند، کوئری باید **دقیقاً** مثل قبل از کامیت باشد (فقط `specialties`/`provinces`/`cities` JOIN + فیلترهای name/gender/degree/active). هیچ JOIN یا subquery اضافه‌ای نباید در این مسیر اجرا شود. این پرتکرارترین حالت سرچ است. + +### ۳. اندازه‌گیری و تأیید بهبود سرعت + +قبل و بعد از تغییر، زمان اجرای کوئری را با profiler یا لاگ مقایسه کن: + +```bash +# دیدن SQL تولیدشده و زمان +ddev exec php bin/console doctrine:query:dql "" # یا از Symfony profiler/توابع لاگ +``` + +سناریوهای تست (هر کدام باید سریع باشند): +- سرچ بدون فیلتر (فقط pagination) — باید به سرعت pre-commit برگردد +- سرچ با `name` تنها +- سرچ با `city=104` (اصفهان) — باید همان پزشکِ کلینیکیِ بدون آدرس مستقیم را هم نشان دهد (regression قبلی) +- سرچ با `state` تنها + +### ۴. بررسی نیاز به ایندکس دیتابیس + +اگر بعد از بهینه‌سازی DQL هنوز کندی هست، بررسی کن آیا ستون‌های زیر ایندکس دارند و در صورت نبود، migration اضافه کن: +- `doctor_address.clinic_id` +- `doctor_address.doctor_id` +- `doctor_address.city_id` / `doctor_address.province_id` +- جدول واسط `clinic_doctor` روی `(clinic_id, doctor_id)` + +```bash +ddev exec php bin/console doctrine:migrations:diff --no-interaction # فقط اگر ایندکس به Entity اضافه شد +ddev exec php bin/console doctrine:migrations:migrate --no-interaction +``` + +ایندکس را با `#[ORM\Index(...)]` روی Entity تعریف کن، نه SQL خام، تا migration درست تولید شود. + +## نکات مهم + +- **رفتار باید حفظ شود:** پزشکی که هیچ `DoctorAddress` با `doctor_id` ندارد ولی عضو کلینیکی است، باید در شهر/استان آن کلینیک ظاهر شود. این دقیقاً همان باگی است که کامیت اصلی حل کرد؛ نباید بشکند. +- **محدودیت Doctrine:** ارجاع به alias بیرونی (`d`) داخل subquery همبسته در DQL در بعضی حالت‌ها پشتیبانی نمی‌شود — به همین دلیل کامیت قبلی به JOIN سوییچ کرد. اگر روش EXISTS خطا داد، مستقیم سراغ روش دوم (محاسبه‌ی `doctorIds` جدا + `IN`) برو. این را در پیاده‌سازی تست کن، حدس نزن. +- **`->distinct()`** فقط وقتی لازم است که JOINهای to-many (مثل `specialties`/`cities`) ردیف تکراری بسازند. اگر فیلتر مکان را از JOIN خارج کردی، `distinct` همچنان برای `specialties` لازم است — نگهش دار اما مطمئن شو که محصول دکارتی بزرگ‌تری نمی‌سازد. +- **`Paginator`** برای شمارش `total` یک کوئری جدا اجرا می‌کند؛ هر پیچیدگی‌ای که به کوئری اصلی اضافه کنی، در شمارش هم تکرار می‌شود. کوئری را تا حد ممکن ساده نگه دار. +- **پارامتر خالی:** اگر `doctorIds` خالی شد، از `[0]` (یا یک id ناموجود) به‌عنوان مقدار `IN` استفاده کن تا SQL معتبر بماند و نتیجه‌ی خالی برگردد. +- **مستندات:** اگر قرارداد response یا رفتار فیلتر تغییر کرد، `docs/api/doctor.md` را در همان session به‌روز کن (قانون استاندارد پروژه). اگر فقط پرفورمنس داخلی عوض شد و قرارداد ثابت ماند، نیازی به تغییر مستند نیست. +- **بدون N+1:** مطمئن شو راه‌حل، کوئری اضافه‌ی per-doctor تولید نمی‌کند (مثلاً lazy-load روی `addresses` در حلقه). فقط همان یک کوئری اصلی + حداکثر یک کوئری کمکی برای `doctorIds`. diff --git a/src/Doctor/Repository/DoctorRepository.php b/src/Doctor/Repository/DoctorRepository.php index 131ba280..94fdcad2 100644 --- a/src/Doctor/Repository/DoctorRepository.php +++ b/src/Doctor/Repository/DoctorRepository.php @@ -49,17 +49,25 @@ class DoctorRepository extends ServiceEntityRepository ->leftJoin('d.specialties', 's') ->leftJoin('d.provinces', 'pr') ->leftJoin('d.cities', 'ci') - ->leftJoin(Clinic::class, 'cl', Join::WITH, 'd MEMBER OF cl.doctors') - ->leftJoin(DoctorAddress::class, 'ca', Join::WITH, 'ca.clinicId = cl.id AND ca.doctor IS NULL') ->distinct(); if (!empty($filters['state'])) { - $qb->andWhere('pr.id = :state OR IDENTITY(ca.province) = :state') - ->setParameter('state', (int) $filters['state']); + $stateId = (int) $filters['state']; + $clinicIds = $this->doctorIdsViaClinicLocation('province', $stateId); + $qb->andWhere('pr.id = :state' . ($clinicIds ? ' OR d.id IN (:stateClinicDoctorIds)' : '')) + ->setParameter('state', $stateId); + if ($clinicIds) { + $qb->setParameter('stateClinicDoctorIds', $clinicIds); + } } if (!empty($filters['city'])) { - $qb->andWhere('ci.id = :city OR IDENTITY(ca.city) = :city') - ->setParameter('city', (int) $filters['city']); + $cityId = (int) $filters['city']; + $clinicIds = $this->doctorIdsViaClinicLocation('city', $cityId); + $qb->andWhere('ci.id = :city' . ($clinicIds ? ' OR d.id IN (:cityClinicDoctorIds)' : '')) + ->setParameter('city', $cityId); + if ($clinicIds) { + $qb->setParameter('cityClinicDoctorIds', $clinicIds); + } } if (!empty($filters['specialty'])) { $qb->andWhere('s.id = :specialty')->setParameter('specialty', (int) $filters['specialty']); @@ -95,6 +103,31 @@ class DoctorRepository extends ServiceEntityRepository ]; } + /** + * IDs of doctors who belong to a clinic whose own address (DoctorAddress with + * doctor IS NULL) is in the given city/province. Used so doctors without a + * direct address still surface under their clinic's location, without a + * per-row correlated subquery on the main search. + * + * @return int[] + */ + private function doctorIdsViaClinicLocation(string $field, int $locationId): array + { + $column = $field === 'city' ? 'ca.city' : 'ca.province'; + + $rows = $this->getEntityManager()->createQueryBuilder() + ->select('cd.id AS doctorId') + ->from(Clinic::class, 'cl') + ->join('cl.doctors', 'cd') + ->join(DoctorAddress::class, 'ca', Join::WITH, 'ca.clinicId = cl.id AND ca.doctor IS NULL') + ->where(sprintf('IDENTITY(%s) = :loc', $column)) + ->setParameter('loc', $locationId) + ->getQuery() + ->getScalarResult(); + + return array_values(array_unique(array_map('intval', array_column($rows, 'doctorId')))); + } + public function findByClinicWithFilters(int $clinicId, array $filters): array { $page = max(1, (int) ($filters['page'] ?? 1));