Mert Özen Her Satırda Daha İleri
Veritabanı SQL Temelleri — #10

GROUP BY ve Aggregate Function'lar: HAVING, Conditional Aggregation, GROUPING SETS ve ROLLUP

Mert Özen 8 Kas 2026 14 dk 3 görüntülenme
GROUP BY ve Aggregate Function'lar: HAVING, Conditional Aggregation, GROUPING SETS ve ROLLUP

GROUP BY row'ları gruplara çökertir, aggregate'ler NULL'ı yok sayar ve SQL Server'da tam sayı column'un AVG'si tam sayı döner. COUNT(*) ile COUNT(column) farkını, WHERE ile HAVING'in yerini, STRING_AGG'yi, CASE ile conditional aggregation'ı, PIVOT karşılaştırmasını ve ROLLUP, CUBE, GROUPING SETS ile GROUPING()'i Bookstore verisinde gerçek çıktılarla gösteriyoruz.

Sürüm notu: Bu yazıdaki SQL (Structured Query Language) örnekleri SQL Server 2025 (17.0, CU10 (Cumulative Update 10)) üzerinde serinin Bookstore veritabanıyla çalıştırılmıştır; lehçe karşılaştırmaları PostgreSQL 18.6 ve MySQL 8.4 ile denenmiştir.

Önce Tahmin Et

Bookstore'da 40 müşteri ve 181 yorum var. Aşağıdaki iki sorgunun ne döndüreceğini kâğıda yaz, sonra okumaya devam et:

SELECT COUNT(*)             AS customers,
       COUNT(phone)         AS with_phone,
       COUNT(city)          AS with_city,
       COUNT(DISTINCT city) AS distinct_cities
FROM dbo.customers;
SELECT AVG(rating) AS avg_rating FROM dbo.reviews;

İlk sorguda dört column'un dördü de "kaç" sorusunu soruyor ama aynı soruyu sormuyor. İkinci sorgu masum bir ortalama; yorumlarda 1 ile 5 arasında puan var ve çoğu 4 ya da 5. Çıktı şu:

customers with_phone with_city distinct_cities
--------- ---------- --------- ---------------
       40         19        37              10
avg_rating
----------
         4

COUNT(*) 40, COUNT(phone) 19, COUNT(city) 37, COUNT(DISTINCT city) 10. Ortalama puan ise 4. Tam 4. Gerçek ortalama 4,116 olduğu hâlde. Bu yazı, bu iki sürprizin arkasındaki kurallarla başlıyor ve günlük raporlamanın neredeyse tamamını taşıyan GROUP BY, HAVING, conditional aggregation ve ROLLUP ailesiyle devam ediyor.

GROUP BY Row'ları Gruplara Çökertir

Bir aggregate function (toplama fonksiyonu) birçok row'u alır, tek bir değer döndürür: COUNT, SUM, AVG, MIN, MAX. GROUP BY yoksa bütün tablo tek bir grup sayılır, bu yüzden yukarıdaki sorgular tek row döndürdü. GROUP BY eklediğinde aynı değeri taşıyan row'lar bir gruba çöker ve her grup için tek row üretilir:

SELECT category_id, COUNT(*) AS books, MIN(list_price) AS cheapest, MAX(list_price) AS priciest
FROM dbo.books
GROUP BY category_id
ORDER BY category_id;
category_id books cheapest priciest
----------- ----- -------- --------
       NULL     2   210.00   670.50
          2    29   200.00   680.50
          3     9   390.50   660.90
          4     4   320.90   580.50
          5     6   200.50   600.00
          6     6   290.90   650.00
          9     5   200.50   600.00
         11     9   890.00  1450.00
         12     2  1150.00  1450.00
         13     5   200.00   670.90

İki şeye dikkat et. Kategorisi olmayan iki kitap (category_id NULL) kendi grubunu oluşturdu: GROUP BY açısından bütün NULL'lar aynı gruptur. Bu, serinin 06. yazısındaki üç değerli mantıkla çelişiyor gibi görünse de standart burada bilinçli olarak "eşit" yerine "ayırt edilemez" (not distinct) kavramını kullanır. İkincisi, sonuç category_id sırasıyla geldi çünkü ORDER BY yazdım; yazmasaydım hiçbir sıra garantisi olmazdı. Buna Üretimde Dikkat bölümünde döneceğiz.

Şimdi kategorinin adını da göstermek istiyoruz. İlk deneme çoğu developer'ın yazdığı şey:

