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

  1. El esquema de referencia: decisiones y alternativas
  2. Las 15 consultas resueltas
  3. Los índices de referencia
  4. La vista, el procedimiento y el trigger
  5. Errores frecuentes en este proyecto
  6. Errores Comunes y Consejos
  7. Ejercicios
  8. Conclusión

  1. 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.

  1. 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.

  1. 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.

  1. 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.

  1. 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é LATERAL y no una CTE, o por qué ROW_NUMBER y no RANK; 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 diff te 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_autores frente al id subrogado, la fecha_prevista almacenada, el estado derivado frente al almacenado, las multas como tabla, la cola calculada, y sobre todo el índice único parcial frente a la restricción EXCLUDE — 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), el GREATEST(..., 0) del retraso (RC-03), el anti-join que no es NOT IN (RC-04), el HAVING que no es WHERE (RC-05), el COUNT(e.id) que no es COUNT(*) (RC-06, RC-09), el ORDER BY dentro del string_agg (RC-07), el COALESCE que convierte el NULL en 0.00 (RC-08), la posición calculada con ROW_NUMBER (RC-10), el LATERAL que ve la fila de fuera (RC-11), el calendario que hace visibles los meses vacíos (RC-12), la ruta que 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_estimada a lo que aún no es multa, el procedimiento idempotente y atómico, y el único trigger que expresa una regla que ningún CHECK puede. 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

Módulo 2: Consultas básicas de SQL

Módulo 3: Trabajando con múltiples tablas

Módulo 4: Filtrado avanzado de datos

Módulo 5: Manipulación de datos

Módulo 6: Funciones avanzadas de SQL

Módulo 7: Subconsultas y consultas anidadas

Módulo 8: Índices y optimización de rendimiento

Módulo 9: Transacciones y concurrencia

Módulo 10: Temas avanzados

Módulo 11: SQL en la práctica

Módulo 12: Proyecto final

© Copyright 2026. Todos los derechos reservados