SQL Server, de la zero Lecția 10 / 40

GROUP BY, HAVING și agregatele

SUM, COUNT, AVG, agregate cu DISTINCT, HAVING vs WHERE și scrierea interogărilor de KPI pe care le rulează chiar fiecare business. Venit, AOV, CAC, LTV explicate în SQL.

Fiecare business rulează pe KPI-uri — Key Performance Indicators. Echipa de leadership Runehold primește un dashboard de luni dimineața cu „venit pe țară săptămâna trecută”, „valoarea medie a comenzii pe canal”, „top 10 SKU-uri după marja brută”, „număr de clienți pe sursă de achiziție”. Toate astea sunt interogări GROUP BY.

Dacă înțelegi GROUP BY, poți transforma 10 milioane de rânduri de comenzi într-un rezumat executiv de 20 de rânduri. Dacă nu, petreci ore construind lucruri în Excel care ar fi trebuit să dureze 30 de secunde în SQL. Lecția de astăzi predă tiparul.

Ce înseamnă agregarea

O funcție de agregare ia multe rânduri și returnează o singură valoare. Cele cinci mari:

  • SUM(expr) — total
  • COUNT(*) / COUNT(expr) — număr de rânduri (am acoperit diferența NULL în lecția 7)
  • AVG(expr) — media aritmetică
  • MIN(expr) / MAX(expr) — cea mai mică / cea mai mare

Plus:

  • STDEV(expr), VAR(expr) — deviație standard, varianță. Rare, dar utile.
  • STRING_AGG(expr, separator) — concatenează șiruri. O acoperim în lecția 14.

Fără un GROUP BY, funcțiile de agregare colapsează tot setul de rezultate într-un singur rând:

SELECT SUM(Total)      AS total_revenue,
       AVG(Total)      AS average_order_value,
       COUNT(*)        AS order_count,
       MIN(OrderDate)  AS first_order_ever,
       MAX(OrderDate)  AS most_recent_order
FROM Sales.Orders;

Acea interogare răspunde la „ce știm la nivel agregat despre toate comenzile?” Un rând la ieșire. Curat.

Adaugă un GROUP BY și obții un rând per grup:

SELECT CountryCode,
       SUM(Total)  AS revenue,
       COUNT(*)    AS orders,
       AVG(Total)  AS avg_order_value
FROM Sales.Orders
GROUP BY CountryCode
ORDER BY revenue DESC;

Asta e „venit, număr de comenzi și valoarea medie a comenzii, defalcate pe țară.” Patru sau cinci rânduri la ieșire. Marketing-ul iubește această interogare. Așa se calculează panoul „venit pe țară” din dashboard-ul tău.

Regula GROUP BY

Fiecare coloană din lista ta SELECT trebuie să fie fie:

  1. În interiorul unei funcții de agregare, fie
  2. Listată în clauza GROUP BY.

De ce? Pentru că dacă încerci să proiectezi o coloană care nu e în GROUP BY și nu e agregată, motorul nu are nicio idee din care din multele valori ale rândurilor grupate să-ți arate. SQL Server returnează o eroare: „Column ‘X’ is invalid in the select list because it is not contained in either an aggregate function or the GROUP BY clause.”

-- ERROR
SELECT CountryCode, Name, SUM(Total)
FROM Sales.Orders
JOIN Sales.Customer ON ...
GROUP BY CountryCode;
-- Name is not grouped and not aggregated.

-- FIX 1: add it to the group
SELECT CountryCode, Name, SUM(Total)
FROM Sales.Orders
JOIN Sales.Customer ON ...
GROUP BY CountryCode, Name;

-- FIX 2: aggregate it
SELECT CountryCode, MIN(Name), SUM(Total)
FROM Sales.Orders
JOIN Sales.Customer ON ...
GROUP BY CountryCode;

MySQL și Postgres în moduri „permisive” îți permit să încalci această regulă; SQL Server nu. E o caracteristică. GROUP BY „permisiv” se sparge întotdeauna în moduri subtile când se schimbă datele. SQL Server te salvează de tine însuți.

WHERE vs HAVING

WHERE filtrează rândurile înainte de grupare. HAVING filtrează grupurile după agregare. Nu sunt interschimbabile:

-- Countries with more than 5 orders AND total revenue above 500 EUR
SELECT CountryCode,
       COUNT(*)    AS orders,
       SUM(Total)  AS revenue
FROM Sales.Orders
WHERE OrderDate >= '2026-01-01'          -- per-row filter: before grouping
GROUP BY CountryCode
HAVING COUNT(*) > 5                       -- group filter: after aggregation
   AND SUM(Total) > 500;

Regulă generală:

  • Dacă filtrul tău poate fi evaluat pe un singur rând al tabelului sursă, folosește WHERE.
  • Dacă filtrul tău are nevoie de un agregat (COUNT(*), SUM(...), etc.), trebuie să fie HAVING.

De fiecare dată când vezi o condiție care ar putea merge în WHERE, pune-o în WHERE — motorul filtrează mai devreme, ceea ce e mai ieftin. Doar filtrele bazate pe agregate aparțin lui HAVING.

