Repàs de SQL avançat
On es treballa el SQL des de zero
Els fonaments de SQL (SELECT, JOIN, subconsultes, GROUP BY, window functions bàsiques...) es treballen íntegrament al mòdul M0372 — Gestió de Bases de Dades:
Aquest bloc dona per fet que ja domines el SQL treballat al mòdul M0372 (Gestió de Bases de Dades): SELECT/FROM/WHERE, JOIN, subconsultes, GROUP BY/HAVING i les window functions bàsiques (ROW_NUMBER, RANK, LAG/LEAD, OVER (PARTITION BY ...)).
Aquesta pàgina no és matèria nova ni té sessió pròpia: és material de repàs i aprofundiment, pensat perquè el revisis pel teu compte abans (o durant) les primeres sessions del bloc. Les tècniques que veuràs aquí — CTEs recursives, window functions amb frames explícits, GROUPING SETS, MERGE, LATERAL/APPLY — apareixen sovint a l'hora de reescriure queries lentes (Optimització de queries) i a la pràctica avaluable del bloc.
Per què un repàs de SQL en un mòdul de Big Data
Abans d'optimitzar una consulta (índexs, particionament, plans d'execució) cal saber-la escriure bé. Moltes consultes "lentes" no ho són per manca d'índexs, sinó perquè estan mal plantejades: un JOIN que hauria de ser una window function, una subconsulta correlacionada que hauria de ser un LATERAL, o un GROUP BY repetit tres vegades que un sol GROUPING SETS resoldria en una passada.
CTEs recursives
Una Common Table Expression (CTE) és una consulta amb nom que es defineix amb WITH i es pot reutilitzar dins de la consulta principal. Una CTE recursiva es defineix a partir de si mateixa, i és la manera estàndard de recórrer estructures jeràrquiques (organigrames, categories niuades, arbres de comentaris) en SQL.
Una CTE recursiva té sempre dues parts unides per UNION ALL: el cas base (l'arrel de la jerarquia) i el cas recursiu (que fa referència a la pròpia CTE i s'atura quan no troba més files).
-- Organigrama: tots els subordinats (directes i indirectes) d'un empleat
WITH RECURSIVE cadena_comandament AS (
-- Cas base: l'empleat arrel
SELECT id, nom, cap_id, 1 AS nivell
FROM empleats
WHERE id = 1
UNION ALL
-- Cas recursiu: els subordinats de cada fila ja trobada
SELECT e.id, e.nom, e.cap_id, cc.nivell + 1
FROM empleats e
INNER JOIN cadena_comandament cc ON e.cap_id = cc.id
)
SELECT * FROM cadena_comandament ORDER BY nivell;
-- Suportat des de MySQL 8.0 / MariaDB 10.2 amb la mateixa sintaxi WITH RECURSIVE
WITH RECURSIVE cadena_comandament AS (
SELECT id, nom, cap_id, 1 AS nivell
FROM empleats
WHERE id = 1
UNION ALL
SELECT e.id, e.nom, e.cap_id, cc.nivell + 1
FROM empleats e
INNER JOIN cadena_comandament cc ON e.cap_id = cc.id
)
SELECT * FROM cadena_comandament ORDER BY nivell;
-- SQL Server admet WITH RECURSIVE (sense la paraula RECURSIVE explícita)
WITH cadena_comandament AS (
SELECT id, nom, cap_id, 1 AS nivell
FROM empleats
WHERE id = 1
UNION ALL
SELECT e.id, e.nom, e.cap_id, cc.nivell + 1
FROM empleats e
INNER JOIN cadena_comandament cc ON e.cap_id = cc.id
)
SELECT * FROM cadena_comandament
ORDER BY nivell
OPTION (MAXRECURSION 100); -- límit de seguretat, per defecte 100
-- Oracle admet WITH ... (RECURSIVE opcional des d'11g R2) i també l'alternativa clàssica CONNECT BY
WITH cadena_comandament (id, nom, cap_id, nivell) AS (
SELECT id, nom, cap_id, 1
FROM empleats
WHERE id = 1
UNION ALL
SELECT e.id, e.nom, e.cap_id, cc.nivell + 1
FROM empleats e
INNER JOIN cadena_comandament cc ON e.cap_id = cc.id
)
SELECT * FROM cadena_comandament ORDER BY nivell;
-- Alternativa idiomàtica d'Oracle, anterior a les CTE recursives:
SELECT id, nom, cap_id, LEVEL AS nivell
FROM empleats
START WITH id = 1
CONNECT BY PRIOR id = cap_id;
Recursió infinita
Si les dades tenen un cicle (per error, un empleat que és cap del seu propi cap) i no hi ha límit de recursió, la consulta no acabarà mai o exhaurirà la memòria. PostgreSQL i MySQL no tenen límit per defecte (cal afegir-lo manualment amb una condició WHERE nivell < N al cas recursiu); SQL Server el limita a 100 nivells per defecte (OPTION (MAXRECURSION n) per canviar-ho).
Window functions avançades: frames explícits
El repàs de M372 cobreix ROW_NUMBER, RANK i LAG/LEAD amb PARTITION BY. El que sovint no s'explicita és la clàusula de frame (ROWS BETWEEN / RANGE BETWEEN), que defineix exactament quines files "veu" la finestra per a cada fila — imprescindible per calcular mitjanes mòbils o acumulats parcials.
-- Sintaxi general del frame (PostgreSQL, MySQL 8+, SQL Server, Oracle: idèntica)
funció_finestra() OVER (
PARTITION BY columna
ORDER BY columna_ordre
ROWS BETWEEN <inici> AND <final>
)
| Expressió | Significat |
|---|---|
UNBOUNDED PRECEDING |
Des de la primera fila de la partició |
N PRECEDING |
Les N files anteriors |
CURRENT ROW |
La fila actual |
N FOLLOWING |
Les N files següents |
UNBOUNDED FOLLOWING |
Fins a l'última fila de la partició |
-- Mitjana mòbil de 3 dies (l'actual i els 2 anteriors) — vàlid als 4 motors
SELECT
data_venda,
import_total,
AVG(import_total) OVER (
ORDER BY data_venda
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
) AS mitjana_mobil_3dies,
SUM(import_total) OVER (
ORDER BY data_venda
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS acumulat_total
FROM vendes;
ROWS vs RANGE: no són el mateix
ROWS BETWEEN compta files físiques. RANGE BETWEEN agrupa per valor de la columna d'ORDER BY: si dues files empaten en el valor (per exemple, dues vendes el mateix dia), RANGE les tracta com una sola posició de la finestra i totes dues reben el mateix resultat, mentre que ROWS les tracta per separat. Quan hi pot haver empats a la columna d'ordenació, RANGE sol donar el resultat que s'espera intuïtivament; per defecte (sense especificar ROWS/RANGE), el motor usa RANGE UNBOUNDED PRECEDING.
Altres funcions de finestra útils més enllà de ROW_NUMBER/RANK/LAG/LEAD:
NTILE(n): reparteix les files enngrups de mida (gairebé) igual — útil per a quartils, decils, segmentació de clients per volum de compra.PERCENT_RANK()iCUME_DIST(): posició relativa (0 a 1) d'una fila dins la seva partició, útils per a percentils.FIRST_VALUE()/LAST_VALUE(): primer o últim valor de la finestra (ambLAST_VALUE, cal anar amb compte amb el frame per defecte — sol requerirROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWINGper obtenir el resultat esperat).
-- Segmentació de clients en 4 grups (quartils) segons import total gastat
SELECT
client_id,
SUM(import_total) AS total_gastat,
NTILE(4) OVER (ORDER BY SUM(import_total) DESC) AS quartil
FROM vendes
GROUP BY client_id;
Agregació condicional i GROUPING SETS / ROLLUP / CUBE
Un patró molt habitual és calcular diverses agregacions amb condicions diferents en una sola passada, en lloc de llançar diverses queries o fer diversos JOIN:
-- Agregació condicional: una sola passada per la taula, quatre mètriques
SELECT
categoria,
COUNT(*) AS total_comandes,
SUM(CASE WHEN estat = 'completat' THEN 1 ELSE 0 END) AS completades,
SUM(CASE WHEN estat = 'cancel·lat' THEN 1 ELSE 0 END) AS cancel·lades,
ROUND(
100.0 * SUM(CASE WHEN estat = 'completat' THEN 1 ELSE 0 END) / COUNT(*), 1
) AS pct_completades
FROM comandes
GROUP BY categoria;
Quan calen subtotals a diversos nivells de detall alhora (per categoria, per any, i el total general, tot en un sol resultat), GROUP BY sol no n'hi ha prou. GROUPING SETS, ROLLUP i CUBE generen diverses agrupacions en una sola consulta:
-- ROLLUP: subtotals jeràrquics (categoria+any, categoria, total general)
SELECT categoria, EXTRACT(YEAR FROM data_venda) AS any_venda, SUM(import_total) AS total
FROM vendes
GROUP BY ROLLUP (categoria, any_venda);
-- GROUPING SETS: només les combinacions que interessen (control total)
SELECT categoria, EXTRACT(YEAR FROM data_venda) AS any_venda, SUM(import_total) AS total
FROM vendes
GROUP BY GROUPING SETS (
(categoria, any_venda),
(categoria),
() -- total general
);
CUBE vs ROLLUP vs GROUPING SETS
ROLLUP(a, b) genera subtotals jeràrquics: (a,b), (a), () — respecta l'ordre. CUBE(a, b) genera totes les combinacions possibles: (a,b), (a), (b), (). GROUPING SETS et deixa triar exactament quines combinacions vols, sense les que no necessitis. ROLLUP i CUBE són, de fet, casos particulars de GROUPING SETS.
MERGE / UPSERT: inserir o actualitzar en una sola sentència
Un patró molt freqüent en càrregues ETL és: "si la fila ja existeix, actualitza-la; si no existeix, insereix-la" (conegut com a upsert). Fer-ho amb un SELECT previ i després un INSERT o UPDATE condicional té una condició de carrera: entre el SELECT i l'INSERT, una altra transacció podria haver inserit la mateixa fila.
-- MERGE: l'estàndard SQL complet, també disponible a Oracle i PostgreSQL 15+
MERGE INTO estoc AS desti
USING (SELECT 101 AS producte_id, 50 AS quantitat) AS origen
ON desti.producte_id = origen.producte_id
WHEN MATCHED THEN
UPDATE SET
quantitat = desti.quantitat + origen.quantitat,
ultima_actualitzacio = GETDATE()
WHEN NOT MATCHED THEN
INSERT (producte_id, quantitat, ultima_actualitzacio)
VALUES (origen.producte_id, origen.quantitat, GETDATE());
-- MERGE, la sentència que va originar l'estàndard SQL:2003
MERGE INTO estoc desti
USING (SELECT 101 AS producte_id, 50 AS quantitat FROM dual) origen
ON (desti.producte_id = origen.producte_id)
WHEN MATCHED THEN
UPDATE SET
quantitat = desti.quantitat + origen.quantitat,
ultima_actualitzacio = SYSDATE
WHEN NOT MATCHED THEN
INSERT (producte_id, quantitat, ultima_actualitzacio)
VALUES (origen.producte_id, origen.quantitat, SYSDATE);
MERGE també a PostgreSQL des de la 15
PostgreSQL 15 va afegir MERGE amb sintaxi pràcticament idèntica a la d'Oracle/SQL Server, com a alternativa a ON CONFLICT quan la lògica és més complexa (per exemple, WHEN MATCHED AND condició THEN ... amb diverses branques).
LATERAL / APPLY: subconsultes correlacionades al FROM
Una subconsulta normal al FROM no pot fer referència a columnes d'altres taules de la mateixa consulta. LATERAL (PostgreSQL, MySQL 8+) i CROSS APPLY/OUTER APPLY (SQL Server) ho permeten: és la manera idiomàtica de resoldre el patró "top N per grup".
-- CROSS APPLY: equivalent a un LATERAL JOIN (INNER); OUTER APPLY equival a un LEFT JOIN LATERAL
SELECT c.nom, ultimes.data_comanda, ultimes.import_total
FROM clients c
CROSS APPLY (
SELECT TOP 3 data_comanda, import_total
FROM comandes
WHERE comandes.client_id = c.id
ORDER BY data_comanda DESC
) AS ultimes;
-- Oracle 12c+ suporta LATERAL de manera explícita; alternativa clàssica: window function + filtre
SELECT c.nom, ultimes.data_comanda, ultimes.import_total
FROM clients c
CROSS JOIN LATERAL (
SELECT data_comanda, import_total
FROM comandes
WHERE comandes.client_id = c.id
ORDER BY data_comanda DESC
FETCH FIRST 3 ROWS ONLY
) ultimes;
Alternativa sense LATERAL: window function + filtre
El mateix resultat es pot obtenir amb ROW_NUMBER() OVER (PARTITION BY client_id ORDER BY data_comanda DESC) dins d'una CTE i un WHERE row_num <= 3 a la consulta externa. Sovint és més llegible per a "top N global per grup", mentre que LATERAL/APPLY és més flexible quan la subconsulta necessita lògica addicional (agregacions pròpies, diverses columnes calculades).
Operadors de conjunts: UNION, INTERSECT, EXCEPT
Combinen els resultats de diverses consultes amb el mateix nombre de columnes i tipus compatibles:
| Operador | Significat | A SQL Server / Oracle |
|---|---|---|
UNION |
Unió, eliminant duplicats | Igual |
UNION ALL |
Unió, conservant duplicats (més ràpid: no cal ordenar/deduplicar) | Igual |
INTERSECT |
Files presents a totes dues consultes | Igual |
EXCEPT |
Files de la primera consulta absents a la segona | A Oracle: MINUS |
-- Clients que han comprat el 2025 PERÒ NO el 2024 (churn candidats)
SELECT client_id FROM comandes WHERE EXTRACT(YEAR FROM data_comanda) = 2024
EXCEPT
SELECT client_id FROM comandes WHERE EXTRACT(YEAR FROM data_comanda) = 2025;
-- A Oracle: substituir EXCEPT per MINUS
UNION ALL per defecte si no cal deduplicar
UNION fa un DISTINCT implícit sobre tot el resultat combinat, cosa que té un cost real en consultes grans (cal ordenar o fer servir una taula hash per detectar duplicats). Si saps que els conjunts no es solapen (per exemple, per construcció, com particions per any), fes servir sempre UNION ALL.
Exercici pràctic
Sense necessitat de cap dataset extern: sobre la mateixa base de dades de vendes que faràs servir a la pràctica PR507402 (o una versió reduïda de prova), escriu les consultes següents aplicant les tècniques d'aquesta pàgina:
- Una CTE recursiva que, donada una taula
categoriesamb una columnacategoria_pare_id, llisti totes les subcategories (a qualsevol profunditat) d'una categoria arrel. - Una window function amb frame explícit que calculi la mitjana mòbil de 7 dies de les vendes diàries.
- Una consulta amb
GROUPING SETS(oROLLUP) que retorni, en un sol resultat, el total de vendes per categoria, per any, i el total general. - Un
MERGE/UPSERTque actualitzi l'estoc d'un producte si ja existeix o l'insereixi si no. - Una consulta amb
LATERAL/CROSS APPLY(o l'alternativa amb window function) que trobi les 3 comandes de més import de cada client.
Guarda les cinc consultes en un fitxer .sql comentat: no cal lliurar-lo, però serà la base per a les reescriptures de queries que faràs a l'activitat AC5074/02/04.
Bloc 2 | Mòdul M5074 Sistemes de Big Data | Institut Sa Palomera (Blanes) | Curs CEIABD 2026-2027