`GET /api/v1/doctors` could not answer either question the public search box asks. Typing a specialty name returned nothing, because `name` only matched `d.name`. And `specialty_id` matched one id exactly, so a parent group only found doctors who happened to carry the parent — which they usually do, but only as a side effect of `expandWithAncestors` running on save. A doctor imported through any other path has no denormalised parent, and a search guarantee resting on a save-time side effect is not a guarantee. `expandWithDescendants` mirrors the existing ancestor walk over the same cached parentMap, so no extra query. It deliberately keeps unknown ids instead of dropping them like its mirror does: the result feeds an `IN (...)`, and an empty array turns the filter into a no-op that returns every doctor — an unknown id must mean "nothing", never "everything". Both specialty filters use their own EXISTS alias rather than the shared `s` join. Two conditions on one alias force a single join row to satisfy both, so a doctor filtered by specialty A while searching the name of specialty B was silently dropped. Verified by reverting to the shared alias and watching testFilterOnOneSpecialtyWhileSearchingTheNameOfAnother fail. toListArray now carries specialties[].parent_id so a client can tell the main specialty from a sub-specialty instead of printing all of them. It is a string, matching toDetailArray and the sibling `id` key — one concept should not have two types across two endpoints. Reading the id off the parent proxy costs no query; measured 6→11 queries with four more doctors both with and without the field. That growth is a pre-existing N+1 (findWithFilters does not fetch-join specialties, unlike findByClinic) and is left untouched here. Also drops the phantom `search` parameter from the OpenAPI annotation — it was advertised but never read, so a client sending it got an unfiltered list — and documents the six live parameters that were missing. Note for deploy: DoctorRepository gained a constructor argument, so a stale container fails with ArgumentCountError until cache:clear runs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
14 KiB
جستجوی پزشک روی همهٔ تخصصها و برگرداندن ساختار والد/فرزند
پروژه
clinicpro — بکاند Symfony.
cross-repo. قرارداد GET /api/v1/doctors را سایت عمومی مصرف میکند.
پرامپت همتا: nobat724_front/.claude/prompt/doctor-multi-specialty-ui.md.
آن را بعد از این یکی اجرا کن — کارت پزشک به parent_id در پاسخ همین اندپوینت نیاز دارد.
زمینه
هر پزشک چند تخصص دارد و ساختار دو سطحی والد/فرزند است. نمونهٔ واقعی — دکتر محمدباقر جهانتاب، شناسهٔ ۱۴۹۹۲:
13 جراحی عمومی (والد)
14 جراحی پلاستیک و زیبایی
167 جراحی لاپاراسکوپی
168 جراح تیروئید
169 جراح گوارش
170 جراحی سرطانها
هنگام ذخیره، expandWithAncestors والدها را خودکار اضافه میکند، پس والد معمولاً روی پزشک نشسته است.
مشکل / هدف
سه شکاف در بکاند:
۱. جستجوی متنی تخصص را نمیبیند. تایپ «جراح گوارش» در کادر جستجو هیچ پزشکی برنمیگرداند،
چون فقط d.name گشته میشود.
۲. فیلتر specialty_id به فرزندان گسترش نمییابد. امروز فقط بهخاطر عارضهٔ جانبیِ
expandWithAncestors در زمان ذخیره کار میکند. هر پزشکی که از مسیر دیگری وارد شود
(مثلاً import دستهای) این تضمین را ندارد. تکیهٔ یک قابلیت جستجو بر یک side effect ذخیره، شکننده است.
۳. پاسخ لیست parent_id ندارد. کارت پزشک در سایت عمومی نمیتواند تشخیص دهد کدام تخصص
والد است، پس نمیتواند «تخصص اصلی + N» بسازد.
معیار پذیرش
- ✅ موفق:
GET /api/v1/doctors?specialty_id=13پزشکی را که فقط169(جراح گوارش) دارد و والد روی او ثبت نشده هم برمیگرداند. - ✅ موفق:
GET /api/v1/doctors?name=جراح گوارشدکتر جهانتاب را برمیگرداند. - ✅ موفق:
GET /api/v1/doctors?name=جهانتابهمچنان همان پزشک را برمیگرداند — تطابق نام نشکسته. - ✅ موفق: هر آیتم پاسخ، در
specialties[]کلیدparent_idدارد؛ برای ریشهnull. - ❌ خطا:
specialty_idبا شناسهٔ ناموجود →200و آرایهٔ خالی، نه500. - ❌ خطا:
nameبا عبارتی که به هیچ پزشک و هیچ تخصصی نمیخورد →200وtotalRecords: 0. - ⚠️ مرزی:
specialty_idوnameبا هم.?specialty_id=13&name=جهانتابباید همان پزشک را بدهد. این حالت با پیادهسازی ساده میشکند — پایین توضیح داده شده. - ⚠️ مرزی: تخصص برگ (بدون فرزند) —
?specialty_id=169فقط پزشکان همان تخصص، نه کل گروه. - ⚠️ مرزی:
totalRecordsباید با تعداد ردیفهای واقعی بخواند. join چندبهچند بدونDISTINCTدر شمارش، پزشک چندتخصصی را چند بار میشمارد.
فایلهای مرتبط
| فایل | نقش |
|---|---|
src/Specialty/Repository/SpecialtyRepository.php |
parentMap() و expandWithAncestors() موجود |
src/Doctor/Repository/DoctorRepository.php |
findWithFilters() — همهٔ فیلترهای لیست عمومی |
src/Doctor/Entity/Doctor.php |
toListArray() — شکل آیتم لیست |
docs/api/doctor.md |
سند اندپوینت |
docs/api/specialty.md |
سند تخصص |
وضعیت فعلی
فیلترها — تکشناسه و فقط نام پزشک
// src/Doctor/Repository/DoctorRepository.php:51
$qb = $this->createQueryBuilder('d')
->leftJoin('d.specialties', 's')
->leftJoin(DoctorAddress::class, 'da', Join::WITH, 'da.doctor = d')
->distinct();
// همان فایل:76
if (!empty($filters['specialty_id'])) {
$qb->andWhere('s.id = :specialty')->setParameter('specialty', (int) $filters['specialty_id']);
}
// همان فایل:89
if (!empty($filters['name'])) {
$qb->andWhere('d.name LIKE :name')->setParameter('name', '%' . $filters['name'] . '%');
}
گسترش به بالا وجود دارد، به پایین نه
// src/Specialty/Repository/SpecialtyRepository.php:75
public function expandWithAncestors(array $ids): array
{
$map = $this->parentMap();
$out = [];
foreach ($ids as $id) {
$cur = (int) $id;
$seen = [];
while (array_key_exists($cur, $map) && !isset($seen[$cur])) {
$seen[$cur] = true;
$out[$cur] = true;
$cur = $map[$cur] ?? 0;
}
}
$out = array_keys($out);
sort($out);
return $out;
}
/** @return array<int,?int> id => parentId for every specialty */
private function parentMap(): array
{
if ($this->parentMap === null) {
$rows = $this->createQueryBuilder('s')
->select('s.id AS id', 'IDENTITY(s.parent) AS parent')
->getQuery()
->getArrayResult();
$this->parentMap = [];
foreach ($rows as $row) {
$this->parentMap[(int) $row['id']] = $row['parent'] !== null ? (int) $row['parent'] : null;
}
}
return $this->parentMap;
}
پاسخ لیست — بدون parent_id
// src/Doctor/Entity/Doctor.php:578
'specialties' => array_map(fn(Specialty $s) => [
'uuid' => $s->getUuid(),
'id' => (string) $s->getId(),
'name' => $s->getName(),
], $this->specialties->toArray()),
وظایف
۱. expandWithDescendants در SpecialtyRepository
قرینهٔ expandWithAncestors. از همان parentMap() کششده استفاده میکند، پس کوئری اضافه ندارد.
درخت امروز دقیقاً دو سطح است — شمارش نوهها صفر است — ولی مثل قرینهاش generic بنویس، نه یکسطحی. عمقِ فرضی، بدهیِ فردا است.
/**
* شناسههای دادهشده بهعلاوهٔ همهٔ نوادگانشان.
*
* قرینهٔ expandWithAncestors: آن برای «این زیرتخصص یعنی والدش هم» است و این برای
* «این گروه یعنی همهٔ زیرشاخههایش هم».
*
* @param list<int|string> $ids
* @return list<int>
*/
public function expandWithDescendants(array $ids): array
{
$map = $this->parentMap();
$children = [];
foreach ($map as $id => $parent) {
if ($parent !== null) {
$children[$parent][] = $id;
}
}
$out = [];
$queue = array_map('intval', $ids);
while ($queue) {
$cur = array_pop($queue);
if (isset($out[$cur])) {
continue;
}
$out[$cur] = true;
foreach ($children[$cur] ?? [] as $child) {
$queue[] = $child;
}
}
$out = array_keys($out);
sort($out);
return $out;
}
isset($out[$cur]) هم dedupe است و هم محافظ حلقه، اگر دادهای چرخه بسازد.
نحوه تست: unit test در tests/Specialty/SpecialtyDescendantsTest.php —
expandWithDescendants([13]) باید 13 و همهٔ فرزندانش را بدهد؛
expandWithDescendants([169]) فقط [169]؛ آرایهٔ خالی → آرایهٔ خالی؛
شناسهٔ ناموجود → همان شناسه بدون خطا.
۲. فیلتر specialty_id به نوادگان گسترش یابد
دقت کن — اینجا یک تلهٔ واقعی هست. alias فعلی s هم برای فیلتر تخصص استفاده میشود و هم
(در وظیفهٔ ۳) برای جستجوی نام تخصص. اگر هر دو روی همان alias بنشینند، DQL مجبور میشود
یک ردیفِ join هر دو شرط را با هم ارضا کند. پزشکی که با تخصص A فیلتر را پاس میکند و
نامِ تخصص B را دارد، حذف میشود.
پس هر دو فیلتر را با زیرکوئری EXISTS بنویس و join اصلی s را دست نزن — آن فقط برای
hydration است.
if (!empty($filters['specialty_id'])) {
$ids = $this->specialtyRepo->expandWithDescendants([(int) $filters['specialty_id']]);
$qb->andWhere(
$qb->expr()->exists(
'SELECT sf.id FROM ' . Specialty::class . ' sf'
. ' WHERE sf MEMBER OF d.specialties AND sf.id IN (:specialtyIds)'
)
)->setParameter('specialtyIds', $ids);
}
SpecialtyRepository را با constructor injection بگیر، new نکن.
نحوه تست: تست فانکشنال در tests/Doctor/DoctorSpecialtySearchTest.php.
پزشکی بساز که فقط یک زیرتخصص دارد و والد روی او ثبت نیست (مستقیم با
$doctor->getSpecialties()->add($child) و flush، نه از راه اندپوینت که والد را اضافه میکند).
سپس GET /api/v1/doctors?specialty_id=<parentId> باید او را برگرداند.
همچنین ?specialty_id=<leafId> نباید پزشکِ یک زیرتخصصِ خواهر را برگرداند.
۳. name نام تخصص را هم بگردد
if (!empty($filters['name'])) {
$qb->andWhere(
$qb->expr()->orX(
'd.name LIKE :name',
$qb->expr()->exists(
'SELECT sn.id FROM ' . Specialty::class . ' sn'
. ' WHERE sn MEMBER OF d.specialties AND sn.name LIKE :name'
)
)
)->setParameter('name', '%' . $filters['name'] . '%');
}
ترتیب نتایج را عوض نکن. مرتبسازی فعلی سر جایش میماند؛ اگر بعداً «نام پزشک اول» خواسته شد، تسک جداست.
نحوه تست: در همان تست فانکشنال —
?name=<نام تخصص> پزشک را میدهد؛ ?name=<بخشی از نام پزشک> همچنان میدهد؛
?specialty_id=<parentId>&name=<نام پزشک> هم میدهد (همان حالت مرزی که با alias مشترک میشکست)؛
عبارت بیربط → totalRecords: 0.
۴. parent_id در پاسخ لیست
'specialties' => array_map(fn(Specialty $s) => [
'uuid' => $s->getUuid(),
'id' => (string) $s->getId(),
'name' => $s->getName(),
// کلاینت بدون این نمیتواند «تخصص اصلی» را از زیرتخصص تشخیص دهد.
'parent_id' => $s->getParent()?->getId(),
], $this->specialties->toArray()),
افزودنی است و هیچ کلید موجودی را عوض نمیکند.
مراقب N+1 باش. getParent() روی proxy یعنی یک کوئری بهازای هر تخصص بهازای هر پزشک.
در findWithFilters روی همان join موجود addSelect('s') بگذار و والد را هم join و select کن.
با تست شمارش کوئری تثبیتش کن — ApiTestCase::countQueries() برای همین هست.
نحوه تست: GET /api/v1/doctors?limit=10 و بررسی اینکه هر آیتم parent_id دارد و برای
ریشه null است. سپس countQueries() دور همان فراخوانی: تعداد کوئری با ۱۰ پزشک نباید
بهطور معنادار از حالت ۱ پزشک بیشتر باشد.
۵. مستندات
docs/api/doctor.md — بخش GET /api/v1/doctors:
specialty_idحالا «این تخصص و همهٔ زیرشاخههایش» است. صریح بنویس، چون معنایش عوض شده.nameحالا نام پزشک یا نام هر یک از تخصصهایش را میگردد.- شکل
specialties[]باparent_idتازه، و JSON واقعی از اجرای واقعی نه دستساز.
docs/api/specialty.md — یادآوری کن که GET /api/v1/specialties بدون parent_id همهٔ
تخصصهای فعال را با parent_id و slug میدهد و سایت عمومی از همین برای ساخت
data/specialties.json در زمان build استفاده میکند. اگر شکل این پاسخ عوض شود، آن اسکریپت میشکند.
نکات مهم
- تلهٔ alias مشترک، مهمترین نکتهٔ این تسک است. اگر وظیفهٔ ۲ و ۳ هر دو روی
sبنویسند، تستهای تکی سبز میشوند و فقط حالت ترکیبیِspecialty_idوnameمیشکند — یعنی همان چیزی که کاربر در فیلترها با هم میزند. DISTINCTو شمارش.findWithFiltersالان->distinct()دارد. مطمئن شو کوئری شمارش هم همان تمایز را دارد، وگرنهtotalRecordsبرای پزشک چندتخصصی باد میکند و صفحهبندی میشکند.- این تغییر هیچ فیلتری را تنگ نمیکند، فقط باز میکند. اگر تستی از رفتار فعلی لیست عمومی شکست، یعنی زیرکوئری اشتباه بسته شده.
expandWithAncestorsرا دست نزن. آن درhydrateDoctorوRepresentationActionControllerاستفاده میشود و کارِ دیگری میکند. دو تابع، دو جهت.- Repository نازک، Controller نازکتر. منطق گسترش در
SpecialtyRepositoryمیماند، نه درDoctorRepositoryو نه در کنترلر. - cross-repo: بعد از این تغییر،
nobat724_frontباید دستی بررسی شود. تغییر شکل پاسخ در build آن خطا نمیدهد و فقط در رانتایم دیده میشود. صفحهٔ/doctorsو/specialties/[slug]را باز کن و مطمئن شو چیزی نشکسته. - دیتابیس تست هرگز reset نمیشود؛ دادهٔ هر تست را با
uniqid()یکتا بساز تا اجرای دوم هم سبز بماند.