EduFlow
SQL للمحلّلين
⁦0%⁩

الوحدة 3 · SQL التحليلي

دوال النافذة للترتيب

17 دقراءة 4 دتمرين SQL

الدرس في لمحة

رتّب الصفوف داخل المجموعات باستخدام ROW_NUMBER وRANK وDENSE_RANK مع OVER (PARTITION BY … ORDER BY …)، وأجب عن أسئلة «أعلى N في كل مجموعة» و«الحدث الأول» دون أن تفقد الصفوف التفصيلية.

  1. 1إبقاء الصفوف مع النظر عبرها
  2. 2ROW_NUMBER وRANK وDENSE_RANK
  3. 3أعلى N في كل مجموعة
  4. 4الأحداث الأولى: صف واحد لكل عميل
  5. 5شرائح بدل المواضع

لا فيديو هنا، بل الشيء الحقيقي: درس مكتوب وأمثلة قابلة للتشغيل واختبار وتدريب.

تريد هدى أن تعرض المنتجين الأكثر مبيعًا من كل فئة في النشرة البريدية لفصل الربيع. يمكنك الحصول على الإيراد لكل منتج بـ GROUP BY. لكن «أعلى اثنين داخل كل فئة» يحتاج إلى شيء لا يستطيعه GROUP BY: ترتيب الصفوف مقابل جيرانها مع إبقاء كل صف. هذه هي مهمة دوال النافذة (window functions)، وحين تمتلكها، تصبح فئة كاملة من الأسئلة قصيرة.

إبقاء الصفوف مع النظر عبرها

يضغط GROUP BY كل مجموعة في صف واحد. أما دالة النافذة فتحسب قيمة عبر مجموعة من الصفوف، هي النافذة، وتلحقها بكل صف، دون أن تضغط شيئًا.

ثلاثة منتجات من فئة Yoga ومنتج واحد من فئة Kitchen مع إيراداتها في 2025. يعيد GROUP BY category صفّين، صفًا لكل فئة. ويعيد RANK على partition حسب الفئة الصفوف الأربعة كلها، مع ترتيب كل صف داخل فئته. إيراد المنتجات Yoga Mat 4665 Yoga Cushion 3923 Yoga Block 1656 Kitchen Board 2039 GROUP BY Yoga 10244 Kitchen 2039 RANK() OVER Yoga Mat 1 Yoga Cushion 2 Yoga Block 3 Kitchen Board 1
GROUP BY يضغط المجموعة؛ ودالة النافذة تضيف قيمة إلى كل صف

الصياغة دالة يتبعها OVER (...). داخل القوسين، يقسم PARTITION BY الصفوف إلى مجموعات، ويُعاد بدء الحساب في كل مجموعة؛ ويحدّد ORDER BY الترتيب الذي تسير به الدالة. إذا حذفت PARTITION BY، أصبحت النتيجة كلها نافذة واحدة.

ROW_NUMBER وRANK وDENSE_RANK

لا تختلف دوال الترتيب الثلاث إلا في طريقة تعاملها مع التعادل. هؤلاء هم أكثر المشترين تكرارًا في الإمارات في 2025:

SQL
WITH uae_orders AS (
  SELECT o.customer_id, COUNT(*) AS orders
  FROM orders AS o
  JOIN customers AS c ON c.customer_id = o.customer_id
  WHERE c.country = 'United Arab Emirates'
    AND o.status <> 'cancelled'
    AND o.order_date >= '2025-01-01' AND o.order_date < '2026-01-01'
  GROUP BY o.customer_id
)
SELECT
  customer_id,
  orders,
  ROW_NUMBER() OVER (ORDER BY orders DESC, customer_id) AS row_num,
  RANK()       OVER (ORDER BY orders DESC) AS rnk,
  DENSE_RANK() OVER (ORDER BY orders DESC) AS dense_rnk
FROM uae_orders
ORDER BY orders DESC, customer_id
LIMIT 8;

قدّم العميلان 163 و534 كلاهما 11 طلبًا. ومع ذلك يعطيهما ROW_NUMBER الرقمين 4 و5، أي موضعين فريدين، مستخدمًا customer_id لكسر التعادل. ويعطي RANK كليهما 4 ثم يقفز إلى 6، كجدول ترتيب الدوري الرياضي. ويعطي DENSE_RANK كليهما 4 ويكمل بـ 5.

