> ## Content Index
> Fetch the complete content index at: https://arabroot.io/llms.txt
> Use this file to discover other available public pages before exploring further.

# تعلم SQL (2): الربط JOIN والتجميع والاستعلامات الفرعية
- URL: https://arabroot.io/articles/تعلم-sql-2-الربط-join-والتجميع-والاستعلامات-الفرعية/
- Published: 2026-10-11T12:00:00.000Z
- Updated: 2026-10-11T06:04:49.000Z
- Description: كيف تجمع الصفوف من أكثر من جدول وتلخصها بالتجميع والاستعلامات الفرعية، دون أن تتضاعف أرقام تقاريرك.
- Author: فريق عرب رووت
- Tags: الأدلة التقنية, أساسيات الحاسب والبرمجة, قواعد البيانات, أساسيات, الاستضافة الذاتية

في [الجزء الأول](https://arabroot.io/articles/%D8%A3%D8%B3%D8%A7%D8%B3%D9%8A%D8%A7%D8%AA-sql-%D9%84%D9%84%D9%85%D8%B7%D9%88%D8%B1%D9%8A%D9%86/) أنشأنا قاعدة بيانات المعهد بجداولها الخمسة، وتعلمنا أن نختار الصفوف من جدول واحد ونرتبها ونحسب عليها. ولكن جدول التسجيلات فيه أرقام فقط: الطالب 1 في الدورة 2، وهذا لا يفيد من يقرأ التقرير، فهو يريد اسم الطالب وعنوان الدورة واسم المدرب في سطر واحد، ويريد أن يعرف كم طالباً في كل دورة وكم دفعوا، وهذه الأسئلة كلها تحتاج أن نجمع صفوفاً من أكثر من جدول، ثم نلخصها في أرقام. وهنا تبدأ أخطاء SQL الأخطر، لأن الربط الخاطئ لا يعطي رسالة خطأ، وإنما يعطيك تقريراً فيه رقم أكبر من الحقيقة يبدو معقولاً تماماً.

المواضيع في السلسلة:

- [تعلم SQL (1): ما هو SQL وكيف تكتب أول استعلام](https://arabroot.io/articles/%D8%A3%D8%B3%D8%A7%D8%B3%D9%8A%D8%A7%D8%AA-sql-%D9%84%D9%84%D9%85%D8%B7%D9%88%D8%B1%D9%8A%D9%86/): قواعد البيانات العلائقية وتاريخ اللغة، وأنواع الأوامر، وإنشاء الجداول والقيود، والإضافة والتعديل والحذف، والاستعلام SELECT والدوال والقيمة NULL.
- **تعلم SQL (2): الربط JOIN والتجميع والاستعلامات الفرعية** (الموضوع الحالي): أنواع الربط كلها، والتجميع GROUP BY وHAVING، والاستعلامات الفرعية Subqueries، وعمليات المجموعات UNION وINTERSECT وEXCEPT.
- [تعلم SQL (3): CTE ودوال النوافذ والمعاملات](https://arabroot.io/articles/%D8%AA%D8%B9%D9%84%D9%85-sql-3-cte-%D9%88%D8%AF%D9%88%D8%A7%D9%84-%D8%A7%D9%84%D9%86%D9%88%D8%A7%D9%81%D8%B0-%D9%88%D8%A7%D9%84%D9%85%D8%B9%D8%A7%D9%85%D9%84%D8%A7%D8%AA/): جمل WITH والاستعلام التكراري، ودوال النوافذ Window Functions، والعروض Views، والإضافة أو التحديث UPSERT، وJSON، والمعاملات ومستويات العزل، وحقن SQL.

وسوف نتناول في هذا الجزء ما يلي:

- أنواع الربط كلها: INNER وLEFT وRIGHT وFULL وCROSS والربط الذاتي SELF JOIN، مع جدول نتيجة صغير لكل نوع.
- لماذا تضلل دوائر Venn في شرح الربط، وفخ الشرط WHERE الذي يحول LEFT JOIN إلى INNER JOIN، وتضاعف الصفوف Fan-out الذي يكرر الأرقام.
- الدوال التجميعية Aggregate Functions والتجميع GROUP BY وHAVING، وأثر NULL في المجاميع، والفرق بين `count(*)` و`count(column)`.
- الاستعلامات الفرعية Subqueries بأنواعها: القيمة الواحدة وIN وEXISTS والاستعلام المرتبط Correlated Subquery، وفخ NOT IN مع NULL.
- عمليات المجموعات UNION وUNION ALL وINTERSECT وEXCEPT، ثم الأخطاء الشائعة وتمارين بحلولها.

## قاعدة البيانات من الجزء الأول

نعمل في هذا الجزء على نفس قاعدة البيانات `academy` ونفس الـ Container، فإذا كان المختبر من الجزء الأول ما زال يعمل فادخل إلى psql مباشرة، وإذا حذفته فأنشئه من جديد كما في [تجهيز المختبر](https://arabroot.io/articles/%D8%A3%D8%B3%D8%A7%D8%B3%D9%8A%D8%A7%D8%AA-sql-%D9%84%D9%84%D9%85%D8%B7%D9%88%D8%B1%D9%8A%D9%86/#lab)، ثم نفذ الملفين `schema.sql` من [قسم إنشاء الجداول](https://arabroot.io/articles/%D8%A3%D8%B3%D8%A7%D8%B3%D9%8A%D8%A7%D8%AA-sql-%D9%84%D9%84%D9%85%D8%B7%D9%88%D8%B1%D9%8A%D9%86/#ddl) و`seed.sql` من [قسم الإضافة](https://arabroot.io/articles/%D8%A3%D8%B3%D8%A7%D8%B3%D9%8A%D8%A7%D8%AA-sql-%D9%84%D9%84%D9%85%D8%B7%D9%88%D8%B1%D9%8A%D9%86/#insert):

```bash
docker run -d --name lab-sql-pg -e POSTGRES_PASSWORD=lab -e POSTGRES_DB=academy \
  -p 127.0.0.1:21471:5432 postgres:18.6
docker exec -i lab-sql-pg psql -U postgres -d academy < schema.sql
docker exec -i lab-sql-pg psql -U postgres -d academy < seed.sql
docker exec -it lab-sql-pg psql -U postgres -d academy

```

ولا تنس الأمر `\pset null NULL` داخل psql حتى ترى القيم المجهولة بالاسم كما في المخرجات أدناه. وتذكر شكل البيانات، لأننا سوف نتوقع النتائج قبل تشغيلها: ستة طلاب منهم فيصل الذي لم يسجل في شيء، وخمس دورات منها Kubernetes التي لا طلاب فيها ولا مدرب لها، وتسعة تسجيلات ثلاثة منها بلا درجة، وعشر دفعات منها دفعتان لسارة عن نفس الدورة، وستة مدربين تديرهم Nora.

## الربط JOIN: كيف تجمع صفوفاً من أكثر من جدول؟

الربط JOIN يأخذ صفاً من الجدول الأول وصفاً من الثاني، ويضعهما في صف واحد إذا تحقق شرط الربط بعد الكلمة ON، وأغلب الوقت يكون الشرط أن المفتاح الأجنبي في جدول يساوي المفتاح الأساسي في الآخر، كما يشرح [توثيق الربط في PostgreSQL](https://www.postgresql.org/docs/18/queries-table-expressions.html?ref=arabroot.io#QUERIES-JOIN). ونبدأ بأبسط مثال، الطلاب مع تسجيلاتهم:

```sql
SELECT s.name, e.course_id, e.grade
FROM students s
JOIN enrollments e ON e.student_id = s.id
ORDER BY s.id, e.course_id;

```

والمخرج سوف يكون كما يلي:

```text
  name  | course_id | grade
--------+-----------+-------
 Sara   |         1 |    90
 Sara   |         2 |    85
 Omar   |         1 |    70
 Omar   |         3 |  NULL
 Lina   |         2 |    95
 Lina   |         4 |  NULL
 Khalid |         4 |    60
 Huda   |         2 |  NULL
 Huda   |         3 |    88
(9 rows)

```

في الاستعلام أعلاه لاحظ التالي:

- الحروف `s` و`e` أسماء مستعارة للجداول تختصر الكتابة، وتصبح ضرورية عندما يوجد عمود بنفس الاسم في جدولين مثل `id`، فتكتب `s.id` و`e.id` حتى تعرف قاعدة البيانات أيهما تقصد.
- سارة ظهرت مرتين لأن لها تسجيلين، فالربط يكرر صف الطالب بعدد شركائه في الجدول الآخر، وهذه أهم حقيقة في هذا الجزء كله.
- فيصل لم يظهر أبداً، لأنه لا يوجد له أي تسجيل يطابق الشرط.

### الربط الداخلي INNER JOIN

الكلمة JOIN وحدها هي INNER JOIN، أي أن الصف يظهر فقط إذا وجد له شريك في الطرف الآخر، ويمكنك أن تربط أكثر من جدولين بإضافة JOIN بعد الآخر، فالمثال التالي يعرض اسم الطالب وعنوان الدورة بدل الأرقام:

```sql
SELECT s.name, c.title, e.grade
FROM enrollments e
JOIN students s ON s.id = e.student_id
JOIN courses  c ON c.id = e.course_id
ORDER BY s.name, c.title;

```

```text
  name  |    title     | grade
--------+--------------+-------
 Huda   | Docker       |    88
 Huda   | PostgreSQL   |  NULL
 Khalid | Python       |    60
 Lina   | PostgreSQL   |    95
 Lina   | Python       |  NULL
 Omar   | Docker       |  NULL
 Omar   | Linux Basics |    70
 Sara   | Linux Basics |    90
 Sara   | PostgreSQL   |    85
(9 rows)

```

ولكي ترى ماذا يحذف INNER JOIN بوضوح، نربط الدورات بمدربيها، ونتذكر أن Kubernetes بلا مدرب:

```sql
SELECT c.title, i.name AS instructor
FROM courses c
JOIN instructors i ON i.id = c.instructor_id
ORDER BY c.id;

```

```text
    title     | instructor
--------------+------------
 Linux Basics | Tariq
 PostgreSQL   | Rania
 Docker       | Yasser
 Python       | Dana
(4 rows)

```

خمس دورات أصبحت أربعاً، واختفت Kubernetes من التقرير دون أي تحذير، لأن `instructor_id` فيها NULL ولا يساوي أي مدرب. وهذا مقبول إذا كان سؤالك «من يدرس ماذا»، وخطأ إذا كان سؤالك «كل الدورات».

### الربط الخارجي LEFT JOIN وRIGHT JOIN

إذا أردت كل الدورات، ومن ليس له مدرب يظهر وبجانبه NULL، فهذا هو LEFT JOIN: كل صفوف الجدول الأيسر، أي المكتوب قبل الكلمة JOIN، ومعها ما يطابقها من الأيمن إن وجد:

```sql
SELECT c.title, i.name AS instructor
FROM courses c
LEFT JOIN instructors i ON i.id = c.instructor_id
ORDER BY c.id;

```

```text
    title     | instructor
--------------+------------
 Linux Basics | Tariq
 PostgreSQL   | Rania
 Docker       | Yasser
 Python       | Dana
 Kubernetes   | NULL
(5 rows)

```

ونفس الشيء مع الطلاب، فهنا يظهر فيصل الذي لم يسجل في أي دورة:

```sql
SELECT s.name, e.course_id
FROM students s
LEFT JOIN enrollments e ON e.student_id = s.id
ORDER BY s.id, e.course_id;

```

```text
  name  | course_id
--------+-----------
 Sara   |         1
 Sara   |         2
 Omar   |         1
 Omar   |         3
 Lina   |         2
 Lina   |         4
 Khalid |         4
 Huda   |         2
 Huda   |         3
 Faisal |      NULL
(10 rows)

```

والربط RIGHT JOIN هو نفس الفكرة من الجهة الأخرى، فهنا كل المدربين حتى من لا يدرس أي دورة:

```sql
SELECT c.title, i.name AS instructor
FROM courses c
RIGHT JOIN instructors i ON i.id = c.instructor_id
ORDER BY i.id;

```

```text
    title     | instructor
--------------+------------
 NULL         | Nora
 NULL         | Majed
 PostgreSQL   | Rania
 Linux Basics | Tariq
 Docker       | Yasser
 Python       | Dana
(6 rows)

```

ظهرت Nora وMajed بلا دورات لأنهما مديران، واختفت Kubernetes لأنها في الجدول الأيسر ولا مدرب لها. وفي الواقع أغلب المطورين لا يكتبون RIGHT JOIN، ويقلبون ترتيب الجدولين ويكتبون LEFT JOIN، لأن قراءة الجملة من الجدول الأساسي إلى الجداول الإضافية أسهل.

### الربط الكامل FULL JOIN

الربط FULL JOIN يجمع الحالتين: كل الصفوف من الطرفين، وNULL حيث لا يوجد شريك، فيظهر فيه المدير بلا دورة والدورة بلا مدرب في نفس النتيجة:

```sql
SELECT c.title, i.name AS instructor
FROM courses c
FULL JOIN instructors i ON i.id = c.instructor_id
ORDER BY i.id, c.id;

```

```text
    title     | instructor
--------------+------------
 NULL         | Nora
 NULL         | Majed
 PostgreSQL   | Rania
 Linux Basics | Tariq
 Docker       | Yasser
 Python       | Dana
 Kubernetes   | NULL
(7 rows)

```

واستخدامه الأشهر هو مقارنة قائمتين لمعرفة ما ينقص من كل طرف، مثل مقارنة جدول عندك بملف وصلك من نظام آخر. والمثال التالي يجد بثلاثة جداول الطالب الذي لم يسجل والدورة التي لا طلاب فيها في استعلام واحد:

```sql
SELECT s.name, c.title
FROM students s
LEFT JOIN enrollments e ON e.student_id = s.id
FULL JOIN courses c ON c.id = e.course_id
WHERE s.id IS NULL OR c.id IS NULL;

```

```text
  name  |   title
--------+------------
 Faisal | NULL
 NULL   | Kubernetes
(2 rows)

```

أما MySQL فلا يدعم FULL JOIN، ولا يذكره [توثيق JOIN في MySQL](https://dev.mysql.com/doc/refman/8.4/en/join.html?ref=arabroot.io) بين أنواع الربط، والأخطر أنه لا يرفض الجملة دائماً، فقد شغلنا على MySQL 8.4 جدولين صغيرين بالصيغة `SELECT x, y FROM a FULL JOIN b USING (id)` فأعطى صفاً واحداً فقط هو الصف المشترك، أي أنه نفذ INNER JOIN بصمت، والسبب أنه قرأ الكلمة FULL كاسم مستعار للجدول a، ولم يخبرنا إلا بتحذير `Using FULL as unquoted identifier is deprecated`. أما الصيغة `FULL OUTER JOIN` فرفضها بخطأ في الصيغة Syntax Error. والحل المعتاد هناك أن تجمع نتيجة LEFT JOIN مع نتيجة RIGHT JOIN بالكلمة UNION، وقد أعطى ذلك على MySQL الصفوف الثلاثة الصحيحة. وأما SQL Server فيدعم FULL JOIN، وكذلك SQLite منذ الإصدار 3.39 كما يذكر [سجل ذلك الإصدار](https://www.sqlite.org/releaselog/3%5F39%5F0.html?ref=arabroot.io)، وقد شغلناه على الاثنين.

### الربط التقاطعي CROSS JOIN

الربط CROSS JOIN لا يحتاج شرطاً، فهو يطابق كل صف من الأول مع كل صف من الثاني، والمثال التالي يعرض كل الدورات المتاحة لفيصل، وهو مفيد عندما تريد كل التركيبات الممكنة، مثل كل طالب مع كل دورة لتعرف ما لم يسجل فيه بعد:

```sql
SELECT s.name, c.title
FROM students s
CROSS JOIN courses c
WHERE s.city = 'Abha'
ORDER BY c.id;

```

```text
  name  |    title
--------+--------------
 Faisal | Linux Basics
 Faisal | PostgreSQL
 Faisal | Docker
 Faisal | Python
 Faisal | Kubernetes
(5 rows)

```

وحجم النتيجة هو حاصل ضرب عدد الصفوف، فستة طلاب في خمس دورات تعطي ثلاثين صفاً، وجدولان من مئة ألف صف تعطي عشرة مليارات، لذلك CROSS JOIN على جداول كبيرة دون شرط بعده قد يوقف السيرفر:

```sql
SELECT (SELECT count(*) FROM students) AS students,
       (SELECT count(*) FROM courses)  AS courses,
       (SELECT count(*) FROM students CROSS JOIN courses) AS cross_join;

```

```text
 students | courses | cross_join
----------+---------+------------
        6 |       5 |         30
(1 row)

```

ويحدث نفس الشيء دون قصد إذا كتبت جدولين بعد FROM بفاصلة ونسيت شرط الربط في WHERE، فالصيغة القديمة `FROM students, courses` هي CROSS JOIN، ولهذا ننصح بكتابة JOIN مع ON دائماً، حتى يكون نسيان الشرط خطأ في الصيغة تراه فوراً.

### الربط الذاتي SELF JOIN

الربط الذاتي ليس كلمة جديدة، وإنما هو ربط الجدول بنفسه باسمين مستعارين مختلفين، وتحتاجه عندما يشير الجدول إلى نفسه مثل جدول المدربين الذي فيه مدير كل مدرب:

```sql
SELECT i.name AS instructor, m.name AS manager
FROM instructors i
LEFT JOIN instructors m ON m.id = i.manager_id
ORDER BY i.id;

```

```text
 instructor | manager
------------+---------
 Nora       | NULL
 Majed      | Nora
 Rania      | Nora
 Tariq      | Majed
 Yasser     | Majed
 Dana       | Rania
(6 rows)

```

نقرأ الجدول مرة بالاسم `i` كمدرب ومرة بالاسم `m` كمدير، واستخدمنا LEFT JOIN حتى لا تختفي Nora التي ليس لها مدير. وهذا يعطيك مستوى واحداً فقط، أما الهيكل الإداري كاملاً بكل مستوياته فيحتاج الاستعلام التكراري الذي نشرحه في [الجزء الثالث](https://arabroot.io/articles/%D8%AA%D8%B9%D9%84%D9%85-sql-3-cte-%D9%88%D8%AF%D9%88%D8%A7%D9%84-%D8%A7%D9%84%D9%86%D9%88%D8%A7%D9%81%D8%B0-%D9%88%D8%A7%D9%84%D9%85%D8%B9%D8%A7%D9%85%D9%84%D8%A7%D8%AA/#recursive).

والصورة التالية تبين الأنواع الأربعة الأساسية على ثلاثة طلاب وأربعة تسجيلات، وكل خط فيها هو صف في النتيجة:

![في الأعلى ثلاثة طلاب Sara وOmar وFaisal، وأربعة تسجيلات: اثنان لسارة في Linux وPostgreSQL واثنان لعمر في Linux وDocker، وخطوط تربط كل طالب بتسجيلاته، وفيصل بلا خط. وفي الأسفل أربع لوحات: INNER JOIN فيها أربعة صفوف هي الأزواج المتطابقة فقط، وLEFT JOIN فيها نفس الأربعة وصف خامس لفيصل مع NULL، وRIGHT JOIN فيها كل التسجيلات الأربعة، وFULL JOIN فيها الطرفان مع NULL حيث لا يوجد شريك](https://arabroot.io/content/images/2026/10/sql-basics-04-joins.webp)

كل خط بين صفين يصبح صفاً في النتيجة، والنوع يحدد هل يظهر من بقي بلا خط أم لا

### لماذا تضلل دوائر Venn في شرح الربط؟

أشهر رسم لشرح JOIN على الإنترنت هو دائرتان متداخلتان، الجدول الأيسر والجدول الأيمن، وINNER JOIN هو منطقة التداخل. والرسم سهل التذكر، ولكنه يزرع فكرتين خاطئتين، والطريقة الأسهل لكسرهما هي أن نعد الصفوف بأنفسنا:

```sql
SELECT (SELECT count(*) FROM students) AS students,
       (SELECT count(*) FROM courses)  AS courses,
       (SELECT count(*) FROM students CROSS JOIN courses) AS cross_join,
       (SELECT count(*) FROM students s JOIN enrollments e ON e.student_id = s.id) AS inner_join,
       (SELECT count(*) FROM students s LEFT JOIN enrollments e ON e.student_id = s.id) AS left_join;

```

```text
 students | courses | cross_join | inner_join | left_join
----------+---------+------------+------------+-----------
        6 |       5 |         30 |          9 |        10
(1 row)

```

في النتيجة أعلاه لاحظ التالي:

- دوائر Venn ترسم مجموعتين من نفس النوع من العناصر، والتداخل هو العناصر المشتركة بينهما، أما الطلاب والتسجيلات فصفوف مختلفة تماماً، ولا يوجد عنصر واحد «مشترك» بين الجدولين، وإنما يوجد شرط يطابق صفاً من هنا بصف من هناك.
- منطقة التداخل في الرسم أصغر من كل دائرة، فيوحي الرسم أن نتيجة INNER JOIN لا تزيد عن عدد صفوف أي جدول، والواقع أن الربط بين ستة طلاب وتسجيلاتهم أعطى تسعة صفوف، لأن أربعة طلاب ظهروا مرتين، وهذا التكرار هو بالضبط سبب أشهر خطأ في التقارير الذي سوف نراه في [تضاعف الصفوف](#fan-out).
- الربط CROSS JOIN يطابق كل صف بكل صف دون شرط، فأعطى 6 × 5 = 30 صفاً، ولا يوجد له أي رسم في دوائر Venn أصلاً.

والصورة التالية تضع رسم Venn بجانب الأرقام الحقيقية:

![في اليسار رسم Venn المعتاد بدائرتين students وenrollments عليه علامة خطأ حمراء مع شرح أنه يوحي بمجموعة واحدة من نفس العناصر وبنتيجة لا تزيد عن التداخل. وفي اليمين أعمدة بعدد الصفوف الحقيقي: students ستة، enrollments تسعة، INNER JOIN تسعة، LEFT JOIN عشرة، CROSS JOIN ثلاثون، مع شرح أن الربط يبني أزواجاً وأن أربعة طلاب ظهروا مرتين وأن فيصل اختفى](https://arabroot.io/content/images/2026/10/sql-basics-05-venn.webp)

الربط يبني أزواجاً من الصفوف، لذلك عد الصفوف ولا تتخيل دوائر

والمكان الصحيح لدوائر Venn هو عمليات المجموعات `UNION` و`INTERSECT` و`EXCEPT`، لأنها تعمل فعلاً على نتيجتين من نفس الشكل وتعطي عناصر مشتركة أو مختلفة، وسوف نراها في [آخر هذا الجزء](#set-operations). أما الربط فالرسم الأصح هو الذي رأيته في الصورة السابقة: صفوف وخطوط بينها، وعدد الخطوط هو عدد صفوف النتيجة.

### الشرط WHERE الذي يحول LEFT JOIN إلى INNER JOIN

لنفرض أنك تريد كل الطلاب، ومع كل طالب تسجيلاته التي درجتها 80 أو أكثر إن وجدت، فتكتب LEFT JOIN لأنك تريد كل الطلاب، ثم تضيف الشرط في WHERE:

```sql
SELECT s.name, e.course_id, e.grade
FROM students s
LEFT JOIN enrollments e ON e.student_id = s.id
WHERE e.grade >= 80
ORDER BY s.id;

```

```text
 name | course_id | grade
------+-----------+-------
 Sara |         1 |    90
 Sara |         2 |    85
 Lina |         2 |    95
 Huda |         3 |    88
(4 rows)

```

اختفى عمر وخالد وفيصل، مع أننا كتبنا LEFT JOIN. والسبب في ترتيب التنفيذ الذي رأيناه في [الجزء الأول](https://arabroot.io/articles/%D8%A3%D8%B3%D8%A7%D8%B3%D9%8A%D8%A7%D8%AA-sql-%D9%84%D9%84%D9%85%D8%B7%D9%88%D8%B1%D9%8A%D9%86/#logical-order): الربط يحدث أولاً فيضع NULL في `grade` لفيصل، ثم يأتي WHERE فيقارن NULL بالرقم 80 فتكون النتيجة «غير معروف» ويحذف الصف، فأصبح LEFT JOIN عملياً INNER JOIN. والحل أن تضع الشرط الذي يخص الجدول الأيمن في ON نفسها، فيصبح جزءاً من شرط المطابقة وليس شرطاً على النتيجة:

```sql
SELECT s.name, e.course_id, e.grade
FROM students s
LEFT JOIN enrollments e ON e.student_id = s.id AND e.grade >= 80
ORDER BY s.id, e.course_id;

```

```text
  name  | course_id | grade
--------+-----------+-------
 Sara   |         1 |    90
 Sara   |         2 |    85
 Omar   |      NULL |  NULL
 Lina   |         2 |    95
 Khalid |      NULL |  NULL
 Huda   |         3 |    88
 Faisal |      NULL |  NULL
(7 rows)

```

والقاعدة: الشرط على الجدول الأيسر مكانه WHERE، والشرط على الجدول الأيمن في LEFT JOIN مكانه ON، إلا إذا كنت تريد فعلاً أن تحذف من لا شريك له.

### تضاعف الصفوف Fan-out: عندما يكرر الربط أرقامك

هذا أخطر خطأ في التقارير، لأنه لا يعطي أي رسالة. لنفرض أن المدير يريد لكل دورة عدد الطلاب، والمبلغ المتوقع منهم وهو مجموع السعر، والمبلغ الذي دفعوه فعلاً. وأول ما يخطر لك أن تربط الدورات بالتسجيلات بالدفعات ثم تجمع، وسوف نشرح التجميع GROUP BY بالتفصيل في [القسم التالي](#group-by)، ولكن يكفي الآن أن تعرف أنه يخرج سطراً لكل دورة:

```sql
SELECT c.title,
       count(e.id)    AS students,
       sum(c.price)   AS expected,
       sum(p.amount)  AS paid
FROM courses c
JOIN enrollments e ON e.course_id = c.id
LEFT JOIN payments p ON p.enrollment_id = e.id
GROUP BY c.id, c.title
ORDER BY c.id;

```

```text
    title     | students | expected |  paid
--------------+----------+----------+---------
 Linux Basics |        2 |   600.00 |  600.00
 PostgreSQL   |        4 |  1800.00 | 1350.00
 Docker       |        3 |  1200.00 |  600.00
 Python       |        2 |   700.00 |  350.00
(4 rows)

```

الأرقام تبدو معقولة، ولكن دورة PostgreSQL فيها ثلاثة طلاب وليس أربعة، والمبلغ المتوقع 1350 وليس 1800\. ولكي ترى السبب انظر إلى الصفوف قبل التجميع:

```sql
SELECT e.id AS enrollment, c.title, c.price, p.amount, p.paid_on
FROM enrollments e
JOIN courses c ON c.id = e.course_id
JOIN payments p ON p.enrollment_id = e.id
WHERE c.title = 'PostgreSQL'
ORDER BY e.id, p.paid_on;

```

```text
 enrollment |   title    | price  | amount |  paid_on
------------+------------+--------+--------+------------
          2 | PostgreSQL | 450.00 | 225.00 | 2026-09-03
          2 | PostgreSQL | 450.00 | 225.00 | 2026-10-03
          5 | PostgreSQL | 450.00 | 450.00 | 2026-09-05
          8 | PostgreSQL | 450.00 | 450.00 | 2026-09-08
(4 rows)

```

سارة دفعت على دفعتين، فتكرر تسجيلها مرتين ومعه سعر الدورة مرتين، فعدها `count` مرتين وجمع `sum(c.price)` سعرها مرتين. أما `sum(p.amount)` فصحيح لأن كل دفعة ظهرت مرة واحدة. وهذا هو تضاعف الصفوف Fan-out: كلما ربطت جدولاً بجدول فيه عدة صفوف لكل صف، تكررت أعمدة الجدول الأول بعدد تلك الصفوف، وأي مجموع على تلك الأعمدة يتضاعف. والحل أن تجمع الجدول الذي فيه عدة صفوف أولاً في استعلام فرعي يعطي صفاً واحداً لكل تسجيل، ثم تربطه:

```sql
SELECT c.title,
       count(e.id)                     AS students,
       sum(c.price)                    AS expected,
       COALESCE(sum(pe.paid), 0)       AS paid
FROM courses c
JOIN enrollments e ON e.course_id = c.id
LEFT JOIN (SELECT enrollment_id, sum(amount) AS paid
           FROM payments
           GROUP BY enrollment_id) pe ON pe.enrollment_id = e.id
GROUP BY c.id, c.title
ORDER BY c.id;

```

```text
    title     | students | expected |  paid
--------------+----------+----------+---------
 Linux Basics |        2 |   600.00 |  600.00
 PostgreSQL   |        3 |  1350.00 | 1350.00
 Docker       |        2 |   800.00 |  600.00
 Python       |        2 |   700.00 |  350.00
(4 rows)

```

والصورة التالية تبين ما حدث في دورة PostgreSQL:

![ثلاث لوحات مرقمة. الأولى جدول enrollments فيه ثلاثة تسجيلات لدورة PostgreSQL لسارة ولينا وهدى بسعر 450، وجدول payments فيه أربع دفعات منها دفعتان لسارة 225 و225. الثانية بالأحمر نتيجة الربط أربعة صفوف وسارة مكررة مرتين، وتحتها count يساوي 4 بدل 3 وsum للسعر يساوي 1800 بدل 1350 وsum للدفعات 1350 صحيح. الثالثة بالأخضر بعد جمع الدفعات لكل تسجيل أولاً ثلاثة صفوف فقط، وcount يساوي 3 وsum للسعر 1350 وsum المدفوع 1350](https://arabroot.io/content/images/2026/10/sql-joins-03-fan-out.webp)

الدفعتان كررتا تسجيل سارة، فتضاعف سعر دورتها في المجموع

وقد يتساءل البعض: لماذا لا أكتب `count(DISTINCT e.id)` وأنتهي؟ والإجابة أنها تصلح العد فعلاً، ولكنها لا تصلح `sum(c.price)`، لأن `sum(DISTINCT c.price)` يحذف الأسعار المتساوية وإن كانت لطلاب مختلفين، فثلاثة طلاب في دورة سعرها 450 سوف تعطي 450 بدل 1350\. لذلك القاعدة: إذا ربطت أكثر من جدول «متعدد» واحد، فاجمع كل واحد منها في استعلام فرعي قبل الربط. وهناك خطأ آخر قريب منه هو الربط على العمود الخاطئ، فالمثال التالي يربط رقم الطالب برقم التسجيل بالكلمة USING التي تعني أن العمودين بنفس الاسم متساويان:

```sql
SELECT s.name, e.course_id
FROM students s
JOIN enrollments e USING (id)
ORDER BY s.id;

```

```text
  name  | course_id
--------+-----------
 Sara   |         1
 Omar   |         2
 Lina   |         1
 Khalid |         3
 Huda   |         2
 Faisal |         4
(6 rows)

```

النتيجة تبدو مقبولة تماماً، وهي خاطئة كلها تقريباً، ففيصل يظهر مسجلاً في Python وهو لم يسجل في شيء، والسبب أن `id` في الجدولين ليس نفس الشيء. لذلك اكتب شرط الربط بالأسماء الكاملة `e.student_id = s.id`، وراجع دائماً صفاً أو صفين تعرفهما في النتيجة.

## التجميع GROUP BY والدوال التجميعية

التقارير غالباً أسئلة من نوع «كم» و«كم لكل»، والدوال التجميعية Aggregate Functions تحسب رقماً واحداً من مجموعة صفوف، وأشهرها خمس من [توثيق الدوال التجميعية](https://www.postgresql.org/docs/18/functions-aggregate.html?ref=arabroot.io): العد `count` والمجموع `sum` والمتوسط `avg` والأصغر `min` والأكبر `max`:

```sql
SELECT sum(amount) AS total, avg(amount) AS average, min(amount) AS smallest, max(amount) AS largest, min(paid_on) AS first_payment
FROM payments;

```

```text
  total  |       average        | smallest | largest | first_payment
---------+----------------------+----------+---------+---------------
 2900.00 | 290.0000000000000000 |   200.00 |  450.00 | 2026-09-01
(1 row)

```

لاحظ أن `min` و`max` تعملان على التواريخ والنصوص أيضاً، وليس على الأرقام فقط. والتجميع GROUP BY يضع الصفوف التي لها نفس القيمة في مجموعة، ثم تحسب الدوال رقماً لكل مجموعة، فالمثال التالي يعد الطلاب في كل مدينة:

```sql
SELECT city, count(*) AS students
FROM students
GROUP BY city
ORDER BY students DESC, city;

```

```text
  city  | students
--------+----------
 Jeddah |        2
 Riyadh |        2
 Abha   |        1
 Dammam |        1
(4 rows)

```

ومع الربط يصبح التجميع أداة التقارير الأساسية، فالمثال التالي يعطي لكل دورة عدد طلابها وعدد من لهم درجة ومتوسط الدرجات:

```sql
SELECT c.title,
       count(e.id)         AS students,
       count(e.grade)      AS graded,
       round(avg(e.grade)) AS avg_grade
FROM courses c
LEFT JOIN enrollments e ON e.course_id = c.id
GROUP BY c.id, c.title
ORDER BY students DESC, c.title;

```

```text
    title     | students | graded | avg_grade
--------------+----------+--------+-----------
 PostgreSQL   |        3 |      2 |        90
 Docker       |        2 |      1 |        88
 Linux Basics |        2 |      2 |        80
 Python       |        2 |      1 |        60
 Kubernetes   |        0 |      0 |      NULL
(5 rows)

```

في الاستعلام أعلاه لاحظ التالي:

- استخدمنا LEFT JOIN حتى تظهر Kubernetes بصفر طلاب، ولو استخدمنا JOIN لاختفت من التقرير، وهو خطأ لا تراه إلا إذا كنت تعرف أن الدورات خمس.
- متوسط PostgreSQL هو 90 وليس 60، لأن `avg` تجاهلت درجة هدى المجهولة وحسبت (85 + 95) / 2، وهذا موضوع القسم التالي.
- جمعنا بـ `c.id` و`c.title` معاً، والسبب أن دورتين قد يكون لهما نفس العنوان، أما الرقم فلا يتكرر.

والصورة التالية تبين ما يحدث في الخطوات الثلاث:

![ثلاث مراحل مرقمة: في الأولى تسعة صفوف بعد الربط، كل صف فيه اسم الدورة والدرجة وبعضها NULL، وفي الثانية أربع مجموعات ملونة لكل دورة: Linux Basics فيها 90 و70، وPostgreSQL فيها 85 و95 وNULL، وDocker فيها NULL و88، وPython فيها NULL و60، وفي الثالثة سطر واحد لكل مجموعة فيه count وavg. وفي الجانب ملاحظة أن count(*) تعد الصفوف وأن count(grade) وavg(grade) تتجاهلان NULL، وأن WHERE تحذف صفوفاً قبل التجميع وHAVING تحذف مجموعات بعده](https://arabroot.io/content/images/2026/10/sql-basics-06-group-by.webp)

كل مجموعة تصبح سطراً واحداً، وما داخلها لم يعد ظاهراً إلا عبر الدوال التجميعية

والآن لنكسر القاعدة عن قصد، ونطلب اسم الدورة ونحن نجمع حسب المسار track فقط:

```sql
SELECT c.track, c.title, count(*)
FROM courses c
JOIN enrollments e ON e.course_id = c.id
GROUP BY c.track;

```

```text
ERROR:  column "c.title" must appear in the GROUP BY clause or be used in an aggregate function
LINE 1: SELECT c.track, c.title, count(*)
                        ^

```

والخطأ منطقي: مسار ops فيه ثلاث دورات، فأي عنوان تعرضه قاعدة البيانات في السطر الواحد؟ لذلك كل عمود في SELECT إما أن يكون في GROUP BY وإما داخل دالة تجميع. وكانت الإصدارات القديمة من MySQL تقبل هذه الجملة وتعرض قيمة غير محددة من المجموعة، أما الآن فالوضع `ONLY_FULL_GROUP_BY` مفعل افتراضياً، وقد رأيناه في قيمة `@@sql_mode` على MySQL 8.4، ويذكره [توثيق أوضاع SQL في MySQL](https://dev.mysql.com/doc/refman/8.4/en/sql-mode.html?ref=arabroot.io) ضمن الأوضاع الافتراضية. ويمكنك أن تجمع بعبارة وليس بعمود فقط، مثل الإيراد الشهري من الدفعات:

```sql
SELECT to_char(paid_on, 'YYYY-MM') AS month, count(*) AS payments, sum(amount) AS revenue
FROM payments
GROUP BY to_char(paid_on, 'YYYY-MM')
ORDER BY month;

```

```text
  month  | payments | revenue
---------+----------+---------
 2026-09 |        8 | 2475.00
 2026-10 |        2 |  425.00
(2 rows)

```

### القيمة NULL في الدوال التجميعية

الدالة `count(*)` تعد الصفوف مهما كان فيها، أما `count(column)` فتعد الصفوف التي ليست قيمة العمود فيها NULL، و`count(DISTINCT column)` تعد القيم المختلفة:

```sql
SELECT count(*)              AS all_rows,
       count(phone)          AS with_phone,
       count(DISTINCT city)  AS cities
FROM students;

```

```text
 all_rows | with_phone | cities
----------+------------+--------
        6 |          4 |      4
(1 row)

```

وكذلك `sum` و`avg` و`min` و`max` تتجاهل NULL، وهذا يغير المتوسط:

```sql
SELECT count(*)                          AS all_rows,
       count(grade)                      AS graded,
       round(avg(grade), 1)              AS avg_grade,
       round(avg(COALESCE(grade, 0)), 1) AS avg_as_zero
FROM enrollments;

```

```text
 all_rows | graded | avg_grade | avg_as_zero
----------+--------+-----------+-------------
        9 |      6 |      81.3 |        54.2
(1 row)

```

أي الرقمين هو المتوسط الصحيح؟ هذا قرار في منطق العمل وليس في SQL: إذا كانت NULL تعني «لم ينه الدورة بعد» فالرقم 81.3 هو متوسط من أنهوا، أما إذا عاملتها كصفر بالدالة COALESCE فقد حسبت من لم يختبر كأنه رسب، والمهم أن تختار عن قصد ولا تترك الاختيار للصدفة. والفخ الثاني أن `sum` على صفر صفوف لا تعطي صفراً وإنما NULL، فإذا طلبت دفعات ديسمبر ولم يدفع أحد:

```sql
SELECT sum(amount) AS total, COALESCE(sum(amount), 0) AS total_or_zero, count(*) AS n
FROM payments
WHERE paid_on > '2026-12-01';

```

```text
 total | total_or_zero | n
-------+---------------+---
  NULL |             0 | 0
(1 row)

```

و`count` هي الوحيدة التي تعطي صفراً، ويذكر [توثيق الدوال التجميعية](https://www.postgresql.org/docs/18/functions-aggregate.html?ref=arabroot.io) ذلك صراحة وينصح بـ COALESCE. والفخ الثالث هو `count(*)` مع LEFT JOIN، فلو عددنا طلاب كل دورة بها بدل `count(e.id)`:

```sql
SELECT c.title, count(*) AS students
FROM courses c
LEFT JOIN enrollments e ON e.course_id = c.id
GROUP BY c.id, c.title
ORDER BY c.id;

```

```text
    title     | students
--------------+----------
 Linux Basics |        2
 PostgreSQL   |        3
 Docker       |        2
 Python       |        2
 Kubernetes   |        1
(5 rows)

```

أصبح في Kubernetes طالب واحد، والسبب أن LEFT JOIN أبقى لها صفاً واحداً فيه NULL في أعمدة التسجيل، فالدالة `count(*)` عدت ذلك الصف، أما `count(e.id)` فتجاهلت NULL وأعطت صفراً. لذلك مع LEFT JOIN عد دائماً عموداً من الجدول الأيمن. وإذا أردت أن تعد بشرط داخل نفس المجموعة، مثل عدد التسجيلات التي بلا درجة في كل دورة، فالطريقة المختصرة في PostgreSQL هي FILTER، والطريقة التي تعمل في كل المحركات هي CASE داخل sum:

```sql
SELECT c.title,
       count(*) FILTER (WHERE e.grade IS NULL)       AS in_progress,
       sum(CASE WHEN e.grade IS NULL THEN 1 ELSE 0 END) AS in_progress_portable
FROM courses c
JOIN enrollments e ON e.course_id = c.id
GROUP BY c.id, c.title
ORDER BY c.id;

```

```text
    title     | in_progress | in_progress_portable
--------------+-------------+----------------------
 Linux Basics |           0 |                    0
 PostgreSQL   |           1 |                    1
 Docker       |           1 |                    1
 Python       |           1 |                    1
(4 rows)

```

### الشرط على المجموعات HAVING

الفرق بين WHERE وHAVING شرحه [درس التجميع في توثيق PostgreSQL](https://www.postgresql.org/docs/18/tutorial-agg.html?ref=arabroot.io) بأن WHERE تختار الصفوف قبل التجميع وHAVING تختار المجموعات بعده. والمثال التالي يحسب إيراد كل مسار من التسجيلات التي تمت منذ 5 سبتمبر، ثم يبقي المسارات التي زاد إيرادها عن 750:

```sql
SELECT c.track, count(*) AS enrollments, sum(c.price) AS revenue
FROM enrollments e
JOIN courses c ON c.id = e.course_id
WHERE e.enrolled_on >= '2026-09-05'
GROUP BY c.track
HAVING sum(c.price) > 750
ORDER BY revenue DESC;

```

```text
 track | enrollments | revenue
-------+-------------+---------
 data  |           2 |  900.00
 ops   |           2 |  800.00
(2 rows)

```

الشرط على التاريخ مكانه WHERE لأنه يخص كل صف، والشرط على المجموع مكانه HAVING لأنه لا يوجد إلا بعد التجميع، ومسار dev فيه تسجيلان مجموعهما 700 فحذفته HAVING. ولو وضعت شرط التاريخ في HAVING لما أعطاك خطأ دائماً، ولكنك تجعل قاعدة البيانات تجمع صفوفاً كان يمكن حذفها قبل ذلك. وتذكر من الجزء الأول أن الاسم المستعار `revenue` لا يعمل في HAVING، لأنها تأتي قبل SELECT في ترتيب التنفيذ، لذلك كررنا `sum(c.price)`، واستخدمنا الاسم في ORDER BY.

## الاستعلامات الفرعية Subqueries

الاستعلام الفرعي Subquery هو SELECT داخل جملة أخرى بين قوسين، وله أربعة أشكال بحسب ما يرجعه وأين تضعه، والمرجع هو [توثيق الاستعلامات الفرعية](https://www.postgresql.org/docs/18/functions-subquery.html?ref=arabroot.io). والشكل الأول يرجع قيمة واحدة Scalar Subquery تستخدمها كأنها رقم، مثل الدورات الأغلى من متوسط الأسعار:

```sql
SELECT title, price
FROM courses
WHERE price > (SELECT avg(price) FROM courses)
ORDER BY price;

```

```text
   title    | price
------------+--------
 PostgreSQL | 450.00
 Kubernetes | 600.00
(2 rows)

```

ويمكنك أن تضعه في SELECT نفسها، فتظهر القيمة بجانب كل صف:

```sql
SELECT title, price,
       price - (SELECT avg(price) FROM courses) AS diff_from_avg
FROM courses
ORDER BY id;

```

```text
    title     | price  |     diff_from_avg
--------------+--------+-----------------------
 Linux Basics | 300.00 | -120.0000000000000000
 PostgreSQL   | 450.00 |   30.0000000000000000
 Docker       | 400.00 |  -20.0000000000000000
 Python       | 350.00 |  -70.0000000000000000
 Kubernetes   | 600.00 |  180.0000000000000000
(5 rows)

```

والشرط هنا أن يرجع الاستعلام الفرعي صفاً واحداً فقط، فإذا أرجع أكثر رفضت قاعدة البيانات الجملة:

```sql
SELECT title FROM courses WHERE price = (SELECT price FROM courses WHERE track = 'ops');

```

```text
ERROR:  more than one row returned by a subquery used as an expression

```

والشكل الثاني IN، وهو يرجع عموداً من عدة قيم ويسأل «هل القيمة في هذه القائمة؟»، مثل الطلاب المسجلين في أي دورة من مسار العمليات:

```sql
SELECT name
FROM students
WHERE id IN (SELECT e.student_id
             FROM enrollments e
             JOIN courses c ON c.id = e.course_id
             WHERE c.track = 'ops')
ORDER BY name;

```

```text
 name
------
 Huda
 Omar
 Sara
(3 rows)

```

لاحظ أن عمر مسجل في دورتين من ops، ومع ذلك ظهر مرة واحدة، بخلاف JOIN الذي كان سيكرره، وهذه ميزة IN وEXISTS عندما تريد «من له شيء» وليس «كل الأشياء». والشكل الثالث EXISTS، ويسأل «هل يوجد صف واحد على الأقل يحقق الشرط؟»:

```sql
SELECT name
FROM students s
WHERE EXISTS (SELECT 1
              FROM enrollments e
              JOIN courses c ON c.id = e.course_id
              WHERE e.student_id = s.id AND c.track = 'ops')
ORDER BY name;

```

```text
 name
------
 Huda
 Omar
 Sara
(3 rows)

```

لاحظ أن الاستعلام الفرعي يشير إلى `s.id` من الاستعلام الخارجي، لذلك يسمى استعلاماً مرتبطاً Correlated Subquery، ومعناه المنطقي أنه يحسب مرة لكل طالب، أما ما يفعله المخطط فعلاً فقد يكون أذكى من ذلك. والكلمة `SELECT 1` داخله لا تهم، لأن EXISTS تنظر هل يوجد صف وليس ما فيه. وعكسه `NOT EXISTS` هو الطريقة الآمنة لإيجاد من ليس له شيء:

```sql
SELECT name
FROM students s
WHERE NOT EXISTS (SELECT 1 FROM enrollments e WHERE e.student_id = s.id);

```

```text
  name
--------
 Faisal
(1 row)

```

وقد يتساءل البعض: لماذا الآمنة، وأين الخطر؟ والإجابة في NOT IN مع NULL، فلنفرض أنك تريد المدربين الذين لا يدرسون أي دورة، فتكتب:

```sql
SELECT name FROM instructors
WHERE id NOT IN (SELECT instructor_id FROM courses);

```

```text
 name
------
(0 rows)

```

صفر صفوف، مع أن Nora وMajed لا يدرسان شيئاً. والسبب أن القائمة فيها NULL من دورة Kubernetes، فالشرط `1 NOT IN (4, 3, 5, 6, NULL)` يعني «1 لا يساوي 4 و1 لا يساوي 3 … و1 لا يساوي NULL»، والجزء الأخير نتيجته «غير معروف» كما رأينا في [المنطق الثلاثي](https://arabroot.io/articles/%D8%A3%D8%B3%D8%A7%D8%B3%D9%8A%D8%A7%D8%AA-sql-%D9%84%D9%84%D9%85%D8%B7%D9%88%D8%B1%D9%8A%D9%86/#null)، فيصبح الشرط كله «غير معروف» ويحذف كل صف. والحل NOT EXISTS التي لا تتأثر بـ NULL، أو أن تحذف NULL من القائمة بنفسك:

```sql
SELECT name FROM instructors i
WHERE NOT EXISTS (SELECT 1 FROM courses c WHERE c.instructor_id = i.id)
ORDER BY id;

```

```text
 name
-------
 Nora
 Majed
(2 rows)

```

والاستعلام المرتبط مفيد أيضاً في المقارنة بمجموعة الصف نفسه، مثل الطلاب الذين درجتهم أعلى من متوسط دورتهم:

```sql
SELECT s.name, e.grade,
       (SELECT round(avg(e2.grade)) FROM enrollments e2 WHERE e2.course_id = e.course_id) AS course_avg
FROM enrollments e
JOIN students s ON s.id = e.student_id
WHERE e.grade > (SELECT avg(e2.grade) FROM enrollments e2 WHERE e2.course_id = e.course_id)
ORDER BY s.name;

```

```text
 name | grade | course_avg
------+-------+------------
 Lina |    95 |         90
 Sara |    90 |         80
(2 rows)

```

وهذا النوع من الأسئلة له حل أوضح بدوال النوافذ في الجزء الثالث. والشكل الرابع هو الاستعلام الفرعي في FROM، ويسمى الجدول المشتق Derived Table، وهو نتيجة تعاملها كأنها جدول، وقد استخدمناه في حل تضاعف الصفوف، وهذا مثال آخر يبقي المسارات التي فيها أكثر من دورة:

```sql
SELECT t.track, t.courses, t.avg_price
FROM (SELECT track, count(*) AS courses, round(avg(price)) AS avg_price
      FROM courses
      GROUP BY track) t
WHERE t.courses > 1;

```

```text
 track | courses | avg_price
-------+---------+-----------
 ops   |       3 |       433
(1 row)

```

والجدول المشتق يجب أن يكون له اسم مستعار مثل `t`. وعندما يكبر الاستعلام الفرعي ويصعب قراءته داخل جملة أخرى، فالأفضل أن تعطيه اسماً في بداية الجملة بالكلمة WITH، وهذا موضوع [الجزء الثالث](https://arabroot.io/articles/%D8%AA%D8%B9%D9%84%D9%85-sql-3-cte-%D9%88%D8%AF%D9%88%D8%A7%D9%84-%D8%A7%D9%84%D9%86%D9%88%D8%A7%D9%81%D8%B0-%D9%88%D8%A7%D9%84%D9%85%D8%B9%D8%A7%D9%85%D9%84%D8%A7%D8%AA/#cte).

## عمليات المجموعات UNION وINTERSECT وEXCEPT

الربط يضع أعمدة جدولين بجانب بعض، أما عمليات المجموعات فتضع نتيجتين فوق بعض، والشرط أن يكون للنتيجتين نفس عدد الأعمدة وأنواع متوافقة، كما يشرح [توثيق دمج الاستعلامات](https://www.postgresql.org/docs/18/queries-union.html?ref=arabroot.io)، وهنا تصلح دوائر Venn فعلاً. والمثال التالي يجمع الطلاب الذين من الرياض مع الطلاب الذين لهم درجة 90 أو أكثر:

```sql
SELECT name FROM students WHERE city = 'Riyadh'
UNION
SELECT s.name FROM students s JOIN enrollments e ON e.student_id = s.id WHERE e.grade >= 90
ORDER BY name;

SELECT name FROM students WHERE city = 'Riyadh'
UNION ALL
SELECT s.name FROM students s JOIN enrollments e ON e.student_id = s.id WHERE e.grade >= 90
ORDER BY name;

```

```text
 name
------
 Lina
 Sara
(2 rows)

 name
------
 Lina
 Lina
 Sara
 Sara
(4 rows)

```

سارة ولينا من الرياض ولهما درجة 90 أو أكثر، فظهرتا في النتيجتين. والكلمة UNION تحذف التكرار، أما UNION ALL فتبقي كل الصفوف، وهي أسرع لأنها لا تحتاج أن تقارن الصفوف ببعضها، لذلك استخدم UNION ALL كلما كنت تعرف أن النتيجتين لا تتداخلان أو كنت تريد التكرار، مثل قائمة واحدة من الطلاب والمدربين:

```sql
SELECT name, 'student' AS role FROM students WHERE city = 'Riyadh'
UNION ALL
SELECT name, 'instructor' FROM instructors WHERE manager_id IS NULL
ORDER BY name;

```

```text
 name |    role
------+------------
 Lina | student
 Nora | instructor
 Sara | student
(3 rows)

```

وأسماء الأعمدة تأتي من الاستعلام الأول، وORDER BY في آخر الجملة يرتب النتيجة كلها. أما INTERSECT فتعطي ما يوجد في النتيجتين معاً، وEXCEPT ما يوجد في الأولى وليس في الثانية، فالمثالان التاليان يجدان الطلاب المسجلين في مساري العمليات والبيانات معاً، ثم المسجلين في العمليات دون البيانات:

```sql
SELECT s.name FROM students s JOIN enrollments e ON e.student_id = s.id JOIN courses c ON c.id = e.course_id WHERE c.track = 'ops'
INTERSECT
SELECT s.name FROM students s JOIN enrollments e ON e.student_id = s.id JOIN courses c ON c.id = e.course_id WHERE c.track = 'data'
ORDER BY name;

SELECT s.name FROM students s JOIN enrollments e ON e.student_id = s.id JOIN courses c ON c.id = e.course_id WHERE c.track = 'ops'
EXCEPT
SELECT s.name FROM students s JOIN enrollments e ON e.student_id = s.id JOIN courses c ON c.id = e.course_id WHERE c.track = 'data'
ORDER BY name;

```

```text
 name
------
 Huda
 Sara
(2 rows)

 name
------
 Omar
(1 row)

```

وإذا اختلف عدد الأعمدة رفضت قاعدة البيانات الجملة:

```sql
SELECT name, city FROM students UNION SELECT name FROM instructors;

```

```text
ERROR:  each UNION query must have the same number of columns
LINE 1: SELECT name, city FROM students UNION SELECT name FROM instr...
                                                     ^

```

وفي MySQL أضيفت INTERSECT وEXCEPT في الإصدار 8.0.31 كما يذكر [توثيق INTERSECT في MySQL 8.0](https://dev.mysql.com/doc/refman/8.0/en/intersect.html?ref=arabroot.io)، وقد عملتا على MySQL 8.4، أما قبل ذلك فكان المطور يكتب بدلهما EXISTS وNOT EXISTS.

## أخطاء شائعة في الربط والتجميع

أغلب أخطاء هذا الجزء صامتة، أي أنها تعطي نتيجة تبدو صحيحة، وهذه قائمتها مع المكان الذي رأيت فيه كل واحد منها:

- INNER JOIN يحذف من لا شريك له، فدورة بلا مدرب أو طالب بلا تسجيل يختفي من التقرير دون تحذير، فإذا كان السؤال عن «كل» شيء فابدأ بـ LEFT JOIN من الجدول الأساسي، كما في [الربط الداخلي](#inner-join).
- شرط على الجدول الأيمن في WHERE يحول LEFT JOIN إلى INNER JOIN، وضعه في ON كما في [هذا القسم](#left-join-where).
- ربط أكثر من جدول «متعدد» ثم الجمع يضاعف الأرقام، فاجمع كل جدول في استعلام فرعي قبل الربط كما في [تضاعف الصفوف](#fan-out).
- الربط على العمود الخاطئ مثل `USING (id)` يعطي نتيجة كاملة الشكل وخاطئة المعنى.
- العد بـ `count(*)` بعد LEFT JOIN يعد الصف الفارغ واحداً، فعد عموداً من الجدول الأيمن.
- الدالة `sum` على صفر صفوف تعطي NULL، فاستخدم COALESCE.
- NOT IN مع قائمة فيها NULL تعطي صفر صفوف، فاستخدم NOT EXISTS.
- DISTINCT لإخفاء التكرار بعد الربط يخفي العرض ولا يصلح السبب، فابحث عن الربط الذي كرر الصفوف.

والعادة التي تحميك من كل ما سبق بسيطة: قبل أي تقرير اكتب كم صفاً تتوقع وما الرقم التقريبي، ثم راجع صفاً أو صفين تعرفهما بيدك.

## تمارين

حل هذه التمارين على نفس قاعدة البيانات، واكتب قبل التشغيل كم صفاً تتوقع.

**التمرين 1:** اعرض كل طالب وعدد الدورات التي سجل فيها، بما في ذلك من لم يسجل في شيء، الأكثر دورات أولاً.

الحل Answer 

```sql
SELECT s.name, count(e.id) AS courses
FROM students s
LEFT JOIN enrollments e ON e.student_id = s.id
GROUP BY s.id, s.name
ORDER BY courses DESC, s.name;

```

```text
  name  | courses
--------+---------
 Huda   |       2
 Lina   |       2
 Omar   |       2
 Sara   |       2
 Khalid |       1
 Faisal |       0
(6 rows)

```

لو كتبت `count(*)` لظهر فيصل بدورة واحدة.

**التمرين 2:** اعرض الدورات التي يدرسها مدربون مديرهم Majed، مع اسم المدرب.

الحل Answer 

```sql
SELECT c.title, i.name AS instructor
FROM courses c
JOIN instructors i ON i.id = c.instructor_id
JOIN instructors m ON m.id = i.manager_id
WHERE m.name = 'Majed'
ORDER BY c.title;

```

```text
    title     | instructor
--------------+------------
 Docker       | Yasser
 Linux Basics | Tariq
(2 rows)

```

**التمرين 3:** اعرض مجموع ما دفعه كل طالب، الأعلى أولاً.

الحل Answer 

```sql
SELECT s.name, sum(p.amount) AS paid
FROM students s
JOIN enrollments e ON e.student_id = s.id
JOIN payments p ON p.enrollment_id = e.id
GROUP BY s.id, s.name
ORDER BY paid DESC, s.name;

```

```text
  name  |  paid
--------+--------
 Huda   | 850.00
 Sara   | 750.00
 Omar   | 500.00
 Lina   | 450.00
 Khalid | 350.00
(5 rows)

```

هنا لا يوجد تضاعف، لأن الجدول «المتعدد» الوحيد هو الدفعات، والمجموع على أعمدتها هي.

**التمرين 4:** اعرض الدورات التي فيها أكثر من طالبين.

الحل Answer 

```sql
SELECT c.title, count(*) AS students
FROM courses c
JOIN enrollments e ON e.course_id = c.id
GROUP BY c.id, c.title
HAVING count(*) > 2;

```

```text
   title    | students
------------+----------
 PostgreSQL |        3
(1 row)

```

**التمرين 5:** اعرض التسجيلات التي لم يدفع عنها أي مبلغ، مع اسم الطالب وعنوان الدورة.

الحل Answer 

```sql
SELECT s.name, c.title
FROM enrollments e
JOIN students s ON s.id = e.student_id
JOIN courses c ON c.id = e.course_id
WHERE NOT EXISTS (SELECT 1 FROM payments p WHERE p.enrollment_id = e.id);

```

```text
 name | title
------+--------
 Lina | Python
(1 row)

```

**التمرين 6:** اعرض المبلغ المتبقي على كل تسجيل لم يدفع كاملاً: السعر والمدفوع والباقي، الأكبر باقياً أولاً.

الحل Answer 

```sql
SELECT s.name, c.title, c.price,
       COALESCE(pe.paid, 0)           AS paid,
       c.price - COALESCE(pe.paid, 0) AS balance
FROM enrollments e
JOIN students s ON s.id = e.student_id
JOIN courses  c ON c.id = e.course_id
LEFT JOIN (SELECT enrollment_id, sum(amount) AS paid
           FROM payments
           GROUP BY enrollment_id) pe ON pe.enrollment_id = e.id
WHERE c.price - COALESCE(pe.paid, 0) > 0
ORDER BY balance DESC;

```

```text
 name | title  | price  |  paid  | balance
------+--------+--------+--------+---------
 Lina | Python | 350.00 |      0 |  350.00
 Omar | Docker | 400.00 | 200.00 |  200.00
(2 rows)

```

جمعنا الدفعات لكل تسجيل أولاً حتى لا يتضاعف السعر، واستخدمنا LEFT JOIN حتى تظهر لينا التي لم تدفع شيئاً.

**التمرين 7:** اعرض المدن التي فيها طلاب ولكن لا أحد منهم مسجل في أي دورة، بعملية مجموعات.

الحل Answer 

```sql
SELECT city FROM students
EXCEPT
SELECT s.city FROM students s JOIN enrollments e ON e.student_id = s.id;

```

```text
 city
------
 Abha
(1 row)

```

## الخلاصة

وصلنا لنهاية الجزء الثاني، وأهم ما تخرج به منه:

- الربط يبني أزواجاً من الصفوف، فيتكرر صف الجدول الأول بعدد شركائه، لذلك عد الصفوف ولا تتخيل دوائر Venn.
- INNER JOIN يحذف من لا شريك له، وLEFT JOIN يبقيه مع NULL، وFULL JOIN يبقي الطرفين، وCROSS JOIN يعطي كل التركيبات، والربط الذاتي هو ربط الجدول بنفسه باسمين.
- الشرط على الجدول الأيمن في LEFT JOIN مكانه ON، وجمع جدولين «متعددين» في نفس الربط يضاعف الأرقام.
- كل عمود في SELECT مع GROUP BY إما في GROUP BY وإما داخل دالة تجميع، وWHERE تختار الصفوف قبل التجميع وHAVING تختار المجموعات بعده.
- الدوال التجميعية تتجاهل NULL، و`count(*)` تعد الصفوف، و`sum` على لا شيء تعطي NULL.
- EXISTS وNOT EXISTS أوضح وأأمن من IN وNOT IN عندما تكون القائمة من جدول قد يكون فيه NULL.
- UNION تحذف التكرار وUNION ALL تبقيه وهي أسرع، وINTERSECT وEXCEPT تعطيان المشترك والفرق.

وفي الجزء التالي سوف نعطي الاستعلامات الطويلة أسماء بالكلمة WITH، ونبني الهيكل الإداري كاملاً بالاستعلام التكراري، ونحسب الترتيب والمجموع التراكمي بدوال النوافذ، ثم نرى ماذا يحدث عندما تعمل جلستان على نفس البيانات في نفس اللحظة. ولتطبيق هذا الجزء على قاعدة بيانات أكبر فيها مئتا ألف طلب، حل [المستوى الثاني من تمارين Kata في SQL](https://arabroot.io/articles/%D8%AA%D9%85%D8%A7%D8%B1%D9%8A%D9%86-kata-%D9%81%D9%8A-sql-%D9%88%D9%82%D9%88%D8%A7%D8%B9%D8%AF-%D8%A7%D9%84%D8%A8%D9%8A%D8%A7%D9%86%D8%A7%D8%AA/#level-2)، ففيه فخ LEFT JOIN وفخ NOT IN على بيانات أخرى:

[![](https://arabroot.io/content/images/2026/10/sql-basics-cover-cover.webp)دليلتعلم SQL (1): ما هو SQL وكيف تكتب أول استعلامتتعلم SQL من الصفر: ما هي قاعدة البيانات العلائقية، وكيف تنشئ الجداول وتضيف البيانات وتكتب أول استعلام بأمان.](https://arabroot.io/articles/%D8%A3%D8%B3%D8%A7%D8%B3%D9%8A%D8%A7%D8%AA-sql-%D9%84%D9%84%D9%85%D8%B7%D9%88%D8%B1%D9%8A%D9%86/)[![](https://arabroot.io/content/images/2026/10/sql-advanced-cover-cover.webp)دليلتعلم SQL (3): CTE ودوال النوافذ والمعاملاتكيف تكتب استعلامات أوضح بجمل WITH ودوال النوافذ، وتحمي بياناتك بالمعاملات ومستويات العزل ومن حقن SQL.](https://arabroot.io/articles/%D8%AA%D8%B9%D9%84%D9%85-sql-3-cte-%D9%88%D8%AF%D9%88%D8%A7%D9%84-%D8%A7%D9%84%D9%86%D9%88%D8%A7%D9%81%D8%B0-%D9%88%D8%A7%D9%84%D9%85%D8%B9%D8%A7%D9%85%D9%84%D8%A7%D8%AA/)[![](https://arabroot.io/content/images/2026/10/katas-sql-cover-cover.webp)دليلتمارين Kata في SQL بثلاثة مستويات مع الحلولتمارين SQL تحلها بيدك دون ذكاء اصطناعي على متجر من مئتي ألف طلب، من فخاخ LEFT JOIN إلى اختيار الفهرس المناسب.](https://arabroot.io/articles/%D8%AA%D9%85%D8%A7%D8%B1%D9%8A%D9%86-kata-%D9%81%D9%8A-sql-%D9%88%D9%82%D9%88%D8%A7%D8%B9%D8%AF-%D8%A7%D9%84%D8%A8%D9%8A%D8%A7%D9%86%D8%A7%D8%AA/)[![](https://arabroot.io/content/images/2026/10/db-design-cover-cover.webp)دليلتصميم قواعد البيانات: التطبيع Normalization ومتى تكسرهلماذا يفشل الجدول الواحد الكبير، وكيف تقسمه إلى جداول لا تناقض نفسها، وما الذي يتغير عندما تكبر البيانات.](https://arabroot.io/articles/%D8%AA%D8%B5%D9%85%D9%8A%D9%85-%D9%82%D9%88%D8%A7%D8%B9%D8%AF-%D8%A7%D9%84%D8%A8%D9%8A%D8%A7%D9%86%D8%A7%D8%AA-%D8%A7%D9%84%D8%AA%D8%B7%D8%A8%D9%8A%D8%B9-%D9%88%D8%A7%D9%84%D8%AA%D9%88%D8%B3%D8%B9/)

## سجل التحديثات

- أكتوبر 2026: كتابة المقال كجزء ثان من سلسلة «تعلم SQL»، ونقل الربط والتجميع والاستعلامات الفرعية إليه من الجزء الأول بعد توسيعها، وتنفيذ كل الاستعلامات على PostgreSQL 18.6 (postgres:18.6، Debian 18.6-1.pgdg13+2) في Docker Engine 29.8.0 (Docker Desktop)، والمقارنات على MySQL 8.4.11 وSQL Server 2022 (16.0.4295.3، CU27) وSQLite 3.49.1.