A 04-05 vam deixar una frase pendent: el top N per grup i "qualsevol càlcul que hagi d'agregar sense col·lapsar les files" necessiten funcions de finestra. Aquest és el problema. SUM(import) sobre les 47 línies de BotigaVerda retorna una fila amb 727,95 €; però el que gairebé sempre vols són les 47 línies, cadascuna amb el seu import i, al costat, el total — per calcular el percentatge que representa, la seva posició en un rànquing o quant portes acumulat.
Un GROUP BY no ho pot fer: col·lapsa exactament el que vols conservar. La clàusula OVER sí, i és la diferència entre saber SQL i saber-lo fer servir. En aquesta lliçó veuràs l'anatomia d'OVER (PARTITION BY ... ORDER BY ... marc), on s'executa una funció de finestra i per què això fa impossible filtrar-la al WHERE —l'error més freqüent que hi ha—, les tres famílies amb la seva taula de referència, el marc i el parany clàssic de LAST_VALUE, i sis casos reals de BotigaVerda: acumulat mensual, mitjana mòbil, variació respecte al mes anterior, top N per categoria, rànquing de clients i cada producte enfront de la mitjana de la seva categoria.
Contingut
- La idea central: agregar sense col·lapsar
- L'anatomia d'
OVER - On s'executa: l'error del
WHERE - Rànquing:
ROW_NUMBER,RANK,DENSE_RANK,NTILE,PERCENT_RANK - Desplaçament:
LAG,LEAD,FIRST_VALUE,LAST_VALUE,NTH_VALUE - Agregats com a finestra, i el marc:
ROWSenfront deRANGE WINDOW: donar nom a una finestra- Els casos de BotigaVerda
- Rendiment i dialecte
- Errors habituals i consells
- Exercicis
- Conclusió
- La idea central: agregar sense col·lapsar
Una funció de finestra calcula un valor per a cada fila a partir d'un conjunt de files relacionades amb ella —la seva finestra—, sense reduir el nombre de files del resultat.
La forma més petita possible és OVER (), la finestra buida: "totes les files del resultat".
-- Fem servir la vista v_detall_vendes de 10-01, que ja porta la columna `import`
SELECT linia_id, producte, import,
SUM(import) OVER () AS total_general,
ROUND(100 * import / SUM(import) OVER (), 2) AS pct
FROM v_detall_vendes
ORDER BY linia_id;| linia_id | producte | import | total_general | pct |
|---|---|---|---|---|
| 1 | Oli d'oliva verge extra 500 ml | 23.90 | 727.95 | 3.28 |
| 2 | Arròs integral ecològic 1 kg | 11.70 | 727.95 | 1.61 |
| 3 | Infusió de camamilla ecològica 20 u | 6.50 | 727.95 | 0.89 |
(3 primeres de 47 files.) Les 47 línies hi continuen sent, i cadascuna porta enganxats els 727,95 € del total i el seu percentatge. Amb GROUP BY això és impossible: o tens el detall, o tens el total. Aquí tens tots dos.
I aquesta és la comparació que cal fixar abans de continuar:
GROUP BY |
Funció de finestra | |
|---|---|---|
| Files del resultat / el detall | Una per grup / es perd | Totes les d'entrada / es conserva |
| Es pot filtrar per l'agregat / serveix per a | Sí, amb HAVING / resumir |
No directament (apartat 3) / enriquir cada fila |
- L'anatomia d'
OVER
OVERLa forma general és funcio(args) OVER (PARTITION BY expr ORDER BY expr ROWS/RANGE ...), amb tres peces: PARTITION BY diu en quins grups es divideix (sense ell, tot és un sol grup), ORDER BY en quin ordre es recorre cada grup, i el marc quina franja del grup entra en el càlcul.
flowchart LR
A["47 línies"] --> B["<b>PARTITION BY</b><br/>divideix en grups"] --> C["<b>ORDER BY</b><br/>ordena cada grup"]
C --> D["<b>marc</b><br/>quines files del grup entren<br/>per a la fila actual"] --> E["un valor per a cadascuna<br/>de les 47 files"]
Les tres parts són opcionals i independents, i cada combinació significa una cosa diferent:
| Escrit | Significa |
|---|---|
OVER () |
Totes les files, sense ordre ni marc: el total general |
OVER (PARTITION BY categoria_id) / OVER (ORDER BY data) |
El total de la seva categoria / l'acumulat fins a la fila actual |
El detall que sorprèn tothom: afegir ORDER BY a una finestra canvia el resultat d'un SUM, perquè activa un marc per omissió que va del principi fins a la fila actual. Sense ORDER BY, SUM dona el total del grup; amb ell, dona l'acumulat. No és un error: és la porta d'entrada al marc de l'apartat 6.
- On s'executa: l'error del
WHERE
WHERELes funcions de finestra s'avaluen gairebé al final: FROM → WHERE → GROUP BY → HAVING → funcions de finestra → SELECT → DISTINCT → ORDER BY → LIMIT. D'aquí se'n dedueixen dos fets importants. El primer: una funció de finestra veu només les files que van sobreviure al WHERE. Si filtres per pais = 'França', el SUM(...) OVER () serà el total de França, no el de la botiga. El segon és l'error número u de la lliçó:
-- ⚠️ INCORRECTA: filtrar per una funció de finestra al WHERE
SELECT producte, SUM(quantitat) AS unitats FROM v_detall_vendes
WHERE ROW_NUMBER() OVER (ORDER BY SUM(quantitat) DESC) <= 3
GROUP BY producte;ERROR: window functions are not allowed in WHERE
LINE 2: WHERE ROW_NUMBER() OVER (ORDER BY SUM(quantitat) DESC) <= 3
^I no és una limitació arbitrària: quan s'avalua el WHERE, la funció de finestra encara no s'ha calculat, perquè necessita saber quines files passen el filtre. Seria circular. La solució és sempre la mateixa —calcular en un nivell i filtrar en el següent—, i amb CTE (10-02) es llegeix perfectament:
-- ✅ CORRECTA
WITH vendes AS (
SELECT producte_id, producte, SUM(quantitat) AS unitats
FROM v_detall_vendes GROUP BY producte_id, producte),
ranquing AS (
SELECT producte, unitats, ROW_NUMBER() OVER (ORDER BY unitats DESC, producte_id) AS posicio
FROM vendes)
SELECT * FROM ranquing WHERE posicio <= 3;| producte | unitats | posicio |
|---|---|---|
| Arròs integral ecològic 1 kg | 14 | 1 |
| Tomàquet triturat ecològic 400 g | 14 | 2 |
| Kombutxa de gingebre 750 ml | 12 | 3 |
Recorda-ho així: una funció de finestra no es filtra, s'embolcalla. El mateix val per al HAVING i per als JOIN.
- Rànquing:
ROW_NUMBER, RANK, DENSE_RANK, NTILE, PERCENT_RANK
ROW_NUMBER, RANK, DENSE_RANK, NTILE, PERCENT_RANK| Funció | Què retorna | Amb empats | Deixa forats? |
|---|---|---|---|
ROW_NUMBER() |
Número correlatiu 1, 2, 3… | Els trenca arbitràriament | No |
RANK() / DENSE_RANK() |
Posició esportiva / posició sense forats | Mateix número als empatats | Sí després de dos primers (3r) / no (2n) |
NTILE(n) / PERCENT_RANK() / CUME_DIST() |
n cubells de la mateixa mida / posició relativa entre 0 i 1 |
Reparteix per posició / igual que RANK |
— |
BotigaVerda té un empat perfecte per veure-ho: l'arròs i el tomàquet, amb 14 unitats venudes cadascun.
SELECT producte, SUM(quantitat) AS unitats,
ROW_NUMBER() OVER (ORDER BY SUM(quantitat) DESC, producte_id) AS row_number,
RANK() OVER (ORDER BY SUM(quantitat) DESC) AS rank,
DENSE_RANK() OVER (ORDER BY SUM(quantitat) DESC) AS dense_rank
FROM v_detall_vendes GROUP BY producte_id, producte ORDER BY unitats DESC, producte_id;| producte | unitats | row_number | rank | dense_rank |
|---|---|---|---|---|
| Arròs integral ecològic 1 kg | 14 | 1 | 1 | 1 |
| Tomàquet triturat ecològic 400 g | 14 | 2 | 1 | 1 |
| Kombutxa de gingebre 750 ml | 12 | 3 | 3 | 2 |
| Oli d'oliva verge extra 500 ml | 9 | 4 | 4 | 3 |
| Infusió de camamilla ecològica 20 u | 9 | 5 | 4 | 3 |
(5 primeres de 17 files; la sisena, el raspall de bambú amb 9 unitats, rep 6, 4 i 3; la setena, Pasta d'espelta amb 7 unitats, rep 7, 7 i 4: els 17 productes que s'han venut alguna vegada.) Llegeix-ho per columnes i no ho oblidaràs. ROW_NUMBER numera 1, 2, 3, 4, 5, 6, 7: no repeteix mai, i per això cal un desempat explícit (, p.id) o l'elecció és arbitrària i pot canviar entre execucions. RANK dona 1, 1, 3, 4, 4, 4, 7: els empatats comparteixen posició i el següent salta tants llocs com empatats hi hagués; és el podi esportiu. DENSE_RANK dona 1, 1, 2, 3, 3, 3, 4: comparteixen posició sense deixar forats, així que és "el segon millor valor", no "el segon".
Quina fer servir: ROW_NUMBER per triar una fila per grup (desduplicar, top 1); RANK per a un rànquing publicable; DENSE_RANK quan el que numeres són valors diferents, no files.
- Desplaçament:
LAG, LEAD, FIRST_VALUE, LAST_VALUE, NTH_VALUE
LAG, LEAD, FIRST_VALUE, LAST_VALUE, NTH_VALUEAquestes funcions miren una altra fila de la mateixa finestra. Sobre la sèrie mensual de BotigaVerda:
SELECT mes, facturacio, LAG(facturacio) OVER w AS mes_anterior,
LAG(facturacio, 2, 0) OVER w AS fa_2_mesos, LEAD(facturacio) OVER w AS mes_seguent
FROM mv_vendes_mensuals WINDOW w AS (ORDER BY mes) ORDER BY mes;| mes | facturacio | mes_anterior | fa_2_mesos | mes_seguent |
|---|---|---|---|---|
| 2025-03 | 68.80 | (null) | 0.00 | 61.28 |
| 2025-04 | 61.28 | 68.80 | 0.00 | 58.85 |
| 2025-05 | 58.85 | 61.28 | 68.80 | 95.48 |
(3 primeres de 12 files.) Els tres arguments de LAG i LEAD són (columna, desplaçament, valor_per_defecte): per omissió el desplaçament és 1 i el valor per defecte és NULL —d'aquí el *(null)* del primer mes—, mentre que LAG(facturacio, 2, 0) mira dues files enrere i retorna 0 quan no hi ha tal fila. Aquest tercer argument evita un COALESCE i, sobretot, evita que una resta es converteixi en NULL.
FIRST_VALUE, LAST_VALUE i NTH_VALUE(expr, n) retornen el valor de la primera, l'última i l'enèsima fila del marc. Sobre aquesta mateixa sèrie i amb el marc complet, FIRST_VALUE dona 68,80 € (març del 2025), LAST_VALUE dona 49,33 € (febrer del 2026) i NTH_VALUE(facturacio, 3) dona 58,85 € a les dotze files. Aquest "amb el marc complet" no és un detall: el final d'aquest apartat 6 explica per què sense ell el resultat és un altre.
- Agregats com a finestra, i el marc
Qualsevol funció d'agregació del mòdul 4 accepta OVER: SUM, AVG, COUNT, MIN, MAX, STRING_AGG, i també les que porten FILTER. La sintaxi és idèntica; l'única cosa que canvia és que el resultat s'enganxa a cada fila en lloc de col·lapsar-la.
SELECT e.nom || ' ' || e.cognoms AS empleat, e.salari,
ROUND(AVG(e.salari) OVER (), 2) AS mitjana_empresa,
ROUND(e.salari - AVG(e.salari) OVER (), 2) AS dif_mitjana,
SUM(e.salari) OVER (ORDER BY e.salari DESC, e.id) AS massa_acumulada
FROM empleats AS e ORDER BY e.salari DESC;| empleat | salari | mitjana_empresa | dif_mitjana | massa_acumulada |
|---|---|---|---|---|
| Rosa Alcázar Vives | 62000.00 | 35037.50 | 26962.50 | 62000.00 |
| Andrés Company Talens | 41000.00 | 35037.50 | 5962.50 | 103000.00 |
| Daniel Vercher Lluch | 35000.00 | 35037.50 | -37.50 | 177500.00 |
(3 dels 8 empleats —hi falta la Beatriz, 39.500 €, al lloc 3—; la columna acumulada tanca en 280.300,00 €, la massa salarial total.) Les xifres canòniques del curs —suma 280.300 €, mitjana 35.037,50 €— apareixen aquí al costat de cada empleat, i la lectura de negoci és immediata: el Daniel guanya 37,50 € menys que la mitjana exacta de l'empresa, i els tres primers salaris es mengen més de la meitat de la massa salarial.
El marc: ROWS enfront de RANGE
El marc defineix quines files del grup entren en el càlcul per a cada fila concreta. S'escriu ROWS BETWEEN inici AND fi o RANGE BETWEEN inici AND fi, amb aquests extrems:
| Extrem | Significa |
|---|---|
UNBOUNDED PRECEDING / UNBOUNDED FOLLOWING |
Des de la primera fila del grup / fins a l'última |
n PRECEDING / n FOLLOWING |
n files abans / després de l'actual |
CURRENT ROW |
L'actual (amb RANGE: l'actual i totes les seves empatades) |
I la diferència entre les dues paraules clau és exactament aquesta: ROWS compta files físiques —2 PRECEDING són les dues files de sobre, es diguin com es diguin—, mentre que RANGE compta valors de l'ORDER BY: totes les files amb el mateix valor d'ordenació que l'actual són parelles i entren o surten juntes.
SELECT producte, SUM(quantitat) AS unitats,
SUM(SUM(quantitat)) OVER (ORDER BY SUM(quantitat) DESC) AS acum_range,
SUM(SUM(quantitat)) OVER (ORDER BY SUM(quantitat) DESC, producte_id
ROWS UNBOUNDED PRECEDING) AS acum_rows
FROM v_detall_vendes GROUP BY producte_id, producte ORDER BY unitats DESC, producte_id;| producte | unitats | acum_range | acum_rows |
|---|---|---|---|
| Arròs integral ecològic 1 kg | 14 | 28 | 14 |
| Tomàquet triturat ecològic 400 g | 14 | 28 | 28 |
| Kombutxa de gingebre 750 ml | 12 | 40 | 40 |
| Oli d'oliva verge extra 500 ml | 9 | 67 | 49 |
(4 primeres de 17 files.) RANGE dona 28 als dos empatats —els suma junts, perquè valen el mateix— i ROWS dona 14 i 28, avançant fila a fila. Els tres productes de 9 unitats repeteixen el patró: 67, 67, 67 amb RANGE; 49, 58, 67 amb ROWS.
El parany de LAST_VALUE
El marc per omissió, quan hi ha ORDER BY i no escrius marc, és RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. És a dir: la finestra acaba a la fila actual. I aquí hi ha el parany més famós de tot SQL:
-- ⚠️ INCORRECTA: no retorna l'últim mes
SELECT mes, facturacio,
FIRST_VALUE(facturacio) OVER (ORDER BY mes) AS primer,
LAST_VALUE(facturacio) OVER (ORDER BY mes) AS ultim
FROM mv_vendes_mensuals ORDER BY mes;| mes | facturacio | primer | ultim |
|---|---|---|---|
| 2025-03 | 68.80 | 68.80 | 68.80 |
| 2025-04 | 61.28 | 68.80 | 61.28 |
(2 primeres de 12 files.) ultim és sempre la mateixa fila, perquè el marc hi acaba: l'"última fila de la finestra" és l'actual. FIRST_VALUE funciona per casualitat, perquè la primera fila del marc sí que és la primera de totes. La correcció és escriure el marc sencer: LAST_VALUE(facturacio) OVER (ORDER BY mes ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING).
I llavors ultim val 49.33 a les dotze files: la facturació del febrer del 2026. Regla: tan bon punt facis servir LAST_VALUE o NTH_VALUE, escriu el marc explícitament. I compte, tampoc no és cert que "sense ORDER BY no hi ha marc": sense ORDER BY, el marc per omissió és el grup sencer, que és justament el que fa que OVER () doni el total general.
WINDOW: donar nom a una finestra
WINDOW: donar nom a una finestraQuan la mateixa finestra apareix tres vegades, es nomena una vegada al final de la consulta —entre el HAVING i l'ORDER BY— i es referencia pel seu nom, com a l'apartat 5: SELECT mes, SUM(facturacio) OVER w, AVG(facturacio) OVER w FROM mv_vendes_mensuals WINDOW w AS (ORDER BY mes ROWS BETWEEN 2 PRECEDING AND CURRENT ROW);
Se'n poden declarar diverses separades per comes, i una pot heretar d'una altra: WINDOW w AS (PARTITION BY categoria_id), w2 AS (w ORDER BY preu DESC). És pur sucre sintàctic, però elimina la font d'errors més avorrida: canviar l'ORDER BY en dues de les tres còpies i oblidar-se de la tercera.
- Els casos de BotigaVerda
8.1. Acumulat, mitjana mòbil i variació mensual
Els tres càlculs que demana qualsevol quadre de comandament, en una sola consulta sobre la sèrie de dotze mesos:
SELECT mes, facturacio,
SUM(facturacio) OVER w AS acumulat,
ROUND(AVG(facturacio) OVER (ORDER BY mes
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW), 2) AS mitjana_mobil_3,
LAG(facturacio) OVER w AS mes_anterior,
ROUND(facturacio - LAG(facturacio) OVER w, 2) AS variacio,
ROUND(100 * (facturacio - LAG(facturacio) OVER w)
/ LAG(facturacio) OVER w, 2) AS variacio_pct
FROM mv_vendes_mensuals
WINDOW w AS (ORDER BY mes) ORDER BY mes;| mes | facturacio | acumulat | mitjana_mobil_3 | mes_anterior | variacio | variacio_pct |
|---|---|---|---|---|---|---|
| 2025-03 | 68.80 | 68.80 | 68.80 | (null) | (null) | (null) |
| 2025-04 | 61.28 | 130.08 | 65.04 | 68.80 | -7.52 | -10.93 |
| 2025-05 | 58.85 | 188.93 | 62.98 | 61.28 | -2.43 | -3.97 |
| 2025-06 | 95.48 | 284.41 | 71.87 | 58.85 | 36.63 | 62.24 |
| 2025-07 | 44.60 | 329.01 | 66.31 | 95.48 | -50.88 | -53.29 |
| 2025-09 | 32.76 | 410.04 | 41.88 | 48.27 | -15.51 | -32.13 |
| 2025-10 | 97.20 | 507.24 | 59.41 | 32.76 | 64.44 | 196.70 |
| 2025-11 | 31.70 | 538.94 | 53.89 | 97.20 | -65.50 | -67.39 |
| 2025-12 | 64.58 | 603.52 | 64.49 | 31.70 | 32.88 | 103.72 |
| 2026-01 | 75.10 | 678.62 | 57.13 | 64.58 | 10.52 | 16.29 |
| 2026-02 | 49.33 | 727.95 | 63.00 | 75.10 | -25.77 | -34.31 |
(Hi falta la fila del 2025-08: 48,27 € de facturació, 377,28 € d'acumulat, 62,78 € de mitjana mòbil i +3,67 € / +8,23 % respecte al juliol.) Tres lectures i un advertiment. L'acumulat tanca en 727,95 € i passa per 603,52 € al desembre del 2025: són les dues xifres canòniques del curs, i confirmen que la sèrie està bé. La mitjana mòbil de 3 mesos allisa el soroll: la facturació real salta entre 31,70 € i 97,20 €, mentre que la mitjana mòbil es mou en una banda molt més estreta, entre 41,88 € i 71,87 €. I la variació percentual és espectacular però enganyosa: aquest +196,70 % d'octubre no és cap boom comercial, és que el setembre va tenir una sola comanda. Amb volums petits, els percentatges menteixen — i aquesta és una lliçó d'anàlisi, no de SQL.
L'advertiment: les tres primeres files de mitjana_mobil_3 no són mitjanes de tres mesos, sinó d'un i de dos, perquè el marc 2 PRECEDING no té d'on estirar. Si l'informe ha de mostrar només mitjanes completes, cal anul·lar-les amb CASE WHEN COUNT(*) OVER (ORDER BY mes ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) = 3 THEN ... END.
Amb
PARTITION BY, l'acumulat es reinicia.SUM(facturacio) OVER (PARTITION BY left(mes, 4) ORDER BY mes)dona l'acumulat de l'any: el gener del 2026 torna a començar en 75,10 € i el febrer tanca en 124,43 €, la facturació del 2026.
8.2. Top N per categoria, i les tres maneres de fer-ho
Aquí es tanquen dues promeses: el DISTINCT ON de 02-04 i el LATERAL de 07-04. El patró canònic és ROW_NUMBER dins d'una CTE, filtrat fora:
WITH vendes AS (
SELECT dv.categoria_id, cat.nom AS categoria, dv.producte_id, dv.producte,
ROUND(SUM(dv.import), 2) AS facturacio
FROM v_detall_vendes AS dv JOIN categories AS cat ON cat.id = dv.categoria_id
GROUP BY dv.categoria_id, cat.nom, dv.producte_id, dv.producte)
SELECT categoria, producte, facturacio, posicio FROM (
SELECT *, ROW_NUMBER() OVER (PARTITION BY categoria_id
ORDER BY facturacio DESC, producte_id) AS posicio
FROM vendes) AS r
WHERE posicio <= 2 ORDER BY categoria, posicio;| categoria | producte | facturacio | posicio |
|---|---|---|---|
| Alimentació | Oli d'oliva verge extra 500 ml | 109.53 | 1 |
| Alimentació | Arròs integral ecològic 1 kg | 54.60 | 2 |
| Begudes | Te verd matcha cerimonial 30 g | 88.00 | 1 |
| Begudes | Kombutxa de gingebre 750 ml | 56.43 | 2 |
| Cosmètica natural | Crema facial d'àloe vera 50 ml | 70.42 | 1 |
| Cosmètica natural | Bàlsam labial de calèndula 15 ml | 32.20 | 2 |
| Higiene personal | Raspall de dents de bambú | 31.50 | 1 |
(6 de 9 files; tanquen Llar sostenible amb les bosses reutilitzables, 39,60 €, i el detergent, 32,48 €.) 9 files en total: quatre categories aporten dos productes i Higiene personal només un, perquè el desodorant no es va vendre mai. Complements no hi apareix: no ha venut res.
I la comparació amb les altres dues maneres, sobre l'exemple de 02-04 —el producte més car de cada categoria—, que les tres resolen amb les mateixes 6 files:
DISTINCT ON (02-04) |
LATERAL (07-04) |
ROW_NUMBER (aquí) |
|
|---|---|---|---|
| Com s'escriu | DISTINCT ON (categoria_id) ... ORDER BY categoria_id, preu DESC |
CROSS JOIN LATERAL (... LIMIT n) |
ROW_NUMBER() OVER (PARTITION BY ...) filtrat fora |
| És portable? | No: només PostgreSQL | Sí (CROSS APPLY a SQL Server) |
Sí: estàndard SQL, a tots els motors moderns |
| Top 1 / top N amb N > 1 | El més curt / no pot | Verbós / sí | Verbós / sí |
| Deixa veure la posició / grups buits | No / no hi apareixen | No / sí amb LEFT JOIN LATERAL ... ON TRUE |
Sí, columna / no hi apareixen |
| Rendiment amb índex per grup | Bo | El millor amb molts grups i LIMIT baix |
Bo; recorre tot el grup |
El criteri: top 1 a PostgreSQL i sense necessitat de portabilitat, DISTINCT ON; top N amb moltíssims grups i un índex que ho suporti, LATERAL; en qualsevol altre cas, ROW_NUMBER, que a més et regala la columna de posició.
8.3. Rànquing de clients
WITH vendes AS (
SELECT client_id, client, pais, ROUND(SUM(import), 2) AS facturacio
FROM v_detall_vendes GROUP BY client_id, client, pais)
SELECT client, pais, facturacio,
RANK() OVER (ORDER BY facturacio DESC) AS posicio,
NTILE(4) OVER (ORDER BY facturacio DESC) AS quartil,
ROUND(100 * facturacio / SUM(facturacio) OVER (), 2) AS pct_total,
ROUND(100 * SUM(facturacio) OVER (ORDER BY facturacio DESC)
/ SUM(facturacio) OVER (), 2) AS pct_acumulat
FROM vendes ORDER BY facturacio DESC;| client | pais | facturacio | posicio | quartil | pct_total | pct_acumulat |
|---|---|---|---|---|---|---|
| Sofia Moreira Costa | Portugal | 111.88 | 1 | 1 | 15.37 | 15.37 |
| Lucía Martínez Soler | Espanya | 107.60 | 2 | 1 | 14.78 | 30.15 |
| Camille Dubois | França | 70.87 | 3 | 1 | 9.74 | 39.89 |
| Carlos Ferrer Ibáñez | Espanya | 59.46 | 6 | 2 | 8.17 | 65.89 |
(4 dels 12 clients amb compres — hi falten Julien Moreau, 4t amb 66,90 €, i Javier Ortega Ruiz, 5è amb 62,93 €; tanquen el Diego amb 31,70 €, l'Elena amb 30,30 € i la Marta amb 29,53 €.) La columna pct_acumulat és una anàlisi de Pareto feta amb una funció de finestra: els 6 primers clients expliquen el 65,89 % de la facturació. I fixa't que NTILE(4) reparteix els 12 clients en quatre quartils d'exactament 3, sense mirar els imports: reparteix per posició, no per valor.
8.4. Cada producte enfront de la mitjana de la seva categoria
A 07-02 això es resolia amb una subconsulta correlacionada que recorria productes una vegada per fila. Amb PARTITION BY és una sola passada:
SELECT p.id, p.nom AS producte, cat.nom AS categoria, p.preu,
ROUND(AVG(p.preu) OVER w, 2) AS mitjana_categoria,
ROUND(p.preu - AVG(p.preu) OVER w, 2) AS diferencia,
ROUND(100 * p.preu / AVG(p.preu) OVER w, 2) AS pct_sobre_mitjana
FROM productes AS p JOIN categories AS cat ON cat.id = p.categoria_id
WINDOW w AS (PARTITION BY p.categoria_id) ORDER BY cat.id, p.preu DESC;| id | producte | categoria | preu | mitjana_categoria | diferencia | pct_sobre_mitjana |
|---|---|---|---|---|---|---|
| 1 | Oli d'oliva verge extra 500 ml | Alimentació | 12.50 | 6.18 | 6.32 | 202.27 |
| 3 | Mel de tarongina crua 500 g | Alimentació | 9.75 | 6.18 | 3.57 | 157.77 |
| 5 | Tomàquet triturat ecològic 400 g | Alimentació | 1.95 | 6.18 | -4.23 | 31.55 |
| 6 | Crema facial d'àloe vera 50 ml | Cosmètica natural | 18.90 | 11.54 | 7.36 | 163.81 |
| 15 | Te verd matcha cerimonial 30 g | Begudes | 22.00 | 8.90 | 13.10 | 247.19 |
(5 de les 20 files.) Els 20 productes hi continuen sent, cadascun amb la mitjana de la seva categoria al costat. El matcha, a 22,00 €, costa gairebé dues vegades i mitja la mitjana de Begudes (8,90 €); l'oli, una mica més del doble de la mitjana d'Alimentació (6,18 €). Cap subconsulta, cap repetició, una sola lectura de productes.
- Rendiment i dialecte
Una funció de finestra obliga el motor a ordenar per PARTITION BY + ORDER BY abans de calcular; a l'EXPLAIN hi veuràs un node WindowAgg gairebé sempre precedit d'un Sort. Tres conseqüències pràctiques: un índex sobre (columna_particio, columna_ordre) pot eliminar aquest Sort; diverses funcions que comparteixen la mateixa finestra es calculen en un sol WindowAgg, així que reutilitzar la finestra (amb WINDOW) també és més ràpid; i filtra abans amb WHERE sempre que puguis, perquè la finestra treballarà sobre menys files. Tot i així, una funció de finestra gairebé sempre guanya a l'alternativa: on una correlacionada fa N passades, la finestra en fa una.
Nota de dialecte: les funcions de finestra són estàndard SQL:2003 i avui són a tot arreu: PostgreSQL (des de la 8.4, amb el conjunt més complet), MySQL 8.0, MariaDB 10.2, SQLite 3.25, SQL Server 2012 i Oracle. Diferències que convé conèixer: SQL Server no admet
RANGEambn PRECEDING(nomésROWS) i fins al 2022 no va tenirIGNORE NULLSaLAG/LEAD; MySQL 8 no té la clàusulaFILTER; Oracle les anomena analytic functions i hi afegeixKEEP (DENSE_RANK FIRST/LAST); iGROUPScom a tercera modalitat de marc (a més deROWSiRANGE), juntament ambEXCLUDE, existeix a PostgreSQL 11+ i falta a la majoria.
Errors habituals i consells
- Filtrar per una funció de finestra al
WHEREo l'HAVING.window functions are not allowed in WHERE. Es calculen després. Embolcalla-la en una CTE o una taula derivada i filtra fora. - Esperar que
LAST_VALUEretorni l'últim valor. Amb el marc per omissió retorna la fila actual. EscriuROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING. I al revés: afegirORDER BYa unSUM() OVERsense voler un acumulat el converteix en acumulat; si vols el total del grup, no hi posisORDER BY. - Fer servir
ROW_NUMBERsense desempat. Amb valors repetits, quina fila rep l'1 és arbitrari i pot canviar entre execucions. Afegeix sempre una columna única a l'ORDER BYde la finestra. - Confondre
RANKambDENSE_RANK—el primer deixa forats després d'un empat (1, 1, 3) i el segon no (1, 1, 2)— o creure queRANGEiROWSsón sinònims: només ho són sense empats; amb empats,RANGEels fica tots junts (28 en lloc de 14). - Oblidar que la finestra només veu el que ha passat el
WHERE: un "percentatge sobre el total" en una consulta filtrada és el percentatge sobre el total filtrat. I posar una funció de finestra dins d'un agregat:SUM(ROW_NUMBER() OVER ())no és vàlid; a l'inrevés sí,SUM(SUM(x)) OVER (...)és correcte i freqüent — l'agregat es calcula primer, la finestra després. - Consell: comença sempre per
OVER ()i ves afegint. Primer el total general, desprésPARTITION BY, desprésORDER BY, i només al final el marc. Executa després de cada pas i mira com canvia la columna. - Consell:
ROWSper omissió, que no té sorpreses amb els empats;RANGEnomés quan el comportament per parelles sigui deliberat. I anomena la finestra ambWINDOWtan bon punt es repeteixi dues vegades: evites canviar l'ORDER BYen una còpia i no a l'altra, i el motor la calcula una sola vegada.
Exercicis
Exercici 1
Sobre les 47 línies de comanda, escriu una consulta que mostri, per a cada línia: la comanda, el producte, el seu import, el total de la seva comanda, el percentatge que representa dins de la comanda i la seva posició dins de la comanda per import. Sense GROUP BY, sense subconsultes: només OVER. Comprova a la comanda 1 que els percentatges sumen 100 i el total és 42,10 €.
Exercici 2
Direcció vol els tres clients que més facturen de cada país, amb la seva posició. (1) Escriu-ho amb ROW_NUMBER i una CTE. (2) Quantes files retorna i per què no en són 9? (3) Si en lloc de "els tres primers" demanessin "tots els que empaten al tercer lloc", quina funció faries servir?
Exercici 3
Un company vol marcar els mesos en què la facturació va superar la mitjana dels dotze mesos i ha escrit SELECT mes, facturacio FROM mv_vendes_mensuals WHERE facturacio > AVG(facturacio) OVER ();. (1) Quin error dona i per què? (2) Corregeix-ho amb una CTE, mostrant també la mitjana i la diferència. (3) Quants mesos superen la mitjana i quina és aquesta mitjana?
Solucions
Solució 1
SELECT comanda_id, producte, import,
SUM(import) OVER w AS total_comanda,
ROUND(100 * import / SUM(import) OVER w, 2) AS pct_comanda,
ROW_NUMBER() OVER (PARTITION BY comanda_id
ORDER BY import DESC, linia_id) AS posicio_en_comanda
FROM v_detall_vendes
WINDOW w AS (PARTITION BY comanda_id) ORDER BY comanda_id, posicio_en_comanda;| comanda_id | producte | import | total_comanda | pct_comanda | posicio_en_comanda |
|---|---|---|---|---|---|
| 1 | Oli d'oliva verge extra 500 ml | 23.90 | 42.10 | 56.77 | 1 |
| 1 | Arròs integral ecològic 1 kg | 11.70 | 42.10 | 27.79 | 2 |
| 1 | Infusió de camamilla ecològica 20 u | 6.50 | 42.10 | 15.44 | 3 |
(3 primeres de 47 files.) 56,77 + 27,79 + 15,44 = 100,00 i el total de la comanda 1 és 42,10 €, com a 07-04. Les 47 files es conserven: PARTITION BY comanda_id calcula el total per comanda i l'enganxa a cadascuna de les seves línies. Aquí es veu el guany: amb GROUP BY hi hauria 20 files i cap detall.
Solució 2 — 1.
WITH vendes AS (
SELECT pais, client, ROUND(SUM(import), 2) AS facturacio
FROM v_detall_vendes GROUP BY client_id, pais, client)
SELECT * FROM (
SELECT *, ROW_NUMBER() OVER (PARTITION BY pais ORDER BY facturacio DESC) AS posicio
FROM vendes) AS r
WHERE posicio <= 3 ORDER BY pais, posicio;| pais | client | facturacio | posicio |
|---|---|---|---|
| Espanya | Lucía Martínez Soler | 107.60 | 1 |
| Espanya | Javier Ortega Ruiz | 62.93 | 2 |
| Espanya | Carlos Ferrer Ibáñez | 59.46 | 3 |
| França | Camille Dubois | 70.87 | 1 |
| França | Julien Moreau | 66.90 | 2 |
| Portugal | Sofia Moreira Costa | 111.88 | 1 |
(6 de 7 files; tanca Tiago Almeida Nunes, 2n de Portugal amb 44,60 €.) 2. Set files, no nou, perquè França i Portugal només tenen dos clients amb compres cadascun. ROW_NUMBER no s'inventa files: si un grup en té menys de N, aporta les que tingui. (Espanya té 8 clients amb compres i n'aporta 3.)
3. RANK(), no DENSE_RANK ni ROW_NUMBER. Amb RANK, tots els empatats al tercer valor reben el 3 i WHERE posicio <= 3 els retorna tots. ROW_NUMBER hauria tallat arbitràriament per un; DENSE_RANK n'hauria retornat de més, perquè el seu "3" és el tercer valor diferent, no el tercer lloc.
Solució 3 — 1. Dona ERROR: window functions are not allowed in WHERE. El motor avalua el WHERE abans de calcular les funcions de finestra, així que en aquell moment AVG(facturacio) OVER () encara no existeix; i no podria existir, perquè la finestra necessita saber quines files passen el filtre i el filtre necessita el valor de la finestra. 2 i 3: amb WITH mensual AS (SELECT mes, facturacio, ROUND(AVG(facturacio) OVER (), 2) AS mitjana_anual FROM mv_vendes_mensuals) i després SELECT mes, facturacio, mitjana_anual, ROUND(facturacio - mitjana_anual, 2) AS diferencia FROM mensual WHERE facturacio > mitjana_anual ORDER BY facturacio DESC, en surten sis dels dotze mesos per sobre d'una mitjana de 60,66 € (727,95 € entre 12): 2025-10 amb +36,54 €, 2025-06 amb +34,82 €, 2026-01 amb +14,44 €, 2025-03 amb +8,14 €, 2025-12 amb +3,92 € i 2025-04 amb +0,62 €. La mitjana es calcula una sola vegada a la CTE i queda disponible com a columna normal: per això es pot filtrar per ella i restar-la en el mateix pas.
Conclusió
Les funcions de finestra són l'eina que faltava des del mòdul 4:
- Una funció de finestra calcula sobre un grup de files sense col·lapsar-les: les 47 línies de BotigaVerda surten intactes, cadascuna amb el seu total, el seu percentatge o la seva posició al costat.
OVER ()és la finestra buida —el total general— i és per on cal començar. L'anatomia ésOVER (PARTITION BY ... ORDER BY ... marc), amb les tres parts opcionals, i afegirORDER BYcanvia el resultat d'un agregat: activa el marc "fins a la fila actual" i converteix el total en acumulat. - S'avaluen després de
WHERE,GROUP BYiHAVING, i per això no s'hi pot filtrar alWHERE(window functions are not allowed in WHERE). La solució universal: calcular en una CTE i filtrar fora. No es filtra, s'embolcalla. - Rànquing:
ROW_NUMBERnumera sense repetir (1, 2, 3),RANKempata i deixa forat (1, 1, 3),DENSE_RANKempata sense forat (1, 1, 2) — demostrat amb l'empat real de l'arròs i el tomàquet a 14 unitats. A mésNTILEper a quartils iPERCENT_RANKper a posicions relatives. Desplaçament:LAGiLEADamb els seus arguments(columna, n, per_defecte), iFIRST_VALUE/LAST_VALUE/NTH_VALUE, que exigeixen marc explícit. - El marc:
ROWScompta files físiques,RANGEagrupa les files amb el mateix valor d'ordenació (28 en lloc de 14 a l'empat de l'arròs). El marc per omissió ambORDER BYésRANGE UNBOUNDED PRECEDING AND CURRENT ROW—d'aquí queLAST_VALUEretorni la fila actual i no l'última—; senseORDER BY, el grup sencer. - Els casos de BotigaVerda, tots verificats: acumulat mensual que tanca en 727,95 € passant per 603,52 € al desembre; mitjana mòbil de 3 mesos entre 41,88 € i 71,87 €; variació mensual amb el seu
+196,70 %enganyós d'octubre; top 2 per categoria ambROW_NUMBER(9 files) i la taula que el confronta ambDISTINCT ONi ambLATERAL; rànquing de clients amb el Pareto que dona un 65,89 % acumulat en els sis primers; i cada producte enfront de la mitjana de la seva categoria en una sola passada, on 07-02 feia una correlacionada.
Amb això ja no hi ha cap pregunta analítica sobre BotigaVerda que no sàpigues escriure. Però tot el que has fet fins ara viu en una sentència: s'escriu, s'executa i s'oblida. El següent és desar lògica, no només consultes. A la lliçó següent, procediments emmagatzemats, veuràs codi que viu dins de la base de dades: la diferència real entre una funció i un procediment a PostgreSQL, el just de PL/pgSQL per escriure alguna cosa útil —variables, IF, bucles, RAISE, gestió d'excepcions—, i tres exemples en ordre de dificultat que acaben en sp_confirmar_comanda, el procediment on per fi viu la lògica transaccional que vas construir a mà a 09-03. I, sobretot, la discussió honesta sobre què val la pena posar-hi a dins i què no.
Curs de SQL
Mòdul 1: Introducció a SQL
- Què és SQL?
- Configurar el teu entorn SQL
- Sintaxi bàsica de SQL
- Entendre bases de dades i taules
- El model relacional: claus primàries i foranes
- La base de dades del curs: BotigaVerda
Mòdul 2: Consultes bàsiques de SQL
- Instrucció SELECT
- Àlies, expressions i columnes calculades
- Filtrar dades amb WHERE
- DISTINCT i eliminació de duplicats
- Ordenar dades amb ORDER BY
- Limitar resultats amb LIMIT
Mòdul 3: Treballar amb múltiples taules
- Operacions JOIN
- INNER JOIN
- LEFT JOIN
- RIGHT JOIN
- FULL OUTER JOIN
- SELF JOIN i CROSS JOIN
- Unions de conjunts: UNION, INTERSECT i EXCEPT
Mòdul 4: Filtratge avançat de dades
- Utilitzar LIKE per a coincidència de patrons
- Operadors IN i BETWEEN
- Valors NULL i IS NULL
- Funcions d'agregació: COUNT, SUM, AVG, MIN i MAX
- Agregar dades amb GROUP BY
- Clàusula HAVING
Mòdul 5: Manipulació de dades
- Crear taules i restriccions amb CREATE TABLE
- Instrucció INSERT
- Instrucció UPDATE
- Instrucció DELETE
- Instrucció UPSERT (MERGE)
- Modificar l'esquema: ALTER TABLE i migracions segures
Mòdul 6: Funcions avançades de SQL
- Funcions de cadena
- Funcions numèriques
- Funcions de data i hora
- Conversió de tipus i tractament dels NULL: CAST i COALESCE
- Expressions condicionals
Mòdul 7: Subconsultes i consultes imbricades
- Introducció a les subconsultes
- Subconsultes correlacionades
- EXISTS i NOT EXISTS
- Utilitzar subconsultes en les clàusules SELECT, FROM i WHERE
- Subconsultes o JOIN: quin triar
Mòdul 8: Índexs i optimització del rendiment
- Entendre els índexs
- Creació i gestió d'índexs
- Tipus d'índex i quan no indexar
- Tècniques d'optimització de consultes
- Anàlisi del rendiment de les consultes
Mòdul 9: Transaccions i concurrència
- Introducció a les transaccions
- Propietats ACID
- Instruccions de control de transaccions
- Nivells d'aïllament i anomalies de concurrència
- Gestió de la concurrència: bloquejos i interbloquejos
Mòdul 10: Temes avançats
- Vistes
- Expressions de taula comunes (CTE)
- Funcions de finestra
- Procediments emmagatzemats
- Disparadors (triggers)
- JSON i dades semiestructurades
Mòdul 11: SQL a la pràctica
- Casos d'ús al món real
- Bones pràctiques
- Seguretat: injecció SQL, permisos i rols
- SQL per a l'anàlisi de dades
- SQL en el desenvolupament web