SELECT c.category_id, c.name, COUNT(*) AS books
FROM dbo.categories AS c
JOIN dbo.books AS b ON b.category_id = c.category_id
GROUP BY c.category_id;
Msg 8120, Level 16, State 1, Server b8be943222fa, Line 1
Column 'dbo.categories.name' is invalid in the select list because it is not contained in either an aggregate function or the GROUP BY clause.

Kural şu: SELECT listesindeki her ifade ya bir aggregate'in içinde olmalı ya da grup başına tek değer taşıdığı garanti olmalı. SQL Server bu garantiyi yalnız ifade GROUP BY listesinde geçiyorsa kabul eder. Oysa category_id primary key olduğu için name zaten grup başına tektir. ANSI (American National Standards Institute) ve ISO (International Organization for Standardization) standardı SQL:1999'dan beri bunu "functional dependency" olarak tanır ve PostgreSQL primary key üzerinden gelen bu bağımlılığı kabul eder: aynı sorgu PostgreSQL 18'de hatasız çalıştı. SQL Server'da ise name'i de GROUP BY'a eklemek gerekir:

SELECT c.category_id, c.name, COUNT(*) AS books
FROM dbo.categories AS c
JOIN dbo.books AS b ON b.category_id = c.category_id
GROUP BY c.category_id, c.name
ORDER BY books DESC, c.category_id;
category_id name            books
----------- --------------- -----
          2 Classics           29
          3 Science Fiction     9
         11 Programming         9
          5 Fantasy             6
          6 Mystery             6
          9 Science             5
         13 Children            5
          4 Dystopian           4
         12 Algorithms          2

MySQL'de durum zamanla değişti. 5.7.5'ten beri varsayılan sql_mode içinde ONLY_FULL_GROUP_BY var ve MySQL 8.4'te gruplanmamış bir column'u seçtiğimde hata 1055 aldım. Eski projelerde bu mod kapatılmışsa MySQL o column için grubun herhangi bir row'undan rastgele bir değer döndürür; sonuç hata vermez ama yanlış olabilir. SQL:2023'ün ANY_VALUE aggregate'i bu "herhangi biri olur" niyetini açıkça yazmanın standart yolu; PostgreSQL 16'dan beri destekliyor, SQL Server 2025'te ise yok ('ANY_VALUE' is not a recognized built-in function name).

Aggregate Function'lar NULL'ı Görmez

Açılıştaki ilk sürprizin cevabı tek cümle: COUNT(*) row sayar, COUNT(column) o column'un NULL olmayan değerlerini sayar. 21 müşterinin telefonu NULL olduğu için COUNT(phone) 19 döndü. COUNT(DISTINCT city) ise NULL'ları attıktan sonra farklı değerleri sayar. SUM, AVG, MIN ve MAX da aynı şekilde NULL'ları hesaba katmaz.

Bunun sinsi bir sonucu var: hiç row gelmediğinde COUNT 0 döndürür ama diğerleri NULL döndürür. Hiç sipariş edilmemiş The Mythical Man-Month (book_id 62) için:

SELECT COUNT(*) AS cnt, SUM(quantity) AS total_qty, AVG(unit_price) AS avg_price
FROM dbo.order_items
WHERE book_id = 62;
cnt total_qty avg_price
--- --------- ---------
  0      NULL      NULL

Uygulamada bu değeri decimal'a okuyan kod NULL'a hazır değilse ilk satılmayan kitapta patlar. Raporda 0 görmek istiyorsan COALESCE(SUM(quantity), 0) yaz; ama ortalamayı 0'a çevirmeden önce iki kez düşün, "ortalama yok" ile "ortalama sıfır" aynı şey değil.

İkinci sürpriz AVG'nin tam sayı bölmesi. SQL Server'da AVG'nin dönüş tipi girdinin tipinden türetilir: tinyint, smallint ve int için int. rating tinyint olduğu için 745 / 181 = 4,116 hesaplanıp 4'e kesildi. Düzeltme, toplamadan önce tipi genişletmek:

SELECT AVG(rating)                      AS avg_int,
       AVG(rating * 1.0)                AS avg_numeric,
       AVG(CAST(rating AS decimal(3,2))) AS avg_decimal,
       SUM(rating)                      AS total,
       COUNT(*)                         AS reviews
FROM dbo.reviews;
avg_int avg_numeric avg_decimal total reviews
------- ----------- ----------- ----- -------
      4    4.116022    4.116022   745     181

