Tienes el encargo (12-01) y el contrato (12-02). Falta construirlo, y construirlo tiene un orden: si escribes las consultas antes de tener datos, no puedes comprobarlas; si creas los índices antes de tener las consultas, los estás inventando; y si empiezas por el CREATE TABLE sin haber dibujado nada, la tercera tabla te obligará a rehacer las dos primeras.
Esta lección es la guía de construcción en siete pasos: el modelo completo, el DDL entero, la técnica para generar datos coherentes, el método para escribir consultas sin equivocarte y el criterio para decidir índices y encapsulados. Lo que no te da son las quince consultas del enunciado hechas: eso es 12-04, y llegar allí sin haberlo intentado es tirar el proyecto.
Contenido
- Paso 1 — Del enunciado al modelo
- Paso 2 — El DDL:
01-esquema.sql - Paso 3 — Datos de prueba coherentes:
02-datos.sql - Paso 4 — Las consultas: método de trabajo
- Paso 5 — Los índices, después de las consultas
- Paso 6 — Vistas, procedimientos y triggers
- Paso 7 — Seguridad y entrega
- Cronograma orientativo
- Errores Comunes y Consejos
- Ejercicios
- Conclusión
- Paso 1 — Del enunciado al modelo
erDiagram
SEDES ||--o{ EJEMPLARES : "custodia"
SEDES ||--o{ BIBLIOTECARIOS : "emplea"
SEDES ||--o{ SOCIOS : "da de alta"
SEDES ||--o{ RESERVAS : "recoge"
BIBLIOTECARIOS ||--o{ BIBLIOTECARIOS : "es responsable de"
BIBLIOTECARIOS ||--o{ PRESTAMOS : "tramita"
MATERIAS ||--o{ OBRAS : "clasifica"
EDITORIALES ||--o{ OBRAS : "publica"
OBRAS ||--o{ OBRAS_AUTORES : "es firmada en"
AUTORES ||--o{ OBRAS_AUTORES : "firma"
OBRAS ||--o{ EJEMPLARES : "se materializa en"
OBRAS ||--o{ RESERVAS : "se reserva"
EJEMPLARES ||--o{ PRESTAMOS : "se presta como"
SOCIOS ||--o{ PRESTAMOS : "toma"
SOCIOS ||--o{ RESERVAS : "solicita"
PRESTAMOS ||--o| MULTAS : "genera"
Doce tablas y dieciséis relaciones: catorce 1:N, una N:M resuelta con la tabla puente obras_autores, una reflexiva en bibliotecarios y una 1 a 0..1 entre prestamos y multas. La forma es la de TiendaVerde; el contenido, no. Las columnas están en el DDL del paso 2.
Las cinco decisiones difíciles
1. Por qué ejemplares es una tabla y no un contador. Argumentado en 12-01: el ejemplar tiene sede, estado e historia propios. La consecuencia técnica es más contundente que el argumento conceptual: sin esa tabla, la FK de prestamos apuntaría a obras y sería imposible saber cuál de los tres volvió. No es estilo: es que el modelo no puede representar el hecho.
2. Por qué prestamos guarda fecha_prevista en lugar de calcularla. Podría deducirse (fecha_prestamo + plazo(tipo del socio)), y no se hace: el argumento es idéntico al de lineas_pedido.precio_unitario (01-06, 11-02), es un dato histórico. Si Marta pasa de general a senior, sus préstamos antiguos se recalcularían a 30 días y uno que llegó tarde pasaría a estar en plazo. Además se mueve con las renovaciones (RN-03), así que ni siquiera es función de la fecha inicial. Regla: si el valor depende de una condición del pasado que puede cambiar, se guarda.
3. Por qué las multas son una tabla y no una columna. Tiene vida propia —se genera, se paga, se puede condonar—, y eso son tres columnas más, nulas para el 83 % de los préstamos; permite contar y sumar sin recorrer todos los préstamos; y mañana podría haber multas que no vengan de un retraso (un libro dañado), y bastaría hacer nulable la FK. Relación 1 a 0..1, forzada con UNIQUE (prestamo_id).
4. Cómo se modela la cola de reservas. Con fecha_reserva y nada más. Las tres opciones:
| Opción | Problema |
|---|---|
Columna posicion INTEGER |
Cancelar la reserva 2 obliga a renumerar las siguientes: concurrencia, huecos y errores |
Columna es_el_siguiente BOOLEAN |
El antipatrón clásico: un booleano que solo puede ser cierto en una fila, sin nada que lo garantice |
fecha_reserva + ROW_NUMBER() |
La posición se calcula al consultar. Cancelar es cambiar un estado; la cola se recoloca sola |
5. Por qué el estado del préstamo no se almacena y el del ejemplar sí. Parecen simétricos y no lo son. El del préstamo (activo, vencido, devuelto) es función de dos fechas y del reloj: guardarlo obligaría a un proceso nocturno que marcara los vencidos. El del ejemplar (disponible, reparacion, extraviado, baja) es un hecho físico que alguien decide y no se deduce de nada. Por eso ejemplares.estado no incluye prestado: eso sí se deduce, y tenerlo sería guardar dos veces lo mismo con dos formas de contradecirse.
- Paso 2 — El DDL:
01-esquema.sql
01-esquema.sqlEl orden es el de 05-01: DROP de hijos a padres, CREATE de padres a hijos.
Ocho de las doce tablas no tienen ninguna sorpresa: son el patrón exacto de categorias, proveedores y devoluciones en TiendaVerde, así que se resumen en esta tabla —columnas, nulables y restricciones nombradas— y escribir su CREATE TABLE te llevará cinco minutos:
| Tabla | Columnas | Restricciones |
|---|---|---|
sedes |
nombre, direccion, telefono (nulable: puede no tener línea propia), fecha_apertura |
pk_sedes, uq_sedes_nombre |
materias |
nombre, cdu (nulable: no todas están clasificadas) |
pk_materias, uq_materias_nombre |
editoriales |
nombre, pais |
pk_editoriales, uq_editoriales_nombre |
autores |
nombre, apellidos, nacionalidad y anio_nacimiento (ambos nulables: pueden desconocerse) |
pk_autores, chk_autores_anio |
obras |
titulo, materia_id, editorial_id (nulable: autoedición), anio_publicacion, isbn (nulable: obras anteriores al ISBN), idioma |
pk_obras, uq_obras_isbn (RI-08), fk_obras_materia con RESTRICT, fk_obras_editorial con SET NULL, chk_obras_anio |
multas |
prestamo_id, importe NUMERIC(10,2), dias_retraso, fecha_generacion, fecha_pago (nulable: NULL = impagada) |
pk_multas, uq_multas_prestamo (RI-07, la que fuerza el 1 a 0..1), fk_multas_prestamo con RESTRICT, chk_multas_importe, chk_multas_dias, chk_multas_pago |
socios |
nombre, apellidos, documento, email (nulable: los infantiles no tienen), fecha_nacimiento, tipo, estado, sede_id, fecha_alta |
pk_socios, uq_socios_documento, uq_socios_email (que admite varios NULL, 05-01), fk_socios_sede, y los dos dominios cerrados de RI-10: chk_socios_tipo IN ('infantil','general','senior') y chk_socios_estado IN ('activo','bloqueado','baja') |
reservas |
obra_id (la obra, no el ejemplar), socio_id, sede_id de recogida, fecha_reserva (de aquí sale la posición en la cola), estado, fecha_aviso y fecha_cierre (nulables) |
pk_reservas, fk_reservas_obra con CASCADE y las otras dos con RESTRICT, chk_reservas_estado IN ('en_espera','disponible','completada','cancelada','caducada'), chk_reservas_aviso |
Y estas son las cuatro que sí tienen decisiones dentro del propio CREATE TABLE:
-- =====================================================================
-- BiblioTeca Municipal de Alvorada - 01-esquema.sql - PostgreSQL 16
-- =====================================================================
-- Borrado en orden inverso a las dependencias, para que el script sea idempotente
DROP TABLE IF EXISTS multas CASCADE; DROP TABLE IF EXISTS reservas CASCADE;
DROP TABLE IF EXISTS prestamos CASCADE; DROP TABLE IF EXISTS ejemplares CASCADE;
DROP TABLE IF EXISTS obras_autores CASCADE; DROP TABLE IF EXISTS obras CASCADE;
DROP TABLE IF EXISTS socios CASCADE; DROP TABLE IF EXISTS bibliotecarios CASCADE;
DROP TABLE IF EXISTS autores CASCADE; DROP TABLE IF EXISTS editoriales CASCADE;
DROP TABLE IF EXISTS materias CASCADE; DROP TABLE IF EXISTS sedes CASCADE;
-- ... y aquí van, en orden de dependencias, las siete tablas de la tabla anterior ...
CREATE TABLE bibliotecarios (
id INTEGER GENERATED BY DEFAULT AS IDENTITY,
nombre VARCHAR(60) NOT NULL,
apellidos VARCHAR(90) NOT NULL,
puesto VARCHAR(80) NOT NULL,
sede_id INTEGER NOT NULL,
responsable_id INTEGER, -- NULL: solo la dirección de la red
email VARCHAR(120) NOT NULL,
fecha_alta DATE NOT NULL,
CONSTRAINT pk_bibliotecarios PRIMARY KEY (id),
CONSTRAINT uq_bibliotecarios_email UNIQUE (email),
CONSTRAINT fk_bib_sede FOREIGN KEY (sede_id) REFERENCES sedes(id) ON DELETE RESTRICT,
CONSTRAINT fk_bib_responsable FOREIGN KEY (responsable_id) -- reflexiva
REFERENCES bibliotecarios(id) ON DELETE SET NULL,
CONSTRAINT chk_bib_no_autojefe CHECK (responsable_id <> id) -- RI-12
);
-- ---------------------------------------- Puente N:M y ejemplares físicos
CREATE TABLE obras_autores (
obra_id INTEGER NOT NULL,
autor_id INTEGER NOT NULL,
rol VARCHAR(20) NOT NULL DEFAULT 'autor',
orden SMALLINT NOT NULL DEFAULT 1, -- orden de firma en la portada
CONSTRAINT pk_obras_autores PRIMARY KEY (obra_id, autor_id), -- PK compuesta
CONSTRAINT fk_oa_obra FOREIGN KEY (obra_id) REFERENCES obras(id) ON DELETE CASCADE,
CONSTRAINT fk_oa_autor FOREIGN KEY (autor_id) REFERENCES autores(id) ON DELETE RESTRICT,
CONSTRAINT chk_oa_rol CHECK (rol IN ('autor','coautor','traductor','ilustrador')),
CONSTRAINT chk_oa_orden CHECK (orden > 0)
);
CREATE TABLE ejemplares (
id INTEGER GENERATED BY DEFAULT AS IDENTITY,
obra_id INTEGER NOT NULL,
sede_id INTEGER NOT NULL,
codigo_barras VARCHAR(20) NOT NULL,
fecha_adquisicion DATE NOT NULL,
estado VARCHAR(15) NOT NULL DEFAULT 'disponible',
CONSTRAINT pk_ejemplares PRIMARY KEY (id),
CONSTRAINT uq_ejemplares_codigo UNIQUE (codigo_barras), -- RI-09
CONSTRAINT fk_ejemplares_obra FOREIGN KEY (obra_id) REFERENCES obras(id) ON DELETE RESTRICT,
CONSTRAINT fk_ejemplares_sede FOREIGN KEY (sede_id) REFERENCES sedes(id) ON DELETE RESTRICT,
-- Ojo: NO existe el valor 'prestado'. Eso se deduce de prestamos
CONSTRAINT chk_ejemplares_estado CHECK (estado IN ('disponible','reparacion',
'extraviado','baja'))
);
-- ------------------------------------------------- Operación: el préstamo
CREATE TABLE prestamos (
id INTEGER GENERATED BY DEFAULT AS IDENTITY,
ejemplar_id INTEGER NOT NULL,
socio_id INTEGER NOT NULL,
bibliotecario_id INTEGER, -- NULL: autopréstamo en la máquina
fecha_prestamo DATE NOT NULL,
fecha_prevista DATE NOT NULL, -- se guarda: dato histórico
fecha_devolucion DATE, -- NULL = préstamo ACTIVO
renovaciones SMALLINT NOT NULL DEFAULT 0,
CONSTRAINT pk_prestamos PRIMARY KEY (id), -- RI-14: el
CONSTRAINT fk_pre_ejemplar FOREIGN KEY (ejemplar_id) -- histórico
REFERENCES ejemplares(id) ON DELETE RESTRICT, -- no se borra
CONSTRAINT fk_pre_socio FOREIGN KEY (socio_id) REFERENCES socios(id) ON DELETE RESTRICT,
CONSTRAINT fk_pre_bibliotecario FOREIGN KEY (bibliotecario_id)
REFERENCES bibliotecarios(id) ON DELETE SET NULL,
CONSTRAINT chk_pre_prevista CHECK (fecha_prevista > fecha_prestamo), -- RI-01
CONSTRAINT chk_pre_devolucion CHECK (fecha_devolucion IS NULL
OR fecha_devolucion >= fecha_prestamo),
CONSTRAINT chk_pre_renovaciones CHECK (renovaciones BETWEEN 0 AND 2) -- RI-04
);
-- RI-03: entre los préstamos SIN devolver, un ejemplar solo puede aparecer una vez
CREATE UNIQUE INDEX uq_prestamo_activo_por_ejemplar
ON prestamos (ejemplar_id)
WHERE fecha_devolucion IS NULL;
-- RI-13: un socio no puede tener dos reservas VIVAS de la misma obra
CREATE UNIQUE INDEX uq_reserva_activa_socio_obra
ON reservas (obra_id, socio_id)
WHERE estado IN ('en_espera','disponible');El índice parcial, a fondo
Es la pieza técnica del proyecto. Un índice parcial (08-02) se construye solo sobre las filas que cumplen una condición; cuando además es UNIQUE, la unicidad solo se exige entre esas filas. Se lee literalmente: "entre los préstamos sin devolver, el ejemplar_id es único". Sus dos pruebas:
-- ⚠️ INCORRECTA: el ejemplar 1 ya está prestado a Sofía sin devolver
INSERT INTO prestamos (ejemplar_id, socio_id, bibliotecario_id, fecha_prestamo, fecha_prevista)
VALUES (1, 13, 5, DATE '2026-06-25', DATE '2026-07-16');
-- ERROR: duplicate key value violates unique constraint "uq_prestamo_activo_por_ejemplar"
-- DETAIL: Key (ejemplar_id)=(1) already exists.
-- ✅ CORRECTA: otro préstamo del mismo ejemplar, ya devuelto. Es historia, no un préstamo vivo
INSERT INTO prestamos (ejemplar_id, socio_id, bibliotecario_id,
fecha_prestamo, fecha_prevista, fecha_devolucion)
VALUES (1, 13, 5, DATE '2024-01-10', DATE '2024-01-31', DATE '2024-01-28');
-- INSERT 0 1Tres propiedades lo hacen la solución correcta y no un truco. Es declarativo: lo hace cumplir el motor, no la aplicación, así que sobrevive a scripts, a otra aplicación y a una consola abierta a las tres de la mañana. Es diminuto: con 300 000 préstamos históricos y 400 activos, el índice tiene 400 entradas, no 300 000. Y además acelera, porque toda consulta con WHERE fecha_devolucion IS NULL puede usarlo: una restricción que de propina es un índice útil.
Y su límite, que hay que decir en el informe: impide dos préstamos activos, no dos préstamos solapados en el pasado. Si alguien registra hoy un préstamo de enero ya devuelto que se pisa con otro de enero, el índice no lo ve. La solución completa está en 12-04.
- Paso 3 — Datos de prueba coherentes:
02-datos.sql
02-datos.sqlLos datos de prueba tienen dos objetivos que se estorban: ser suficientes para que las consultas signifiquen algo y pequeños para leerlos con los ojos. El reparto: a mano, con id explícitos, todo lo que aparezca en las consultas —así "el préstamo 29" significa siempre lo mismo—; con generate_series, el volumen para medir rendimiento.
-- Volumen sintético para poder medir índices: ~50 000 préstamos históricos
INSERT INTO prestamos (ejemplar_id, socio_id, bibliotecario_id,
fecha_prestamo, fecha_prevista, fecha_devolucion)
SELECT 1 + (random() * 19)::int, 1 + (random() * 14)::int, 1 + (random() * 7)::int,
f, f + 21, f + 21 - (random() * 10)::int -- TODOS devueltos: no rompen RI-03
FROM generate_series(DATE '2020-01-01', DATE '2025-08-31', INTERVAL '1 hour') AS g(f);El detalle que decide si esto funciona: todas las filas generadas llevan fecha_devolucion. Si dejaras nulos al azar, el índice único parcial abortaría la carga en cuanto dos préstamos del mismo ejemplar quedaran abiertos. Lejos de ser una molestia, es la prueba de que la restricción funciona.
Los casos límite que los datos deben contener
TiendaVerde tenía huecos deliberados —clientes sin pedidos, productos sin vender, pedidos sin empleado— y las lecciones los necesitaban. Ahora te toca ponerlos: sin ellos, media consulta mal escrita devuelve lo mismo que una bien escrita y no te enteras.
| Caso límite | Para qué es imprescindible | En el juego de datos |
|---|---|---|
| Socios sin ningún préstamo | Anti-join (RC-04) y LEFT JOIN de rankings (RC-13) |
3 |
| Obra sin ejemplares y ejemplares nunca prestados | LEFT JOIN de disponibilidad (RC-06) y anti-join (RC-04) |
1 y 3 |
| Préstamos devueltos con retraso y multas impagadas | Tasa de retraso, deuda pendiente y bloqueo (RC-08, RN-11) | 6 (de 7 a 46 días) y 2, que suman 13,40 € |
| Préstamos activos vencidos | Vista de vencidos y deuda potencial (RC-03) | 3 |
| Todos los ejemplares de una obra prestados, y reservas en tres estados | Cola de reservas (RC-10): sin lo primero la cola no puede existir | El jardín de las horas: sus 3; y 3 reservas en espera, 1 completada, 1 caducada |
| Bibliotecario sin responsable y préstamo sin bibliotecario | CTE recursiva (RC-14, es el caso base) y NULL en FK con LEFT JOIN (04-03) |
1 y 1 (autopréstamo) |
| Ejemplar en reparación | Distinguir habilitado de disponible (RC-06) | 1 |
| Socio bloqueado y socio de baja | Filtros por estado; histórico que sobrevive a la baja | 1 y 1 |
| Préstamos renovados | RN-03: fecha_prevista ≠ fecha_prestamo + plazo |
2 |
| Meses sin ningún préstamo | Serie temporal sin huecos (RC-12) | 2 |
Verificar la carga
Como en 01-06, comprueba antes de escribir una sola consulta. El juego de datos de referencia tiene 3 sedes, 6 materias, 5 editoriales, 10 autores, 8 bibliotecarios, 12 obras, 15 filas de obras_autores, 20 ejemplares, 15 socios, 36 préstamos, 5 reservas y 6 multas. Y los huecos, en una sola consulta:
SELECT (SELECT COUNT(*) FROM socios AS s WHERE NOT EXISTS
(SELECT 1 FROM prestamos AS p WHERE p.socio_id = s.id)) AS socios_sin_prestamos,
(SELECT COUNT(*) FROM obras AS o WHERE NOT EXISTS
(SELECT 1 FROM ejemplares AS e WHERE e.obra_id = o.id)) AS obras_sin_ejemplares,
(SELECT COUNT(*) FROM ejemplares AS e WHERE NOT EXISTS
(SELECT 1 FROM prestamos AS p WHERE p.ejemplar_id = e.id)) AS ejemplares_sin_prestar,
(SELECT COUNT(*) FROM prestamos WHERE fecha_devolucion IS NULL) AS activos,
(SELECT COUNT(*) FROM prestamos WHERE fecha_devolucion IS NULL
AND fecha_prevista < DATE '2026-06-30') AS vencidos,
(SELECT COUNT(*) FROM prestamos WHERE bibliotecario_id IS NULL) AS sin_bibliotecario,
(SELECT COUNT(*) FROM bibliotecarios WHERE responsable_id IS NULL) AS sin_responsable,
(SELECT COUNT(*) FROM multas WHERE fecha_pago IS NULL) AS multas_impagadas;| socios_sin_prestamos | obras_sin_ejemplares | ejemplares_sin_prestar | activos | vencidos | sin_bibliotecario | sin_responsable | multas_impagadas |
|---|---|---|---|---|---|---|---|
| 3 | 1 | 3 | 6 | 3 | 1 | 1 | 2 |
- Paso 4 — Las consultas: método de trabajo
- Empieza por el
FROM, no por elSELECT. Decide primero de qué tabla sale una fila del resultado: ¿una por obra? ElFROMesobras. ¿Una por préstamo? Esprestamos. Todo lo demás se le une. - Comprueba el recuento después de cada
JOIN. Si al unirejemplaresconprestamospasas de 20 filas a 36, has cambiado de granularidad: puede ser correcto, pero tienes que saberlo, porque a partir de ahíCOUNT(*)cuenta préstamos, no ejemplares. - Decide
JOINoLEFT JOINpreguntando por los ceros. ¿Quiero ver la obra sin ejemplares, la sede sin préstamos, el mes vacío? EntoncesLEFT JOIN— y recuerda queCOUNT(*)cuenta la fila fantasma yCOUNT(columna_de_la_derecha)no (04-04). Y valida el total por dos caminos: el pivote de RC-15 debe sumar lo mismo queSELECT COUNT(*) FROM prestamos; si no cuadra, no discutas con la consulta, está mal (11-04).
Ejemplo resuelto A — el mapa de una obra
"Dime dónde están los tres ejemplares de El jardín de las horas y quién los tiene." Es la pregunta con la que empezaba el correo de Helena, y resume el proyecto entero.
SELECT e.codigo_barras, sd.nombre AS sede, e.estado,
COALESCE(so.nombre || ' ' || so.apellidos, '-- en la estantería --') AS lo_tiene,
p.fecha_prevista AS devuelve_el
FROM ejemplares AS e
JOIN sedes AS sd ON sd.id = e.sede_id
LEFT JOIN prestamos AS p ON p.ejemplar_id = e.id AND p.fecha_devolucion IS NULL
LEFT JOIN socios AS so ON so.id = p.socio_id
WHERE e.obra_id = 1
ORDER BY e.codigo_barras;| codigo_barras | sede | estado | lo_tiene | devuelve_el |
|---|---|---|---|---|
| ALV-0001 | Biblioteca Central de Alvorada | disponible | Sofía Terán | 2026-07-06 |
| ALV-0002 | Biblioteca Central de Alvorada | disponible | Rosa Pimentel | 2026-07-01 |
| ALV-0003 | Biblioteca de Vila Nova | disponible | Lena Fuentes | 2026-05-25 |
Tres decisiones que justificar. FROM ejemplares, porque quiero una fila por ejemplar, esté prestado o no. La condición p.fecha_devolucion IS NULL va en el ON, no en el WHERE: allí desaparecerían los ejemplares sin préstamo activo y el LEFT JOIN sería un JOIN disfrazado (03-03). Y estado sigue diciendo disponible en los tres, y está bien: es la condición física del volumen; que esté fuera se ve en lo_tiene. Es la separación del paso 1, hecha columna.
Ejemplo resuelto B — socios que no vienen
Helena pidió "los socios que no vienen desde hace tiempo". Antes de escribir nada hay que definirlo (11-04): aquí, socio en estado activo cuyo último préstamo es anterior a hace 90 días o que no ha tomado ninguno nunca. Los de baja quedan fuera a propósito.
SELECT s.id, s.nombre || ' ' || s.apellidos AS socio, s.tipo,
MAX(p.fecha_prestamo) AS ultimo_prestamo,
COUNT(p.id) AS prestamos_totales
FROM socios AS s
LEFT JOIN prestamos AS p ON p.socio_id = s.id
WHERE s.estado = 'activo'
GROUP BY s.id, s.nombre, s.apellidos, s.tipo
HAVING COALESCE(MAX(p.fecha_prestamo), DATE '1900-01-01')
< DATE '2026-06-30' - INTERVAL '90 days'
ORDER BY ultimo_prestamo, s.id;| id | socio | tipo | ultimo_prestamo | prestamos_totales |
|---|---|---|---|---|
| 12 | Irene Sampaio | senior | (null) | 0 |
| 13 | Hugo Marques | general | (null) | 0 |
| 14 | Carla Nieto | infantil | (null) | 0 |
| 1 | Marta Coelho | general | 2026-03-09 | 4 |
Cuatro socios y tres lecciones. El LEFT JOIN es obligatorio: con un JOIN normal, los tres que nunca han pedido nada —los más inactivos de todos— desaparecerían. El COALESCE del HAVING es lo que los deja pasar, porque NULL < fecha da UNKNOWN y HAVING descarta lo que no es cierto (04-03). Y COUNT(p.id), no COUNT(*): con COUNT(*) los tres saldrían con 1 préstamo en vez de 0. Es el error más repetido del proyecto entero. Y una lectura que no es de SQL: los tres sin préstamos no son el mismo problema —Carla se dio de alta en febrero, Irene lleva un año con el carné sin usar y Marta tiene cuatro préstamos y tres meses sin venir—; meterlos en la misma cifra es lo que 11-04 llamaba mezclar dos preguntas en un número.
- Paso 5 — Los índices, después de las consultas
El orden es innegociable: primero las quince consultas, después los índices. Un índice existe para servir a una consulta concreta; si no puedes nombrarla, sobra y solo frena las escrituras (08-02). El método es mecánico:
- Lista el
WHERE, elJOINy elORDER BYde cada consulta. Esa lista es tu lista de candidatos, y solo esa (11-05). - Añade las claves foráneas que navegas: PostgreSQL indexa la PK, no la FK (08-01). Es la causa número uno de barridos secuenciales en un esquema de doce tablas.
- Descarta lo que no aporta —una columna de tres valores como
socios.tipoo una tabla de seis filas comomateriasno se indexan: el planificador las ignorará, y hará bien— y mide conEXPLAIN(08-05), no con la intuición.
EXPLAIN (ANALYZE, BUFFERS) SELECT p.id, p.fecha_prevista FROM prestamos AS p
WHERE p.socio_id = 6 AND p.fecha_devolucion IS NULL;Sin índice sobre prestamos(socio_id) y con volumen, el plan es un Seq Scan que lee la tabla entera; con él, un Index Scan que va directo. Lo importante no es que mejore, es que lo compruebes y lo escribas (RP-06). Y con las 36 filas del juego de pruebas verás Seq Scan en todo, porque leer 36 filas es más barato que abrir un índice — lo cual es una lección en sí misma: para hablar de rendimiento hay que tener volumen, y para eso está el generate_series del paso 3. Los índices de referencia, con su plan, están en 12-04.
- Paso 6 — Vistas, procedimientos y triggers
La regla que evita el desastre: encapsula lo que se repite y lo que es delicado; no encapsules por gusto. Tres piezas bastan y una cuarta ya sería sospechosa. El código está en 12-04; aquí va el criterio, que es lo que hay que saber decidir.
| Pieza | Qué encapsula | Por qué esa herramienta y no otra |
|---|---|---|
Vista v_prestamos_vencidos |
La definición de "vencido" y el cálculo de la multa estimada | Es una consulta que van a lanzar todos los días desde tres sedes. Define una vez la métrica, que es la capa semántica de 10-01 y 11-04 |
Procedimiento registrar_devolucion |
Cerrar el préstamo, generar la multa si toca y avisar a la primera reserva de la cola | Son tres escrituras que van juntas o no van (módulo 9). Un procedimiento las mete en una transacción y devuelve un error claro si algo falla (10-04) |
Trigger trg_prestamo_socio_activo |
Impedir prestar a un socio bloqueado (RN-11) | Es una regla que depende de otra tabla, y por tanto fuera del alcance de un CHECK (05-01). Debe cumplirse pase lo que pase, venga de donde venga el INSERT (10-05) |
Dos detalles de la vista importan más de lo que parecen: su columna se llama multa_estimada, no multa, porque todavía no existe (RN-10) —llamarla multa sería la primera piedra de un informe que suma dinero que nadie debe—; y usa CURRENT_DATE, así que cambia sola cada noche, que es exactamente lo que se quería y la razón de no guardar "vencido" en una columna.
Y aquí se para. La tentación de añadir un trigger para el máximo de préstamos simultáneos, otro para validar renovaciones y otro para recalcular el bloqueo es enorme, y es un error: los triggers son lógica invisible —quien lee el INSERT no ve lo que pasa— y depurarlos es incómodo. El criterio de 10-05: trigger para lo que debe cumplirse pase lo que pase y no se pueda declarar; procedimiento para la operación de negocio con varios pasos; aplicación para el resto.
- Paso 7 — Seguridad y entrega
Los tres roles de RS-01 a RS-03, con el patrón de 11-03: privilegios al grupo, nunca al usuario.
CREATE ROLE bib_consulta NOLOGIN; CREATE ROLE bib_mostrador NOLOGIN; CREATE ROLE bib_admin NOLOGIN;
GRANT USAGE ON SCHEMA public TO bib_consulta, bib_mostrador, bib_admin;
-- Consulta: solo catálogo. Ni socios, ni préstamos, ni multas
GRANT SELECT ON obras, ejemplares, autores, obras_autores, materias, editoriales, sedes
TO bib_consulta;
-- Mostrador: opera, pero NO borra (RD-10, RS-04). Administración: además, catálogo
GRANT bib_consulta TO bib_mostrador;
GRANT SELECT, INSERT, UPDATE ON prestamos, reservas, multas TO bib_mostrador;
GRANT SELECT, UPDATE ON socios TO bib_mostrador;
GRANT USAGE ON ALL SEQUENCES IN SCHEMA public TO bib_mostrador;
GRANT bib_mostrador TO bib_admin;
GRANT INSERT, UPDATE ON obras, ejemplares, autores, obras_autores, materias, editoriales
TO bib_admin;
-- El usuario real solo es miembro del grupo que le toca: GRANT bib_mostrador TO fatima;
-- Y se comprueba lo concedido, en lugar de suponerlo
SELECT has_table_privilege('bib_consulta', 'socios', 'SELECT') AS consulta_ve_socios,
has_table_privilege('bib_mostrador', 'prestamos', 'DELETE') AS mostrador_borra;| consulta_ve_socios | mostrador_borra |
|---|---|
| false | false |
Dos falsos, que es lo que se pedía. Esa comprobación de dos líneas es la prueba de RS-01 y RS-04, y va en el informe.
- Cronograma orientativo
Por sesiones de unas dos horas. Desviarse mucho en la sesión 3 suele ser señal de que el modelo tiene un problema, no de que vayas lento:
| Sesión | Trabajo | Entregable al final |
|---|---|---|
| 1 | Glosario, reglas de negocio, boceto del modelo en papel | Diagrama con las 12 cajas y sus relaciones |
| 2 | 01-esquema.sql: tablas, restricciones nombradas, índice parcial |
El esquema se crea sin errores dos veces seguidas |
| 3 | 02-datos.sql con todos los casos límite; consultas de verificación |
Los recuentos y los ocho huecos cuadran |
| 4 y 5 | RC-01 a RC-08 una a una; después RC-09 a RC-15: correlacionadas, LATERAL, ventanas, recursiva y pivote |
Las quince, con los totales validados |
| 6 y 7 | Índices y EXPLAIN antes y después; vista, procedimiento y trigger; roles; 04-informe.md; repaso con la rúbrica de 12-02 |
El proyecto entero, ejecutable desde cero |
Errores Comunes y Consejos
- Crear las tablas en el orden en que se te ocurren. La integridad referencial impone el orden: padres antes que hijos al crear, al revés al borrar (05-01). Si tu script solo funciona la primera vez, no es idempotente y RE-02 no se cumple.
- Poner la condición del
LEFT JOINen elWHERE. El error más caro y más silencioso del proyecto: convierte elLEFT JOINenJOIN, desaparecen los ceros y el resultado sigue pareciendo razonable. Condición sobre la tabla de la derecha ⇒ va en elON. COUNT(*)después de unLEFT JOIN. Cuenta la fila fantasma y los socios sin préstamos salen con 1. UsaCOUNT(columna_de_la_derecha).- Añadir el valor
prestadoaejemplares.estado, o generar datos al azar sin mirar las restricciones. Lo primero duplica información que acabará contradiciendo aprestamos; lo segundo (devoluciones anteriores al préstamo, dos activos del mismo ejemplar, multas sin retraso) aborta el script a la mitad y deja la base a medio cargar. - Consejo: después de crear el esquema, intenta violarlo, y escribe el DDL con los
-- RI-nnal lado de cada restricción. UnINSERTque debe fallar y falla vale más que tres párrafos de informe, y repasar los catorce requisitos con la rúbrica te llevará un minuto en vez de media hora. - Consejo: guarda un
99-comprobaciones.sqlcon los recuentos, los ocho huecos, losINSERTque deben fallar y las dos consultas de privilegios. Es tu batería de pruebas: ejecutarla tras cada cambio te dirá al instante si has roto algo. - Consejo: escribe el informe a medida que decides. Cada decisión difícil, su párrafo, en el momento. Reconstruirlo la última noche es imposible y se nota.
Ejercicios
Ejercicio 1
Implementa la RN-04 completa: no se puede renovar si se alcanzó el máximo, si el préstamo está vencido o si la obra tiene reservas en espera. (1) ¿Cuáles de las tres condiciones se pueden declarar con un CHECK y cuáles no, y por qué? (2) Escribe un UPDATE que renueve el préstamo 32 solo si las tres lo permiten. (3) ¿Qué devuelve con los datos del proyecto?
Ejercicio 2
El préstamo 29 (Lena Fuentes, ejemplar ALV-0003) lleva 36 días de retraso y se devuelve hoy, 2026-06-30. (1) Enumera todas las filas que cambian, en qué tablas y con qué valores. (2) ¿Por qué las tres operaciones tienen que ir en la misma transacción, y qué pasaría si fallara la tercera después de las dos primeras? (3) ¿Qué efecto tiene la devolución sobre el índice único parcial?
Ejercicio 3
Un compañero propone añadir a obras una columna num_ejemplares INTEGER "para no tener que contar cada vez". (1) Da tres argumentos en contra. (2) Da un escenario en el que sí sería defendible. (3) Si hubiera que hacerlo, ¿cómo lo mantendrías y qué te costaría?
Soluciones
Solución 1 —
(1) Ninguna, y por motivos distintos. El máximo de renovaciones depende del tipo del socio, que está en otra tabla, y un CHECK no puede consultar otras tablas (05-01): lo único declarable es el rango absoluto BETWEEN 0 AND 2. Que el préstamo esté vencido depende de CURRENT_DATE, y un CHECK con función no determinista está desaconsejado —una fila válida hoy dejaría de serlo mañana y una restauración de copia fallaría sin motivo—. Las reservas están en otra tabla. Las tres son lógica de operación. (2) Todo en el WHERE, que es donde se comprueba sin leer antes:
UPDATE prestamos AS p
SET fecha_prevista = p.fecha_prevista
+ CASE s.tipo WHEN 'infantil' THEN 14
WHEN 'general' THEN 21 ELSE 30 END,
renovaciones = p.renovaciones + 1
FROM socios AS s
WHERE s.id = p.socio_id AND p.id = 32 AND p.fecha_devolucion IS NULL
AND p.fecha_prevista >= DATE '2026-06-30' -- no vencido
AND p.renovaciones < CASE s.tipo WHEN 'infantil' THEN 1 ELSE 2 END -- máximo
AND NOT EXISTS (SELECT 1 -- sin cola
FROM reservas AS r
JOIN ejemplares AS e ON e.obra_id = r.obra_id
WHERE e.id = p.ejemplar_id
AND r.estado = 'en_espera');(3) Devuelve UPDATE 0. El préstamo 32 es de Sofía Terán con el ejemplar ALV-0001, de El jardín de las horas — la obra que tiene tres socios en la cola. La tercera condición lo bloquea, y hace bien: renovarlo dejaría a Nuno Barros esperando un mes más por un libro cuyos tres ejemplares están fuera. Y UPDATE 0 no es un error: hay que comprobarlo en la aplicación y traducirlo a un mensaje.
Solución 2 — (1) Cambian tres filas en tres tablas:
| Tabla | Operación | Valores |
|---|---|---|
prestamos |
UPDATE de la fila 29 |
fecha_devolucion pasa de NULL a 2026-06-30 |
multas |
INSERT de una fila nueva |
prestamo_id 29, dias_retraso 36, importe 7,20 € (0,20 × 36, por debajo del tope de 20 €), sin fecha_pago |
reservas |
UPDATE de la reserva más antigua de la obra 1 |
La de Nuno Barros del 2026-06-10 pasa de en_espera a disponible, con fecha_aviso = hoy |
(2) Porque las tres son una sola operación de negocio: un ejemplar devuelto que genera su multa y activa su reserva. Si fallara la tercera sin transacción, quedaría un préstamo cerrado con su multa correcta y una cola que nadie ha avisado: Nuno seguiría esperando un libro que ya está en el mostrador, y sin huella del fallo. Es ACID en su forma más simple (09-01, 09-02): o las tres, o ninguna. En PL/pgSQL el bloque del procedimiento ya es una transacción implícita, así que una excepción en el paso 3 deshace los dos primeros. (3) Al dejar de estar activo, el préstamo 29 sale del índice único parcial —su fila ya no cumple WHERE fecha_devolucion IS NULL— y el ejemplar ALV-0003 vuelve a poder prestarse. La restricción se suelta sola: esa es la elegancia del índice parcial frente a una columna activo que alguien tendría que acordarse de cambiar.
Solución 3 — (1) Primero, es redundancia pura: el dato ya está en ejemplares y un COUNT(*) lo obtiene en microsegundos con el índice de ejemplares(obra_id). Segundo, hay que mantenerlo en cada alta, baja y traslado de ejemplar; el día que alguien inserte desde un script, la cifra queda mal para siempre y nadie se entera, porque un número plausible no salta a la vista. Tercero, no responde a la pregunta real: nadie pregunta cuántos ejemplares hay, preguntan cuántos hay disponibles ahora, que depende de prestamos y cambia cada minuto.
(2) Sería defendible con millones de obras, en una pantalla de catálogo que muestra el recuento en cada resultado de búsqueda y se lee miles de veces por segundo, si el agregado se hubiera medido y fuera el cuello de botella. Es decir: cuando haya un EXPLAIN que lo justifique, no antes (08-04). Y aun así, la primera opción sería una vista materializada (10-01) refrescada cada noche, que aísla la redundancia en un objeto marcado como derivado en lugar de esconderla en una columna que parece un dato.
(3) Con un trigger AFTER INSERT OR UPDATE OR DELETE sobre ejemplares que sume o reste. El coste: cada escritura en ejemplares escribe también en obras, lo que crea contención sobre la fila de la obra —dos altas simultáneas de ejemplares del mismo título se serializan (09-05)— y añade lógica invisible; más una consulta de recálculo desde cero, para corregirlo cuando (no si) se desincronice. Todo eso, para ahorrar un COUNT.
Conclusión
Ya sabes construirlo:
- El modelo: doce tablas y dieciséis relaciones, con una N:M de clave primaria compuesta y una reflexiva. Y las cinco decisiones difíciles argumentadas:
ejemplareses tabla porque el objeto tiene sede, estado e historia propios;fecha_previstase guarda porque es dato histórico y además se mueve con las renovaciones, igual queprecio_unitario; las multas son tabla porque tienen ciclo de vida; la cola se calcula desdefecha_reservaen vez de guardar una posición; y el estado del préstamo se deriva mientras el del ejemplar se almacena, porque uno es función del reloj y el otro un hecho físico. - El DDL, con restricciones nombradas y anotadas con su
RI-nn, y el índice único parcialWHERE fecha_devolucion IS NULL: declarativo, diminuto y útil también como índice, con su límite reconocido —no detecta solapamientos históricos—. Los datos: a mano lo que aparece en las consultas,generate_seriespara el volumen (con todas las filas devueltas, o RI-03 aborta la carga) y los casos límite sin los cuales una consulta mal escrita pasa por buena, con los recuentos y los ocho huecos verificados antes de escribir la primera consulta. - El método: empezar por el
FROMdecidiendo qué es una fila del resultado, comprobar el recuento tras cadaJOIN, elegirLEFT JOINpreguntando por los ceros y validar el total por dos caminos. Los dos ejemplos resueltos enseñan las dos trampas: la condición delLEFT JOINva en elON, yCOUNT(*)cuenta la fila fantasma. - Los índices después de las consultas, no al revés; tres encapsulados y ni uno más —la vista de vencidos con su honesto
multa_estimada, el procedimiento atómico de devolución y el único trigger necesario—; y tres roles de privilegio mínimo verificados conhas_table_privilege.
Ya lo has construido; ahora toca compararlo, que es donde de verdad se aprende. En la lección siguiente, Soluciones comentadas del proyecto, está el solucionario: las decisiones del esquema justificadas una a una con las alternativas que también serían correctas —incluida la restricción EXCLUDE y el debate entre estado derivado y almacenado—; las 15 consultas resueltas con su resultado, su decisión clave y el error típico de cada una; los índices de referencia con el plan que los aprovecha; el código de la vista, el procedimiento y el trigger; y el catálogo de lo que más falla en este proyecto en concreto.
Curso de SQL
Módulo 1: Introducción a SQL
- ¿Qué es SQL?
- Configurando tu entorno SQL
- Sintaxis básica de SQL
- Entendiendo bases de datos y tablas
- El modelo relacional: claves primarias y foráneas
- La base de datos del curso: TiendaVerde
Módulo 2: Consultas básicas de SQL
- Instrucción SELECT
- Alias, expresiones y columnas calculadas
- Filtrando datos con WHERE
- DISTINCT y eliminación de duplicados
- Ordenando datos con ORDER BY
- Limitando resultados con LIMIT
Módulo 3: Trabajando con múltiples tablas
- Operaciones JOIN
- INNER JOIN
- LEFT JOIN
- RIGHT JOIN
- FULL OUTER JOIN
- SELF JOIN y CROSS JOIN
- Uniones de conjuntos: UNION, INTERSECT y EXCEPT
Módulo 4: Filtrado avanzado de datos
- Usando LIKE para coincidencia de patrones
- Operadores IN y BETWEEN
- Valores NULL y IS NULL
- Funciones de agregación: COUNT, SUM, AVG, MIN y MAX
- Agregando datos con GROUP BY
- Cláusula HAVING
Módulo 5: Manipulación de datos
- Creando tablas y restricciones con CREATE TABLE
- Instrucción INSERT
- Instrucción UPDATE
- Instrucción DELETE
- Instrucción UPSERT (MERGE)
- Modificando el esquema: ALTER TABLE y migraciones seguras
Módulo 6: Funciones avanzadas de SQL
- Funciones de cadena
- Funciones numéricas
- Funciones de fecha y hora
- Conversión de tipos y manejo de NULL: CAST y COALESCE
- Expresiones condicionales
Módulo 7: Subconsultas y consultas anidadas
- Introducción a subconsultas
- Subconsultas correlacionadas
- EXISTS y NOT EXISTS
- Usando subconsultas en cláusulas SELECT, FROM y WHERE
- Subconsultas o JOIN: cuál elegir
Módulo 8: Índices y optimización de rendimiento
- Entendiendo los índices
- Creación y gestión de índices
- Tipos de índice y cuándo no indexar
- Técnicas de optimización de consultas
- Análisis del rendimiento de consultas
Módulo 9: Transacciones y concurrencia
- Introducción a las transacciones
- Propiedades ACID
- Instrucciones de control de transacciones
- Niveles de aislamiento y anomalías de concurrencia
- Manejo de concurrencia: bloqueos e interbloqueos
Módulo 10: Temas avanzados
- Vistas
- Expresiones de tabla comunes (CTE)
- Funciones de ventana
- Procedimientos almacenados
- Triggers
- JSON y datos semiestructurados
Módulo 11: SQL en la práctica
- Casos de uso en el mundo real
- Mejores prácticas
- Seguridad: inyección SQL, permisos y roles
- SQL para análisis de datos
- SQL en desarrollo web
