Saltar al contenido

028 — CTE, subconsultas y funciones de ventana

🗂️ parte 🎚️ nivel ⏱️ duración 📗 clase

Programa · Parte 04 · ← Anterior · Siguiente →

Parte 04 — SQL en profundidad · Intermedio · 4 horas estimadas · motores postgresql, sqlite, duckdb · laboratorio labs/01-sql-foundations · 4 fuentes.

Conceptos centrales: CTE · recursión · subconsulta correlacionada · partición de ventana · marco

En este caso se comparan 6 motores: 5 lo resuelven (5 con el resultado comprobado por máquina) y 1 no, con el motivo escrito.

De qué trata esta clase

Las herramientas para consultas que no caben en una sola expresión: CTE para nombrar pasos, recursión para recorrer jerarquías y funciones de ventana para calcular por grupo sin perder el detalle. La sutileza que más resultados cambia es el marco por defecto de una ventana con ORDER BY.

flowchart LR
    C["🗄️ Clase 028"]
    C --> K1["CTE"]
    C --> K2["recursión"]
    C --> K3["subconsulta correlacionada"]
    C --> K4["partición de ventana"]
    C --> K5["marco"]
    classDef raiz fill:#0b3d2e,stroke:#3fb950,color:#fff
    class C raiz

Antes de empezar

Esta clase supone que ya trabajaste lo siguiente. Si algo de la última columna no te suena, vuelve a esa clase antes de seguir: aquí se usa sin volver a explicarlo.

# Clase previa Lo que se da por sabido
026 Reuniones: interna, externa, semi y anti reunión interna · reunión externa · semirreunion · antirreunion · multiplicación de filas
027 Agregación, GROUP BY y HAVING sin duplicar filas agrupación · agregado · HAVING · doble conteo · dependencia funcional en GROUP BY

Vocabulario de la clase

Los términos que siguen se usan más adelante con este significado exacto. La definición completa, con sus términos relacionados, está en el glosario del programa.

Término Qué significa Procedencia
CTE Expresión de tabla común (WITH … AS): un resultado con nombre, visible en la consulta que la sigue. Sirve para nombrar pasos intermedios y hacer legible una consulta larga; en algunos motores es además una barrera de optimización, y eso puede ayudar o estorbar. se introduce aquí
recursión WITH RECURSIVE: una CTE que se referencia a sí misma para recorrer jerarquías y grafos —organigramas, listas de materiales, caminos—. Necesita siempre una condición de parada; sin ella el motor recorre hasta agotar la memoria. se introduce aquí
subconsulta correlacionada Subconsulta que referencia una columna de la consulta externa y por tanto se evalúa en función de cada fila. Conceptualmente es un bucle; los optimizadores modernos suelen convertirla en una reunión, pero conviene comprobarlo en el plan y no suponerlo. se introduce aquí
partición de ventana El PARTITION BY de una función de ventana: divide las filas en grupos para calcular el agregado dentro de cada uno, pero sin colapsarlas. Es la diferencia esencial con GROUP BY: la ventana conserva el detalle y añade el cálculo al lado. se introduce aquí
marco El ROWS/RANGE BETWEEN que define qué filas de la partición entran en el cálculo de cada fila. Su valor por defecto no es «toda la partición» cuando hay ORDER BY, y esa sutileza cambia el resultado de una suma acumulada. se introduce aquí

Propósito

Resolver con SQL preguntas que exigen comparar una fila con su grupo, recorrer jerarquías o numerar dentro de particiones — sin sacar los datos a la aplicación para procesarlos allí.

Resultados de aprendizaje

Al terminar podrás:

  1. Elegir entre subconsulta, CTE y función de ventana con criterio.
  2. Escribir funciones de ventana con PARTITION BY, ORDER BY y marco explícito.
  3. Distinguir ROWS de RANGE y explicar cuándo cambia el resultado.
  4. Escribir una CTE recursiva y acotarla para que termine.
  5. Resolver «el más reciente por grupo» de tres formas y compararlas.

Fundamentos

Las tres herramientas

Herramienta Qué aporta Coste típico
Subconsulta no correlacionada Se evalúa una vez Bajo
Subconsulta correlacionada Se evalúa por fila del exterior Alto si no hay índice
CTE (WITH) Nombra un resultado intermedio; permite recursión Depende de si el motor la materializa
Función de ventana Calcula sobre un grupo sin colapsar filas Un ordenamiento por partición

La diferencia esencial entre agregado y ventana: GROUP BY reduce N filas a una; la función de ventana conserva las N y añade una columna con el valor del grupo. Por eso la ventana es la respuesta natural a «compara cada fila con su grupo».