DISTINCT în interiorul agregatelor

Alt truc util. Poți înfășura DISTINCT în jurul argumentului unui agregat:

-- How many unique customers placed an order last month?
SELECT COUNT(DISTINCT CustomerId) AS unique_customers
FROM Sales.Orders
WHERE OrderDate >= '2026-03-01' AND OrderDate < '2026-04-01';

-- Average order value per unique SKU per country
-- "On average, across products we sold in each country, what was the per-SKU revenue?"
SELECT CountryCode,
       SUM(Total) / COUNT(DISTINCT ProductId) AS avg_rev_per_sku
FROM Sales.OrderLine
JOIN Sales.Orders ON ...
GROUP BY CountryCode;

COUNT(DISTINCT customer_id) este KPI-ul la care majoritatea echipelor se referă ca „clienți activi” sau „MAU/DAU” (utilizatori activi lunar/zilnic). Diferit de COUNT(*) când vreun client are mai multe comenzi.

GROUP BY cu mai multe coloane

Grupează după câte coloane vrei; obții un rând per combinație unică:

-- Revenue per country per month
SELECT CountryCode,
       YEAR(OrderDate)  AS yr,
       MONTH(OrderDate) AS mo,
       SUM(Total)       AS revenue,
       COUNT(*)         AS orders
FROM Sales.Orders
WHERE OrderDate >= '2026-01-01'
GROUP BY CountryCode, YEAR(OrderDate), MONTH(OrderDate)
ORDER BY yr, mo, revenue DESC;

Notă: folosirea YEAR(OrderDate) și MONTH(OrderDate) în GROUP BY e bine, dar împiedică folosirea unui index pe OrderDate pentru seek-uri pe interval. Alternativă comună: grupează după o expresie de dată trunchiată sau pre-calculează o coloană YearMonth în schemă. Vom face acest refactor cum trebuie în lecția 15.

KPI-urile de business, în SQL

Iată câteva KPI-uri reale pe care le rulează echipele de finanțe și marketing din Runehold, scrise integral. Sunt utile ca referință când ești întrebat „poți să-mi obții…?” de cineva non-tehnic.

Venit total

SELECT SUM(Total) AS gmv_eur          -- GMV = Gross Merchandise Value
FROM Sales.Orders
WHERE OrderDate >= '2026-01-01'
  AND OrderDate  < '2026-04-01';

Valoarea medie a comenzii (AOV)

SELECT SUM(Total) * 1.0 / COUNT(*) AS aov_eur
FROM Sales.Orders
WHERE OrderDate >= '2026-01-01' AND OrderDate < '2026-04-01';

* 1.0 forțează împărțirea să returneze o valoare zecimală în loc de împărțire întreagă. Detaliu mic, diferență mare: 15 / 7 e 2; 15 * 1.0 / 7 e 2.142857....

Rata de conversie

„Sesiuni care au rezultat într-o achiziție, împărțite la totalul sesiunilor.”

-- Schema sketch: Marketing.Session tracks visits, has HasPurchase bit
SELECT
    CountryCode,
    COUNT(*)                                           AS sessions,
    SUM(CAST(HasPurchase AS INT))                      AS purchasing_sessions,
    SUM(CAST(HasPurchase AS INT)) * 100.0 / COUNT(*)   AS conversion_rate_pct
FROM Marketing.Session
WHERE SessionStart >= '2026-03-01'
GROUP BY CountryCode
ORDER BY conversion_rate_pct DESC;

SUM(CAST(HasPurchase AS INT)) este modul idiomatic de a număra rândurile care îndeplinesc o condiție când vrei și totalul rândurilor în același agregat. COUNT(CASE WHEN HasPurchase = 1 THEN 1 END) e echivalent și mai lizibil în cazuri complexe.

Număr de clienți pe țară — „proxy DAU”

SELECT CountryCode,
       COUNT(DISTINCT CustomerId) AS unique_customers_last_30d
FROM Sales.Orders
WHERE OrderDate >= DATEADD(DAY, -30, GETDATE())
GROUP BY CountryCode
ORDER BY unique_customers_last_30d DESC;

Customer Acquisition Cost (CAC), un KPI în două etape

CAC = cheltuieli de marketing pentru o perioadă / clienți noi achiziționați în acea perioadă.

DECLARE @start DATE = '2026-03-01';
DECLARE @end   DATE = '2026-04-01';

WITH spend AS (
    SELECT Channel, SUM(Amount) AS total_spend
    FROM Marketing.Spend
    WHERE SpendDate >= @start AND SpendDate < @end
    GROUP BY Channel
),
new_customers AS (
    SELECT AcquisitionChannel AS Channel, COUNT(*) AS new_count
    FROM Sales.Customer
    WHERE CreatedAt >= @start AND CreatedAt < @end
    GROUP BY AcquisitionChannel
)
SELECT
    s.Channel,
    s.total_spend,
    n.new_count,
    s.total_spend / NULLIF(n.new_count, 0) AS cac_eur
FROM spend AS s
LEFT JOIN new_customers AS n ON n.Channel = s.Channel
ORDER BY cac_eur DESC;

