Aquí es donde se aprende. No leyendo estas soluciones, sino comparándolas con las tuyas: viendo dónde coincides, dónde has elegido otro camino igual de bueno y dónde te has dejado un LEFT JOIN que hacía falta. Si has llegado sin haber intentado el proyecto, vuelve a 12-03: el solucionario leído en frío no enseña casi nada. Dos advertencias: una solución distinta a la de referencia puede ser igual de válida —no se corrige el parecido con este texto, sino que cumpla 12-02 y que la decisión esté justificada—, y todos los resultados están calculados sobre el juego de datos de 12-03 (36 préstamos, 20 ejemplares, 15 socios) con DATE '2026-06-30' como fecha de referencia.
Contenido
- El esquema de referencia: decisiones y alternativas
- Las 15 consultas resueltas
- Los índices de referencia
- La vista, el procedimiento y el trigger
- Errores frecuentes en este proyecto
- Errores Comunes y Consejos
- Ejercicios
- Conclusión
- El esquema de referencia: decisiones y alternativas
El DDL completo está en 12-03. Aquí van las decisiones que se corrigen, cada una con la alternativa que también sería correcta y cuándo elegirla.
| Decisión de referencia | Alternativa válida | Cuándo elegir la alternativa |
|---|---|---|
obras + ejemplares en dos tablas, y ejemplares.estado sin el valor prestado |
(ninguna) | Nunca. Son RD-05/RD-07, y añadir prestado duplicaría lo que ya dice prestamos |
obras_autores con PK compuesta (obra_id, autor_id) |
id subrogado + UNIQUE (obra_id, autor_id) |
Si otra tabla tuviera que referenciar la firma (royalties por autor y obra). Mientras no exista, la PK compuesta expresa mejor la regla (05-01) |
fecha_prevista almacenada, y estado del préstamo derivado de las fechas |
Calcularla desde el tipo del socio; columna estado con CHECK mantenida de noche |
Si el plazo no pudiera cambiar ni por tipo ni por renovación (aquí cambia por las dos cosas), o si hicieran falta estados no deducibles (en_reclamacion, condonado) |
multas como tabla 1 a 0..1, y cola por fecha_reserva |
Columnas multa_importe/multa_pagada en prestamos; columna posicion mantenida por trigger |
Si la multa fuera un número sin fecha de pago ni condonación; y si hubiera que reordenar la cola a mano (prioridades). Con FIFO puro, calcular la posición es estrictamente mejor |
| Índice único parcial para RI-03 | Restricción EXCLUDE con btree_gist |
Cuando además haya que impedir solapamientos históricos: ver abajo |
La alternativa fuerte a RI-03: EXCLUDE
El índice parcial impide dos préstamos activos del mismo ejemplar, pero no dos préstamos pasados que se solapen. Si eso importa —y en una migración de datos antiguos importa mucho—, PostgreSQL tiene una restricción para exactamente esto:
CREATE EXTENSION IF NOT EXISTS btree_gist;
ALTER TABLE prestamos ADD CONSTRAINT excl_prestamos_solape EXCLUDE USING gist (ejemplar_id WITH =,
daterange(fecha_prestamo, COALESCE(fecha_devolucion, DATE 'infinity'), '[]') WITH &&);Se lee: "no puede haber dos filas con el mismo ejemplar_id cuyos intervalos de préstamo se solapen", y el COALESCE(..., 'infinity') convierte el préstamo abierto en un intervalo sin fin, con lo que también cubre RI-03. Es más potente y más cara: exige una extensión, usa un índice GiST y no sirve de apoyo para WHERE fecha_devolucion IS NULL. El criterio: índice parcial si solo te preocupa el presente; EXCLUDE si vas a cargar historia o si se registran préstamos retroactivos. Las dos son correctas; hay que saber decir cuál has elegido.
- Las 15 consultas resueltas
Bloque A — básicas y agregación (RC-01 a RC-08)
-- RC-01 · Obras publicadas desde 2015
SELECT o.id, o.titulo, o.anio_publicacion, o.isbn
FROM obras AS o
WHERE o.anio_publicacion >= 2015 ORDER BY o.anio_publicacion DESC, o.titulo;Nueve filas, encabezadas por Cuaderno de sombras (2024) y El bosque de los nombres (2023). La decisión clave es el desempate: ORDER BY anio_publicacion DESC a secas dejaría los empates a merced del plan y el resultado cambiaría entre ejecuciones. Y aparece Redes y sistemas distribuidos, la obra sin ningún ejemplar: está catalogada, así que en el catálogo sale.
-- RC-02 · Ejemplares de una sede, con su obra y su materia
SELECT e.codigo_barras, o.titulo, m.nombre AS materia, e.estado, e.fecha_adquisicion
FROM ejemplares AS e JOIN obras AS o ON o.id = e.obra_id
JOIN materias AS m ON m.id = o.materia_id JOIN sedes AS sd ON sd.id = e.sede_id
WHERE sd.nombre = 'Biblioteca Infantil do Parque' ORDER BY o.titulo, e.codigo_barras;| codigo_barras | titulo | materia | estado | fecha_adquisicion |
|---|---|---|---|---|
| ALV-0014 | La niña que contaba estrellas | Infantil | disponible | 2024-02-05 |
| ALV-0015 | La niña que contaba estrellas | Infantil | disponible | 2024-02-05 |
(2 de 3 filas.) Dos filas con el mismo título, y no es un error: son dos ejemplares distintos de la misma obra, que es el corazón del proyecto. Quien haya escrito SELECT DISTINCT o.titulo ha perdido justo la información que se pedía.
-- RC-03 · Préstamos activos, con los días fuera y el retraso a fecha de referencia
SELECT p.id, so.nombre || ' ' || so.apellidos AS socio, o.titulo, sd.nombre AS sede,
p.fecha_prevista, DATE '2026-06-30' - p.fecha_prestamo AS dias_fuera,
GREATEST(DATE '2026-06-30' - p.fecha_prevista, 0) AS dias_retraso
FROM prestamos AS p JOIN socios AS so ON so.id = p.socio_id
JOIN ejemplares AS e ON e.id = p.ejemplar_id JOIN obras AS o ON o.id = e.obra_id
JOIN sedes AS sd ON sd.id = e.sede_id
WHERE p.fecha_devolucion IS NULL ORDER BY dias_retraso DESC, p.fecha_prevista;| id | socio | titulo | sede | fecha_prevista | dias_fuera | dias_retraso |
|---|---|---|---|---|---|---|
| 29 | Lena Fuentes | El jardín de las horas | Biblioteca de Vila Nova | 2026-05-25 | 57 | 36 |
| 34 | Nuno Barros | Historia mínima de Alvorada | Biblioteca de Vila Nova | 2026-06-29 | 22 | 1 |
(2 de 6 filas; Alba Rey también lleva 1 día, y las otras tres van en plazo, con 0 de retraso.) Seis préstamos activos, y tres son de la misma obra —los tres ejemplares de El jardín de las horas están fuera, y de ahí sale la cola de RC-10—. La decisión clave es GREATEST(..., 0): sin él, los préstamos en plazo saldrían con retraso negativo, que no significa nada y ensucia cualquier suma posterior. Y fecha_devolucion IS NULL, nunca = NULL: el error de 04-03 que devuelve cero filas sin avisar.
-- RC-04 · Los tres huecos del sistema, en un solo resultado
SELECT 'socio sin préstamos' AS hueco, s.id, s.nombre || ' ' || s.apellidos AS descripcion
FROM socios AS s LEFT JOIN prestamos AS p ON p.socio_id = s.id WHERE p.id IS NULL
UNION ALL SELECT 'obra sin ejemplares', o.id, o.titulo
FROM obras AS o LEFT JOIN ejemplares AS e ON e.obra_id = o.id WHERE e.id IS NULL
UNION ALL SELECT 'ejemplar nunca prestado', e.id, e.codigo_barras || ' — ' || o.titulo
FROM ejemplares AS e JOIN obras AS o ON o.id = e.obra_id
LEFT JOIN prestamos AS p ON p.ejemplar_id = e.id WHERE p.id IS NULL ORDER BY 1, 2;| hueco | id | descripcion |
|---|---|---|
| obra sin ejemplares | 12 | Redes y sistemas distribuidos |
| socio sin préstamos | 12 | Irene Sampaio |
(2 de 7 filas: 3 ejemplares nunca prestados, 1 obra sin ejemplares y 3 socios sin préstamos.) Tres lecturas de negocio distintas: ejemplares que ocupan estantería sin salir nunca, un título catalogado que aún no ha llegado y carnés sin estrenar. La técnica es el anti-join de 03-03 (LEFT JOIN + WHERE ... IS NULL), equivalente a NOT EXISTS (07-03) y no a NOT IN, que con un NULL en la subconsulta devolvería cero filas en silencio. UNION ALL y no UNION, porque no hay duplicados que eliminar; y las tres ramas necesitan el mismo número de columnas y tipos compatibles (03-07), de ahí la etiqueta hueco.
-- RC-05 · Materias con 5+ préstamos y su duración media. Definición: préstamo = cualquier
-- fila de prestamos; duración = días hasta la devolución, o hasta hoy si sigue abierto
SELECT m.nombre AS materia, COUNT(p.id) AS prestamos, COUNT(DISTINCT o.id) AS obras,
ROUND(AVG(COALESCE(p.fecha_devolucion, DATE '2026-06-30')
- p.fecha_prestamo), 1) AS dias_medios
FROM prestamos AS p
JOIN ejemplares AS e ON e.id = p.ejemplar_id JOIN obras AS o ON o.id = e.obra_id
JOIN materias AS m ON m.id = o.materia_id
GROUP BY m.id, m.nombre HAVING COUNT(p.id) >= 5 ORDER BY prestamos DESC, m.nombre;Tres materias pasan el corte: Narrativa (12 préstamos, 2 obras, 32,3 días de media), Infantil (8, 2, 14,6) e Informática (6, 1, 26,8). El filtro va en HAVING y no en WHERE porque se aplica al grupo ya agregado: WHERE COUNT(*) >= 5 es un error de sintaxis, y es el fallo más repetido de 04-06. Las seis materias reparten los 36 préstamos (12 + 8 + 6 + 4 + 4 + 2). Y el COALESCE de la duración es una decisión de definición: sin él, AVG ignoraría los seis préstamos abiertos y la media de Narrativa bajaría, porque los tres préstamos más largos que hay ahora son justo los que no han vuelto.
-- RC-06 · Disponibilidad por obra. Habilitados = en estado 'disponible' (excluye reparación,
-- extravío y baja); disponibles ahora = habilitados menos los que están prestados
SELECT o.titulo, COUNT(e.id) AS ejemplares, COUNT(pa.id) AS prestados,
COUNT(e.id) FILTER (WHERE e.estado = 'disponible') AS habilitados,
COUNT(e.id) FILTER (WHERE e.estado = 'disponible') - COUNT(pa.id) AS disponibles
FROM obras AS o
LEFT JOIN ejemplares AS e ON e.obra_id = o.id
LEFT JOIN prestamos AS pa ON pa.ejemplar_id = e.id AND pa.fecha_devolucion IS NULL
GROUP BY o.id, o.titulo ORDER BY disponibles, o.titulo;| titulo | ejemplares | prestados | habilitados | disponibles |
|---|---|---|---|---|
| El jardín de las horas | 3 | 3 | 3 | 0 |
| Redes y sistemas distribuidos | 0 | 0 | 0 | 0 |
(2 de 12 filas.) Las dos filas dicen 0 disponibles por razones opuestas, y un informe honesto las distingue: de una hay tres ejemplares y los tres están prestados; de la otra no hay ninguno. Por eso se publican las cuatro columnas y no solo la última. Tres decisiones: COUNT(e.id) y no COUNT(*), o la obra sin ejemplares saldría con 1; la condición del LEFT JOIN a prestamos va en el ON; y Bases de datos relacionales sale con 3 ejemplares pero solo 2 habilitados, porque uno está en reparación — esa diferencia es exactamente lo que un contador único no podría expresar.
-- RC-07 · Obras firmadas por más de un autor, en orden de firma
SELECT o.titulo, COUNT(*) AS n_autores,
string_agg(a.nombre || ' ' || a.apellidos, ', ' ORDER BY oa.orden) AS autores
FROM obras AS o JOIN obras_autores AS oa ON oa.obra_id = o.id
JOIN autores AS a ON a.id = oa.autor_id
GROUP BY o.id, o.titulo HAVING COUNT(*) > 1 ORDER BY o.titulo;| titulo | n_autores | autores |
|---|---|---|
| Bases de datos relacionales | 2 | Pere Aymà, Nora Ibáñez |
| El bosque de los nombres | 2 | Clara Meireles, Ada Quiroga |
(2 de 3 filas; falta "Historia mínima de Alvorada", de Ruy Castelo y Tomás Vega.) El ORDER BY oa.orden dentro del string_agg es la decisión clave, y se la salta casi todo el mundo: sin él, el orden de los autores dentro de la celda es el que quiera el motor, y una portada firmada "Aymà e Ibáñez" podría salir invertida. Es la razón de que obras_autores tenga columna orden. Y fíjate en que la tabla puente lleva datos propios (rol y orden): eso la convierte en una entidad de pleno derecho y no en un simple par de claves.
-- RC-08 · Multas por tipo de socio. Multa = fila de multas, que solo existe tras la devolución
-- (RN-10); pendiente = sin fecha_pago. NO incluye la deuda potencial de los vencidos
SELECT so.tipo, COUNT(mu.id) AS multas,
COALESCE(SUM(mu.importe), 0) AS importe_total,
COALESCE(SUM(mu.importe) FILTER (WHERE mu.fecha_pago IS NOT NULL), 0) AS cobrado,
COALESCE(SUM(mu.importe) FILTER (WHERE mu.fecha_pago IS NULL), 0) AS pendiente
FROM socios AS so
LEFT JOIN prestamos AS p ON p.socio_id = so.id LEFT JOIN multas AS mu ON mu.prestamo_id = p.id
GROUP BY so.tipo ORDER BY importe_total DESC;| tipo | multas | importe_total | cobrado | pendiente |
|---|---|---|---|---|
| general | 5 | 25.80 | 12.40 | 13.40 |
| infantil | 1 | 1.40 | 1.40 | 0.00 |
| senior | 0 | 0.00 | 0.00 | 0.00 |
Los 13,40 € pendientes son de un solo socio, Diego Andrade, y son exactamente los que superan el umbral de 10 € de la RN-11 y explican su estado bloqueado. La cifra de control: 6 multas sobre 30 préstamos devueltos son una tasa de retraso del 20,00 %, y 25,80 + 1,40 = 27,20 € es el total del sistema. El COALESCE es imprescindible —sin él, la fila de los sénior mostraría NULL en una columna de dinero, que alguien leerá como "no hay dato" (06-04)— y esa fila debe aparecer: la salva el LEFT JOIN.
Bloque B — subconsultas, colas, ventanas y recursivas (RC-09 a RC-15)
-- RC-09 · Socios con más préstamos que la media de su tipo
SELECT s.id, s.nombre || ' ' || s.apellidos AS socio, s.tipo,
(SELECT COUNT(*) FROM prestamos AS p WHERE p.socio_id = s.id) AS prestamos,
ROUND((SELECT COUNT(p2.id)::numeric / COUNT(DISTINCT s2.id) FROM socios AS s2
LEFT JOIN prestamos AS p2 ON p2.socio_id = s2.id
WHERE s2.tipo = s.tipo), 2) AS media_de_su_tipo
FROM socios AS s
WHERE (SELECT COUNT(*) FROM prestamos AS p WHERE p.socio_id = s.id)
> (SELECT COUNT(p2.id)::numeric / COUNT(DISTINCT s2.id) FROM socios AS s2
LEFT JOIN prestamos AS p2 ON p2.socio_id = s2.id WHERE s2.tipo = s.tipo)
ORDER BY s.tipo, prestamos DESC, s.id;| id | socio | tipo | prestamos | media_de_su_tipo |
|---|---|---|---|---|
| 1 | Marta Coelho | general | 4 | 2.56 |
| 5 | Alba Rey | infantil | 4 | 2.33 |
(2 de 10 filas: 6 generales, 2 infantiles y 2 sénior.) La subconsulta es correlacionada por el WHERE s2.tipo = s.tipo: se evalúa una vez por socio, con su tipo (07-02). Y el detalle que decide si la cifra es correcta es COUNT(p2.id) frente a COUNT(*): con COUNT(*), los socios sin préstamos aportarían una fila fantasma cada uno y la media de los generales saldría 2,67 en vez de 2,56 — lo bastante parecida para que nadie lo note. Alternativa igual de válida y más legible: una CTE con las medias por tipo y un JOIN contra ella; con quince socios da igual, con quince mil la CTE se evalúa una vez en lugar de una por fila.
-- RC-10 · La cola de reservas en espera, con la posición de cada socio
SELECT o.titulo, s.nombre || ' ' || s.apellidos AS socio, r.fecha_reserva, sd.nombre AS recogida_en,
ROW_NUMBER() OVER (PARTITION BY r.obra_id ORDER BY r.fecha_reserva, r.id) AS posicion
FROM reservas AS r
JOIN obras AS o ON o.id = r.obra_id JOIN socios AS s ON s.id = r.socio_id
JOIN sedes AS sd ON sd.id = r.sede_id WHERE r.estado = 'en_espera'
ORDER BY o.titulo, posicion;| titulo | socio | fecha_reserva | recogida_en | posicion |
|---|---|---|---|---|
| El jardín de las horas | Nuno Barros | 2026-06-10 | Biblioteca Central de Alvorada | 1 |
| El jardín de las horas | Manuel Otero | 2026-06-18 | Biblioteca Central de Alvorada | 2 |
| El jardín de las horas | Óscar Vilar | 2026-06-22 | Biblioteca de Vila Nova | 3 |
La posición no está en ninguna columna: la calcula ROW_NUMBER(). Esa es toda la solución al problema de la cola, y por eso el PARTITION BY r.obra_id es obligatorio: cada obra tiene su cola y las numeraciones no deben mezclarse. El r.id como segundo criterio no es opcional —dos reservas del mismo día quedarían en orden arbitrario, y la posición de un socio cambiaría entre dos consultas—. Es ROW_NUMBER y no RANK a propósito: en una cola no puede haber dos primeros. Y el WHERE deja fuera la reserva ya recogida y la caducada, que son historia.
-- RC-11 · Las 3 obras más prestadas de cada sede
SELECT sd.nombre AS sede, t.puesto, t.titulo, t.prestamos
FROM sedes AS sd LEFT JOIN LATERAL (
SELECT o.titulo, COUNT(*) AS prestamos,
ROW_NUMBER() OVER (ORDER BY COUNT(*) DESC, o.titulo) AS puesto
FROM ejemplares AS e JOIN obras AS o ON o.id = e.obra_id
JOIN prestamos AS p ON p.ejemplar_id = e.id
WHERE e.sede_id = sd.id GROUP BY o.id, o.titulo
ORDER BY prestamos DESC, o.titulo LIMIT 3) AS t ON TRUE
ORDER BY sd.id, t.puesto;| sede | puesto | titulo | prestamos |
|---|---|---|---|
| Biblioteca Central de Alvorada | 1 | El jardín de las horas | 6 |
| Biblioteca Infantil do Parque | 1 | La niña que contaba estrellas | 4 |
(2 de 8 filas; en Vila Nova el primer puesto es "Bases de datos relacionales", con 2.) LATERAL es lo que permite que la subconsulta vea sd.id de la fila de fuera; sin él, un LIMIT 3 dentro de una subconsulta normal daría las 3 mejores del sistema entero, repetidas en las tres sedes (07-04). El LEFT JOIN LATERAL ... ON TRUE en lugar de CROSS JOIN LATERAL es lo que salva a una sede sin préstamos. Alternativa igual de válida: una CTE con ROW_NUMBER() particionado y WHERE puesto <= 3 fuera — más portable, porque LATERAL no está en todos los motores. LATERAL gana cuando la tabla de fuera es pequeña y la de dentro enorme, porque solo lee lo que necesita de cada grupo.
-- RC-12 · Préstamos por mes de los últimos 12 meses, sin huecos
WITH calendario AS (SELECT generate_series(DATE '2025-07-01', DATE '2026-06-01',
INTERVAL '1 month')::date AS mes),
mensual AS (SELECT date_trunc('month', fecha_prestamo)::date AS mes, COUNT(*) AS n
FROM prestamos GROUP BY 1)
SELECT to_char(c.mes, 'YYYY-MM') AS mes, COALESCE(m.n, 0) AS prestamos,
SUM(COALESCE(m.n, 0)) OVER (ORDER BY c.mes) AS acumulado,
ROUND(AVG(COALESCE(m.n, 0)) OVER (ORDER BY c.mes
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW), 2) AS media_movil_3
FROM calendario AS c LEFT JOIN mensual AS m ON m.mes = c.mes ORDER BY c.mes;
-- Sin el calendario, la serie tendría 10 filas en vez de 12 y empezaría en septiembre| mes | prestamos | acumulado | media_movil_3 |
|---|---|---|---|
| 2025-07 | 0 | 0 | 0.00 |
| 2026-06 | 4 | 36 | 4.00 |
(2 de 12 filas: la primera y la última; 2025-07 y 2025-08 están vacíos.) Los dos meses con cero son el motivo de la consulta. Sin el calendario, un GROUP BY sobre prestamos devolvería diez filas y la gráfica empezaría en septiembre como si el servicio no hubiera existido antes; el LEFT JOIN contra generate_series los hace visibles (11-01). El COALESCE no es cosmético: sin él la ventana arrastraría NULL y el acumulado quedaría inservible desde la primera fila vacía. El acumulado cierra en 36, el total de préstamos: la cifra de control que valida la serie entera (11-04).
-- RC-13 · Los 3 socios más lectores de cada sede, con desempate explícito
WITH ranking AS (
SELECT sd.id AS sede_id, sd.nombre AS sede, s.nombre || ' ' || s.apellidos AS socio,
COUNT(p.id) AS prestamos, MAX(p.fecha_prestamo) AS ultimo,
ROW_NUMBER() OVER (PARTITION BY sd.id ORDER BY COUNT(p.id) DESC,
MAX(p.fecha_prestamo) DESC, s.id) AS puesto
FROM socios AS s
JOIN sedes AS sd ON sd.id = s.sede_id LEFT JOIN prestamos AS p ON p.socio_id = s.id
WHERE s.estado <> 'baja' GROUP BY s.id, sd.id, sd.nombre, s.nombre, s.apellidos)
SELECT sede, puesto, socio, prestamos, ultimo FROM ranking
WHERE puesto <= 3 ORDER BY sede_id, puesto;| sede | puesto | socio | prestamos | ultimo |
|---|---|---|---|---|
| Biblioteca Central de Alvorada | 1 | Iván Losada | 4 | 2026-05-12 |
| Biblioteca Infantil do Parque | 3 | Carla Nieto | 0 | (null) |
(2 de 9 filas; Marta Coelho es la segunda de la Central, también con 4.) El desempate es la decisión que se corrige. Iván y Marta tienen los mismos 4 préstamos: con RANK() los dos serían primeros, y en Vila Nova, donde hay un triple empate a 3, un "top 3" devolvería tres primeros. Con ROW_NUMBER() y el criterio más préstamos → más reciente → menor id, el orden es total y estable. Ninguna de las dos es incorrecta —RANK es lo que quieres en una clasificación deportiva—, pero hay que elegir a conciencia y decirlo. Y la segunda fila es una lección de honestidad: la sede Infantil solo tiene tres socios, así que su "tercer socio más lector" tiene cero préstamos. Es el aviso de 11-04 sobre publicar rankings con muestras minúsculas.
-- RC-14 · Organigrama de bibliotecarios, con su nivel y su ruta jerárquica
WITH RECURSIVE arbol AS (
SELECT b.id, b.nombre || ' ' || b.apellidos AS bibliotecario, b.puesto,
1 AS nivel, b.apellidos::text AS ruta
FROM bibliotecarios AS b WHERE b.responsable_id IS NULL -- caso base: la dirección
UNION ALL
SELECT h.id, h.nombre || ' ' || h.apellidos, h.puesto, a.nivel + 1, a.ruta || ' > ' || h.apellidos
FROM bibliotecarios AS h JOIN arbol AS a ON a.id = h.responsable_id) -- paso recursivo
SELECT nivel, repeat(' ', nivel - 1) || bibliotecario AS organigrama, puesto, ruta
FROM arbol ORDER BY ruta; -- ORDER BY ruta = cada persona debajo de su responsable| nivel | organigrama | puesto | ruta |
|---|---|---|---|
| 1 | Helena Corvo | Directora de la red | Corvo |
| 3 | ········Fátima Cordero | Auxiliar de préstamo | Corvo > Nogueira > Cordero |
(2 de 8 filas: 1 en el nivel 1, 3 en el 2 y 4 en el 3.) La ruta hace dos trabajos a la vez, y ese es el truco que hay que conocer: se lee de un vistazo y, sobre todo, es lo que permite ORDER BY ruta para que cada persona salga debajo de su responsable. Ordenar por nivel daría todos los responsables juntos y luego todos los auxiliares, que no es un organigrama. El caso base es responsable_id IS NULL, y por eso el juego de datos necesita un bibliotecario sin responsable: sin él la recursión no arranca y la consulta devuelve cero filas. Con datos reales conviene además acumular los id visitados y cortar los ciclos, porque un responsable_id en bucle haría girar la consulta para siempre (10-02).
-- RC-15 · Informe pivotado: préstamos por sede y materia, sin perder ninguna sede
SELECT sd.nombre AS sede,
COUNT(p.id) FILTER (WHERE m.nombre = 'Narrativa') AS narrativa,
COUNT(p.id) FILTER (WHERE m.nombre = 'Poesía') AS poesia,
COUNT(p.id) FILTER (WHERE m.nombre = 'Historia') AS historia,
COUNT(p.id) FILTER (WHERE m.nombre = 'Ciencia') AS ciencia,
COUNT(p.id) FILTER (WHERE m.nombre = 'Infantil') AS infantil,
COUNT(p.id) FILTER (WHERE m.nombre = 'Informática') AS informatica, COUNT(p.id) AS total
FROM sedes AS sd
LEFT JOIN ejemplares AS e ON e.sede_id = sd.id LEFT JOIN obras AS o ON o.id = e.obra_id
LEFT JOIN materias AS m ON m.id = o.materia_id LEFT JOIN prestamos AS p ON p.ejemplar_id = e.id
GROUP BY sd.id, sd.nombre ORDER BY total DESC;| sede | narrativa | poesia | historia | ciencia | infantil | informatica | total |
|---|---|---|---|---|---|---|---|
| Biblioteca Central de Alvorada | 8 | 2 | 2 | 2 | 2 | 4 | 20 |
| Biblioteca de Vila Nova | 4 | 0 | 2 | 2 | 0 | 2 | 10 |
| Biblioteca Infantil do Parque | 0 | 0 | 0 | 0 | 6 | 0 | 6 |
La validación por dos caminos: las filas suman 20 + 10 + 6 = 36, el total de préstamos, y las columnas suman 12 + 2 + 4 + 4 + 8 + 6 = 36 también. Si cualquiera de las dos no diera, la consulta estaría mal aunque las cifras parecieran razonables (11-04). La cadena de cuatro LEFT JOIN es deliberada: basta que uno sea INNER para que una sede sin ejemplares desaparezca. Y la lectura de negocio es inmediata: la Infantil do Parque presta solo infantil, mientras la Central es la única con fondo en las seis materias — un desequilibrio que ningún total general enseña.
- Los índices de referencia
Siete índices, cada uno con la consulta que lo justifica (RP-05). Ninguno más:
| Índice | Consulta que lo usa | Qué cambia en el plan |
|---|---|---|
uq_prestamo_activo_por_ejemplar (único, parcial) |
RC-03, RC-06, vista de vencidos | Es la restricción RI-03 y el acceso a los activos: Index Scan sobre unas pocas entradas en vez de Seq Scan sobre todo el histórico |
idx_prestamos_socio |
RC-09, RC-13, ficha de socio | PostgreSQL no indexa las FK (08-01): sin él, la ficha de un socio lee la tabla entera. Lo mismo vale para idx_ejemplares_obra e idx_ejemplares_sede, las dos navegaciones más frecuentes del sistema (RC-02, RC-06, RC-11, RC-15) |
idx_prestamos_ejemplar_fecha sobre (ejemplar_id, fecha_prestamo), e idx_prestamos_fecha |
RC-11, RC-12, RC-15 e informes por periodo | El compuesto filtra por ejemplar y de paso da el orden por fecha sin ordenar (08-02); el segundo convierte el barrido del histórico en una lectura de rango |
idx_obras_titulo_lower sobre LOWER(titulo) |
Búsqueda del catálogo | Índice de expresión: un WHERE LOWER(titulo) = ... no puede usar un índice sobre titulo (08-03) |
Lo que se decide no indexar (RP-07): socios.tipo y estado (tres y cuatro valores, el planificador preferirá el barrido), materias, sedes y editoriales enteras (caben en una página), y obras_autores, cuya PK compuesta ya sirve para ir de la obra al autor — aunque no al revés: si hiciera falta "todas las obras de un autor", habría que añadir (autor_id, obra_id). Ese matiz es el orden de las columnas de un índice compuesto que explicaba 08-02. Y sobre la búsqueda por título (RP-04): LOWER(titulo) resuelve la igualdad y el prefijo (LIKE 'jardin%'), pero no una palabra en medio; para eso hacen falta trigramas (pg_trgm con GIN) o texto completo (to_tsvector). La respuesta correcta en el informe no es "uso GIN", es decir qué tipo de búsqueda hace falta y elegir en consecuencia.
- La vista, el procedimiento y el trigger
CREATE OR REPLACE VIEW v_prestamos_vencidos AS
SELECT p.id AS prestamo_id, so.id AS socio_id, so.nombre || ' ' || so.apellidos AS socio,
so.email, o.titulo, e.codigo_barras, sd.nombre AS sede, p.fecha_prevista,
CURRENT_DATE - p.fecha_prevista AS dias_retraso,
LEAST(0.20 * (CURRENT_DATE - p.fecha_prevista), 20.00) AS multa_estimada
FROM prestamos AS p JOIN ejemplares AS e ON e.id = p.ejemplar_id
JOIN obras AS o ON o.id = e.obra_id JOIN sedes AS sd ON sd.id = e.sede_id
JOIN socios AS so ON so.id = p.socio_id
WHERE p.fecha_devolucion IS NULL AND p.fecha_prevista < CURRENT_DATE;Con la fecha de referencia devuelve tres filas: Lena Fuentes con 36 días y 7,20 €, y Nuno Barros y Alba Rey con 1 día y 0,20 € cada uno. Dos decisiones: se llama multa_estimada porque la multa no existe hasta la devolución (RN-10), y el LEAST aplica el tope de 20 € dentro de la vista, para que nadie tenga que acordarse de él.
CREATE OR REPLACE PROCEDURE registrar_devolucion(p_prestamo_id INTEGER)
LANGUAGE plpgsql AS $$
DECLARE v_prevista DATE; v_obra_id INTEGER; v_retraso INTEGER;
BEGIN UPDATE prestamos SET fecha_devolucion = CURRENT_DATE -- 1. cerrar
WHERE id = p_prestamo_id AND fecha_devolucion IS NULL RETURNING fecha_prevista INTO v_prevista;
IF NOT FOUND THEN RAISE EXCEPTION 'El préstamo % ya estaba devuelto', p_prestamo_id; END IF;
v_retraso := CURRENT_DATE - v_prevista; -- 2. multa (RN-09)
IF v_retraso > 0 THEN INSERT INTO multas (prestamo_id, importe, dias_retraso, fecha_generacion)
VALUES (p_prestamo_id, LEAST(0.20 * v_retraso, 20.00), v_retraso, CURRENT_DATE);
END IF;
SELECT e.obra_id INTO v_obra_id -- 3. avisar (RN-08)
FROM prestamos AS p JOIN ejemplares AS e ON e.id = p.ejemplar_id WHERE p.id = p_prestamo_id;
UPDATE reservas SET estado = 'disponible', fecha_aviso = CURRENT_DATE
WHERE id = (SELECT r.id FROM reservas AS r WHERE r.obra_id = v_obra_id
AND r.estado = 'en_espera' ORDER BY r.fecha_reserva, r.id LIMIT 1);
END; $$;Cuatro detalles que conviene copiar: el UPDATE ... RETURNING lee y escribe en un solo paso (05-02); el AND fecha_devolucion IS NULL hace la operación idempotente, así que llamarla dos veces no cierra dos veces ni genera dos multas; el IF NOT FOUND convierte un fallo silencioso en una excepción que deshace la transacción entera; y el ORDER BY r.fecha_reserva, r.id respeta la cola con desempate, igual que RC-10. Con varias cajas abiertas a la vez, ese SELECT ... LIMIT 1 querría además FOR UPDATE SKIP LOCKED (09-05), para que dos devoluciones simultáneas de la misma obra no avisen al mismo socio.
CREATE OR REPLACE FUNCTION fn_verificar_socio() RETURNS TRIGGER LANGUAGE plpgsql AS $$
DECLARE v_estado TEXT;
BEGIN SELECT estado INTO v_estado FROM socios WHERE id = NEW.socio_id;
IF v_estado <> 'activo' THEN
RAISE EXCEPTION 'El socio % está en estado "%": no puede tomar prestado (RN-11)',
NEW.socio_id, v_estado;
END IF; RETURN NEW;
END; $$;
CREATE TRIGGER trg_prestamo_socio_activo BEFORE INSERT ON prestamos
FOR EACH ROW EXECUTE FUNCTION fn_verificar_socio();Con Diego Andrade (socio 6, bloqueado por sus 13,40 € impagados), cualquier INSERT en prestamos termina en ERROR: El socio 6 está en estado "bloqueado": no puede tomar prestado (RN-11). Es el único trigger del proyecto, y su justificación es la de 10-05: la regla depende de otra tabla, así que un CHECK no puede expresarla (05-01), y debe cumplirse venga el INSERT de donde venga.
- Errores frecuentes en este proyecto
| Error | Cómo se manifiesta | Cómo se arregla |
|---|---|---|
| Confundir obra y ejemplar, o no poder expresar "préstamo activo" | Una tabla libros con num_ejemplares, o prestamos.obra_id; un activo BOOLEAN que acaba contradiciendo a fecha_devolucion |
Rehacer el modelo —es el fallo que anula el bloque entero de la rúbrica—; y recordar que fecha_devolucion IS NULL es el estado, blindado por el índice parcial |
| Restar fechas mal, o sumar deuda cerrada y potencial | Retrasos negativos, multas calculadas sobre fecha_prestamo, o un pendiente mayor que la suma de multas |
GREATEST(dif, 0), LEAST(importe, tope), retraso siempre contra la fecha prevista, y dos métricas separadas (RN-10) |
| Borrar en vez de dar de baja, o modelar la cola con un booleano | Un CASCADE que se lleva el histórico; un es_el_siguiente que dos procesos ponen a TRUE a la vez |
estado = 'baja' con ON DELETE RESTRICT (RD-10), y fecha_reserva + ROW_NUMBER() (RC-10) |
COUNT(*) tras un LEFT JOIN, o filtrar la tabla derecha en el WHERE |
Socios sin préstamos con 1; o el LEFT JOIN convertido en JOIN y los ceros desaparecidos |
COUNT(columna_de_la_derecha), y la condición de la tabla derecha en el ON |
Errores Comunes y Consejos
- Leer estas soluciones en lugar de compararlas. Abre tu fichero al lado y ve consulta por consulta: donde coincidas, confirma que sabes por qué; donde difieras, decide cuál es mejor y anótalo en el informe. Esa es la parte que se corrige.
- Copiar una solución que no entiendes. En la defensa (12-05) te preguntarán por qué
LATERALy no una CTE, o por quéROW_NUMBERy noRANK; si no lo sabes, se nota en diez segundos. Y dar por buena una consulta porque devuelve filas. Devolver filas no es devolver las correctas. valida por dos caminos: RC-15 suma 36 por filas y por columnas, RC-12 cierra el acumulado en 36 y RC-08 cuadra con las 6 multas. - Consejo: guarda la salida de las quince consultas en un fichero. Cuando toques el esquema o los datos, un
diffte dirá al instante qué se ha movido y si era lo que esperabas (11-04). Y escribe al lado de cada consulta la lección que aplica: convierte el proyecto en un índice de lo que sabes hacer.
Ejercicios
Ejercicio 1
Sobre RC-08. (1) Escribe la consulta de la deuda total real de cada socio: multas impagadas más la multa estimada de sus préstamos vencidos sin devolver. (2) ¿Cuánto debe Diego Andrade con esa definición y cuánto con la de RC-08? (3) ¿Cuál publicarías en tesorería y cuál en dirección?
Ejercicio 2
RC-11 usa LATERAL. (1) Reescríbela con una CTE y ROW_NUMBER(). (2) Da un motivo para preferir cada versión. (3) ¿Qué le pasa a cada una si una sede no tiene ningún préstamo?
Ejercicio 3
Un compañero entrega esto como "obras más prestadas": SELECT o.titulo, COUNT(*) FROM obras o JOIN ejemplares e ON e.obra_id = o.id JOIN prestamos p ON p.ejemplar_id = e.id GROUP BY o.titulo ORDER BY 2 DESC LIMIT 5; (1) ¿Qué tres problemas tiene? (2) Corrígela. (3) ¿Cuál de los tres se manifiesta hoy con estos datos?
Soluciones
Solución 1 — Dos subconsultas correlacionadas, una por métrica, y nunca sumadas en la misma columna:
SELECT s.id, s.nombre || ' ' || s.apellidos AS socio, s.estado,
COALESCE((SELECT SUM(mu.importe) FROM multas AS mu JOIN prestamos AS p2 ON p2.id = mu.prestamo_id
WHERE p2.socio_id = s.id AND mu.fecha_pago IS NULL), 0) AS deuda_cerrada,
COALESCE((SELECT SUM(LEAST(0.20 * (DATE '2026-06-30' - p3.fecha_prevista), 20.00))
FROM prestamos AS p3 WHERE p3.socio_id = s.id
AND p3.fecha_devolucion IS NULL
AND p3.fecha_prevista < DATE '2026-06-30'), 0) AS deuda_potencial
FROM socios AS s ORDER BY deuda_cerrada + deuda_potencial DESC, s.id;(2) Diego Andrade debe 13,40 € con las dos definiciones, porque no tiene ningún préstamo vencido sin devolver. Quienes cambian son otros: Lena Fuentes pasa de 0,00 € a 7,20 €, y Nuno Barros y Alba Rey de 0,00 € a 0,20 €; la deuda potencial total es de 7,60 €. (3) En tesorería, la de RC-08: son los únicos euros exigibles hoy, con una multa emitida detrás. En dirección, las dos columnas juntas, porque la potencial anticipa lo que entrará y señala a quién llamar antes de que la deuda crezca. Lo que no se puede hacer nunca es sumarlas en una columna llamada "deuda": es el error del apartado 5.
Solución 2 — La CTE es la de RC-13 aplicada a obras: ROW_NUMBER() OVER (PARTITION BY sd.id ORDER BY COUNT(*) DESC, o.titulo) sobre un GROUP BY sd.id, o.id, y un WHERE puesto <= 3 fuera. (2) A favor de la CTE: es SQL estándar y portable —LATERAL no existe en MySQL antes de la 8.0.14 ni en SQLite— y se lee de arriba abajo. A favor de LATERAL: solo trae 3 filas por sede en lugar de calcular el ranking de todas las obras para descartar casi todas, lo que con cien mil títulos es la diferencia entre milisegundos y segundos. (3) La CTE pierde la sede sin préstamos, porque no habría nada que agrupar; la versión LEFT JOIN LATERAL ... ON TRUE la conserva con NULL en las columnas de la subconsulta. Para igualarlas habría que partir de sedes con un LEFT JOIN contra el conteo.
Solución 3 — (1) Agrupa por titulo en vez de por id: dos obras distintas con el mismo título —dos ediciones de un clásico, cosa habitual en una biblioteca— se fundirían en una fila con la suma de las dos. ORDER BY 2 DESC sin desempate: con LIMIT 5 y varias obras empatadas, qué cinco salen depende del plan y el informe deja de ser reproducible. Y COUNT(*) sin alias: la columna se llamará count y quien lea el resultado no sabrá si cuenta préstamos, ejemplares o filas del JOIN (11-02). Falta además la definición: cuenta todos los préstamos, incluidos los abiertos y los de socios de baja. (2) La versión correcta:
SELECT o.id, o.titulo, COUNT(p.id) AS prestamos
FROM obras AS o JOIN ejemplares AS e ON e.obra_id = o.id JOIN prestamos AS p ON p.ejemplar_id = e.id
GROUP BY o.id, o.titulo ORDER BY prestamos DESC, o.titulo LIMIT 5;(3) El del desempate. Con estos datos no hay dos obras con el mismo título, así que el primer problema no se manifiesta — y ese es el peligro: la consulta pasa las pruebas y falla el día que alguien catalogue una segunda edición. En cambio Cartas desde el faro, El átomo y la duda, Historia mínima de Alvorada y La niña que contaba estrellas tienen las cuatro 4 préstamos, así que la quinta posición del LIMIT 5 es hoy una lotería entre cuatro candidatas.
Conclusión
Ya tienes con qué compararte:
- El esquema de referencia y sus decisiones, cada una con su alternativa válida: la PK compuesta de
obras_autoresfrente alidsubrogado, lafecha_previstaalmacenada, el estado derivado frente al almacenado, las multas como tabla, la cola calculada, y sobre todo el índice único parcial frente a la restricciónEXCLUDE— la primera si solo importa el presente, la segunda si hay que impedir también los solapamientos históricos. - Las 15 consultas resueltas, cada una con su decisión clave: el desempate del
ORDER BY(RC-01, RC-13), los dos ejemplares del mismo título que no son un duplicado (RC-02), elGREATEST(..., 0)del retraso (RC-03), el anti-join que no esNOT IN(RC-04), elHAVINGque no esWHERE(RC-05), elCOUNT(e.id)que no esCOUNT(*)(RC-06, RC-09), elORDER BYdentro delstring_agg(RC-07), elCOALESCEque convierte elNULLen0.00(RC-08), la posición calculada conROW_NUMBER(RC-10), elLATERALque ve la fila de fuera (RC-11), el calendario que hace visibles los meses vacíos (RC-12), larutaque ordena el organigrama (RC-14) y el pivote que cuadra por filas y por columnas (RC-15). - Siete índices con la consulta que justifica cada uno y la lista de lo que se decide no indexar; más una vista, un procedimiento y un trigger, y ni uno más: la vista que define "vencido" y llama
multa_estimadaa lo que aún no es multa, el procedimiento idempotente y atómico, y el único trigger que expresa una regla que ningúnCHECKpuede. Y los errores frecuentes del proyecto, encabezados por el que lo anula —confundir obra con ejemplar—, con el recordatorio de que una solución distinta puede ser igual de válida si cumple los requisitos y está justificada.
Falta la última parte, y es la que decide cómo se valora todo lo anterior. En la lección siguiente, Presentación del proyecto, verás cómo se comunica un trabajo técnico: la estructura del informe apartado por apartado, cómo se presentan resultados de datos sin engañar de buena fe, las preguntas que te harán en la defensa y cómo prepararlas, cómo se publica el proyecto en un repositorio que alguien pueda ejecutar en cinco minutos, la autoevaluación con la rúbrica convertida en checklist — y el cierre del curso entero.
Curso de SQL
Módulo 1: Introducción a SQL
- ¿Qué es SQL?
- Configurando tu entorno SQL
- Sintaxis básica de SQL
- Entendiendo bases de datos y tablas
- El modelo relacional: claves primarias y foráneas
- La base de datos del curso: TiendaVerde
Módulo 2: Consultas básicas de SQL
- Instrucción SELECT
- Alias, expresiones y columnas calculadas
- Filtrando datos con WHERE
- DISTINCT y eliminación de duplicados
- Ordenando datos con ORDER BY
- Limitando resultados con LIMIT
Módulo 3: Trabajando con múltiples tablas
- Operaciones JOIN
- INNER JOIN
- LEFT JOIN
- RIGHT JOIN
- FULL OUTER JOIN
- SELF JOIN y CROSS JOIN
- Uniones de conjuntos: UNION, INTERSECT y EXCEPT
Módulo 4: Filtrado avanzado de datos
- Usando LIKE para coincidencia de patrones
- Operadores IN y BETWEEN
- Valores NULL y IS NULL
- Funciones de agregación: COUNT, SUM, AVG, MIN y MAX
- Agregando datos con GROUP BY
- Cláusula HAVING
Módulo 5: Manipulación de datos
- Creando tablas y restricciones con CREATE TABLE
- Instrucción INSERT
- Instrucción UPDATE
- Instrucción DELETE
- Instrucción UPSERT (MERGE)
- Modificando el esquema: ALTER TABLE y migraciones seguras
Módulo 6: Funciones avanzadas de SQL
- Funciones de cadena
- Funciones numéricas
- Funciones de fecha y hora
- Conversión de tipos y manejo de NULL: CAST y COALESCE
- Expresiones condicionales
Módulo 7: Subconsultas y consultas anidadas
- Introducción a subconsultas
- Subconsultas correlacionadas
- EXISTS y NOT EXISTS
- Usando subconsultas en cláusulas SELECT, FROM y WHERE
- Subconsultas o JOIN: cuál elegir
Módulo 8: Índices y optimización de rendimiento
- Entendiendo los índices
- Creación y gestión de índices
- Tipos de índice y cuándo no indexar
- Técnicas de optimización de consultas
- Análisis del rendimiento de consultas
Módulo 9: Transacciones y concurrencia
- Introducción a las transacciones
- Propiedades ACID
- Instrucciones de control de transacciones
- Niveles de aislamiento y anomalías de concurrencia
- Manejo de concurrencia: bloqueos e interbloqueos
Módulo 10: Temas avanzados
- Vistas
- Expresiones de tabla comunes (CTE)
- Funciones de ventana
- Procedimientos almacenados
- Triggers
- JSON y datos semiestructurados
Módulo 11: SQL en la práctica
- Casos de uso en el mundo real
- Mejores prácticas
- Seguridad: inyección SQL, permisos y roles
- SQL para análisis de datos
- SQL en desarrollo web