اختر حسب السؤال. «صف واحد بالضبط لكل مجموعة» (آخر طلب، أول زيارة) يعني ROW_NUMBER. و«أعلى 3، مع كل من يتعادل في المركز الثالث» يعني RANK. و«أعلى ثلاث قيم» يعني DENSE_RANK.

أعلى N في كل مجموعة

تعمل دوال النافذة بعد WHERE، في خطوة SELECT، فلا يمكنك التصفية على الترتيب في WHERE الخاص بالـ query نفسه. النمط المعتاد هو CTE يحسب الترتيب، وquery خارجي يصفّيه:

SQL
WITH ranked AS (
  SELECT
    category,
    product,
    revenue,
    RANK() OVER (PARTITION BY category ORDER BY revenue DESC) AS rnk
  FROM product_revenue
)
SELECT category, product, revenue, rnk
FROM ranked
WHERE rnk <= 2;

يمثّل product_revenue هنا الـ CTE الخاص بالإيراد لكل منتج الذي ستكتبه أولًا، مستخدمًا الـ JOIN من درس الـ JOIN. وبناؤه هو التمرين.

الأحداث الأولى: صف واحد لكل عميل

تجيب الأداة نفسها عن سؤال تنتظره هدى منذ أشهر: أي قناة تجلب عملاء جددًا؟ إنها قناة الطلب الأول لكل عميل، أي صف واحد لكل partition:

SQL
WITH numbered AS (
  SELECT
    customer_id,
    channel,
    ROW_NUMBER() OVER (
      PARTITION BY customer_id
      ORDER BY order_date, order_id
    ) AS order_seq
  FROM orders
  WHERE status <> 'cancelled'
)
SELECT channel AS first_order_channel, COUNT(*) AS customers
FROM numbered
WHERE order_seq = 1
GROUP BY channel
ORDER BY customers DESC;

يجلب الويب 268 عميلًا، والتطبيق 172، والـ marketplace 63. قارن ذلك بإجمالي الطلبات حسب القناة وسترى هل يجد الـ marketplace مشترين جددًا في الغالب أم يخدم مشترين حاليين. والعمود order_seq مفيد بحدّ ذاته أيضًا: order_seq = 2 يجد الطلبات الثانية، وهي بداية كل تحليل لتكرار الشراء.

شرائح بدل المواضع

أحيانًا لا يكون السؤال «من الأول؟» بل «في أي شريحة يقع كل عميل؟». يقسم فريق المالية لدى كريم العملاء إلى أرباع حسب إيرادهم الكلي لمقارنة استخدام الخصومات بين الشرائح. توزّع NTILE(n) الصفوف المرتّبة على n شريحة متقاربة الحجم:

SQL
WITH lifetime AS (
  SELECT o.customer_id,
         SUM(oi.quantity * oi.unit_price * (1 - oi.discount)) AS revenue
  FROM orders AS o
  JOIN order_items AS oi ON oi.order_id = o.order_id
  WHERE o.status <> 'cancelled'
  GROUP BY o.customer_id
),
tiered AS (
  SELECT customer_id, revenue,
         NTILE(4) OVER (ORDER BY revenue DESC) AS quartile
  FROM lifetime
)
SELECT quartile,
       COUNT(*) AS customers,
       ROUND(MIN(revenue), 2) AS min_revenue,
       ROUND(SUM(revenue), 2) AS revenue
FROM tiered
GROUP BY quartile
ORDER BY quartile;

إيراد الربع الأعلى يتضاءل أمامه إيراد الربع الأدنى، وهو الشكل المألوف في الغالبية العظمى من قواعد العملاء. لاحظ التدرّج: جمّع إلى صف واحد لكل عميل، ثم حدّد الشريحة بدالة نافذة، ثم جمّع مجددًا حسب الشريحة. دوال النافذة وGROUP BY ليسا متنافسين؛ معظم التحليلات الحقيقية تستخدم الاثنين، في خطوات منفصلة.

تقسم NTILE حسب عدد الصفوف لا حسب القيمة، فقد يقع عملاء متساوون في الإيراد في شرائح مختلفة. أما الشرائح ذات الحدود الثابتة («أكثر من 2,000 يعني ذهبي») فأداتها الصادقة هي تعبير CASE.

الترتيب نصف دوال النافذة. أما النصف الآخر، المجاميع التراكمية والمقارنة بالصف السابق، فيحوّل الجدول الشهري إلى اتجاه، وهو موضوعنا التالي.