Standart, tam sayı ortalamasının ölçeğini (scale) implementation-defined bırakır; lehçeler de farklı karar verir. PostgreSQL'de integer column'un ortalaması numeric, MySQL'de decimal döner: 4, 5 ve 4 puanlarıyla yaptığım küçük denemede PostgreSQL 4.3333333333333333, MySQL 4.3333 verdi. Yani SQL Server'da yazdığın bir rapor PostgreSQL'e taşındığında "düzelir", tersi yönde ise sessizce bozulur. Kendi kuralın şu olsun: tam sayı column'un ortalamasını alıyorsan tipi her zaman açıkça genişlet.

WHERE mi, HAVING mi?

Serinin 05. yazısındaki mantıksal sorgu işleme sırasını hatırla: FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY. WHERE gruplar oluşmadan önce tek tek row'ları süzer; HAVING gruplar oluştuktan sonra grupları süzer. Bu yüzden WHERE içinde aggregate kullanamazsın, çünkü o anda ortada grup yok:

SELECT customer_id, COUNT(*) AS orders
FROM dbo.orders
WHERE COUNT(*) >= 20
GROUP BY customer_id;
Msg 147, Level 15, State 1, Server b8be943222fa, Line 3
An aggregate may not appear in the WHERE clause unless it is in a subquery contained in a HAVING clause or a select list, and the column being aggregated is an outer reference.

Doğru sorgu iki filtreyi de kendi yerine koyar: iptal edilen siparişler gruplamadan önce elenir, 15'ten az siparişi olan müşteriler gruplamadan sonra:

SELECT customer_id, COUNT(*) AS orders
FROM dbo.orders
WHERE status <> 'Cancelled'
GROUP BY customer_id
HAVING COUNT(*) >= 15
ORDER BY orders DESC;
customer_id orders
----------- ------
          1     37
          3     33
         32     30
         21     23
          2     22
         14     21
         30     21
         22     19

Pratik kural: aggregate'e bakmayan her koşulu WHERE'e yaz. HAVING customer_id <> 5 de çalışır, ama gereksiz row'ları gruplamaya sokar ve okuyana "bu koşul gruba mı row'a mı?" sorusunu sordurur. Optimizer basit durumlarda bu koşulu kendisi aşağı iter; yine de niyetini doğru yere yazmak senin işin.

STRING_AGG ile Listeyi Tek Row'a Toplamak

Bazen gruptan bir sayı değil, bir liste istersin: çok yazarlı kitapların yazarları gibi. STRING_AGG (SQL Server 2017+) değerleri ayraçla birleştirir, WITHIN GROUP (ORDER BY ...) ise sıralamayı belirler:

SELECT b.title,
       STRING_AGG(a.last_name, ', ') WITHIN GROUP (ORDER BY ba.author_order) AS authors
FROM dbo.books AS b
JOIN dbo.book_authors AS ba ON ba.book_id = b.book_id
JOIN dbo.authors AS a ON a.author_id = ba.author_id
GROUP BY b.book_id, b.title
HAVING COUNT(*) > 1
ORDER BY b.book_id;
title                                             authors
------------------------------------------------- -------------------------------
Good Omens                                        Pratchett, Gaiman
The C Programming Language                        Kernighan, Ritchie
The Pragmatic Programmer                          Hunt, Thomas
Design Patterns                                   Gamma, Helm, Johnson, Vlissides
Structure and Interpretation of Computer Programs Abelson, Sussman
The Elements of Style                             Strunk, White

WITHIN GROUP yazmazsan sıra yine garanti değildir; Design Patterns'ın yazarları bir gün "Helm, Gamma, ..." diye gelebilir. Standardın bu iş için tanımladığı function SQL:2016'daki LISTAGG; SQL Server ve PostgreSQL onu değil kendi STRING_AGG'ini sunuyor (her ikisinde de LISTAGG bilinmeyen function hatası verdi), MySQL ise GROUP_CONCAT kullanıyor. Aynı fikir, üç ayrı syntax: lehçe farklarını 16. yazıda topluca karşılaştıracağız.

Conditional Aggregation: Tek Sorguda Aylık Rapor

Raporlamanın en kullanışlı tekniği budur: aggregate'in içine bir CASE koyarak her column'a ayrı bir filtre vermek. Aşağıdaki sorgu 2025'in ikinci yarısını ay ay döküyor; her ay için toplam sipariş, iptal sayısı ve Türkiye'den ve yurt dışından gelen siparişler tek taramada hesaplanıyor:

SELECT DATEFROMPARTS(YEAR(o.ordered_at), MONTH(o.ordered_at), 1) AS month_start,
       COUNT(*)                                                  AS orders,
       COUNT(CASE WHEN o.status = 'Cancelled' THEN 1 END)        AS cancelled,
       SUM(CASE WHEN c.country_code = 'TR' THEN 1 ELSE 0 END)    AS tr_orders,
       SUM(CASE WHEN c.country_code <> 'TR' THEN 1 ELSE 0 END)   AS foreign_orders
FROM dbo.orders AS o
JOIN dbo.customers AS c ON c.customer_id = o.customer_id
WHERE o.ordered_at >= '2025-07-01' AND o.ordered_at < '2026-01-01'
GROUP BY DATEFROMPARTS(YEAR(o.ordered_at), MONTH(o.ordered_at), 1)
ORDER BY month_start;
month_start orders cancelled tr_orders foreign_orders
----------- ------ --------- --------- --------------
2025-07-01      14         2         7              7
2025-08-01      17         0        12              5
2025-09-01      34         4        17             17
2025-10-01      24         1        19              5
2025-11-01      23         0        15              8
2025-12-01      42         2        24             18

İki pattern kullandım ve ikisi de aynı işi görüyor. COUNT(CASE WHEN ... THEN 1 END)'de ELSE olmadığı için eşleşmeyen row'lar NULL olur ve COUNT NULL'ı saymaz. SUM(CASE WHEN ... THEN 1 ELSE 0 END) ise eşleşmeyenlere 0 ekler. Ay column'u için DATEFROMPARTS ile ayın ilk gününü ürettim; böylece sonuç gerçek bir date olarak sıralanıyor ve uygulamaya DateOnly olarak dönebiliyor.

Standart, bu iş için daha okunaklı bir syntax tanımlar: COUNT(*) FILTER (WHERE status = 'Cancelled'). PostgreSQL bunu destekliyor; SQL Server 2025'te ise Incorrect syntax near the keyword 'WHERE' hatası alıyorsun, yani CASE pattern'i taşınabilir tek yol.

Aynı teknik row'ları column'lara çevirmek için de kullanılır. Ödeme yöntemine göre aylık tahsilat:

SELECT DATEFROMPARTS(YEAR(paid_at), MONTH(paid_at), 1) AS month_start,
       SUM(CASE WHEN method = 'card'          THEN amount ELSE 0 END) AS card,
       SUM(CASE WHEN method = 'bank_transfer' THEN amount ELSE 0 END) AS bank_transfer,
       SUM(CASE WHEN method = 'gift_card'     THEN amount ELSE 0 END) AS gift_card
FROM dbo.payments
WHERE paid_at >= '2025-10-01' AND paid_at < '2026-01-01'
GROUP BY DATEFROMPARTS(YEAR(paid_at), MONTH(paid_at), 1)
ORDER BY month_start;
month_start     card bank_transfer gift_card
----------- -------- ------------- ---------
2025-10-01  21633.50       4856.40   1000.00
2025-11-01  16533.50       3392.30    400.00
2025-12-01  28328.01      12338.19   1200.00

T-SQL (Transact-SQL) bunun için ayrıca PIVOT operator'ünü sunar ve aynı sonucu üretir:

SELECT month_start, [card], [bank_transfer], [gift_card]
FROM (
    SELECT DATEFROMPARTS(YEAR(paid_at), MONTH(paid_at), 1) AS month_start, method, amount
    FROM dbo.payments
    WHERE paid_at >= '2025-10-01' AND paid_at < '2026-01-01'
) AS src
PIVOT (SUM(amount) FOR method IN ([card], [bank_transfer], [gift_card])) AS p
ORDER BY month_start;
month_start     card bank_transfer gift_card
----------- -------- ------------- ---------
2025-10-01  21633.50       4856.40   1000.00
2025-11-01  16533.50       3392.30    400.00
2025-12-01  28328.01      12338.19   1200.00

Ben conditional aggregation'ı tercih ediyorum. PIVOT yalnız tek bir aggregate alır, column listesini sabit ister, standart değildir ve gruplanacak column'ları kaynaktaki "geri kalan her şey" olarak örtük belirler; derived table'a fazladan bir column eklersen sonuç sessizce daha fazla row'a bölünür. CASE pattern'inde ise neyin gruplandığı GROUP BY'da açıkça yazar ve aynı sorguda SUM, COUNT, AVG'yi karıştırabilirsin.