Anatomía de una ventana

funcion() OVER (
    PARTITION BY expr      -- grupos independientes
    ORDER BY     expr      -- orden dentro del grupo
    ROWS BETWEEN ... AND ...   -- marco: qué filas entran
)

Familias de funciones:

Familia Funciones Necesita ORDER BY
Numeración ROW_NUMBER, RANK, DENSE_RANK, NTILE
Desplazamiento LAG, LEAD, FIRST_VALUE, LAST_VALUE
Agregación SUM, AVG, COUNT, MIN, MAX sobre OVER Opcional

ROWS frente a RANGE: la trampa del marco

Si hay ORDER BY y no se especifica marco, el implícito es:

RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW

RANGE agrupa por valor: todas las filas con el mismo valor de ordenación entran juntas en el marco. ROWS cuenta filas físicas.

Con notas 4.0, 5.0, 5.0, 6.0 y SUM(nota) OVER (ORDER BY nota):

RANGE (implícito)  ->  4.0, 14.0, 14.0, 20.0
ROWS  UNBOUNDED PRECEDING -> 4.0,  9.0, 14.0, 20.0

Las dos filas empatadas en 5,0 reciben con RANGE el mismo acumulado (14,0), porque el marco incluye a ambas. Para un total acumulado fila a fila, hay que escribir ROWS explícitamente. Esta es la causa número uno de acumulados «raros» y no aparece hasta que hay empates.

Lo mismo afecta a LAST_VALUE: con el marco implícito, «el último valor» es la fila actual, no el último del grupo. Hay que escribir ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING.

CTE recursiva

WITH RECURSIVE prereq(curso_id, requiere_id, profundidad) AS (
    SELECT curso_id, requiere_id, 1
    FROM prerequisitos WHERE curso_id = :inicio
  UNION ALL
    SELECT p.curso_id, pr.requiere_id, p.profundidad + 1
    FROM prereq p
    JOIN prerequisitos pr ON pr.curso_id = p.requiere_id
    WHERE p.profundidad < 10                 -- cota obligatoria
)
SELECT DISTINCT requiere_id, MIN(profundidad) FROM prereq GROUP BY requiere_id;

Dos advertencias que no son opcionales:

flowchart TD
    P["¿Qué necesito?"] --> A{"¿Colapsar filas<br/>a un resumen?"}
    A -- "Sí" --> G["GROUP BY"]
    A -- "No" --> B{"¿Comparar cada fila<br/>con su grupo?"}
    B -- "Sí" --> W["Función de ventana"]
    B -- "No" --> C{"¿Profundidad<br/>variable?"}
    C -- "Sí" --> R["CTE recursiva<br/>con cota"]
    C -- "No" --> D{"¿Reutilizo el<br/>resultado intermedio?"}
    D -- "Sí" --> CT["CTE"]
    D -- "No" --> S["Subconsulta"]

Ejemplo trabajado

Pregunta: «la nota más reciente de cada estudiante en cada curso, junto con su diferencia respecto de la anterior».

Forma 1 — ventana (recomendada):

WITH ordenadas AS (
  SELECT student_id, course_id, nota, registrada_en,
         ROW_NUMBER() OVER (PARTITION BY student_id, course_id
                            ORDER BY registrada_en DESC, id DESC) AS rn,
         LAG(nota)    OVER (PARTITION BY student_id, course_id
                            ORDER BY registrada_en ASC,  id ASC)  AS nota_anterior
  FROM notas
)
SELECT student_id, course_id, nota,
       nota - nota_anterior AS variacion
FROM ordenadas
WHERE rn = 1;

Puntos clave:

Forma 2 — subconsulta correlacionada:

SELECT n.* FROM notas n
WHERE n.registrada_en = (
  SELECT MAX(n2.registrada_en) FROM notas n2
  WHERE n2.student_id = n.student_id AND n2.course_id = n.course_id
);

Legible, pero devuelve dos filas si hay empate en la marca de tiempo, y se evalúa una vez por fila del exterior.

Forma 3 — DISTINCT ON (solo PostgreSQL):

SELECT DISTINCT ON (student_id, course_id) *
FROM notas ORDER BY student_id, course_id, registrada_en DESC, id DESC;

La más corta y la más rápida en PostgreSQL, y no portable.

Comparación medida sobre 5 millones de filas de notas con índice en (student_id, course_id, registrada_en):

Forma Recorridos Determinista ante empates Portable
Ventana 1 + ordenamiento por partición Sí, con desempate
Correlacionada 1 + 1 por fila (o índice) No
DISTINCT ON 1 No

