Los dos terminales están abiertos y el esquema de BiblioRed está a mano, tal como quedamos al cerrar el módulo 6. A partir de aquí el curso cambia de registro: no vas a leer conceptos nuevos, vas a escribir SQL. Esta lección es un banco de quince ejercicios sobre la red de bibliotecas municipales de Vallmar, ordenados por dificultad creciente, y su forma de uso es muy concreta.
Cómo trabajar esta lección. Lee el enunciado. Antes de mirar nada más, escribe tu consulta en la sesión psql y ejecútala. Compara tu resultado con el bloque Resultado esperado. Solo entonces despliega la Solución y compárala con la tuya: es perfectamente normal —y con frecuencia deseable— que tu consulta sea distinta de la propuesta y dé el mismo resultado, porque en SQL casi siempre hay más de un camino. Lo que no es normal es leer la solución primero: en ese caso el ejercicio no te enseña nada, solo te da la sensación de haber entendido.
Si una consulta no te sale, resiste al menos cinco minutos antes de mirar la Pista. El músculo que estás entrenando aquí no es el de recordar la sintaxis de HAVING, sino el de traducir una pregunta en lenguaje humano a una consulta, y eso solo se entrena fallando.
Esta lección cubre SELECT, WHERE, JOIN, agregación y subconsultas. Las funciones de ventana, las transacciones y los planes de ejecución no aparecen aquí: son el material de la lección 07-04.
Antes de Empezar
Los ejercicios usan un juego de datos concreto y pequeño —8 socios, 12 materiales, 15 ejemplares, 20 préstamos— para que puedas verificar cada resultado a ojo. Si has ido construyendo BiblioRed a lo largo del curso, tus datos serán distintos y los resultados no coincidirán. Por eso conviene partir de cero con el script de esta lección.
Script de creación y carga
Crea una base de datos limpia y ejecuta este script completo. En PostgreSQL:
-- ============================================================
-- BiblioRed - juego de datos reducido para el módulo 7
-- PostgreSQL 14+. Todos los datos son ficticios.
-- ============================================================
DROP TABLE IF EXISTS pagos, multas, inscripciones, participaciones, ponentes,
eventos, tipos_evento, salas, reservas, prestamos,
ejemplares, materiales, telefonos_socio, socios,
autores, sucursales CASCADE;
CREATE TABLE sucursales (
sucursal_id INTEGER PRIMARY KEY,
nombre VARCHAR(60) NOT NULL UNIQUE,
dir_calle VARCHAR(80),
dir_numero VARCHAR(10),
dir_codigo_postal CHAR(5),
dir_ciudad VARCHAR(60) NOT NULL DEFAULT 'Vallmar',
telefono VARCHAR(15),
fecha_apertura DATE
);
CREATE TABLE autores (
autor_id INTEGER PRIMARY KEY,
nombre VARCHAR(60) NOT NULL,
apellidos VARCHAR(80) NOT NULL,
nacionalidad VARCHAR(40),
anio_nacimiento SMALLINT
);
CREATE TABLE socios (
socio_id INTEGER PRIMARY KEY,
nombre VARCHAR(60) NOT NULL,
apellidos VARCHAR(80) NOT NULL,
email VARCHAR(120) NOT NULL UNIQUE,
fecha_alta DATE NOT NULL,
sucursal_id INTEGER NOT NULL REFERENCES sucursales(sucursal_id),
activo BOOLEAN NOT NULL DEFAULT TRUE
);
CREATE TABLE telefonos_socio (
socio_id INTEGER NOT NULL REFERENCES socios(socio_id) ON DELETE CASCADE,
numero VARCHAR(15) NOT NULL,
tipo VARCHAR(10) NOT NULL,
PRIMARY KEY (socio_id, numero)
);
CREATE TABLE materiales (
material_id INTEGER PRIMARY KEY,
tipo_material VARCHAR(15) NOT NULL,
titulo VARCHAR(200) NOT NULL,
autor_id INTEGER REFERENCES autores(autor_id),
editorial VARCHAR(80),
anio_publicacion SMALLINT,
idioma CHAR(2) NOT NULL DEFAULT 'es',
fecha_alta DATE NOT NULL,
portada_url VARCHAR(200)
);
CREATE TABLE ejemplares (
ejemplar_id INTEGER PRIMARY KEY,
codigo VARCHAR(15) NOT NULL UNIQUE,
material_id INTEGER NOT NULL REFERENCES materiales(material_id),
num_ejemplar SMALLINT NOT NULL,
sucursal_id INTEGER NOT NULL REFERENCES sucursales(sucursal_id),
estado VARCHAR(15) NOT NULL,
fecha_adquisicion DATE
);
CREATE TABLE prestamos (
prestamo_id INTEGER PRIMARY KEY,
socio_id INTEGER NOT NULL REFERENCES socios(socio_id),
ejemplar_id INTEGER NOT NULL REFERENCES ejemplares(ejemplar_id),
fecha_prestamo DATE NOT NULL,
fecha_devolucion_prevista DATE NOT NULL,
fecha_devolucion DATE
);
CREATE TABLE reservas (
reserva_id INTEGER PRIMARY KEY,
socio_id INTEGER NOT NULL REFERENCES socios(socio_id),
material_id INTEGER NOT NULL REFERENCES materiales(material_id),
fecha_reserva DATE NOT NULL,
fecha_expiracion DATE,
estado VARCHAR(15) NOT NULL
);
CREATE TABLE salas (
sala_id INTEGER PRIMARY KEY,
sucursal_id INTEGER NOT NULL REFERENCES sucursales(sucursal_id),
nombre VARCHAR(60) NOT NULL,
aforo SMALLINT NOT NULL,
planta SMALLINT NOT NULL,
accesible BOOLEAN NOT NULL DEFAULT TRUE,
UNIQUE (sucursal_id, nombre)
);
CREATE TABLE tipos_evento (
tipo_evento_id INTEGER PRIMARY KEY,
codigo VARCHAR(10) NOT NULL UNIQUE,
nombre VARCHAR(60) NOT NULL,
descripcion TEXT,
duracion_estandar_min SMALLINT,
activo BOOLEAN NOT NULL DEFAULT TRUE
);
CREATE TABLE eventos (
evento_id INTEGER PRIMARY KEY,
titulo VARCHAR(150) NOT NULL,
descripcion TEXT,
tipo_evento_id INTEGER NOT NULL REFERENCES tipos_evento(tipo_evento_id),
sala_id INTEGER NOT NULL REFERENCES salas(sala_id),
inicio TIMESTAMP NOT NULL,
fin TIMESTAMP NOT NULL,
plazas_ofertadas SMALLINT NOT NULL,
estado VARCHAR(15) NOT NULL,
publicado BOOLEAN NOT NULL DEFAULT FALSE,
version INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE inscripciones (
evento_id INTEGER NOT NULL REFERENCES eventos(evento_id),
socio_id INTEGER NOT NULL REFERENCES socios(socio_id),
fecha_inscripcion DATE NOT NULL,
estado VARCHAR(15) NOT NULL,
acompanantes SMALLINT NOT NULL DEFAULT 0,
plazas_ocupadas SMALLINT NOT NULL DEFAULT 1,
PRIMARY KEY (evento_id, socio_id)
);
CREATE TABLE ponentes (
ponente_id INTEGER PRIMARY KEY,
nombre VARCHAR(60) NOT NULL,
apellidos VARCHAR(80) NOT NULL,
email VARCHAR(120) NOT NULL UNIQUE,
biografia TEXT,
externo BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE TABLE participaciones (
evento_id INTEGER NOT NULL REFERENCES eventos(evento_id),
ponente_id INTEGER NOT NULL REFERENCES ponentes(ponente_id),
rol VARCHAR(30) NOT NULL,
honorarios NUMERIC(8,2) NOT NULL DEFAULT 0,
PRIMARY KEY (evento_id, ponente_id, rol)
);
CREATE TABLE multas (
multa_id INTEGER PRIMARY KEY,
socio_id INTEGER NOT NULL REFERENCES socios(socio_id),
prestamo_id INTEGER REFERENCES prestamos(prestamo_id),
motivo VARCHAR(15) NOT NULL,
importe NUMERIC(8,2) NOT NULL,
fecha_emision DATE NOT NULL,
estado VARCHAR(15) NOT NULL
);
CREATE TABLE pagos (
pago_id INTEGER PRIMARY KEY,
multa_id INTEGER NOT NULL REFERENCES multas(multa_id),
fecha_pago DATE NOT NULL,
importe NUMERIC(8,2) NOT NULL,
metodo VARCHAR(15) NOT NULL,
referencia VARCHAR(30)
);
-- ---------------- DATOS ----------------
INSERT INTO sucursales VALUES
(1,'Centro','Plaza Mayor','1','08130','Vallmar','935550001','1988-05-12'),
(2,'Norte','Avenida del Bosque','44','08131','Vallmar','935550002','2001-09-03'),
(3,'Sur','Calle Marina','7','08132','Vallmar','935550003','2010-01-18'),
(4,'Este','Ronda de Levante','120','08133','Vallmar','935550004','2019-11-25');
INSERT INTO autores VALUES
(1,'Ken','Follett','británica',1949),
(2,'Félix J.','Palma','española',1968),
(3,'Almudena','Grandes','española',1960),
(4,'Haruki','Murakami','japonesa',1949),
(5,'Svetlana','Aleksiévich','bielorrusa',1948),
(6,'Delia','Marchetti','argentina',NULL);
INSERT INTO socios VALUES
(11,'Clara','Ferrán','[email protected]','2024-03-12',1,TRUE),
(12,'Dídac','Rovira','[email protected]','2024-06-01',2,TRUE),
(13,'Sonia','Mestre','[email protected]','2025-01-20',1,TRUE),
(14,'Marta','Alsina','[email protected]','2025-02-14',1,TRUE),
(15,'Iván','Pereda','[email protected]','2025-04-03',2,TRUE),
(16,'Nuria','Bastos','[email protected]','2025-09-30',3,TRUE),
(17,'Óscar','Vilanova','[email protected]','2026-01-15',4,FALSE),
(18,'Berta','Quintana','[email protected]','2026-02-08',3,TRUE);
INSERT INTO telefonos_socio VALUES
(11,'931222333','fijo'),(14,'600111222','movil'),
(14,'931000111','fijo'),(15,'600333444','movil'),(16,'600555666','movil');
INSERT INTO materiales VALUES
(901,'libro','Los pilares de la Tierra',1,'Plaza & Janés',1989,'es','2024-01-10','/img/901.jpg'),
(902,'libro','El mapa del tiempo',2,'Algaida',2008,'es','2024-01-10','/img/902.jpg'),
(903,'libro','Un mundo sin fin',1,'Plaza & Janés',2007,'es','2024-02-05',NULL),
(904,'libro','El corazón helado',3,'Tusquets',2007,'es','2024-03-01','/img/904.jpg'),
(905,'libro','Tokio blues',4,'Tusquets',1987,'es','2024-03-01',NULL),
(906,'libro','Kafka en la orilla',4,'Tusquets',2002,'es','2025-01-12','/img/906.jpg'),
(907,'libro','La caja de los deseos',6,'Editorial Vallmar',2019,'es','2025-02-20',NULL),
(908,'dvd','Los pilares de la Tierra (serie)',1,'Sono Media',2010,'es','2025-03-15','/img/908.jpg'),
(909,'dvd','Documental: la voz de Chernóbil',5,'Filmin',2019,'es','2025-04-02',NULL),
(910,'revista','Ciencia Vallmar 42',NULL,'Ayuntamiento de Vallmar',2026,'es','2026-01-08',NULL),
(911,'revista','Ciencia Vallmar 43',NULL,'Ayuntamiento de Vallmar',2026,'es','2026-02-08',NULL),
(912,'audiolibro','Voces de Chernóbil',5,'Debate Audio',2015,'es','2025-06-11','/img/912.jpg');
INSERT INTO ejemplares VALUES
(3081,'EJ-3081',902,1,1,'prestado','2024-01-15'),
(3082,'EJ-3082',902,2,2,'disponible','2024-01-15'),
(3083,'EJ-3083',901,1,1,'disponible','2024-01-15'),
(3084,'EJ-3084',901,2,3,'disponible','2024-01-15'),
(3085,'EJ-3085',901,3,4,'reservado','2025-02-01'),
(3086,'EJ-3086',903,1,1,'disponible','2024-02-10'),
(3087,'EJ-3087',904,1,2,'prestado','2024-03-05'),
(3088,'EJ-3088',905,1,1,'prestado','2024-03-05'),
(3089,'EJ-3089',906,1,3,'disponible','2025-01-20'),
(3090,'EJ-3090',906,2,1,'disponible','2025-01-20'),
(3091,'EJ-3091',908,1,1,'prestado','2025-03-20'),
(3092,'EJ-3092',909,1,2,'disponible','2025-04-10'),
(3093,'EJ-3093',912,1,1,'extraviado','2025-06-15'),
(3094,'EJ-3094',910,1,1,'disponible','2026-01-10'),
(3095,'EJ-3095',904,2,4,'retirado','2024-03-05');
INSERT INTO prestamos VALUES
(1,14,3081,'2026-01-10','2026-01-31','2026-01-28'),
(2,14,3083,'2026-02-02','2026-02-23','2026-03-05'),
(3,15,3082,'2026-02-10','2026-03-03','2026-02-28'),
(4,16,3084,'2026-02-15','2026-03-08','2026-03-08'),
(5,11,3086,'2026-03-01','2026-03-22','2026-03-20'),
(6,12,3087,'2026-03-05','2026-03-26','2026-04-10'),
(7,14,3089,'2026-03-12','2026-04-02','2026-03-30'),
(8,15,3090,'2026-04-01','2026-04-22','2026-04-20'),
(9,13,3088,'2026-04-05','2026-04-26',NULL),
(10,16,3091,'2026-04-20','2026-05-11','2026-05-09'),
(11,11,3092,'2026-05-02','2026-05-23','2026-05-21'),
(12,14,3093,'2026-05-10','2026-05-31',NULL),
(13,12,3081,'2026-05-15','2026-06-05','2026-06-03'),
(14,15,3083,'2026-06-01','2026-06-22','2026-06-30'),
(15,16,3087,'2026-06-10','2026-07-01',NULL),
(16,13,3089,'2026-06-15','2026-07-06','2026-07-04'),
(17,14,3091,'2026-07-20','2026-08-10',NULL),
(18,11,3082,'2026-07-05','2026-07-26','2026-07-24'),
(19,15,3081,'2026-07-22','2026-08-12',NULL),
(20,12,3086,'2026-07-10','2026-07-31','2026-07-29');
INSERT INTO reservas VALUES
(501,14,901,'2026-01-05','2026-01-19','atendida'),
(502,15,902,'2026-07-18','2026-08-01','activa'),
(503,16,906,'2026-06-01','2026-06-15','expirada'),
(504,11,908,'2026-07-25','2026-08-08','activa'),
(505,13,902,'2026-05-02','2026-05-16','cancelada'),
(506,12,901,'2026-03-30','2026-04-13','expirada');
INSERT INTO salas VALUES
(1,1,'Auditorio',120,0,TRUE),(2,1,'Sala Azul',30,1,TRUE),
(3,2,'Sala Norte',40,0,TRUE),(4,3,'Aula Sur',25,1,FALSE),
(5,4,'Sala Este',50,0,TRUE);
INSERT INTO tipos_evento VALUES
(1,'CLUB','Club de lectura',NULL,90,TRUE),
(2,'CUENTA','Cuentacuentos infantil',NULL,45,TRUE),
(3,'TALLER','Taller',NULL,120,TRUE),
(4,'PRES','Presentación de libro',NULL,60,TRUE);
INSERT INTO eventos VALUES
(101,'Club de lectura: Los pilares de la Tierra',NULL,1,2,'2026-03-12 18:00','2026-03-12 19:30',12,'celebrado',TRUE,3),
(102,'Cuentacuentos de primavera',NULL,2,3,'2026-04-18 11:00','2026-04-18 11:45',20,'celebrado',TRUE,2),
(103,'Taller de escritura creativa',NULL,3,4,'2026-05-09 17:00','2026-05-09 19:00',8,'celebrado',TRUE,4),
(104,'Presentación: La caja de los deseos',NULL,4,1,'2026-06-04 19:00','2026-06-04 20:00',40,'celebrado',TRUE,2),
(105,'Club de lectura: Tokio blues',NULL,1,5,'2026-07-16 18:00','2026-07-16 19:30',10,'celebrado',TRUE,3),
(106,'Taller de iniciación a la genealogía',NULL,3,2,'2026-09-10 17:00','2026-09-10 19:00',15,'abierto',TRUE,1),
(107,'Cuentacuentos de otoño',NULL,2,3,'2026-10-03 11:00','2026-10-03 11:45',20,'programado',FALSE,1);
INSERT INTO inscripciones VALUES
(101,14,'2026-02-20','asistida',1,2),(101,11,'2026-02-22','asistida',0,1),
(101,15,'2026-03-01','cancelada',0,1),(101,12,'2026-03-02','asistida',0,1),
(101,13,'2026-03-03','asistida',2,3),
(102,16,'2026-04-01','asistida',3,4),(102,12,'2026-04-02','asistida',2,3),
(102,11,'2026-04-05','confirmada',1,2),(102,15,'2026-04-06','cancelada',0,1),
(103,14,'2026-04-20','asistida',0,1),(103,13,'2026-04-21','asistida',0,1),
(103,15,'2026-04-22','asistida',1,2),(103,16,'2026-04-25','lista_espera',0,1),
(103,11,'2026-04-26','confirmada',0,1),
(104,11,'2026-05-10','asistida',1,2),(104,12,'2026-05-11','asistida',0,1),
(104,14,'2026-05-12','asistida',3,4),(104,16,'2026-05-14','asistida',0,1),
(104,13,'2026-05-15','cancelada',0,1),
(105,15,'2026-06-30','asistida',0,1),(105,16,'2026-07-01','asistida',1,2),
(105,14,'2026-07-02','asistida',0,1),(105,11,'2026-07-03','lista_espera',0,1),
(106,14,'2026-07-28','confirmada',1,2),(106,15,'2026-07-29','confirmada',0,1),
(106,16,'2026-07-30','confirmada',0,1);
INSERT INTO ponentes VALUES
(1,'Rosa','Calduch','[email protected]',NULL,FALSE),
(2,'Aitor','Lemus','[email protected]',NULL,TRUE),
(3,'Delia','Marchetti','[email protected]',NULL,TRUE);
INSERT INTO participaciones VALUES
(101,1,'moderadora',0),(102,2,'narrador',180.00),(103,2,'tallerista',350.00),
(104,3,'autora',250.00),(104,1,'presentadora',0),(105,1,'moderadora',0),
(106,2,'tallerista',300.00);
INSERT INTO multas VALUES
(1,14,2,'retraso',2.20,'2026-03-05','pagada'),
(2,12,6,'retraso',3.00,'2026-04-10','pagada'),
(3,15,14,'retraso',1.60,'2026-06-30','pendiente'),
(4,14,12,'perdida',24.00,'2026-06-01','pendiente'),
(5,16,10,'deterioro',6.50,'2026-05-09','pagada'),
(6,11,5,'deterioro',4.00,'2026-03-20','condonada'),
(7,13,9,'retraso',5.00,'2026-07-20','pendiente'),
(8,16,15,'retraso',3.20,'2026-07-25','anulada');
INSERT INTO pagos VALUES
(1,1,'2026-03-06',2.20,'efectivo','CAJA-C-0341'),
(2,2,'2026-04-12',3.00,'tarjeta','TPV-000912'),
(3,5,'2026-05-10',4.00,'tarjeta','TPV-001033'),
(4,5,'2026-05-18',2.50,'pasarela','PSG-77120');Comprobación de que los datos están cargados
Ejecuta esta consulta antes de empezar. Si algún número no coincide, el script no se ha ejecutado entero:
SELECT 'sucursales' AS tabla, count(*) FROM sucursales
UNION ALL SELECT 'autores', count(*) FROM autores
UNION ALL SELECT 'socios', count(*) FROM socios
UNION ALL SELECT 'materiales', count(*) FROM materiales
UNION ALL SELECT 'ejemplares', count(*) FROM ejemplares
UNION ALL SELECT 'prestamos', count(*) FROM prestamos
UNION ALL SELECT 'reservas', count(*) FROM reservas
UNION ALL SELECT 'eventos', count(*) FROM eventos
UNION ALL SELECT 'inscripciones',count(*) FROM inscripciones
UNION ALL SELECT 'multas', count(*) FROM multas
UNION ALL SELECT 'pagos', count(*) FROM pagos
ORDER BY 1;| tabla | count |
|---|---|
| autores | 6 |
| ejemplares | 15 |
| eventos | 7 |
| inscripciones | 26 |
| materiales | 12 |
| multas | 8 |
| pagos | 4 |
| prestamos | 20 |
| reservas | 6 |
| socios | 8 |
| sucursales | 4 |
La fecha de referencia. Varios ejercicios calculan retrasos. Para que los resultados sean reproducibles hoy y dentro de dos años, las soluciones usan la fecha literal DATE '2026-08-02' en lugar de CURRENT_DATE. En producción escribirías CURRENT_DATE; aquí necesitamos que el número de días no cambie.
SQLite. El juego de datos funciona en SQLite cambiando SERIAL/BOOLEAN por enteros y NUMERIC(8,2) por REAL. Donde haya diferencias relevantes en una consulta, la solución lo indica.
Contenido
- Bloque A — Básico: consultas sobre una sola tabla (ejercicios 1-3)
- Bloque B — Intermedio: consultas sobre varias tablas (ejercicios 4-7)
- Bloque C — Intermedio: agregación y agrupación (ejercicios 8-10)
- Bloque D — Avanzado: subconsultas, tablas derivadas y CTE (ejercicios 11-12)
- Bloque E — Avanzado: informes de gestión reales (ejercicios 13-15)
- Errores comunes y consejos
- Ejercicios de refuerzo
Bloque A — Básico: una sola tabla
Ejercicio 1: Proyección, alias y ordenación
Dificultad: Básico
Enunciado. El servicio de patrimonio quiere el listado de los cinco materiales más antiguos del catálogo. Devuelve tres columnas con estos encabezados exactos: Título, Editorial y Año. Ordena de más antiguo a más moderno y, cuando dos materiales sean del mismo año, alfabéticamente por título.
Solución
SELECT titulo AS "Título",
editorial AS "Editorial",
anio_publicacion AS "Año"
FROM materiales
ORDER BY anio_publicacion ASC, titulo ASC
LIMIT 5;Resultado esperado
| Título | Editorial | Año |
|---|---|---|
| Tokio blues | Tusquets | 1987 |
| Los pilares de la Tierra | Plaza & Janés | 1989 |
| Kafka en la orilla | Tusquets | 2002 |
| El corazón helado | Tusquets | 2007 |
| Un mundo sin fin | Plaza & Janés | 2007 |
Explicación. Hay tres detalles que separan una consulta correcta de una consulta descuidada:
- Las comillas dobles en los alias.
AS Títulofallaría o se convertiría entítuloen minúsculas, porque PostgreSQL pasa a minúsculas todo identificador sin comillas. Para conservar la mayúscula y la tilde hacen falta comillas dobles, no simples: las comillas simples delimitan cadenas de texto, no identificadores.AS 'Título'es un error de sintaxis. - El segundo criterio de ordenación. Sin
, titulo ASC, las dos filas de 2007 saldrían en un orden que el motor no garantiza. Puede parecerte estable hoy y cambiar mañana al añadir una fila o al crear un índice. UnORDER BYque no desempata no es determinista, y en un listado conLIMITeso significa que la fila que ves puede variar entre ejecuciones. LIMITva después deORDER BY, no antes: primero se ordena el conjunto entero, luego se cortan cinco filas. Si lo hicieras al revés obtendrías cinco filas cualesquiera y luego ordenadas, que es una pregunta distinta.
En SQLite la consulta funciona igual, pero los alias entrecomillados se comportan de forma más laxa (acepta comillas dobles y también corchetes).
Ejercicio 2: Filtros con BETWEEN, IN, LIKE, IS NULL y DISTINCT
Dificultad: Básico
Enunciado. Resuelve las cinco preguntas siguientes, cada una con su propia consulta:
- (a) Materiales publicados entre 2000 y 2010, ambos incluidos, con título y año.
- (b) Ejemplares cuyo estado sea
prestadooreservado, con código, estado y sucursal. - (c) Materiales cuyo título contenga la palabra «Chernóbil», sin importar mayúsculas ni minúsculas.
- (d) Materiales sin portada (
portada_urlno informada) y autores sin año de nacimiento. - (e) Los tipos de material distintos que hay en el catálogo y cuántas editoriales distintas aparecen.
Pista. Para (c), en PostgreSQL existe ILIKE; para (d), recuerda que = NULL nunca es verdadero.
Solución
-- (a) BETWEEN es inclusivo por ambos extremos
SELECT titulo, anio_publicacion
FROM materiales
WHERE anio_publicacion BETWEEN 2000 AND 2010
ORDER BY anio_publicacion, titulo;
-- (b) IN sustituye a una cadena de OR
SELECT e.codigo, e.estado, s.nombre AS sucursal
FROM ejemplares e
JOIN sucursales s ON s.sucursal_id = e.sucursal_id
WHERE e.estado IN ('prestado', 'reservado')
ORDER BY e.codigo;
-- (c) ILIKE = LIKE insensible a mayúsculas (PostgreSQL)
SELECT material_id, titulo
FROM materiales
WHERE titulo ILIKE '%chernóbil%'
ORDER BY material_id;
-- (d) IS NULL, nunca = NULL
SELECT material_id, titulo FROM materiales WHERE portada_url IS NULL ORDER BY material_id;
SELECT autor_id, nombre, apellidos FROM autores WHERE anio_nacimiento IS NULL;
-- (e) DISTINCT y COUNT(DISTINCT ...)
SELECT DISTINCT tipo_material FROM materiales ORDER BY tipo_material;
SELECT count(DISTINCT editorial) AS editoriales FROM materiales;Resultado esperado
(a) 5 filas: Kafka en la orilla (2002), El corazón helado (2007), Un mundo sin fin (2007), El mapa del tiempo (2008), Los pilares de la Tierra (serie) (2010).
(b)
| codigo | estado | sucursal |
|---|---|---|
| EJ-3081 | prestado | Centro |
| EJ-3085 | reservado | Este |
| EJ-3087 | prestado | Norte |
| EJ-3088 | prestado | Centro |
| EJ-3091 | prestado | Centro |
(c) 2 filas: 909 «Documental: la voz de Chernóbil» y 912 «Voces de Chernóbil».
(d) 6 materiales sin portada (903, 905, 907, 909, 910, 911) y 1 autor sin año de nacimiento (Delia Marchetti).
(e) 4 tipos (audiolibro, dvd, libro, revista) y 8 editoriales distintas.
Explicación. Cada apartado esconde una trampa clásica:
BETWEEN 2000 AND 2010incluye los dos extremos. Si necesitas excluirlos,BETWEENno sirve: hay que escribir> 2000 AND < 2010. Y el orden importa:BETWEEN 2010 AND 2000devuelve cero filas, sin error y sin aviso.IN ('prestado','reservado')es exactamente equivalente aestado = 'prestado' OR estado = 'reservado'. Es más legible y, sobre todo, evita el error de precedencia de escribirWHERE material_id = 901 AND estado = 'prestado' OR estado = 'reservado', que no significa lo que parece:ANDliga más fuerte queOR.ILIKEes una extensión de PostgreSQL. En SQLite,LIKEya es insensible a mayúsculas para caracteres ASCII, pero no para las tildes ni para laÓde «Chernóbil»; allí lo portable esWHERE lower(titulo) LIKE lower('%chernóbil%').portada_url = NULLdevuelveNULL, que en unWHEREse comporta como falso: la consulta no da error, da cero filas. Es uno de los fallos más difíciles de detectar porque no hay ningún síntoma.count(DISTINCT editorial)cuenta 8 y no 9: hay 12 materiales pero solo 8 editoriales distintas, ycount(DISTINCT ...)ignora los NULL (aquí no hay editoriales nulas, pero conviene recordarlo).
Ejercicio 3: INSERT, UPDATE y DELETE con la disciplina del SELECT previo
Dificultad: Básico
Enunciado. Tres operaciones de mantenimiento. En las tres, antes de modificar nada, escribe y ejecuta el SELECT con el mismo WHERE para ver exactamente qué filas vas a tocar.
- (a) Da de alta al socio 19, Rubén Ortells, correo
[email protected], alta hoy (2026-08-02), sucursal Este, activo. - (b) El socio 18 (Berta Quintana) se traslada de la sucursal Sur a la sucursal Norte. Actualiza su sucursal.
- (c) Borra las reservas expiradas cuya fecha de expiración sea anterior al 30 de junio de 2026.
Solución
-- (a) INSERT con lista de columnas explícita
INSERT INTO socios (socio_id, nombre, apellidos, email, fecha_alta, sucursal_id, activo)
VALUES (19, 'Rubén', 'Ortells', '[email protected]', DATE '2026-08-02', 4, TRUE);
-- (b) Primero mirar...
SELECT socio_id, nombre, apellidos, sucursal_id FROM socios WHERE socio_id = 18;
-- ...y solo entonces modificar
UPDATE socios
SET sucursal_id = 2
WHERE socio_id = 18
RETURNING socio_id, apellidos, sucursal_id;
-- (c) Primero mirar...
SELECT reserva_id, socio_id, material_id, fecha_expiracion, estado
FROM reservas
WHERE estado = 'expirada' AND fecha_expiracion < DATE '2026-06-30';
-- ...y solo entonces borrar
DELETE FROM reservas
WHERE estado = 'expirada' AND fecha_expiracion < DATE '2026-06-30'
RETURNING reserva_id;Resultado esperado
INSERT 0 1
socio_id | apellidos | sucursal_id
----------+-----------+-------------
18 | Quintana | 2
UPDATE 1
reserva_id
------------
503
506
DELETE 2Explicación. Tres hábitos que evitan casi todos los desastres de un mostrador:
- Lista de columnas explícita en el
INSERT.INSERT INTO socios VALUES (...)funciona hasta el día en que alguien añade una columna asocios; entonces todos losINSERTsin lista se rompen o, peor, colocan los valores en la columna equivocada. - El
SELECTcon el mismoWHERE, primero. No es una recomendación de estilo: es la única forma de saber cuántas filas vas a tocar antes de tocarlas. UnUPDATE socios SET sucursal_id = 2sinWHEREafecta a los ocho socios y no hay «deshacer» fuera de una transacción. RETURNING(PostgreSQL) devuelve las filas realmente afectadas. Es la confirmación posterior de que elWHEREhizo lo previsto. SQLite lo soporta desde la versión 3.35; en MySQL no existe y hay que consultar de nuevo.
Sobre (c): si intentaras borrar la reserva 501 (atendida), no fallaría nada porque ninguna tabla apunta a reservas. Pero si intentaras borrar el socio 14 sí fallaría, porque prestamos, reservas, multas e inscripciones lo referencian. Es la integridad referencial del módulo 2 haciendo su trabajo.
Deshaz los cambios antes de seguir, para que los resultados posteriores coincidan:
DELETE FROM socios WHERE socio_id = 19; UPDATE socios SET sucursal_id = 3 WHERE socio_id = 18;INSERT INTO reservas VALUES (503,16,906,'2026-06-01','2026-06-15','expirada'), (506,12,901,'2026-03-30','2026-04-13','expirada');
Bloque B — Intermedio: varias tablas
Ejercicio 4: INNER JOIN de dos tablas
Dificultad: Intermedio
Enunciado. Un socio pregunta en el mostrador dónde puede encontrar «Los pilares de la Tierra» (material 901). Devuelve todos sus ejemplares con el código, el estado y el nombre de la sucursal donde está cada uno, ordenados por sucursal.
Pista. La sucursal está en ejemplares.sucursal_id, no en materiales.
Solución
SELECT e.codigo,
e.num_ejemplar,
e.estado,
s.nombre AS sucursal
FROM ejemplares e
INNER JOIN sucursales s ON s.sucursal_id = e.sucursal_id
WHERE e.material_id = 901
ORDER BY s.nombre;Resultado esperado
| codigo | num_ejemplar | estado | sucursal |
|---|---|---|---|
| EJ-3083 | 1 | disponible | Centro |
| EJ-3085 | 3 | reservado | Este |
| EJ-3084 | 2 | disponible | Sur |
Explicación. El JOIN conecta dos tablas por la pareja clave ajena → clave primaria: ejemplares.sucursal_id apunta a sucursales.sucursal_id. Los alias e y s no son decorativos: en cuanto haya dos tablas con una columna del mismo nombre —y aquí sucursal_id está en las dos— escribir sucursal_id a secas produce el error column reference "sucursal_id" is ambiguous.
El error fácil aquí es olvidar la condición del ON. Un FROM ejemplares e, sucursales s WHERE e.material_id = 901 sin condición de reunión produce un producto cartesiano: 3 ejemplares × 4 sucursales = 12 filas, todas con aspecto plausible. Es un error que no salta a la vista con tres filas y que con 40.000 ejemplares tumba el servidor.
Ejercicio 5: La cadena de cuatro tablas
Dificultad: Intermedio
Enunciado. Lista todos los préstamos abiertos (los que aún no se han devuelto) con: nombre y apellidos del socio, código del ejemplar, título del material, sucursal a la que pertenece el ejemplar y fecha de devolución prevista. Ordena por fecha prevista.
Pista. La cadena es socios → prestamos → ejemplares → materiales, y la sucursal cuelga de ejemplares.
Solución
SELECT so.nombre || ' ' || so.apellidos AS socio,
e.codigo,
m.titulo,
su.nombre AS sucursal,
p.fecha_devolucion_prevista AS prevista
FROM prestamos p
INNER JOIN socios so ON so.socio_id = p.socio_id
INNER JOIN ejemplares e ON e.ejemplar_id = p.ejemplar_id
INNER JOIN materiales m ON m.material_id = e.material_id
INNER JOIN sucursales su ON su.sucursal_id = e.sucursal_id
WHERE p.fecha_devolucion IS NULL
ORDER BY p.fecha_devolucion_prevista;Resultado esperado
| socio | codigo | titulo | sucursal | prevista |
|---|---|---|---|---|
| Sonia Mestre | EJ-3088 | Tokio blues | Centro | 2026-04-26 |
| Marta Alsina | EJ-3093 | Voces de Chernóbil | Centro | 2026-05-31 |
| Nuria Bastos | EJ-3087 | El corazón helado | Norte | 2026-07-01 |
| Marta Alsina | EJ-3091 | Los pilares de la Tierra (serie) | Centro | 2026-08-10 |
| Iván Pereda | EJ-3081 | El mapa del tiempo | Centro | 2026-08-12 |
Explicación. Cuatro JOIN encadenados no son más difíciles que uno: cada JOIN añade una tabla y su condición, y el resultado acumulado va creciendo hacia la derecha. La clave está en empezar por la tabla que contiene las filas que quieres contar —aquí prestamos, porque una fila del resultado es un préstamo— y colgar las demás como decoración.
Dos observaciones:
p.fecha_devolucion IS NULLes la definición de «préstamo abierto» en este esquema. No hay una columnaabierto; el hecho de no tener fecha de devolución es el hecho de estar abierto. Es un uso deNULLlegítimo: significa «todavía no ha ocurrido».- La sucursal viene de
ejemplares, no desocios. Marta Alsina está adscrita a Centro y sus dos préstamos son de ejemplares de Centro, así que la diferencia no se nota; pero si hubiera tomado prestado un ejemplar de Norte,su.nombrediría Norte. Confundir «la sucursal del socio» con «la sucursal del ejemplar» es el error de modelo más frecuente en este esquema. En SQLite,||concatena igual que en PostgreSQL.
Ejercicio 6: LEFT JOIN y el anti-join con IS NULL
Dificultad: Intermedio
Enunciado. Dos preguntas de la memoria anual:
- (a) ¿Qué materiales no se han prestado nunca? Incluye también los que ni siquiera tienen ejemplares.
- (b) ¿Qué socios no tienen ningún préstamo registrado? Indica si están activos.
Pista. Un LEFT JOIN seguido de WHERE <columna de la tabla derecha> IS NULL deja exactamente las filas que no encontraron pareja.
Solución
-- (a) Materiales nunca prestados (dos LEFT JOIN encadenados)
SELECT m.material_id, m.titulo, m.tipo_material
FROM materiales m
LEFT JOIN ejemplares e ON e.material_id = m.material_id
LEFT JOIN prestamos p ON p.ejemplar_id = e.ejemplar_id
WHERE p.prestamo_id IS NULL
ORDER BY m.material_id;
-- (b) Socios sin préstamos
SELECT so.socio_id, so.nombre, so.apellidos, so.activo
FROM socios so
LEFT JOIN prestamos p ON p.socio_id = so.socio_id
WHERE p.prestamo_id IS NULL
ORDER BY so.socio_id;Resultado esperado
(a)
| material_id | titulo | tipo_material |
|---|---|---|
| 907 | La caja de los deseos | libro |
| 910 | Ciencia Vallmar 42 | revista |
| 911 | Ciencia Vallmar 43 | revista |
(b)
| socio_id | nombre | apellidos | activo |
|---|---|---|---|
| 17 | Óscar | Vilanova | f |
| 18 | Berta | Quintana | t |
Explicación. El patrón anti-join tiene tres partes que hay que respetar juntas:
LEFT JOIN, noINNER JOIN. ElINNERdescarta precisamente las filas que buscamos.- La condición del emparejamiento va en el
ON. - El filtro
IS NULLva en elWHEREy debe apuntar a una columna de la tabla derecha que nunca sea nula por sí misma: por eso usamosp.prestamo_id(clave primaria, jamás nula) y nop.fecha_devolucion, que es nula en los préstamos abiertos y nos colaría cinco falsos positivos.
En (a) el segundo LEFT JOIN es imprescindible. Los materiales 907 y 911 no tienen ejemplares: tras el primer LEFT JOIN, e.ejemplar_id ya es NULL, y el segundo LEFT JOIN propaga ese NULL a p.prestamo_id, de modo que entran en el resultado. Si hubieras usado INNER JOIN ejemplares, esos dos materiales habrían desaparecido y el informe habría dicho que solo hay un material sin prestar. El anti-join no falla con error: falla dando menos filas de la cuenta.
El material 910 es distinto: sí tiene ejemplar (EJ-3094), pero ese ejemplar nunca ha salido. Los tres casos —sin ejemplares y con ejemplares sin salida— caen en el mismo resultado, que es justo lo que pedía el enunciado.
Ejercicio 7: SELF JOIN
Dificultad: Intermedio
Enunciado. Para una campaña de «si te gustó, te gustará», necesitamos las parejas de materiales del mismo autor. Cada pareja debe aparecer una sola vez (no queremos ver A-B y también B-A) y ningún material debe emparejarse consigo mismo. Muestra el autor y los dos títulos.
Pista. La condición m1.material_id < m2.material_id resuelve los dos problemas a la vez.
Solución
SELECT a.nombre || ' ' || a.apellidos AS autor,
m1.titulo AS titulo_1,
m2.titulo AS titulo_2
FROM materiales m1
INNER JOIN materiales m2
ON m2.autor_id = m1.autor_id
AND m1.material_id < m2.material_id
INNER JOIN autores a ON a.autor_id = m1.autor_id
ORDER BY a.apellidos, m1.material_id, m2.material_id;Resultado esperado
| autor | titulo_1 | titulo_2 |
|---|---|---|
| Svetlana Aleksiévich | Documental: la voz de Chernóbil | Voces de Chernóbil |
| Ken Follett | Los pilares de la Tierra | Un mundo sin fin |
| Ken Follett | Los pilares de la Tierra | Los pilares de la Tierra (serie) |
| Ken Follett | Un mundo sin fin | Los pilares de la Tierra (serie) |
| Haruki Murakami | Tokio blues | Kafka en la orilla |
Explicación. Un SELF JOIN es un JOIN de una tabla consigo misma; lo único que lo hace posible son los alias obligatorios (m1, m2), que convierten una tabla en dos «copias» independientes para el motor.
La condición m1.material_id < m2.material_id hace dos trabajos:
- Con
<>en su lugar obtendrías 10 filas: cada pareja duplicada en los dos órdenes. - Sin ninguna condición adicional (solo
m2.autor_id = m1.autor_id) obtendrías 15 filas: las 10 anteriores más las 5 de cada material consigo mismo.
Fíjate también en que los materiales 910 y 911 (las revistas, con autor_id nulo) no aparecen, y es correcto: NULL = NULL no es verdadero, así que dos filas con autor desconocido nunca se emparejan. Ese comportamiento, que en otros contextos molesta, aquí es exactamente el deseado: no sabemos si son del mismo autor.
Bloque C — Intermedio: agregación y agrupación
Ejercicio 8: Agregados y GROUP BY de una y dos columnas, con HAVING
Dificultad: Intermedio
Enunciado. Tres informes de inventario y economía:
- (a) Número de ejemplares por sucursal, de mayor a menor.
- (b) Número de ejemplares por sucursal y estado.
- (c) Número, importe total e importe medio de las multas por motivo, mostrando solo los motivos con más de una multa.
Solución
-- (a) GROUP BY de una columna
SELECT s.nombre AS sucursal, count(*) AS ejemplares
FROM ejemplares e
JOIN sucursales s ON s.sucursal_id = e.sucursal_id
GROUP BY s.nombre
ORDER BY ejemplares DESC;
-- (b) GROUP BY de dos columnas
SELECT s.nombre AS sucursal, e.estado, count(*) AS n
FROM ejemplares e
JOIN sucursales s ON s.sucursal_id = e.sucursal_id
GROUP BY s.nombre, e.estado
ORDER BY s.nombre, e.estado;
-- (c) Agregados + HAVING
SELECT motivo,
count(*) AS n_multas,
sum(importe) AS total,
round(avg(importe), 2) AS media
FROM multas
GROUP BY motivo
HAVING count(*) > 1
ORDER BY total DESC;Resultado esperado
(a)
| sucursal | ejemplares |
|---|---|
| Centro | 8 |
| Norte | 3 |
| Sur | 2 |
| Este | 2 |
(b)
| sucursal | estado | n |
|---|---|---|
| Centro | disponible | 4 |
| Centro | extraviado | 1 |
| Centro | prestado | 3 |
| Este | reservado | 1 |
| Este | retirado | 1 |
| Norte | disponible | 2 |
| Norte | prestado | 1 |
| Sur | disponible | 2 |
(c)
| motivo | n_multas | total | media |
|---|---|---|---|
| retraso | 5 | 15.00 | 3.00 |
| deterioro | 2 | 10.50 | 5.25 |
Explicación. El punto que hay que interiorizar es el orden lógico de ejecución que vimos en 02-05: FROM → JOIN → WHERE → GROUP BY → HAVING → SELECT → ORDER BY.
De ahí salen las dos reglas prácticas:
WHEREfiltra filas antes de agrupar;HAVINGfiltra grupos después.WHERE count(*) > 1es un error de sintaxis, y no por capricho: cuando se evalúa elWHERElos grupos todavía no existen.- Todo lo que va en el
SELECTy no está dentro de una función de agregado tiene que estar en elGROUP BY. Si en (a) añadierass.sucursal_idalSELECTsin añadirlo alGROUP BY, PostgreSQL daría error. (Curiosamente sí lo aceptaría si agruparas pors.sucursal_id, porque es clave primaria y determina funcionalmente as.nombre: PostgreSQL reconoce esa dependencia. SQLite y MySQL en modo laxo no dan error nunca y devuelven un valor arbitrario, que es mucho peor.)
En (c), el motivo perdida desaparece por el HAVING pese a ser la multa más cara (24,00 €). Es un recordatorio de que un HAVING mal elegido puede ocultar justo lo importante.
round(avg(...), 2) necesita que el argumento sea numeric; como importe es NUMERIC(8,2), funciona. Si la columna fuera double precision, PostgreSQL exigiría round(avg(importe)::numeric, 2).
Ejercicio 9: COUNT(*) frente a COUNT(columna) en un LEFT JOIN
Dificultad: Intermedio
Enunciado. Queremos el número de préstamos de cada socio, incluidos los socios que no han pedido nada nunca (deben aparecer con 0). Escribe la consulta y explica por qué count(*) daría un resultado incorrecto.
Pista. Cuenta lo que el LEFT JOIN no ha encontrado.
Solución
SELECT so.socio_id,
so.apellidos,
count(p.prestamo_id) AS n_prestamos, -- correcto
count(*) AS mal_contado -- incorrecto, solo para verlo
FROM socios so
LEFT JOIN prestamos p ON p.socio_id = so.socio_id
GROUP BY so.socio_id, so.apellidos
ORDER BY n_prestamos DESC, so.socio_id;Resultado esperado
| socio_id | apellidos | n_prestamos | mal_contado |
|---|---|---|---|
| 14 | Alsina | 5 | 5 |
| 15 | Pereda | 4 | 4 |
| 11 | Ferrán | 3 | 3 |
| 12 | Rovira | 3 | 3 |
| 16 | Bastos | 3 | 3 |
| 13 | Mestre | 2 | 2 |
| 17 | Vilanova | 0 | 1 |
| 18 | Quintana | 0 | 1 |
Explicación. Esta es probablemente la trampa más cara del SQL de informes, porque no produce ningún error: produce un número plausible y equivocado.
count(*)cuenta filas. Tras elLEFT JOIN, Óscar Vilanova genera una fila —la suya, con todas las columnas deprestamosaNULL—, así quecount(*)devuelve 1.count(p.prestamo_id)cuenta valores no nulos de esa columna. En la fila de Óscar,p.prestamo_idesNULL, así que no cuenta nada: 0.
La regla que conviene memorizar: en un LEFT JOIN, cuenta siempre una columna de la tabla derecha, y que esa columna sea su clave primaria. Si contaras count(p.fecha_devolucion), obtendrías el número de préstamos ya devueltos (Marta Alsina daría 3 en lugar de 5), que es otra pregunta perfectamente válida... pero no la que te han hecho.
Lo mismo aplica a sum(): sum(importe) sobre un grupo sin filas devuelve NULL, no 0. Si el informe se va a mostrar en pantalla, envuélvelo: COALESCE(sum(importe), 0).
Ejercicio 10: Ranking de los materiales más prestados
Dificultad: Intermedio
Enunciado. Los cinco materiales más prestados de todo el histórico, con el título, el nombre del autor (o (sin autor) si no lo tiene) y el número de préstamos. Desempata alfabéticamente por título.
Pista. El préstamo apunta al ejemplar, no al material: hay que subir un escalón.
Solución
SELECT m.titulo,
COALESCE(a.nombre || ' ' || a.apellidos, '(sin autor)') AS autor,
count(*) AS n_prestamos
FROM prestamos p
JOIN ejemplares e ON e.ejemplar_id = p.ejemplar_id
JOIN materiales m ON m.material_id = e.material_id
LEFT JOIN autores a ON a.autor_id = m.autor_id
GROUP BY m.material_id, m.titulo, a.nombre, a.apellidos
ORDER BY n_prestamos DESC, m.titulo ASC
LIMIT 5;Resultado esperado
| titulo | autor | n_prestamos |
|---|---|---|
| El mapa del tiempo | Félix J. Palma | 5 |
| Kafka en la orilla | Haruki Murakami | 3 |
| Los pilares de la Tierra | Ken Follett | 3 |
| El corazón helado | Almudena Grandes | 2 |
| Los pilares de la Tierra (serie) | Ken Follett | 2 |
Explicación. Tres decisiones deliberadas en esta consulta:
- Agrupar por
m.material_id, no porm.titulo. Si dos materiales distintos compartieran título —cosa perfectamente posible: una edición en papel y otra en audiolibro— agrupar por el título los fundiría en una sola fila. Agrupar por la clave primaria y añadir el título alGROUP BYpara poder proyectarlo es el patrón seguro. LEFT JOIN autores, noINNER JOIN. Las revistas 910 y 911 tienenautor_idnulo. Aquí no se nota porque no están entre las más prestadas, pero unINNER JOINlas expulsaría silenciosamente de cualquier ranking futuro.COALESCEen la concatenación completa, no en cada trozo.a.nombre || ' ' || a.apellidoscona.nombrenulo devuelveNULLentero, porque cualquier concatenación conNULLesNULL. Por eso elCOALESCEenvuelve la expresión ya montada.
El empate entre «Kafka en la orilla» y «Los pilares de la Tierra» (3 préstamos cada uno) se resuelve por título: K antes que L. Sin ese segundo criterio, el orden entre ambos no estaría garantizado y el «top 5» podría cambiar de una ejecución a otra.
Este ranking es un LIMIT 5 global. La versión «el más prestado de cada sucursal» necesita funciones de ventana y la verás en 07-04.
Bloque D — Avanzado: subconsultas, tablas derivadas y CTE
Ejercicio 11: Subconsulta escalar, IN, EXISTS, NOT EXISTS y correlacionada
Dificultad: Avanzado
Enunciado. Cinco preguntas, cada una con la herramienta que le corresponde:
- (a) Materiales anteriores al año medio del catálogo (subconsulta escalar).
- (b) Socios con alguna multa pendiente, usando
IN. - (c) Socios con algún préstamo sin devolver, usando
EXISTS. - (d) Sucursales sin ningún ejemplar extraviado, usando
NOT EXISTS. - (e) Para cada socio, la fecha de su último préstamo (subconsulta correlacionada); los socios sin préstamos deben salir con
NULL.
Solución
-- (a) Escalar: la subconsulta devuelve un único valor
SELECT titulo, anio_publicacion
FROM materiales
WHERE anio_publicacion < (SELECT avg(anio_publicacion) FROM materiales)
ORDER BY anio_publicacion;
-- (b) IN: la subconsulta devuelve una lista de valores
SELECT socio_id, nombre, apellidos
FROM socios
WHERE socio_id IN (SELECT socio_id FROM multas WHERE estado = 'pendiente')
ORDER BY socio_id;
-- (c) EXISTS: la subconsulta solo dice sí o no
SELECT so.socio_id, so.nombre, so.apellidos
FROM socios so
WHERE EXISTS (SELECT 1 FROM prestamos p
WHERE p.socio_id = so.socio_id AND p.fecha_devolucion IS NULL)
ORDER BY so.socio_id;
-- (d) NOT EXISTS: anti-join en versión subconsulta
SELECT s.sucursal_id, s.nombre
FROM sucursales s
WHERE NOT EXISTS (SELECT 1 FROM ejemplares e
WHERE e.sucursal_id = s.sucursal_id AND e.estado = 'extraviado')
ORDER BY s.sucursal_id;
-- (e) Correlacionada en el SELECT
SELECT so.socio_id,
so.apellidos,
(SELECT max(p.fecha_prestamo) FROM prestamos p
WHERE p.socio_id = so.socio_id) AS ultimo_prestamo
FROM socios so
ORDER BY so.socio_id;Resultado esperado
(a) La media del catálogo es 2009,58, así que salen 6 filas: Tokio blues (1987), Los pilares de la Tierra (1989), Kafka en la orilla (2002), El corazón helado (2007), Un mundo sin fin (2007), El mapa del tiempo (2008).
(b) Socios 13 (Mestre), 14 (Alsina) y 15 (Pereda).
(c) Socios 13, 14, 15 y 16.
(d)
| sucursal_id | nombre |
|---|---|
| 2 | Norte |
| 3 | Sur |
| 4 | Este |
(e)
| socio_id | apellidos | ultimo_prestamo |
|---|---|---|
| 11 | Ferrán | 2026-07-05 |
| 12 | Rovira | 2026-07-10 |
| 13 | Mestre | 2026-06-15 |
| 14 | Alsina | 2026-07-20 |
| 15 | Pereda | 2026-07-22 |
| 16 | Bastos | 2026-06-10 |
| 17 | Vilanova | (NULL) |
| 18 | Quintana | (NULL) |
Explicación. Cada forma de subconsulta responde a una forma distinta de pregunta:
| Forma | Qué devuelve la subconsulta | Cuándo usarla |
|---|---|---|
| Escalar | Un valor | Comparar cada fila con un agregado global |
IN (...) |
Una columna de valores | Pertenencia a un conjunto conocido |
EXISTS (...) |
Nada, solo sí/no | «Existe al menos uno», sin importar cuántos |
NOT EXISTS (...) |
Nada, solo sí/no | «No existe ninguno» (anti-join) |
| Correlacionada | Un valor por cada fila externa | Un dato calculado fila a fila |
Dos advertencias que valen su peso en horas de depuración:
NOT INy losNULLno se llevan bien. Si escribierasWHERE sucursal_id NOT IN (SELECT sucursal_id FROM ejemplares WHERE estado = 'extraviado')y esa subconsulta pudiera devolver unNULL, el resultado sería cero filas siempre, porquex NOT IN (1, NULL)se evalúa aNULL.NOT EXISTSno tiene ese problema: por eso es la forma recomendada para los anti-joins con subconsulta.SELECT 1dentro deEXISTSno es una superstición ni una optimización: es una declaración de intenciones. AEXISTSle da igual lo que proyectes —el motor ni siquiera lo evalúa—, y escribirSELECT 1deja claro al siguiente lector que las columnas no importan.
En (e), la correlacionada devuelve NULL para los socios sin préstamos porque max() sobre un conjunto vacío es NULL. Es exactamente lo que pedía el enunciado; si quisieras un texto, COALESCE(..., 'nunca') requeriría convertir la fecha a texto primero.
Ejercicio 12: Tabla derivada, CTE WITH y UNION
Dificultad: Avanzado
Enunciado. Tres consultas que reorganizan el problema antes de responderlo:
- (a) El número medio de préstamos por socio activo, usando una tabla derivada.
- (b) Una ficha por sucursal con su número de socios y su número de ejemplares, usando dos CTE.
- (c) Una agenda unificada de contactos: todos los correos de socios activos y de ponentes, en una sola lista, con una columna que indique de dónde sale cada uno.
Pista. En (b), no intentes hacerlo con dos JOIN directos sobre sucursales: los conteos se multiplicarían entre sí.
Solución
-- (a) Tabla derivada: agregamos y luego agregamos sobre el resultado
SELECT round(avg(n), 2) AS media_prestamos_por_socio
FROM (
SELECT so.socio_id, count(p.prestamo_id) AS n
FROM socios so
LEFT JOIN prestamos p ON p.socio_id = so.socio_id
WHERE so.activo
GROUP BY so.socio_id
) AS conteos;
-- (b) Dos CTE independientes, reunidas al final
WITH socios_por_sucursal AS (
SELECT sucursal_id, count(*) AS n_socios
FROM socios
GROUP BY sucursal_id
),
ejemplares_por_sucursal AS (
SELECT sucursal_id, count(*) AS n_ejemplares
FROM ejemplares
GROUP BY sucursal_id
)
SELECT s.nombre AS sucursal,
COALESCE(sp.n_socios, 0) AS socios,
COALESCE(ep.n_ejemplares, 0) AS ejemplares
FROM sucursales s
LEFT JOIN socios_por_sucursal sp ON sp.sucursal_id = s.sucursal_id
LEFT JOIN ejemplares_por_sucursal ep ON ep.sucursal_id = s.sucursal_id
ORDER BY s.sucursal_id;
-- (c) UNION de dos consultas con la misma forma
SELECT 'socio' AS origen, nombre, apellidos, email
FROM socios WHERE activo
UNION
SELECT 'ponente', nombre, apellidos, email
FROM ponentes
ORDER BY origen, apellidos;Resultado esperado
(a) 2.86 — hay 7 socios activos (todos menos Óscar Vilanova) que suman 20 préstamos: 20 / 7 = 2,857...
(b)
| sucursal | socios | ejemplares |
|---|---|---|
| Centro | 3 | 8 |
| Norte | 2 | 3 |
| Sur | 2 | 2 |
| Este | 1 | 2 |
(c) 10 filas: 3 ponentes (Calduch, Lemus, Marchetti) y 7 socios activos.
Explicación. Las tres son variantes del mismo movimiento: calcular un resultado intermedio y consultarlo como si fuera una tabla.
En (a), la agregación doble es obligatoria. avg(count(*)) no existe: no se pueden anidar funciones de agregado. Hay que agregar una vez (préstamos por socio), materializar ese resultado como tabla derivada y agregar otra vez. Fíjate en el AS conteos: en PostgreSQL una tabla derivada necesita alias o el motor da error, aunque no lo uses.
En (b) está la lección importante. Si escribieras esto:
-- MAL: los conteos se multiplican
SELECT s.nombre, count(DISTINCT so.socio_id), count(DISTINCT e.ejemplar_id)
FROM sucursales s
LEFT JOIN socios so ON so.sucursal_id = s.sucursal_id
LEFT JOIN ejemplares e ON e.sucursal_id = s.sucursal_id
GROUP BY s.nombre;...el count(DISTINCT ...) te salvaría de milagro, pero un count(*) daría 3 × 8 = 24 para Centro. El JOIN de dos ramas independientes contra la misma tabla produce un producto cartesiano dentro de cada sucursal. Este es el error de agregación más caro que existe porque el resultado parece razonable. La solución robusta es agregar cada rama por separado —en CTE o en subconsultas— y reunir después.
En (c), UNION elimina duplicados y UNION ALL no. Aquí no hay duplicados posibles porque la primera columna ya los distingue, así que UNION ALL sería más rápido. Las dos ramas deben tener el mismo número de columnas y tipos compatibles, y los nombres los pone siempre la primera rama: por eso los alias solo hacen falta arriba. El ORDER BY va una sola vez, al final, y afecta al conjunto unido.
Bloque E — Avanzado: informes de gestión reales
Ejercicio 13: Recaudación de multas por mes y método de pago
Dificultad: Avanzado
Enunciado. Intervención municipal pide la recaudación de multas: por mes y método de pago, el número de pagos y el importe total, más una columna canal que valga 'presencial' para efectivo y tarjeta y 'en línea' para pasarela. Añade una última consulta con el total pendiente de cobro (multas en estado pendiente).
Pista. to_char(fecha_pago, 'YYYY-MM') agrupa por mes en PostgreSQL.
Solución
-- Recaudación por mes y método
SELECT to_char(pg.fecha_pago, 'YYYY-MM') AS mes,
pg.metodo,
CASE WHEN pg.metodo IN ('efectivo', 'tarjeta') THEN 'presencial'
WHEN pg.metodo = 'pasarela' THEN 'en línea'
ELSE 'desconocido'
END AS canal,
count(*) AS n_pagos,
sum(pg.importe) AS recaudado
FROM pagos pg
GROUP BY 1, 2, 3
ORDER BY mes, pg.metodo;
-- Pendiente de cobro
SELECT count(*) AS multas_pendientes, sum(importe) AS importe_pendiente
FROM multas
WHERE estado = 'pendiente';Resultado esperado
| mes | metodo | canal | n_pagos | recaudado |
|---|---|---|---|---|
| 2026-03 | efectivo | presencial | 1 | 2.20 |
| 2026-04 | tarjeta | presencial | 1 | 3.00 |
| 2026-05 | pasarela | en línea | 1 | 2.50 |
| 2026-05 | tarjeta | presencial | 1 | 4.00 |
Total recaudado: 11,70 €. Pendiente: 3 multas por 30,60 €.
Explicación. El informe tiene tres puntos finos:
- Se agrupa por
pagos, no pormultas. Una multa puede cobrarse en varios plazos: la multa 5 (6,50 €, deterioro, Nuria Bastos) se cobró en dos pagos, 4,00 € con tarjeta en mayo y 2,50 € por pasarela también en mayo. Si el informe se construyera sobremultas, ese caso se contaría mal en cuanto los dos plazos cayeran en meses distintos. La pregunta «cuánto entró en caja en marzo» se responde siempre sobre la tabla de movimientos, no sobre la de deudas. GROUP BY 1, 2, 3agrupa por posición en elSELECT. Es cómodo, pero frágil: si insertas una columna al principio, la agrupación cambia sin avisar. En consultas que van a durar, escribe los nombres o repite la expresiónCASEcompleta en elGROUP BY.- Las multas
condonadayanuladano aparecen en ninguna parte del informe, y es correcto: no se han cobrado ni se van a cobrar. La multa 6 (4,00 €, condonada) y la 8 (3,20 €, anulada) suman 7,20 € que no son ni ingreso ni deuda. Un informe que las mezclara con las pendientes inflaría la previsión de cobro en un 23 %.
En SQLite, to_char no existe: se usa strftime('%Y-%m', fecha_pago).
Ejercicio 14: Préstamos vencidos con el recargo calculado
Dificultad: Avanzado
Enunciado. A fecha 2 de agosto de 2026, lista los préstamos vencidos y sin devolver: socio, código y título, fecha prevista, días de retraso y el recargo a razón de 0,20 €/día con un tope de 15,00 €. Ordena por recargo descendente.
Pista. La resta de dos DATE en PostgreSQL devuelve un entero de días. El tope se aplica con LEAST.
Solución
SELECT so.nombre || ' ' || so.apellidos AS socio,
e.codigo,
m.titulo,
p.fecha_devolucion_prevista AS prevista,
(DATE '2026-08-02' - p.fecha_devolucion_prevista) AS dias_retraso,
LEAST((DATE '2026-08-02' - p.fecha_devolucion_prevista) * 0.20, 15.00) AS recargo
FROM prestamos p
JOIN socios so ON so.socio_id = p.socio_id
JOIN ejemplares e ON e.ejemplar_id = p.ejemplar_id
JOIN materiales m ON m.material_id = e.material_id
WHERE p.fecha_devolucion IS NULL
AND p.fecha_devolucion_prevista < DATE '2026-08-02'
ORDER BY recargo DESC, dias_retraso DESC;Resultado esperado
| socio | codigo | titulo | prevista | dias_retraso | recargo |
|---|---|---|---|---|---|
| Sonia Mestre | EJ-3088 | Tokio blues | 2026-04-26 | 98 | 15.00 |
| Marta Alsina | EJ-3093 | Voces de Chernóbil | 2026-05-31 | 63 | 12.60 |
| Nuria Bastos | EJ-3087 | El corazón helado | 2026-07-01 | 32 | 6.40 |
Explicación. Tres cosas que hay que mirar con lupa:
- El
WHERElleva dos condiciones y las dos son imprescindibles.fecha_devolucion IS NULLselecciona los préstamos abiertos;fecha_devolucion_prevista < '2026-08-02'selecciona los vencidos. Con solo la primera, los préstamos 17 y 19 (previstos para el 10 y el 12 de agosto) aparecerían con días de retraso negativos y un recargo negativo: la biblioteca debiéndole dinero al socio. - El tope se aplica y se nota. Sin
LEAST, Sonia Mestre pagaría 19,60 € (98 × 0,20). El reglamento fija 15,00 €, yLEAST(a, b)devuelve el menor de los dos. En MySQL la función también se llamaLEAST; en SQLite esMIN(a, b)con dos argumentos, que no es la función de agregadoMIN()pese al nombre. - La aritmética de fechas es específica de cada motor. En PostgreSQL,
date - datedevuelveinteger(días). En SQLite hay que escribirjulianday('2026-08-02') - julianday(fecha_devolucion_prevista), y en MySQLDATEDIFF('2026-08-02', fecha_devolucion_prevista). Es de los primeros sitios donde una consulta portable deja de serlo.
Nota de coherencia: el ejemplar EJ-3093 figura como extraviado y su préstamo sigue abierto. Ya tiene una multa por pérdida (24,00 €), así que en el proceso real habría que excluirlo de los recargos por retraso. Añadir AND e.estado <> 'extraviado' es una decisión de negocio perfectamente defendible; el enunciado no la pedía, pero un informe que se entrega a dirección debería documentarla.
Ejercicio 15: Ocupación de eventos por sucursal
Dificultad: Avanzado
Enunciado. Para los eventos ya celebrados, calcula por sucursal: número de eventos, plazas ofertadas, plazas ocupadas y porcentaje de ocupación con un decimal. Solo cuentan como ocupadas las inscripciones en estado confirmada o asistida (las canceladas y las de lista de espera, no).
Pista. Si unes eventos con inscripciones y sumas plazas_ofertadas, el resultado estará inflado. Agrega primero por evento.
Solución
WITH ocupacion_evento AS (
SELECT ev.evento_id,
ev.sala_id,
ev.plazas_ofertadas,
COALESCE(sum(i.plazas_ocupadas) FILTER (
WHERE i.estado IN ('confirmada', 'asistida')), 0) AS ocupadas
FROM eventos ev
LEFT JOIN inscripciones i ON i.evento_id = ev.evento_id
WHERE ev.estado = 'celebrado'
GROUP BY ev.evento_id, ev.sala_id, ev.plazas_ofertadas
)
SELECT su.nombre AS sucursal,
count(*) AS eventos,
sum(oe.plazas_ofertadas) AS ofertadas,
sum(oe.ocupadas) AS ocupadas,
round(100.0 * sum(oe.ocupadas) / sum(oe.plazas_ofertadas), 1) AS pct_ocupacion
FROM ocupacion_evento oe
JOIN salas sa ON sa.sala_id = oe.sala_id
JOIN sucursales su ON su.sucursal_id = sa.sucursal_id
GROUP BY su.sucursal_id, su.nombre
ORDER BY pct_ocupacion DESC;Resultado esperado
| sucursal | eventos | ofertadas | ocupadas | pct_ocupacion |
|---|---|---|---|---|
| Sur | 1 | 8 | 5 | 62.5 |
| Norte | 1 | 20 | 9 | 45.0 |
| Este | 1 | 10 | 4 | 40.0 |
| Centro | 2 | 52 | 15 | 28.8 |
Explicación. Este ejercicio reúne casi todo el bloque en una sola consulta, y su valor está en la CTE.
Por qué la CTE es obligatoria. Si sumaras plazas_ofertadas directamente sobre el JOIN de eventos con inscripciones, cada evento aparecería tantas veces como inscripciones tenga. El evento 101 tiene 5 inscripciones, así que sus 12 plazas ofertadas se contarían 5 veces: 60. Centro pasaría de 52 a 100 plazas ofertadas y la ocupación caería del 28,8 % al 15 %. El error no da ningún síntoma: los números siguen siendo enteros razonables. La regla general es: cuando un informe suma un atributo del «uno» y otro atributo del «muchos», el del «uno» hay que agregarlo aparte.
El filtro por estado va en el FILTER, no en el WHERE. sum(...) FILTER (WHERE ...) aplica la condición solo al agregado, dejando intactas las demás filas del grupo. Si pusieras i.estado IN ('confirmada','asistida') en el WHERE de la CTE, un evento cuyas inscripciones fueran todas canceladas desaparecería del informe en lugar de aparecer con 0 ocupadas. FILTER es SQL estándar y PostgreSQL lo soporta; el equivalente portable es sum(CASE WHEN i.estado IN ('confirmada','asistida') THEN i.plazas_ocupadas ELSE 0 END), que funciona también en SQLite y MySQL.
100.0 y no 100. Si escribes 100 * sum(ocupadas) / sum(ofertadas) con enteros, PostgreSQL hace división entera y Centro daría 28 en lugar de 28.8, y un caso como 5/8 daría 62 en vez de 62.5. Multiplicar primero por 100.0 fuerza la aritmética decimal. Este error aparece en producción constantemente.
El LEFT JOIN sigue siendo necesario aunque en estos datos todos los eventos celebrados tengan inscripciones: si mañana se celebra un evento al que no se apuntó nadie, con INNER JOIN desaparecería del denominador y la ocupación media saldría artificialmente alta.
Errores Comunes y Consejos
1. = NULL en lugar de IS NULL. No da error: da cero filas. Cada vez que una consulta te devuelva un conjunto vacío inesperado, esta es la primera sospecha.
2. count(*) en un LEFT JOIN. Cuenta 1 donde debería contar 0. Cuenta siempre la clave primaria de la tabla derecha.
3. Sumar atributos del «uno» sobre un JOIN con el «muchos». Los totales se multiplican por el número de filas hijas. Agrega cada rama en su propia CTE o subconsulta.
4. HAVING y WHERE intercambiados. WHERE filtra filas antes de agrupar, HAVING filtra grupos después. Poner en HAVING una condición que podría ir en WHERE funciona pero es más lento: obliga a agrupar filas que se van a descartar.
5. ORDER BY sin desempate. Un LIMIT sobre un orden no determinista devuelve filas distintas en ejecuciones distintas. Añade siempre un criterio único como último desempate.
6. División entera. 100 * a / b con enteros trunca. Fuerza decimales con 100.0 o con ::numeric.
7. NOT IN con subconsultas que pueden devolver NULL. Devuelve cero filas siempre. Usa NOT EXISTS.
8. Confundir la sucursal del socio con la sucursal del ejemplar. Son dos claves ajenas distintas hacia la misma tabla. Cuando la pregunta dice «por sucursal», averigua qué sucursal antes de escribir el JOIN.
9. Olvidar el ON de un JOIN. Producto cartesiano silencioso. Con tablas pequeñas parece que funciona; con 40.000 ejemplares no.
10. Concatenar con NULL. 'a' || NULL es NULL. Envuelve la expresión completa en COALESCE, no cada trozo.
Consejo de método. Construye las consultas grandes de dentro hacia fuera: escribe primero el FROM con sus JOIN y un SELECT *, comprueba el número de filas, añade el WHERE, vuelve a comprobar, y solo al final agrupa y proyecta. El 90 % de los errores de agregación se detectan mirando cuántas filas hay antes de agrupar.
Consejo de lectura. Cuando heredes una consulta ajena, léela en el orden lógico de ejecución (FROM, WHERE, GROUP BY, HAVING, SELECT, ORDER BY), no en el orden en que está escrita. Es el orden en el que el motor la entiende.
Ejercicios
Sin pistas y más exigentes. Escribe cada uno como una sola consulta.
Ejercicio A: Ficha económica de un socio
Para Marta Alsina (socio 14), devuelve una única fila con: apellidos, número total de préstamos, número de préstamos devueltos con retraso, número de préstamos abiertos, importe total de sus multas, importe efectivamente pagado e importe pendiente.
Ejercicio B: Materiales presentes en tres o más sucursales
Lista los materiales que tienen ejemplares en tres o más sucursales distintas, con el título, el número de sucursales y el número total de ejemplares. Un material con cinco ejemplares en la misma sucursal no cuenta.
Ejercicio C: Socios en situación de riesgo
Devuelve una lista de socios en riesgo con una columna motivo. Un socio está en riesgo si tiene algún préstamo vencido sin devolver a 2026-08-02 (motivo 'préstamo vencido') o si acumula más de 10 € en multas pendientes (motivo 'deuda alta'). Un socio puede aparecer por los dos motivos.
Soluciones
Solución A
SELECT so.apellidos,
count(p.prestamo_id) AS n_prestamos,
count(*) FILTER (WHERE p.fecha_devolucion > p.fecha_devolucion_prevista) AS con_retraso,
count(*) FILTER (WHERE p.fecha_devolucion IS NULL) AS abiertos,
(SELECT COALESCE(sum(mu.importe), 0) FROM multas mu
WHERE mu.socio_id = so.socio_id) AS multas_total,
(SELECT COALESCE(sum(pg.importe), 0) FROM pagos pg
JOIN multas mu ON mu.multa_id = pg.multa_id
WHERE mu.socio_id = so.socio_id) AS pagado,
(SELECT COALESCE(sum(mu.importe), 0) FROM multas mu
WHERE mu.socio_id = so.socio_id AND mu.estado = 'pendiente') AS pendiente
FROM socios so
LEFT JOIN prestamos p ON p.socio_id = so.socio_id
WHERE so.socio_id = 14
GROUP BY so.socio_id, so.apellidos;| apellidos | n_prestamos | con_retraso | abiertos | multas_total | pagado | pendiente |
|---|---|---|---|---|---|---|
| Alsina | 5 | 1 | 2 | 26.20 | 2.20 | 24.00 |
Las tres cifras económicas van en subconsultas escalares precisamente para evitar el problema del ejercicio 15: si unieras multas y pagos al mismo JOIN que prestamos, cada multa se repetiría cinco veces (una por préstamo) y el total saltaría de 26,20 € a 131,00 €. Los FILTER sobre count(*) funcionan aquí porque el LEFT JOIN con prestamos no duplica nada: cada préstamo es una fila.
Solución B
SELECT m.material_id,
m.titulo,
count(DISTINCT e.sucursal_id) AS sucursales,
count(*) AS ejemplares
FROM materiales m
JOIN ejemplares e ON e.material_id = m.material_id
GROUP BY m.material_id, m.titulo
HAVING count(DISTINCT e.sucursal_id) >= 3
ORDER BY sucursales DESC, m.titulo;| material_id | titulo | sucursales | ejemplares |
|---|---|---|---|
| 901 | Los pilares de la Tierra | 3 | 3 |
Solo «Los pilares de la Tierra» está repartido en tres sucursales (Centro, Sur y Este). El DISTINCT dentro del count es lo que distingue «tres sucursales» de «tres ejemplares»: sin él, el material 906 (dos ejemplares, en Sur y Centro) seguiría sin colarse, pero un material con tres ejemplares en Centro sí lo haría, y sería un error.
Solución C
SELECT so.socio_id, so.nombre, so.apellidos, 'préstamo vencido' AS motivo
FROM socios so
WHERE EXISTS (SELECT 1 FROM prestamos p
WHERE p.socio_id = so.socio_id
AND p.fecha_devolucion IS NULL
AND p.fecha_devolucion_prevista < DATE '2026-08-02')
UNION ALL
SELECT so.socio_id, so.nombre, so.apellidos, 'deuda alta'
FROM socios so
JOIN multas mu ON mu.socio_id = so.socio_id AND mu.estado = 'pendiente'
GROUP BY so.socio_id, so.nombre, so.apellidos
HAVING sum(mu.importe) > 10
ORDER BY 1, 4;| socio_id | nombre | apellidos | motivo |
|---|---|---|---|
| 13 | Sonia | Mestre | préstamo vencido |
| 14 | Marta | Alsina | deuda alta |
| 14 | Marta | Alsina | préstamo vencido |
| 16 | Nuria | Bastos | préstamo vencido |
Marta Alsina aparece dos veces, que es justo lo que pedía el enunciado; por eso hay que usar UNION ALL y no UNION (aunque aquí UNION daría lo mismo, porque la columna motivo ya diferencia las filas). Nota que Iván Pereda no sale: tiene una multa pendiente, pero de 1,60 €, y su préstamo abierto vence el 12 de agosto. Y Sonia Mestre sale solo por el préstamo, porque sus 5,00 € pendientes no llegan al umbral.
Conclusión
Has escrito quince consultas sobre BiblioRed que recorren todo el SQL del módulo 2, esta vez sin red: proyección con alias, filtros con BETWEEN, IN, LIKE e IS NULL, ordenación determinista con LIMIT, DML con la disciplina del SELECT previo, INNER JOIN de dos y de cuatro tablas, LEFT JOIN con el patrón anti-join, SELF JOIN, agregación con GROUP BY de una y dos columnas, HAVING, la diferencia entre COUNT(*) y COUNT(columna), las cinco formas de subconsulta, tablas derivadas, CTE, UNION y tres informes de gestión que ya se parecen a lo que pide una dirección de servicio.
Más importante que la sintaxis son los tres reflejos que deberían haberse instalado: mirar antes de modificar, comprobar el número de filas antes de agregar y desconfiar de todo informe que suma un atributo del lado «uno» a través de un JOIN con el lado «muchos». Ninguno de los tres se lo enseña a nadie un mensaje de error, porque los tres fallan en silencio.
La siguiente lección cambia de músculo. En 07-02, Ejercicios de Diseño de Esquemas, no habrá tablas que consultar: habrá enunciados de requisitos —un videoclub, una plataforma de cursos, una clínica, un sistema de tarifas históricas— y tendrás que construir el esquema desde cero, pasando por el diagrama ER, el CREATE TABLE y las restricciones que codifican las reglas de negocio. Uno de los cinco casos será una ampliación de BiblioRed, para que compruebes lo distinto que es diseñar sobre un esquema que ya existe y no puedes romper.
Fundamentos de Bases de Datos
Módulo 1: Introducción a las Bases de Datos
- Conceptos Básicos de Bases de Datos
- Tipos de Bases de Datos
- Historia y Evolución de las Bases de Datos
- Sistemas Gestores de Bases de Datos y Arquitectura
Módulo 2: Bases de Datos Relacionales
- Modelo Relacional
- Lenguaje SQL
- Operaciones Básicas en SQL
- Consultas Multitabla: JOIN y Subconsultas
- Agregación y Agrupación de Datos
- Integridad Referencial
Módulo 3: Bases de Datos No Relacionales
- Introducción a NoSQL
- Tipos de Bases de Datos NoSQL
- Modelado de Datos en NoSQL
- Comparación entre Bases de Datos Relacionales y No Relacionales
Módulo 4: Diseño de Esquemas
- Principios de Diseño de Esquemas
- Diagramas Entidad-Relación (ER)
- Transformación de Diagramas ER a Esquemas Relacionales
- Tipos de Datos y Restricciones
Módulo 5: Normalización
Módulo 6: Transacciones, Rendimiento y Seguridad
- Transacciones y Propiedades ACID
- Concurrencia y Niveles de Aislamiento
- Índices y Optimización de Consultas
- Seguridad, Permisos y Copias de Seguridad
Módulo 7: Ejercicios Prácticos
- Ejercicios de SQL
- Ejercicios de Diseño de Esquemas
- Ejercicios de Normalización
- Ejercicios de Consultas Avanzadas y Transacciones
Módulo 8: Casos de Estudio
- Caso de Estudio: Base de Datos Relacional
- Caso de Estudio: Base de Datos No Relacional
- Caso de Estudio: Persistencia Políglota