ROLLUP, CUBE ve GROUPING SETS

Raporlar genelde ara toplam ister: ülke ve yıl bazında ciro, her ülkenin toplamı ve genel toplam. Bunu üç sorguyu UNION ALL ile birleştirerek yazabilirsin, ama standardın SQL:1999'da eklediği ROLLUP tek sorguda yapar:

SELECT c.country_code,
       YEAR(o.ordered_at)                AS order_year,
       COUNT(DISTINCT o.order_id)        AS orders,
       SUM(oi.quantity * oi.unit_price)  AS revenue,
       GROUPING(c.country_code)          AS g_country,
       GROUPING(YEAR(o.ordered_at))      AS g_year
FROM dbo.orders AS o
JOIN dbo.customers AS c    ON c.customer_id = o.customer_id
JOIN dbo.order_items AS oi ON oi.order_id = o.order_id
WHERE o.status <> 'Cancelled'
GROUP BY ROLLUP (c.country_code, YEAR(o.ordered_at))
ORDER BY GROUPING(c.country_code), c.country_code, GROUPING(YEAR(o.ordered_at)), order_year;
country_code order_year orders   revenue g_country g_year
------------ ---------- ------ --------- --------- ------
DE                 2025     61  57102.36         0      0
DE                 2026     26  32246.84         0      0
DE                 NULL     87  89349.20         0      1
GB                 2025     31  26911.82         0      0
GB                 2026     12  12485.15         0      0
GB                 NULL     43  39396.97         0      1
NL                 2025      4   5518.40         0      0
NL                 2026      2   1880.50         0      0
NL                 NULL      6   7398.90         0      1
TR                 2025    151 149683.15         0      0
TR                 2026     68  76318.86         0      0
TR                 NULL    219 226002.01         0      1
NULL               NULL    355 362147.08         1      1

ROLLUP (country_code, order_year) üç grouping set üretir: (ülke, yıl), (ülke) ve () yani genel toplam. Ara toplam row'larında gruplanmayan column NULL görünür; bu NULL'ı gerçek bir NULL değerinden ayırmanın yolu GROUPING() function'ıdır: column o row'da gruplamaya katılmadıysa 1 döner. Sıralamada da GROUPING() kullandım ki toplamlar kendi grubunun altına düşsün. Bir de COUNT(DISTINCT o.order_id)'ye dikkat: order_items ile join yaptığım için her sipariş kalem sayısı kadar çoğaldı; COUNT(*) yazsaydım siparişleri değil kalemleri sayardım. Bu, serinin 09. yazısındaki fan-out sorununun aggregate'teki yüzü.

GROUPING SETS hangi kombinasyonları istediğini tek tek yazmanı sağlar. Ülkeye göre ve duruma göre sayıları, iki ayrı rapor gibi, tek sonuçta almak için:

SELECT CASE WHEN GROUPING(c.country_code) = 1 THEN 'ALL' ELSE c.country_code END AS country,
       CASE WHEN GROUPING(o.status) = 1 THEN 'ALL' ELSE o.status END            AS status,
       COUNT(*) AS orders
FROM dbo.orders AS o
JOIN dbo.customers AS c ON c.customer_id = o.customer_id
GROUP BY GROUPING SETS ((c.country_code), (o.status), ())
ORDER BY GROUPING(c.country_code), GROUPING(o.status), country, status;
country status    orders
------- --------- ------
DE      ALL           94
GB      ALL           45
NL      ALL            6
TR      ALL          234
ALL     Cancelled     24
ALL     Delivered    345
ALL     Paid           1
ALL     Pending        1
ALL     Shipped        8
ALL     ALL          379

CUBE (a, b) ise bütün kombinasyonları üretir: (a, b), (a), (b), (). Ülke ve durum için CUBE 22 row döndürdü. Kural basit: ROLLUP hiyerarşik ara toplam (yıl → ay → gün), CUBE çapraz tablo, GROUPING SETS ise ikisinin de yetmediği özel kombinasyonlar için.

Üretimde Dikkat

GROUP BY sıralama garantisi vermez. SQL Server eskiden çoğu zaman gruplamayı sort ile yaptığı için sonuç sıralı gelirdi ve bu alışkanlık birçok raporda yaşıyor. Optimizer hash aggregate seçtiği an, ya da plan paralelleştiği an, sıra bozulur. Sıra istiyorsan ORDER BY yaz; bedava değil ama doğru.