Acumulado con marco explícito — nota acumulada por estudiante:

SELECT student_id, registrada_en, nota,
       SUM(nota) OVER (PARTITION BY student_id ORDER BY registrada_en, id
                       ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS acumulado
FROM notas;

Escribir ROWS es lo que garantiza que dos notas registradas en el mismo instante no compartan el mismo acumulado.

Comparación

Pregunta Herramienta
«Total por grupo» GROUP BY
«Porcentaje de cada fila sobre su grupo» SUM() OVER (PARTITION BY ...)
«Top N por grupo» ROW_NUMBER() + filtro externo
«Diferencia con la fila anterior» LAG
«Todos los ancestros» CTE recursiva
«¿Existe alguno?» EXISTS (clase 016)
«Media móvil de 7 días» AVG() OVER (... ROWS 6 PRECEDING)

Errores frecuentes

  1. Filtrar una ventana en WHERE. No existe todavía; hace falta CTE o subconsulta.
  2. Omitir el marco y esperar acumulado fila a fila. El implícito es RANGE y agrupa empates.
  3. LAST_VALUE sin marco completo. Devuelve la fila actual.
  4. CTE recursiva sin cota. Un ciclo en los datos bloquea la sesión.
  5. RANK cuando se quería ROW_NUMBER. RANK deja huecos y repite posiciones en empates.
  6. Ordenación no determinista. Sin desempate, el «más reciente» cambia entre ejecuciones.

De la clase a la operación

Bajar 5 millones de filas a la aplicación para calcular un acumulado consume ancho de banda, memoria y tiempo, y produce un resultado que el motor habría calculado usando un índice ya existente. Las funciones de ventana son, en la práctica, la diferencia entre un informe de 200 ms y uno de 40 s.

Reto de transferencia

  1. Busca en tu código un cálculo por grupo que hoy se hace en la aplicación y llévalo a una ventana.
  2. Mide el volumen transferido antes y después.
  3. Escribe la misma consulta con RANGE y con ROWS y demuestra la diferencia con empates.
  4. Implementa una CTE recursiva sobre una jerarquía tuya, con cota, y prueba qué pasa al introducir un ciclo.

Preguntas de evaluación

  1. ¿Por qué no se puede filtrar ROW_NUMBER() en WHERE?
  2. Da un conjunto de datos donde ROWS y RANGE produzcan acumulados distintos.
  3. Explica por qué la subconsulta correlacionada puede devolver dos filas donde la ventana devuelve una.
  4. Escribe la cota de una CTE recursiva sobre tu jerarquía y justifica el valor elegido.

🌐 El mismo problema en cada motor

Caso: El mejor de cada curso, con su nota, sin perder ninguna columna

«El máximo por grupo» es fácil; «la fila del máximo por grupo» es la pregunta que rompe a GROUP BY. Al agrupar, las filas del grupo se colapsan y con ellas desaparece el nombre del estudiante que sacó esa nota.

La función de ventana calcula por grupo sin colapsar: numera las filas dentro de cada curso por nota descendente y deja intacta cada fila. Después basta quedarse con la primera. El caso devuelve, por curso ordenado, quién obtuvo la nota más alta y cuál fue.

Salida esperada, idéntica en todos los motores que lo resuelven:

curso estudiante nota
DB-101 Ada 90
SE-201 Grace 78

El contrato vive en motores.yaml y lo comprueba python scripts/verificar_equivalencia.py --clase 028: 5 de las 5 implementaciones se ejecutan de verdad y su resultado se compara con esa tabla; el resto se declara como material revisado, no ejecutado.

Motor ¿Resuelve el caso? Nivel de prueba Código Fuente
SQLite núcleo código doc oficial
DuckDB núcleo código doc oficial
PostgreSQL servicio código doc oficial
MySQL servicio código doc oficial
MongoDB servicio código doc oficial
Apache Cassandra no doc oficial

Los que resuelven el caso

SQLite · implementaciones/sqlite/consulta.sql

verificado — se ejecuta en CI sin servicios

-- motor: sqlite
-- doc: https://sqlite.org/windowfunctions.html
-- nota: ROW_NUMBER, RANK y DENSE_RANK no son lo mismo con empates: ROW_NUMBER
--       elige uno arbitrario salvo que el ORDER BY desempate, y por eso aqui
--       lleva `, estudiante` al final.

-- === preparacion ===
CREATE TABLE notas (
    estudiante TEXT NOT NULL,
    curso      TEXT NOT NULL,
    nota       INTEGER NOT NULL,
    PRIMARY KEY (estudiante, curso)
);
INSERT INTO notas (estudiante, curso, nota) VALUES
    ('Ada',   'DB-101', 90),
    ('Linus', 'DB-101', 58),
    ('Grace', 'DB-101', 72),
    ('Ada',   'SE-201', 66),
    ('Grace', 'SE-201', 78);

-- === consulta ===
-- La CTE nombra el paso intermedio y la ventana hace lo que GROUP BY no puede:
-- calcular por grupo SIN colapsar las filas del grupo. Por eso la nota y el
-- nombre siguen disponibles al filtrar por la posicion.
WITH clasificacion AS (
    SELECT curso,
           estudiante,
           nota,
           ROW_NUMBER() OVER (PARTITION BY curso ORDER BY nota DESC, estudiante) AS puesto
    FROM notas
)
SELECT curso, estudiante, nota
FROM clasificacion
WHERE puesto = 1
ORDER BY curso;

DuckDB · implementaciones/duckdb/consulta.sql

verificado — se ejecuta en CI sin servicios

-- motor: duckdb
-- doc: https://duckdb.org/docs/stable/sql/functions/window_functions.html
-- nota: la forma corta de DuckDB seria
--         SELECT curso, estudiante, nota FROM notas
--         QUALIFY ROW_NUMBER() OVER (PARTITION BY curso ORDER BY nota DESC) = 1
--       Aqui se escribe la portable, que es la que sirve en los demas motores.

-- === preparacion ===
CREATE TABLE notas (
    estudiante VARCHAR NOT NULL,
    curso      VARCHAR NOT NULL,
    nota       INTEGER NOT NULL,
    PRIMARY KEY (estudiante, curso)
);
INSERT INTO notas (estudiante, curso, nota) VALUES
    ('Ada',   'DB-101', 90),
    ('Linus', 'DB-101', 58),
    ('Grace', 'DB-101', 72),
    ('Ada',   'SE-201', 66),
    ('Grace', 'SE-201', 78);

-- === consulta ===
-- La CTE nombra el paso intermedio y la ventana hace lo que GROUP BY no puede:
-- calcular por grupo SIN colapsar las filas del grupo. Por eso la nota y el
-- nombre siguen disponibles al filtrar por la posicion.
WITH clasificacion AS (
    SELECT curso,
           estudiante,
           nota,
           ROW_NUMBER() OVER (PARTITION BY curso ORDER BY nota DESC, estudiante) AS puesto
    FROM notas
)
SELECT curso, estudiante, nota
FROM clasificacion
WHERE puesto = 1
ORDER BY curso;

PostgreSQL · implementaciones/postgresql/consulta.sql

verificado — se ejecuta contra el motor real levantado con docker compose

-- motor: postgresql
-- doc: https://www.postgresql.org/docs/current/tutorial-window.html
-- nota: la forma propia de PostgreSQL es
--         SELECT DISTINCT ON (curso) curso, estudiante, nota
--         FROM notas ORDER BY curso, nota DESC, estudiante;
--       que suele ser mas barata. Ata la consulta a PostgreSQL, y eso hay que
--       decidirlo, no descubrirlo al migrar.

DROP TABLE IF EXISTS notas;

-- === preparacion ===
CREATE TABLE notas (
    estudiante text NOT NULL,
    curso      text NOT NULL,
    nota       integer NOT NULL,
    PRIMARY KEY (estudiante, curso)
);
INSERT INTO notas (estudiante, curso, nota) VALUES
    ('Ada',   'DB-101', 90),
    ('Linus', 'DB-101', 58),
    ('Grace', 'DB-101', 72),
    ('Ada',   'SE-201', 66),
    ('Grace', 'SE-201', 78);

-- === consulta ===
-- La CTE nombra el paso intermedio y la ventana hace lo que GROUP BY no puede:
-- calcular por grupo SIN colapsar las filas del grupo. Por eso la nota y el
-- nombre siguen disponibles al filtrar por la posicion.
WITH clasificacion AS (
    SELECT curso,
           estudiante,
           nota,
           ROW_NUMBER() OVER (PARTITION BY curso ORDER BY nota DESC, estudiante) AS puesto
    FROM notas
)
SELECT curso, estudiante, nota
FROM clasificacion
WHERE puesto = 1
ORDER BY curso;

MySQL · implementaciones/mysql/consulta.sql

verificado — se ejecuta contra el motor real levantado con docker compose

-- motor: mysql
-- doc: https://dev.mysql.com/doc/refman/8.4/en/window-functions.html
-- nota: esta consulta NO funciona en MySQL 5.7: alli habia que emularla con
--       variables de sesion, cuyo resultado dependia del orden de evaluacion y
--       dejo de estar garantizado en 8.0.

DROP TABLE IF EXISTS notas;

-- === preparacion ===
CREATE TABLE notas (
    estudiante VARCHAR(50) NOT NULL,
    curso      VARCHAR(50) NOT NULL,
    nota       INT NOT NULL,
    PRIMARY KEY (estudiante, curso)
);
INSERT INTO notas (estudiante, curso, nota) VALUES
    ('Ada',   'DB-101', 90),
    ('Linus', 'DB-101', 58),
    ('Grace', 'DB-101', 72),
    ('Ada',   'SE-201', 66),
    ('Grace', 'SE-201', 78);

-- === consulta ===
-- La CTE nombra el paso intermedio y la ventana hace lo que GROUP BY no puede:
-- calcular por grupo SIN colapsar las filas del grupo. Por eso la nota y el
-- nombre siguen disponibles al filtrar por la posicion.
WITH clasificacion AS (
    SELECT curso,
           estudiante,
           nota,
           ROW_NUMBER() OVER (PARTITION BY curso ORDER BY nota DESC, estudiante) AS puesto
    FROM notas
)
SELECT curso, estudiante, nota
FROM clasificacion
WHERE puesto = 1
ORDER BY curso;

MongoDB · implementaciones/mongodb/consulta.js

verificado — se ejecuta contra el motor real levantado con docker compose

// motor: mongodb
// doc: https://www.mongodb.com/docs/manual/reference/operator/aggregation/setWindowFields/
// nota: $setWindowFields es la ventana: partitionBy es el PARTITION BY y
//       sortBy es el ORDER BY de dentro de la ventana. Y aqui aparece un
//       limite que SQL no tiene: $documentNumber, $rank y $denseRank exigen un
//       sortBy de UNA sola clave, asi que no se puede desempatar por un
//       segundo criterio como hace el ROW_NUMBER de la version SQL.

// === preparacion ===
db.notas.drop();
db.notas.insertMany([
  { estudiante: "Ada", curso: "DB-101", nota: 90 },
  { estudiante: "Linus", curso: "DB-101", nota: 58 },
  { estudiante: "Grace", curso: "DB-101", nota: 72 },
  { estudiante: "Ada", curso: "SE-201", nota: 66 },
  { estudiante: "Grace", curso: "SE-201", nota: 78 },
]);

// === consulta ===
db.notas
  .aggregate([
    { $setWindowFields: {
        partitionBy: "$curso",
        sortBy: { nota: -1 },
        output: { puesto: { $documentNumber: {} } } } },
    { $match: { puesto: 1 } },
    { $project: { _id: 0, curso: 1, estudiante: 1, nota: 1 } },
    { $sort: { curso: 1 } },
  ])
  .forEach((d) => print(d.curso + "|" + d.estudiante + "|" + d.nota));

Los que no resuelven este caso — y qué se hace en su lugar

Descartar un motor con un argumento es tan formativo como usarlo. Ninguna de estas filas dice que el motor sea peor: dice que este problema no es el suyo.

Motor Por qué no Qué se hace en su lugar Fuente
Apache Cassandra No hay funciones de ventana ni CTE. GROUP BY existe, pero solo dentro de una partición y con agregados básicos: «la fila del máximo por grupo» no se puede expresar. Modelar la tabla con la nota como columna de agrupamiento descendente y pedir LIMIT 1 por partición: el ranking se resuelve por el orden físico, y solo para el criterio con el que se modeló. doc

Laboratorio

python scripts/validate_repository.py
python labs/01-sql-foundations/run_lab.py

Guarda como evidencia la salida completa, la versión del motor y la semilla o los parámetros usados. Una captura sin comando no es evidencia: no se puede repetir.

Evaluación

Criterio Peso Qué se comprueba
Comprensión conceptual 25 % Explica el mecanismo, no solo el resultado
Ejecución reproducible 25 % Otra persona obtiene lo mismo con las instrucciones dadas
Interpretación basada en evidencia 25 % Cada conclusión se apoya en una salida o una medición
Límites y riesgos declarados 25 % Dice qué no demuestra el ejercicio y qué faltaría en producción

La clase se da por superada cuando la respuesta explica el mecanismo, muestra la salida que la respalda y declara al menos un límite del ejercicio.

Fuentes de esta clase

Todo lo afirmado arriba procede de estas obras. Los identificadores viven en catalog/sources.json y el estado de los enlaces se comprueba con python scripts/check_external_links.py.


Programa · Parte 04 · ← Anterior · Siguiente →