NULLIF(n.new_count, 0) previne împărțirea la zero. Dacă un canal a cheltuit bani și nu a achiziționat pe nimeni, altfel ai primi o eroare T-SQL; NULLIF transformă zero-ul în NULL și X / NULL este NULL. Rândul apare cu CAC NULL, ceea ce e corect — e nedefinit.

Lifetime Value (LTV), formă de bază

LTV per client = suma tuturor totalurilor comenzilor lor.

SELECT c.CustomerId,
       c.Name,
       c.Country,
       COALESCE(SUM(o.Total), 0) AS lifetime_value_eur,
       COUNT(o.OrderId)          AS order_count
FROM Sales.Customer AS c
LEFT JOIN Sales.Orders AS o ON o.CustomerId = c.CustomerId
GROUP BY c.CustomerId, c.Name, c.Country
ORDER BY lifetime_value_eur DESC;

Tiparul LEFT JOIN + COALESCE(SUM(...), 0) gestionează clienții care nu au comandat niciodată (primesc LTV = 0 în loc de NULL). Leadership-ul vrea întotdeauna clienții cu LTV zero în export-urile lor — sunt cea mai mare populație neexploatată.

ROLLUP, CUBE, GROUPING SETS — subtotaluri fără durere

Vrei un raport care arată venitul pe țară și un total general în același rezultat? WITH ROLLUP:

SELECT CountryCode,
       SUM(Total) AS revenue
FROM Sales.Orders
GROUP BY CountryCode
WITH ROLLUP;

Produce un rând per țară plus un rând extra în care CountryCode este NULL și revenue este totalul general.

CUBE îți dă toate combinațiile de subtotaluri (fiecare coloană × fiecare alta). GROUPING SETS îți dă control explicit:

SELECT CountryCode, YEAR(OrderDate) AS yr, SUM(Total) AS revenue
FROM Sales.Orders
GROUP BY GROUPING SETS (
    (CountryCode, YEAR(OrderDate)),   -- per country per year
    (CountryCode),                      -- per country (all years)
    (YEAR(OrderDate)),                   -- per year (all countries)
    ()                                   -- grand total
);

Acestea sunt nepretuite pentru rapoarte de tip pivot. Folosește GROUPING() ca să spui diferența între „NULL real” și „null dintr-un rollup”:

SELECT CASE WHEN GROUPING(CountryCode) = 1 THEN 'TOTAL'
            ELSE CountryCode
       END AS label,
       SUM(Total) AS revenue
FROM Sales.Orders
GROUP BY CountryCode WITH ROLLUP;

Rulează asta pe propria mașină

USE Runehold;
GO

-- Revenue per country last month
SELECT CountryCode,
       SUM(Total)   AS revenue_eur,
       COUNT(*)     AS order_count,
       AVG(Total)   AS aov_eur,
       MIN(Total)   AS smallest_order,
       MAX(Total)   AS biggest_order
FROM Sales.Orders
WHERE OrderDate >= DATEADD(MONTH, -1, GETDATE())
GROUP BY CountryCode
ORDER BY revenue_eur DESC;

-- With HAVING: only countries with 2+ orders
SELECT CountryCode,
       COUNT(*)   AS orders,
       SUM(Total) AS revenue
FROM Sales.Orders
GROUP BY CountryCode
HAVING COUNT(*) >= 2
ORDER BY revenue DESC;

-- Active customers (unique) per country
SELECT CountryCode,
       COUNT(DISTINCT CustomerId) AS unique_customers,
       COUNT(*)                   AS orders,
       SUM(Total)                 AS revenue
FROM Sales.Orders
GROUP BY CountryCode;

-- Customer LTV ranked
SELECT c.CustomerId,
       c.Name,
       c.Country,
       COALESCE(SUM(o.Total), 0) AS ltv_eur,
       COUNT(o.OrderId)          AS orders
FROM Sales.Customer AS c
LEFT JOIN Sales.Orders AS o ON o.CustomerId = c.CustomerId
GROUP BY c.CustomerId, c.Name, c.Country
ORDER BY ltv_eur DESC;

-- ROLLUP for grand total
SELECT CASE WHEN GROUPING(CountryCode) = 1 THEN 'ALL COUNTRIES'
            ELSE CountryCode
       END AS country_label,
       SUM(Total) AS revenue
FROM Sales.Orders
GROUP BY CountryCode WITH ROLLUP
ORDER BY GROUPING(CountryCode), revenue DESC;

Observă cum fiecare interogare se mapează la o întrebare pe care un om de business chiar ar pune-o: „care e venitul nostru de luna trecută?”, „care țări au clienți recurenți?”, „cine sunt cei mai buni clienți ai noștri?”. Acela e obiectivul. Să le scrii pe astea în somn e ce separă un data engineer de cineva care rulează ocazional interogări SELECT.

Lecția următoare: subinterogări vs CTE-uri. Când să folosești pe care, cum să scrii unele lizibile și de ce un CTE recursiv nu e nici pe departe atât de înfricoșător pe cât sună.

Caută