Formatlanmış string'e göre gruplamak hem sıralamayı hem performansı bozar. Ay etiketiyle gruplayan şu sorgu alfabetik sıralandı:

SELECT FORMAT(ordered_at, 'MMM yyyy', 'en-US') AS month_label, COUNT(*) AS orders
FROM dbo.orders
WHERE ordered_at >= '2025-01-01' AND ordered_at < '2025-07-01'
GROUP BY FORMAT(ordered_at, 'MMM yyyy', 'en-US')
ORDER BY month_label;
month_label orders
----------- ------
Apr 2025        17
Feb 2025        15
Jan 2025        13
Jun 2025        20
Mar 2025        28
May 2025        16

Nisan, Şubat'tan önce geldi. Üstelik FORMAT .NET CLR (Common Language Runtime) üzerinden çalışır ve row başına belirgin bir maliyeti vardır. Gruplamayı DATEFROMPARTS ya da DATETRUNC(month, ...) (SQL Server 2022+) ile gerçek bir tarihe göre yap, etiketi en sonda, sunum katmanında üret.

Büyük tablolarda gruplama maliyeti iki yerden gelir: kaç row'un okunduğu ve gruplamanın sort mu hash mi olduğu. WHERE ile tarih aralığını daraltmak, GROUP BY column'larıyla başlayan bir index'in sort'u ortadan kaldırması ve COUNT(DISTINCT ...) sayısını azaltmak (her biri ayrı bir aggregate adımı demektir) ilk bakılacak üç şey. Index ve execution plan tarafı bu serinin değil, SQL Server Derinlemesine serisinin konusu; orada aynı sorguların planlarını operator operator okuyacağız.

Join'li sorgularda aggregate'ten önce row sayısını kontrol et. Bookstore'da 68 siparişin iki ödemesi var; order_items ve payments'ı aynı sorguda join edip SUM(amount) alırsan bu siparişlerin tutarları kalem sayısıyla çarpılır. Önce her tabloyu kendi grain'inde (sipariş başına bir row) toplayıp sonra join etmek güvenli yoldur.

Sık Düşülen Tuzaklar

AVG'yi tam sayı column'unda çağırıp sonucu ondalık sanmak. SQL Server'da int girer int çıkar; tipi önce genişlet.

COUNT(column) ile COUNT(*)'ı eş anlamlı sanmak. NULL içerebilen column'da farklı sayarlar; "kaç row" diye soruyorsan COUNT(*) yaz.

Boş grup için SUM'ın 0 döneceğini varsaymak. NULL döner; uygulama tarafında nullable okuyacak ya da COALESCE kullanacaksın.

HAVING'i WHERE yerine kullanmak. Çalışır, ama gereksiz row'lar gruplanır ve niyetin bulanıklaşır.

Join'den sonra COUNT(*) ile "sipariş sayısı" almak. Join row'ları çoğalttıysa sipariş değil kalem sayarsın; COUNT(DISTINCT order_id) ya da önce-topla-sonra-join kullan.

PIVOT'un kaynağına fazladan column koyup row'ların neden bölündüğünü anlamamak. PIVOT, saymadığın her column'a göre örtük olarak gruplar.

Küçük Bir Deneme

★☆☆ Her kategori için yorum sayısını ve doğru ölçekte ortalama puanı (iki basamak) döndür. Hiç yorumu olmayan kategoriler de 0 yorumla listede olsun. İpucu: LEFT JOIN ve COUNT(r.review_id).

★★☆ 2025 yılı için ay ay "teslim edilen", "iptal edilen" ve "diğer" sipariş sayılarını ve teslim edilen siparişlerin cirosunu tek sorguda döndür. Sonra aynı raporu PIVOT ile yazmayı dene ve hangi noktada zorlandığını not et.

★★★ ROLLUP ile yıl → ay → gün hiyerarşisinde sipariş sayısı üret, ama yalnız (yıl, ay) ara toplamlarını ve genel toplamı göster; gün detayını gizle. Aynı sonucu GROUPING SETS ile yaz ve iki sorgunun ürettiği row sayısını karşılaştır.

Gruplama "bu kümeyi birkaç row'a nasıl indiririm?" sorusunu cevaplıyor. Bir sonraki yazıda soruyu tersine çeviriyoruz: bir sorgunun sonucunu başka bir sorgunun içinde kullanmak. Subquery'ler, EXISTS ve NOT IN'in NULL ile verdiği o meşhur boş sonuç, sonra da UNION, INTERSECT ve EXCEPT ile kümeleri birleştirmek.