أهم النقاط

  • دالة النافذة (window function) تحسب قيمة من مجموعة صفوف مترابطة لكنها تُبقي كل صف، على عكس GROUP BY الذي يضغطها.
  • يعيد PARTITION BY بدء الحساب لكل مجموعة؛ ويحدّد ORDER BY داخل OVER التسلسل الذي يتبعه الترتيب.
  • يعطي ROW_NUMBER مواضع فريدة، ويترك RANK فجوات بعد التعادل (1، 1، 3) بينما لا يتركها DENSE_RANK (1، 1، 2).
  • تُحسب دوال النافذة بعد WHERE، لذا لتصفية النتائج حسب الترتيب، احسبه في CTE ثم صفِّ في الـ query الخارجي.
  • اجعل ROW_NUMBER حتميًا بإضافة عمود فريد لكسر التعادل في الـ ORDER BY الخاص به.

قراءات إضافية

تحقّق من فهمك

4 أسئلة

السؤال 1 من 4

لثلاثة منتجات إيرادات قدرها 900 و900 و700. ماذا يعطي RANK وDENSE_RANK (بترتيب الإيراد تنازليًا) للمنتج صاحب 700؟

السؤال 2 من 4

لماذا يفشل SELECT ... WHERE ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_date) = 1؟

السؤال 3 من 4

ترقّم طلبات كل عميل بـ ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY date(order_date)). يحصل طلبان في اليوم نفسه على 1 و2 بترتيب لا يمكن التنبؤ به. ما الإصلاح؟

السؤال 4 من 4

كم صفًا يعيد SELECT customer_id, COUNT(*) OVER (PARTITION BY customer_id) FROM orders؟

تدريب

تمرين SQL

المنتجان الأكثر مبيعًا في كل فئة

أكمل قائمة هدى للنشرة البريدية: أعلى منتجين من حيث إيراد 2025 داخل كل فئة (الطلبات غير الملغاة). إذا تعادل منتجان في المركز الثاني، فأبقِ الاثنين.

أعِد category وproduct وrevenue (مقرّبًا إلى منزلتين عشريتين) وrnk (ترتيب المنتج داخل فئته، 1 = الأفضل). ترتيب الصفوف لا يهم.

الـ CTE المسمّى product_revenue في الكود المبدئي صحيح. أما الترتيب فليس كذلك: إنه يعيد منتجين فقط للمتجر كله.

الجداول
customers
(customer_id, first_name, last_name, email, country, city, signup_date, segment, referred_by)
orders
(order_id, customer_id, order_date, status, channel, coupon_code, shipping_fee)
order_items
(order_item_id, order_id, product_id, quantity, unit_price, discount)
products
(product_id, name, category_id, unit_price, unit_cost, launched_on, is_active)
categories
(category_id, name, parent_id)
returns
(return_id, order_item_id, return_date, reason, refund_amount)
employees
(employee_id, first_name, last_name, title, department, manager_id, hire_date, salary)
support_tickets
(ticket_id, customer_id, order_id, opened_at, closed_at, priority, channel, satisfaction, agent_id)
web_sessions
(session_id, customer_id, started_at, source, device, pages_viewed, added_to_cart, purchased)
-- Top 2 products by 2025 revenue in each category
WITH product_revenue AS (
  SELECT
    c.name AS category,
    p.name AS product,
    SUM(oi.quantity * oi.unit_price * (1 - oi.discount)) AS revenue
  FROM orders AS o
  JOIN order_items AS oi ON oi.order_id = o.order_id
  JOIN products AS p ON p.product_id = oi.product_id
  JOIN categories AS c ON c.category_id = p.category_id
  WHERE o.status <> 'cancelled'
    AND o.order_date >= '2025-01-01'
    AND o.order_date < '2026-01-01'
  GROUP BY c.name, p.name
),
ranked AS (
  SELECT
    category,
    product,
    revenue,
    ROW_NUMBER() OVER (ORDER BY revenue DESC) AS rnk
  FROM product_revenue
)
SELECT category, product, ROUND(revenue, 2) AS revenue, rnk
FROM ranked
WHERE rnk <= 2;جارٍ تحميل المحرّر…

Ctrl/⌘ + Enter للتشغيل · يعمل في بيئة معزولة داخل متصفّحك. لا شيء يغادر جهازك.