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:

createdb biblioredx
psql -d biblioredx -f datos_m7.sql
-- ============================================================
-- 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

  1. Bloque A — Básico: consultas sobre una sola tabla (ejercicios 1-3)
  2. Bloque B — Intermedio: consultas sobre varias tablas (ejercicios 4-7)
  3. Bloque C — Intermedio: agregación y agrupación (ejercicios 8-10)
  4. Bloque D — Avanzado: subconsultas, tablas derivadas y CTE (ejercicios 11-12)
  5. Bloque E — Avanzado: informes de gestión reales (ejercicios 13-15)
  6. Errores comunes y consejos
  7. 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ítulo fallaría o se convertiría en título en 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. Un ORDER BY que no desempata no es determinista, y en un listado con LIMIT eso significa que la fila que ves puede variar entre ejecuciones.
  • LIMIT va después de ORDER 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 prestado o reservado, 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_url no 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 2010 incluye los dos extremos. Si necesitas excluirlos, BETWEEN no sirve: hay que escribir > 2000 AND < 2010. Y el orden importa: BETWEEN 2010 AND 2000 devuelve cero filas, sin error y sin aviso.
  • IN ('prestado','reservado') es exactamente equivalente a estado = 'prestado' OR estado = 'reservado'. Es más legible y, sobre todo, evita el error de precedencia de escribir WHERE material_id = 901 AND estado = 'prestado' OR estado = 'reservado', que no significa lo que parece: AND liga más fuerte que OR.
  • ILIKE es una extensión de PostgreSQL. En SQLite, LIKE ya es insensible a mayúsculas para caracteres ASCII, pero no para las tildes ni para la Ó de «Chernóbil»; allí lo portable es WHERE lower(titulo) LIKE lower('%chernóbil%').
  • portada_url = NULL devuelve NULL, que en un WHERE se 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, y count(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 2

Explicación. Tres hábitos que evitan casi todos los desastres de un mostrador:

  1. Lista de columnas explícita en el INSERT. INSERT INTO socios VALUES (...) funciona hasta el día en que alguien añade una columna a socios; entonces todos los INSERT sin lista se rompen o, peor, colocan los valores en la columna equivocada.
  2. El SELECT con el mismo WHERE, primero. No es una recomendación de estilo: es la única forma de saber cuántas filas vas a tocar antes de tocarlas. Un UPDATE socios SET sucursal_id = 2 sin WHERE afecta a los ocho socios y no hay «deshacer» fuera de una transacción.
  3. RETURNING (PostgreSQL) devuelve las filas realmente afectadas. Es la confirmación posterior de que el WHERE hizo 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 NULL es la definición de «préstamo abierto» en este esquema. No hay una columna abierto; el hecho de no tener fecha de devolución es el hecho de estar abierto. Es un uso de NULL legítimo: significa «todavía no ha ocurrido».
  • La sucursal viene de ejemplares, no de socios. 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.nombre dirí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:

  1. LEFT JOIN, no INNER JOIN. El INNER descarta precisamente las filas que buscamos.
  2. La condición del emparejamiento va en el ON.
  3. El filtro IS NULL va en el WHERE y debe apuntar a una columna de la tabla derecha que nunca sea nula por sí misma: por eso usamos p.prestamo_id (clave primaria, jamás nula) y no p.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: FROMJOINWHEREGROUP BYHAVINGSELECTORDER BY.

De ahí salen las dos reglas prácticas:

  • WHERE filtra filas antes de agrupar; HAVING filtra grupos después. WHERE count(*) > 1 es un error de sintaxis, y no por capricho: cuando se evalúa el WHERE los grupos todavía no existen.
  • Todo lo que va en el SELECT y no está dentro de una función de agregado tiene que estar en el GROUP BY. Si en (a) añadieras s.sucursal_id al SELECT sin añadirlo al GROUP BY, PostgreSQL daría error. (Curiosamente sí lo aceptaría si agruparas por s.sucursal_id, porque es clave primaria y determina funcionalmente a s.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 el LEFT JOIN, Óscar Vilanova genera una fila —la suya, con todas las columnas de prestamos a NULL—, así que count(*) devuelve 1.
  • count(p.prestamo_id) cuenta valores no nulos de esa columna. En la fila de Óscar, p.prestamo_id es NULL, 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 por m.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 al GROUP BY para poder proyectarlo es el patrón seguro.
  • LEFT JOIN autores, no INNER JOIN. Las revistas 910 y 911 tienen autor_id nulo. Aquí no se nota porque no están entre las más prestadas, pero un INNER JOIN las expulsaría silenciosamente de cualquier ranking futuro.
  • COALESCE en la concatenación completa, no en cada trozo. a.nombre || ' ' || a.apellidos con a.nombre nulo devuelve NULL entero, porque cualquier concatenación con NULL es NULL. Por eso el COALESCE envuelve 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:

  1. NOT IN y los NULL no se llevan bien. Si escribieras WHERE sucursal_id NOT IN (SELECT sucursal_id FROM ejemplares WHERE estado = 'extraviado') y esa subconsulta pudiera devolver un NULL, el resultado sería cero filas siempre, porque x NOT IN (1, NULL) se evalúa a NULL. NOT EXISTS no tiene ese problema: por eso es la forma recomendada para los anti-joins con subconsulta.
  2. SELECT 1 dentro de EXISTS no es una superstición ni una optimización: es una declaración de intenciones. A EXISTS le da igual lo que proyectes —el motor ni siquiera lo evalúa—, y escribir SELECT 1 deja 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 por multas. 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 sobre multas, 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, 3 agrupa por posición en el SELECT. 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ón CASE completa en el GROUP BY.
  • Las multas condonada y anulada no 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 WHERE lleva dos condiciones y las dos son imprescindibles. fecha_devolucion IS NULL selecciona 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 €, y LEAST(a, b) devuelve el menor de los dos. En MySQL la función también se llama LEAST; en SQLite es MIN(a, b) con dos argumentos, que no es la función de agregado MIN() pese al nombre.
  • La aritmética de fechas es específica de cada motor. En PostgreSQL, date - date devuelve integer (días). En SQLite hay que escribir julianday('2026-08-02') - julianday(fecha_devolucion_prevista), y en MySQL DATEDIFF('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

Módulo 2: Bases de Datos Relacionales

Módulo 3: Bases de Datos No Relacionales

Módulo 4: Diseño de Esquemas

Módulo 5: Normalización

Módulo 6: Transacciones, Rendimiento y Seguridad

Módulo 7: Ejercicios Prácticos

Módulo 8: Casos de Estudio

Módulo 9: Recursos Adicionales

© Copyright 2026. Todos los derechos reservados