En la lección anterior consultabas un esquema que ya existía. Aquí no hay esquema: hay un cliente que te cuenta lo que necesita, en el desorden con el que lo cuentan los clientes, y tu trabajo es convertir ese relato en tablas, claves y restricciones que no se puedan romper.

Cómo trabajar esta lección. Cada ejercicio es un caso completo, no una pregunta de un minuto. Lee el enunciado entero, coge papel —o un editor de texto— y haz los cuatro pasos por tu cuenta antes de mirar nada:

  1. Identificar entidades y relaciones: subraya los sustantivos del enunciado, decide cuáles son entidades y cuáles atributos, y anota la cardinalidad de cada relación.
  2. Dibujar el diagrama ER con notación de pata de gallo (puedes usar mermaid, papel o cualquier herramienta).
  3. Escribir el CREATE TABLE aplicando las diez reglas de transformación de la lección 04-03, con sus claves ajenas y sus acciones ON DELETE / ON UPDATE.
  4. Añadir las restriccionesNOT NULL, UNIQUE, CHECK, DEFAULT, dominios, EXCLUDE— que codifican las reglas de negocio del enunciado.

Solo después compara con la solución propuesta. Es normal que tu esquema no sea idéntico: en diseño casi nunca hay una única respuesta correcta, sino respuestas defendibles y respuestas que rompen un requisito. Por eso cada solución termina con un apartado de decisiones discutibles, donde se explica qué alternativas también serían válidas y qué se gana y se pierde con cada una. Y al final de la lección hay una rúbrica de autoevaluación con la que puedes puntuar tu propio diseño.

En esta lección no se hace análisis formal de dependencias funcionales ni se citan formas normales: eso es exactamente la materia de 07-03. Aquí diseñamos bien desde el principio; allí diagnosticamos y arreglamos diseños que ya han salido mal.

Antes de Empezar

No necesitas ningún juego de datos cargado: cuatro de los cinco casos son dominios nuevos, y el quinto se apoya en el esquema de BiblioRed que ya conoces. Sí necesitas tener a mano:

  • Las diez reglas de transformación ER → relacional (lección 04-03), en especial las de relaciones N:M, entidades débiles, jerarquías de generalización y relaciones ternarias.
  • El catálogo de restricciones de la lección 04-04: CHECK, UNIQUE sobre varias columnas, UNIQUE parcial mediante índice, dominios (CREATE DOMAIN), columnas generadas y EXCLUDE con btree_gist.
  • Una sesión psql para ejecutar tus CREATE TABLE. No des por bueno un esquema que no has ejecutado: la mitad de los errores de diseño los detecta el propio motor al crear las tablas.

Para el ejercicio 2 conviene tener presente el esquema de BiblioRed, en particular sucursales, materiales, ejemplares, socios y prestamos.

Si vas a usar restricciones EXCLUDE, activa la extensión una sola vez por base de datos:

CREATE EXTENSION IF NOT EXISTS btree_gist;

SQLite. No soporta CREATE DOMAIN, ni EXCLUDE, ni ALTER TABLE ADD CONSTRAINT, y solo aplica las claves ajenas si activas PRAGMA foreign_keys = ON. Los CHECK sí funcionan. Donde una solución use algo exclusivo de PostgreSQL, se indica la alternativa portable.

Contenido

  1. Ejercicio 1 — Básico: videoclub de barrio
  2. Ejercicio 2 — Intermedio: préstamo interbibliotecario en BiblioRed
  3. Ejercicio 3 — Intermedio: plataforma de cursos en línea
  4. Ejercicio 4 — Avanzado: taller mecánico con jerarquía y relación ternaria
  5. Ejercicio 5 — Avanzado: tarifas con vigencia temporal
  6. Errores comunes y consejos
  7. Ejercicios de refuerzo
  8. Rúbrica de autoevaluación

Ejercicio 1: Videoclub de barrio

Dificultad: Básico

Enunciado. «Cinema Vallmar» es un videoclub que sobrevive alquilando películas en formato físico. Su dueño te cuenta lo siguiente:

«Tengo unas 3.000 películas. De cada una guardo el título, el año, la duración en minutos y la clasificación por edades. Cada película es de un género —drama, comedia, documental...— aunque hay algunas que son de dos, y me gustaría poder buscarlas por los dos. De cada película tengo entre una y seis copias físicas; cada copia tiene una etiqueta pegada con un código, un formato (DVD o Blu-ray) y un estado, porque algunas están rayadas y no las alquilo. Los clientes se hacen socios con nombre, teléfono y correo; el correo no se puede repetir. Cuando alguien alquila una copia apunto la fecha, la fecha de devolución prevista y, cuando la trae, la real. Un mismo socio puede tener varias copias alquiladas a la vez, pero una copia solo puede estar alquilada a una persona. También me gustaría poder buscar por actor: cada película tiene varios actores y cada actor sale en varias películas, y me interesa saber qué personaje interpretaba.»

Necesita poder responder: qué copias de una película están disponibles ahora mismo, qué tiene alquilado un socio, qué películas hay de un género, en qué películas ha trabajado un actor y qué alquileres están vencidos.

Pista. Hay dos relaciones N:M en el enunciado, y una de ellas tiene un atributo propio.

Solución

Paso 1 — Entidades y relaciones

Entidad Justificación
peliculas Tiene atributos propios y se referencia desde varios sitios
generos Un género es una entidad, no un texto libre: hay que buscar por él
actores Tiene identidad propia y se repite entre películas
copias El objeto físico que se alquila; no es lo mismo que la película
socios Clientes
alquileres El hecho de que una copia salga de la tienda

Relaciones:

  • peliculas N:M generos → tabla intermedia peliculas_generos.
  • peliculas N:M actores, con atributo personaje → tabla intermedia reparto.
  • peliculas 1:N copias (una película tiene entre 1 y 6 copias).
  • socios 1:N alquileres, copias 1:N alquileres.

La distinción clave del caso es película frente a copia. La película es la obra; la copia es el disco de plástico con una etiqueta. El socio no alquila «Casablanca»: alquila la copia CV-0412. Confundir ambas es el error de diseño más frecuente en este dominio, y es exactamente la misma distinción que hay en BiblioRed entre materiales y ejemplares.

Paso 2 — Diagrama ER

erDiagram
    PELICULAS ||--o{ COPIAS : "existe en"
    PELICULAS ||--o{ PELICULAS_GENEROS : ""
    GENEROS   ||--o{ PELICULAS_GENEROS : ""
    PELICULAS ||--o{ REPARTO : ""
    ACTORES   ||--o{ REPARTO : ""
    COPIAS    ||--o{ ALQUILERES : "se alquila en"
    SOCIOS    ||--o{ ALQUILERES : "realiza"

    PELICULAS {
        int  pelicula_id PK
        text titulo
        int  anio
        int  duracion_min
        text clasificacion
    }
    GENEROS {
        int  genero_id PK
        text nombre UK
    }
    PELICULAS_GENEROS {
        int pelicula_id PK_FK
        int genero_id PK_FK
    }
    ACTORES {
        int  actor_id PK
        text nombre
        text apellidos
    }
    REPARTO {
        int  pelicula_id PK_FK
        int  actor_id PK_FK
        text personaje PK
    }
    COPIAS {
        int  copia_id PK
        text codigo UK
        int  pelicula_id FK
        text formato
        text estado
    }
    SOCIOS {
        int  socio_id PK
        text nombre
        text email UK
        text telefono
        bool activo
    }
    ALQUILERES {
        int  alquiler_id PK
        int  copia_id FK
        int  socio_id FK
        date fecha_alquiler
        date fecha_prevista
        date fecha_devolucion
    }

Paso 3 — CREATE TABLE

CREATE TABLE generos (
    genero_id SERIAL PRIMARY KEY,
    nombre    VARCHAR(40) NOT NULL UNIQUE
);

CREATE TABLE peliculas (
    pelicula_id    SERIAL PRIMARY KEY,
    titulo         VARCHAR(200) NOT NULL,
    anio           SMALLINT     NOT NULL,
    duracion_min   SMALLINT     NOT NULL,
    clasificacion  VARCHAR(5)   NOT NULL
);

CREATE TABLE peliculas_generos (
    pelicula_id INTEGER NOT NULL REFERENCES peliculas(pelicula_id) ON DELETE CASCADE,
    genero_id   INTEGER NOT NULL REFERENCES generos(genero_id)     ON DELETE RESTRICT,
    PRIMARY KEY (pelicula_id, genero_id)
);

CREATE TABLE actores (
    actor_id  SERIAL PRIMARY KEY,
    nombre    VARCHAR(60) NOT NULL,
    apellidos VARCHAR(80) NOT NULL
);

CREATE TABLE reparto (
    pelicula_id INTEGER NOT NULL REFERENCES peliculas(pelicula_id) ON DELETE CASCADE,
    actor_id    INTEGER NOT NULL REFERENCES actores(actor_id)      ON DELETE RESTRICT,
    personaje   VARCHAR(80) NOT NULL,
    PRIMARY KEY (pelicula_id, actor_id, personaje)
);

CREATE TABLE copias (
    copia_id    SERIAL PRIMARY KEY,
    codigo      VARCHAR(12) NOT NULL UNIQUE,
    pelicula_id INTEGER     NOT NULL REFERENCES peliculas(pelicula_id) ON DELETE RESTRICT,
    formato     VARCHAR(10) NOT NULL,
    estado      VARCHAR(12) NOT NULL DEFAULT 'disponible'
);

CREATE TABLE socios (
    socio_id  SERIAL PRIMARY KEY,
    nombre    VARCHAR(100) NOT NULL,
    email     VARCHAR(120) NOT NULL UNIQUE,
    telefono  VARCHAR(15),
    activo    BOOLEAN NOT NULL DEFAULT TRUE
);

CREATE TABLE alquileres (
    alquiler_id      SERIAL PRIMARY KEY,
    copia_id         INTEGER NOT NULL REFERENCES copias(copia_id)  ON DELETE RESTRICT,
    socio_id         INTEGER NOT NULL REFERENCES socios(socio_id)  ON DELETE RESTRICT,
    fecha_alquiler   DATE NOT NULL DEFAULT CURRENT_DATE,
    fecha_prevista   DATE NOT NULL,
    fecha_devolucion DATE
);

Paso 4 — Restricciones que codifican las reglas de negocio

ALTER TABLE peliculas
    ADD CONSTRAINT ck_peliculas_anio     CHECK (anio BETWEEN 1888 AND 2100),
    ADD CONSTRAINT ck_peliculas_duracion CHECK (duracion_min BETWEEN 1 AND 600),
    ADD CONSTRAINT ck_peliculas_clasif   CHECK (clasificacion IN ('TP','7','12','16','18'));

ALTER TABLE copias
    ADD CONSTRAINT ck_copias_formato CHECK (formato IN ('DVD','Blu-ray')),
    ADD CONSTRAINT ck_copias_estado  CHECK (estado IN ('disponible','alquilada','dañada','retirada'));

ALTER TABLE alquileres
    ADD CONSTRAINT ck_alq_prevista   CHECK (fecha_prevista > fecha_alquiler),
    ADD CONSTRAINT ck_alq_devolucion CHECK (fecha_devolucion IS NULL
                                         OR fecha_devolucion >= fecha_alquiler);

-- Regla dura: una copia no puede estar alquilada a dos personas a la vez.
-- Un índice UNIQUE PARCIAL sobre los alquileres abiertos lo garantiza.
CREATE UNIQUE INDEX uq_alquiler_abierto
    ON alquileres (copia_id)
    WHERE fecha_devolucion IS NULL;

-- Consulta más frecuente del mostrador: copias disponibles de una película
CREATE INDEX idx_copias_pelicula ON copias (pelicula_id) WHERE estado = 'disponible';

Resultado esperado

El esquema debe responder las cinco consultas del enunciado. Comprobación rápida:

Pregunta del cliente Consulta que la responde
Copias disponibles de una película SELECT ... FROM copias WHERE pelicula_id = ? AND estado='disponible'
Qué tiene alquilado un socio alquileres JOIN copias JOIN peliculas WHERE socio_id=? AND fecha_devolucion IS NULL
Películas de un género peliculas JOIN peliculas_generos JOIN generos WHERE g.nombre=?
Películas de un actor reparto JOIN peliculas WHERE actor_id=?
Alquileres vencidos WHERE fecha_devolucion IS NULL AND fecha_prevista < CURRENT_DATE

Explicación y decisiones discutibles

Por qué generos es una tabla y no una columna de texto. El dueño dijo «hay algunas que son de dos géneros». Eso descarta de plano una columna genero VARCHAR(40) y descarta con más fuerza aún el anti-patrón de la lista con comas ('drama,comedia'), que ya vimos en 04-01: hace imposible el índice, imposible el JOIN e imposible garantizar que el texto esté bien escrito. Una tabla generos con UNIQUE (nombre) impide además que convivan «Documental», «documental» y «documentales».

La clave primaria de reparto incluye personaje. Es una decisión discutible y merece la pena entender por qué. Con PRIMARY KEY (pelicula_id, actor_id), un actor que interpreta dos papeles en la misma película —gemelos, dobles papeles— sería imposible de registrar. Incluir personaje en la clave lo permite. La alternativa igualmente válida es poner una clave sustituta reparto_id SERIAL y un UNIQUE (pelicula_id, actor_id, personaje); el efecto es el mismo y las claves ajenas hacia reparto quedan más cortas si algún día hacen falta.

Las acciones referenciales no son todas iguales, y eso es deliberado:

Clave ajena Acción Motivo
peliculas_generos → peliculas ON DELETE CASCADE Si desaparece la película, su clasificación por género no significa nada
peliculas_generos → generos ON DELETE RESTRICT Borrar el género «drama» no debe borrar en cascada su asignación a 400 películas: primero hay que reclasificarlas
alquileres → socios ON DELETE RESTRICT El histórico de alquileres es información contable. Un socio que se da de baja se marca activo = FALSE, no se borra
copias → peliculas ON DELETE RESTRICT Si hay copias físicas en la estantería, la película no puede desaparecer del catálogo

El índice único parcial es la pieza más interesante del diseño. La regla «una copia solo puede estar alquilada a una persona» no se puede expresar con un UNIQUE (copia_id) sobre alquileres, porque entonces una copia solo podría alquilarse una vez en toda su historia. Lo que hay que restringir es que haya como mucho un alquiler abierto por copia, y eso es exactamente lo que hace CREATE UNIQUE INDEX ... WHERE fecha_devolucion IS NULL. Es una restricción de PostgreSQL; en SQLite existe el mismo índice parcial, en MySQL 8 no, y allí habría que resolverlo con un disparador o confiando en la aplicación (peor).

Alternativa razonable que no he tomado: una columna copias.estado redundante con la existencia de un alquiler abierto. La he mantenido porque el dueño distingue estados que no dependen del alquiler (dañada, retirada), pero eso introduce una posibilidad de incoherencia: una copia disponible con un alquiler abierto. Si el volumen fuera mayor, valdría la pena mantenerla con un disparador; con 3.000 películas, la aplicación puede encargarse.


Ejercicio 2: Préstamo interbibliotecario en BiblioRed

Dificultad: Intermedio

Enunciado. BiblioRed quiere lanzar un servicio de préstamo interbibliotecario. La dirección lo describe así:

«Si un socio de Sur quiere un libro que solo está en Norte, ahora tiene que desplazarse. Queremos que pueda pedirlo desde su sucursal y que el ejemplar viaje. También queremos poder pedir a bibliotecas de otras ciudades con las que tenemos convenio —tienen nombre, ciudad, persona de contacto y correo—, y prestarles a ellas. Cada petición la hace un socio en una sucursal, sobre un material concreto (no sobre un ejemplar: nos da igual cuál venga). La petición pasa por estados: solicitada, aceptada, en tránsito, disponible para recoger, prestada, devuelta, rechazada o cancelada, y de cada cambio de estado queremos saber cuándo ocurrió y quién lo hizo. Cuando la petición se acepta, se le asigna un ejemplar concreto. El envío tiene un coste que paga la biblioteca solicitante, y las peticiones a bibliotecas externas tienen un plazo máximo distinto del interno.»

Restricciones adicionales: una petición se dirige o bien a otra sucursal de BiblioRed o bien a una biblioteca externa, nunca a las dos ni a ninguna. Un socio no puede tener más de tres peticiones activas simultáneamente (esta la puedes dejar para la aplicación, pero indícalo).

Pista. El historial de cambios de estado es una entidad débil que depende de la petición.

Solución

Paso 1 — Entidades y relaciones

Entidades nuevas (las existentes de BiblioRed no se tocan):

Entidad Tipo Justificación
bibliotecas_externas Fuerte Tiene identidad y atributos propios
peticiones_interbib Fuerte El hecho central del servicio
peticiones_estados Débil No existe sin su petición; su clave incluye la de la petición

Relaciones:

  • socios 1:N peticiones_interbib (quién pide).
  • sucursales 1:N peticiones_interbib como origen (dónde lo recoge).
  • sucursales 1:N peticiones_interbib como destino opcional (a quién se pide).
  • bibliotecas_externas 1:N peticiones_interbib como destino opcional.
  • materiales 1:N peticiones_interbib (qué se pide).
  • ejemplares 1:N peticiones_interbib (qué ejemplar se asignó, nulo hasta la aceptación).
  • peticiones_interbib 1:N peticiones_estados (identificadora, entidad débil).

Paso 2 — Diagrama ER

erDiagram
    SOCIOS               ||--o{ PETICIONES_INTERBIB : solicita
    SUCURSALES           ||--o{ PETICIONES_INTERBIB : "origen / destino"
    BIBLIOTECAS_EXTERNAS ||--o{ PETICIONES_INTERBIB : "destino externo"
    MATERIALES           ||--o{ PETICIONES_INTERBIB : "se pide"
    EJEMPLARES           ||--o{ PETICIONES_INTERBIB : "se asigna"
    PETICIONES_INTERBIB  ||--|{ PETICIONES_ESTADOS  : "registra"

    BIBLIOTECAS_EXTERNAS {
        int  biblioteca_id PK
        text nombre
        text ciudad
        text contacto_nombre
        text contacto_email
        date convenio_desde
        bool activa
    }
    PETICIONES_INTERBIB {
        int     peticion_id PK
        int     socio_id FK
        int     sucursal_origen_id FK
        int     sucursal_destino_id FK "nulo si es externa"
        int     biblioteca_externa_id FK "nulo si es interna"
        int     material_id FK
        int     ejemplar_id FK "nulo hasta aceptar"
        date    fecha_solicitud
        date    fecha_limite
        numeric coste_envio
        text    estado_actual
    }
    PETICIONES_ESTADOS {
        int       peticion_id PK_FK
        int       secuencia PK
        text      estado
        timestamp momento
        text      usuario
        text      observaciones
    }

Paso 3 — CREATE TABLE

CREATE TABLE bibliotecas_externas (
    biblioteca_id   SERIAL PRIMARY KEY,
    nombre          VARCHAR(120) NOT NULL,
    ciudad          VARCHAR(60)  NOT NULL,
    contacto_nombre VARCHAR(100),
    contacto_email  VARCHAR(120),
    convenio_desde  DATE NOT NULL,
    activa          BOOLEAN NOT NULL DEFAULT TRUE,
    CONSTRAINT uq_bibext_nombre_ciudad UNIQUE (nombre, ciudad)
);

CREATE TABLE peticiones_interbib (
    peticion_id           SERIAL PRIMARY KEY,
    socio_id              INTEGER NOT NULL REFERENCES socios(socio_id)         ON DELETE RESTRICT,
    sucursal_origen_id    INTEGER NOT NULL REFERENCES sucursales(sucursal_id)  ON DELETE RESTRICT,
    sucursal_destino_id   INTEGER          REFERENCES sucursales(sucursal_id)  ON DELETE RESTRICT,
    biblioteca_externa_id INTEGER          REFERENCES bibliotecas_externas(biblioteca_id) ON DELETE RESTRICT,
    material_id           INTEGER NOT NULL REFERENCES materiales(material_id)  ON DELETE RESTRICT,
    ejemplar_id           INTEGER          REFERENCES ejemplares(ejemplar_id)  ON DELETE SET NULL,
    fecha_solicitud       DATE NOT NULL DEFAULT CURRENT_DATE,
    fecha_limite          DATE NOT NULL,
    coste_envio           NUMERIC(6,2) NOT NULL DEFAULT 0,
    estado_actual         VARCHAR(22)  NOT NULL DEFAULT 'solicitada'
);

-- Entidad débil: su clave primaria arrastra la de la petición
CREATE TABLE peticiones_estados (
    peticion_id   INTEGER   NOT NULL REFERENCES peticiones_interbib(peticion_id) ON DELETE CASCADE,
    secuencia     SMALLINT  NOT NULL,
    estado        VARCHAR(22) NOT NULL,
    momento       TIMESTAMPTZ NOT NULL DEFAULT now(),
    usuario       VARCHAR(60) NOT NULL,
    observaciones TEXT,
    PRIMARY KEY (peticion_id, secuencia)
);

Paso 4 — Restricciones

-- Un dominio reutilizable para el conjunto de estados (PostgreSQL)
CREATE DOMAIN estado_interbib AS VARCHAR(22)
    CHECK (VALUE IN ('solicitada','aceptada','en_transito','disponible_recogida',
                     'prestada','devuelta','rechazada','cancelada'));

ALTER TABLE peticiones_interbib
    ALTER COLUMN estado_actual TYPE estado_interbib;
ALTER TABLE peticiones_estados
    ALTER COLUMN estado TYPE estado_interbib;

ALTER TABLE peticiones_interbib
    -- O destino interno O destino externo, exactamente uno de los dos
    ADD CONSTRAINT ck_pet_destino_exclusivo CHECK (
        (sucursal_destino_id IS NOT NULL AND biblioteca_externa_id IS NULL)
     OR (sucursal_destino_id IS NULL     AND biblioteca_externa_id IS NOT NULL)
    ),
    -- No tiene sentido pedirse un material a la propia sucursal
    ADD CONSTRAINT ck_pet_origen_distinto CHECK (
        sucursal_destino_id IS NULL OR sucursal_destino_id <> sucursal_origen_id
    ),
    ADD CONSTRAINT ck_pet_limite CHECK (fecha_limite > fecha_solicitud),
    ADD CONSTRAINT ck_pet_coste  CHECK (coste_envio >= 0),
    -- Solo puede haber ejemplar asignado a partir de 'aceptada'
    ADD CONSTRAINT ck_pet_ejemplar_estado CHECK (
        ejemplar_id IS NOT NULL
     OR estado_actual IN ('solicitada','rechazada','cancelada')
    );

-- Un socio no puede pedir dos veces el mismo material mientras la petición siga viva
CREATE UNIQUE INDEX uq_peticion_viva
    ON peticiones_interbib (socio_id, material_id)
    WHERE estado_actual NOT IN ('devuelta','rechazada','cancelada');

CREATE INDEX idx_pet_estado   ON peticiones_interbib (estado_actual, fecha_limite);
CREATE INDEX idx_pet_socio    ON peticiones_interbib (socio_id, fecha_solicitud DESC);

Resultado esperado

Tres tablas nuevas, cero modificaciones en las tablas existentes de BiblioRed. Ejemplo de carga coherente:

INSERT INTO bibliotecas_externas (nombre, ciudad, contacto_nombre, contacto_email, convenio_desde)
VALUES ('Biblioteca Municipal de Port Alt','Port Alt','Lidia Serna','[email protected]','2025-04-01');

INSERT INTO peticiones_interbib
  (socio_id, sucursal_origen_id, sucursal_destino_id, material_id, fecha_limite)
VALUES (16, 3, 2, 904, DATE '2026-08-20');          -- Nuria Bastos (Sur) pide a Norte

INSERT INTO peticiones_estados (peticion_id, secuencia, estado, usuario)
VALUES (1, 1, 'solicitada', 'mostrador.sur');

Y la comprobación de que la regla del destino exclusivo funciona:

INSERT INTO peticiones_interbib
  (socio_id, sucursal_origen_id, sucursal_destino_id, biblioteca_externa_id, material_id, fecha_limite)
VALUES (16, 3, 2, 1, 904, DATE '2026-08-20');
-- ERROR: new row violates check constraint "ck_pet_destino_exclusivo"

Explicación y decisiones discutibles

Diseñar sobre un esquema existente cambia las reglas del juego. El requisito implícito más fuerte de este ejercicio es que no puedes romper nada. Por eso la solución no añade columnas a prestamos ni a ejemplares: cualquier consulta, informe o índice que ya existiera en BiblioRed sigue funcionando exactamente igual después de instalar el servicio.

La petición apunta a materiales y no a ejemplares, porque el socio dijo «me da igual cuál venga». ejemplar_id es nulo al principio y se rellena al aceptar. Ese NULL no es una carencia del diseño: es información («todavía no se ha asignado»), y el CHECK ck_pet_ejemplar_estado la ata al estado para que no pueda haber una petición «en tránsito» sin ejemplar.

Dos claves ajenas hacia la misma tabla. sucursal_origen_id y sucursal_destino_id apuntan las dos a sucursales. Es perfectamente legal y muy habitual; lo único que exige es nombres de columna que digan su papel, porque sucursal_id a secas sería ambiguo. Es el mismo patrón que ya aparece en BiblioRed entre socios.sucursal_id y ejemplares.sucursal_id.

El destino exclusivo: tres alternativas.

Opción Cómo Ventaja Inconveniente
La elegida: dos columnas nulables + CHECK Un solo CHECK con IS NULL/IS NOT NULL Simple, legible, integridad garantizada por el motor Dos columnas donde conceptualmente hay una
Jerarquía: tabla destinos con subtipos destinos genérica + destinos_sucursal y destinos_externa Extensible a un tercer tipo de destino Un JOIN más en toda consulta; sobredimensionado para dos casos
Columna genérica destino_tipo + destino_id Una sola clave ajena «polimórfica» Compacta Imposible declarar la clave ajena. Anti-patrón; el motor deja de proteger la integridad

La tercera es la que suele proponer quien viene de un ORM y es la única claramente incorrecta.

El historial como entidad débil. peticiones_estados tiene clave primaria (peticion_id, secuencia): el número de secuencia solo tiene sentido dentro de su petición. Es el caso de libro de entidad débil con relación identificadora, y por eso lleva ON DELETE CASCADE: si la petición desaparece, su historial no significa nada.

Aquí hay una redundancia deliberada: estado_actual en la cabecera duplica el último estado del historial. Se podría calcular siempre con ORDER BY secuencia DESC LIMIT 1, pero es la consulta más frecuente del sistema (el panel de bandeja de entrada de cada sucursal) y la columna permite indexarla. Es la desnormalización controlada de la lección 05-04: se acepta a cambio de tener que mantener las dos cosas sincronizadas, idealmente con un disparador.

El límite de tres peticiones activas por socio. No se puede expresar con un CHECK, porque un CHECK solo ve la fila que se está insertando y esta regla implica contar filas de la tabla. Las opciones reales son un disparador BEFORE INSERT que cuente y lance excepción, o la lógica de aplicación dentro de la transacción con un SELECT ... FOR UPDATE sobre el socio. Lo importante es decirlo en el diseño, no dejarlo implícito: una regla de negocio sin restricción es una regla que algún día se incumplirá.


Ejercicio 3: Plataforma de cursos en línea

Dificultad: Intermedio

Enunciado. Una plataforma de formación quiere su base de datos:

«Tenemos cursos, y cada curso está dividido en módulos, y cada módulo en lecciones. Las lecciones van numeradas dentro de su módulo y los módulos dentro de su curso. Una lección tiene título, tipo (vídeo, texto o cuestionario) y duración estimada. Los cursos pueden tener requisitos previos: para hacer «SQL avanzado» hay que haber hecho antes «SQL básico», y un curso puede tener varios requisitos. Los alumnos se matriculan en cursos; de la matrícula guardamos la fecha, el precio pagado y si está activa, completada o abandonada. Queremos saber, de cada alumno y cada lección, si la ha terminado y cuándo; también el porcentaje de avance del curso. Las lecciones de tipo cuestionario tienen preguntas con varias opciones, de las que una es la correcta, y cada intento de un alumno guarda la nota y la fecha. Un alumno puede intentar un cuestionario varias veces.»

Pista. «Requisitos previos» es una relación N:M de una tabla consigo misma.

Solución

Paso 1 — Entidades y relaciones

Jerarquía de contenidos: cursos 1:N modulos 1:N lecciones. Los tres son entidades fuertes con clave sustituta, pero modulos y lecciones llevan además una clave alternativa que refleja su numeración dentro del padre.

Autorreferencia: cursos N:M cursos a través de requisitos_curso (curso_id, requisito_id).

N:M con atributos: alumnos N:M cursos a través de matriculas, que tiene fecha, precio y estado propios.

Progreso: matriculas N:M lecciones a través de progreso_leccion. Nota que el progreso se cuelga de la matrícula, no del alumno: si alguien se matricula dos veces en el mismo curso, cada matrícula tiene su propio avance.

Cuestionarios: lecciones 1:N preguntas 1:N opciones; matriculas 1:N intentos (sobre una lección de tipo cuestionario).

Paso 2 — Diagrama ER

erDiagram
    CURSOS    ||--o{ MODULOS    : contiene
    MODULOS   ||--o{ LECCIONES  : contiene
    CURSOS    ||--o{ REQUISITOS_CURSO : "exige"
    CURSOS    ||--o{ REQUISITOS_CURSO : "es requisito de"
    ALUMNOS   ||--o{ MATRICULAS : realiza
    CURSOS    ||--o{ MATRICULAS : recibe
    MATRICULAS||--o{ PROGRESO_LECCION : avanza
    LECCIONES ||--o{ PROGRESO_LECCION : "se completa en"
    LECCIONES ||--o{ PREGUNTAS  : "cuestionario de"
    PREGUNTAS ||--|{ OPCIONES   : ofrece
    MATRICULAS||--o{ INTENTOS   : genera
    LECCIONES ||--o{ INTENTOS   : "se evalúa en"

    CURSOS    { int curso_id PK
                text titulo
                text nivel
                bool publicado }
    MODULOS   { int modulo_id PK
                int curso_id FK
                int orden
                text titulo }
    LECCIONES { int leccion_id PK
                int modulo_id FK
                int orden
                text titulo
                text tipo
                int duracion_min }
    REQUISITOS_CURSO { int curso_id PK_FK
                       int requisito_id PK_FK }
    ALUMNOS   { int alumno_id PK
                text email UK
                text nombre }
    MATRICULAS{ int matricula_id PK
                int alumno_id FK
                int curso_id FK
                date fecha_matricula
                numeric precio_pagado
                text estado }
    PROGRESO_LECCION { int matricula_id PK_FK
                       int leccion_id PK_FK
                       timestamp completada_en }
    PREGUNTAS { int pregunta_id PK
                int leccion_id FK
                int orden
                text enunciado }
    OPCIONES  { int opcion_id PK
                int pregunta_id FK
                text texto
                bool correcta }
    INTENTOS  { int intento_id PK
                int matricula_id FK
                int leccion_id FK
                timestamp momento
                numeric nota }

Paso 3 — CREATE TABLE

CREATE TABLE cursos (
    curso_id  SERIAL PRIMARY KEY,
    titulo    VARCHAR(150) NOT NULL,
    nivel     VARCHAR(15)  NOT NULL,
    publicado BOOLEAN NOT NULL DEFAULT FALSE
);

CREATE TABLE modulos (
    modulo_id SERIAL PRIMARY KEY,
    curso_id  INTEGER NOT NULL REFERENCES cursos(curso_id) ON DELETE CASCADE,
    orden     SMALLINT NOT NULL,
    titulo    VARCHAR(150) NOT NULL,
    CONSTRAINT uq_modulo_orden UNIQUE (curso_id, orden)
);

CREATE TABLE lecciones (
    leccion_id   SERIAL PRIMARY KEY,
    modulo_id    INTEGER NOT NULL REFERENCES modulos(modulo_id) ON DELETE CASCADE,
    orden        SMALLINT NOT NULL,
    titulo       VARCHAR(150) NOT NULL,
    tipo         VARCHAR(12)  NOT NULL,
    duracion_min SMALLINT,
    CONSTRAINT uq_leccion_orden UNIQUE (modulo_id, orden)
);

CREATE TABLE requisitos_curso (
    curso_id     INTEGER NOT NULL REFERENCES cursos(curso_id) ON DELETE CASCADE,
    requisito_id INTEGER NOT NULL REFERENCES cursos(curso_id) ON DELETE RESTRICT,
    PRIMARY KEY (curso_id, requisito_id),
    CONSTRAINT ck_req_no_reflexivo CHECK (curso_id <> requisito_id)
);

CREATE TABLE alumnos (
    alumno_id SERIAL PRIMARY KEY,
    email     VARCHAR(120) NOT NULL UNIQUE,
    nombre    VARCHAR(100) NOT NULL,
    alta      DATE NOT NULL DEFAULT CURRENT_DATE
);

CREATE TABLE matriculas (
    matricula_id    SERIAL PRIMARY KEY,
    alumno_id       INTEGER NOT NULL REFERENCES alumnos(alumno_id) ON DELETE RESTRICT,
    curso_id        INTEGER NOT NULL REFERENCES cursos(curso_id)   ON DELETE RESTRICT,
    fecha_matricula DATE NOT NULL DEFAULT CURRENT_DATE,
    precio_pagado   NUMERIC(8,2) NOT NULL,
    estado          VARCHAR(12) NOT NULL DEFAULT 'activa'
);

CREATE TABLE progreso_leccion (
    matricula_id  INTEGER NOT NULL REFERENCES matriculas(matricula_id) ON DELETE CASCADE,
    leccion_id    INTEGER NOT NULL REFERENCES lecciones(leccion_id)    ON DELETE CASCADE,
    completada_en TIMESTAMPTZ NOT NULL DEFAULT now(),
    PRIMARY KEY (matricula_id, leccion_id)
);

CREATE TABLE preguntas (
    pregunta_id SERIAL PRIMARY KEY,
    leccion_id  INTEGER NOT NULL REFERENCES lecciones(leccion_id) ON DELETE CASCADE,
    orden       SMALLINT NOT NULL,
    enunciado   TEXT NOT NULL,
    CONSTRAINT uq_pregunta_orden UNIQUE (leccion_id, orden)
);

CREATE TABLE opciones (
    opcion_id   SERIAL PRIMARY KEY,
    pregunta_id INTEGER NOT NULL REFERENCES preguntas(pregunta_id) ON DELETE CASCADE,
    texto       TEXT NOT NULL,
    correcta    BOOLEAN NOT NULL DEFAULT FALSE
);

CREATE TABLE intentos (
    intento_id   SERIAL PRIMARY KEY,
    matricula_id INTEGER NOT NULL REFERENCES matriculas(matricula_id) ON DELETE CASCADE,
    leccion_id   INTEGER NOT NULL REFERENCES lecciones(leccion_id)    ON DELETE CASCADE,
    momento      TIMESTAMPTZ NOT NULL DEFAULT now(),
    nota         NUMERIC(5,2) NOT NULL
);

Paso 4 — Restricciones

ALTER TABLE cursos
    ADD CONSTRAINT ck_cursos_nivel CHECK (nivel IN ('inicial','intermedio','avanzado'));

ALTER TABLE lecciones
    ADD CONSTRAINT ck_lecc_tipo CHECK (tipo IN ('video','texto','cuestionario')),
    ADD CONSTRAINT ck_lecc_dur  CHECK (duracion_min IS NULL OR duracion_min > 0),
    ADD CONSTRAINT ck_lecc_orden CHECK (orden > 0);

ALTER TABLE matriculas
    ADD CONSTRAINT ck_matr_estado CHECK (estado IN ('activa','completada','abandonada')),
    ADD CONSTRAINT ck_matr_precio CHECK (precio_pagado >= 0);

-- Un alumno no puede tener dos matrículas activas en el mismo curso,
-- pero sí puede rematricularse tras abandonar
CREATE UNIQUE INDEX uq_matricula_activa
    ON matriculas (alumno_id, curso_id)
    WHERE estado = 'activa';

ALTER TABLE intentos
    ADD CONSTRAINT ck_intento_nota CHECK (nota BETWEEN 0 AND 10);

-- Cada pregunta debe tener exactamente una opción correcta
CREATE UNIQUE INDEX uq_opcion_correcta
    ON opciones (pregunta_id)
    WHERE correcta;

Resultado esperado

Once tablas. La consulta del porcentaje de avance, que es la razón de ser de media plataforma, sale directa:

SELECT m.matricula_id,
       count(pl.leccion_id) AS completadas,
       (SELECT count(*) FROM lecciones l
          JOIN modulos mo ON mo.modulo_id = l.modulo_id
         WHERE mo.curso_id = m.curso_id) AS totales,
       round(100.0 * count(pl.leccion_id) /
             NULLIF((SELECT count(*) FROM lecciones l
                       JOIN modulos mo ON mo.modulo_id = l.modulo_id
                      WHERE mo.curso_id = m.curso_id), 0), 1) AS pct
FROM matriculas m
LEFT JOIN progreso_leccion pl ON pl.matricula_id = m.matricula_id
WHERE m.matricula_id = 1
GROUP BY m.matricula_id, m.curso_id;

Explicación y decisiones discutibles

El progreso cuelga de la matrícula, no del alumno. Es la decisión más importante del diseño y la más fácil de errar. Si progreso_leccion tuviera (alumno_id, leccion_id), un alumno que abandona un curso y se vuelve a matricular arrastraría todo su avance anterior, y no habría forma de saber a qué matrícula pertenecía cada lección completada. Con (matricula_id, leccion_id), cada intento de hacer el curso tiene su propia historia. El precio es un JOIN más para llegar del alumno al progreso; es un precio bajo.

La numeración dentro del padre: clave sustituta + UNIQUE compuesto. modulos tiene modulo_id como clave primaria y UNIQUE (curso_id, orden) como clave alternativa. La alternativa —clave primaria compuesta (curso_id, orden)— también es defendible y es más «pura», pero tiene dos inconvenientes prácticos: reordenar los módulos obliga a actualizar la clave primaria (y en cascada todas las lecciones), y las claves ajenas hacia lecciones acabarían siendo de tres columnas. Con clave sustituta, reordenar es un UPDATE de la columna orden y nada más.

La autorreferencia N:M. requisitos_curso es una tabla de reunión cuyas dos claves ajenas apuntan a la misma tabla. El CHECK (curso_id <> requisito_id) impide el caso trivial de que un curso sea requisito de sí mismo. Lo que no puede impedir ninguna restricción declarativa es un ciclo más largo: A exige B, B exige C, C exige A. Detectar eso requiere recorrer el grafo, y se hace con una CTE recursiva —la verás en 07-04— o con un disparador que la ejecute antes de insertar.

uq_opcion_correcta garantiza como mucho una correcta, no exactamente una. El índice único parcial impide dos opciones marcadas como correctas en la misma pregunta, pero no impide que no haya ninguna. Esa segunda mitad de la regla —«al menos una»— es una restricción de conjunto que las bases de datos relacionales no expresan bien de forma declarativa; se resuelve con un disparador AFTER o con una comprobación al publicar el curso. Reconocer el límite es parte del diseño.

Alternativa razonable que no he tomado: guardar en intentos las respuestas concretas de cada pregunta (intentos_respuestas). El enunciado solo pedía la nota, y añadir esa tabla sin que nadie la pida es sobrediseño. Pero si mañana quieren estadísticas de «qué pregunta falla más gente», haría falta, y la extensión sería limpia: (intento_id, pregunta_id, opcion_id).


Ejercicio 4: Taller mecánico con jerarquía y relación ternaria

Dificultad: Avanzado

Enunciado. Un taller mecánico de Vallmar:

«Atendemos vehículos: coches, motos y furgonetas. De todos guardamos matrícula, marca, modelo, año y el cliente propietario. De los coches nos interesa además el número de plazas y el tipo de combustible; de las motos, la cilindrada; y de las furgonetas, la carga máxima en kilos y si tiene tacógrafo. Un cliente puede tener varios vehículos y un vehículo tiene un único propietario. Cuando entra un vehículo abrimos una orden de reparación con la fecha de entrada, el kilometraje y una descripción del problema. En una orden se hacen varias intervenciones; cada intervención la realiza un mecánico concreto sobre la orden, aplicando un tipo de trabajo del catálogo (cambio de aceite, alineación, diagnosis...), y anotamos las horas dedicadas. El mismo mecánico puede hacer varios tipos de trabajo en la misma orden, y el mismo tipo de trabajo lo pueden hacer mecánicos distintos en la misma orden en días distintos. También registramos las piezas que se usan en cada intervención, con la cantidad y el precio unitario aplicado ese día.»

Pista. «Intervención» relaciona tres entidades a la vez. Y una jerarquía de generalización se puede transformar de tres formas distintas: elige y justifica.

Solución

Paso 1 — Entidades y relaciones

La jerarquía: vehiculos es la superentidad, con coches, motos y furgonetas como subentidades. La generalización es total (todo vehículo es de uno de los tres tipos) y disjunta (ninguno es dos cosas a la vez).

La relación ternaria: intervenciones relaciona ordenes × mecanicos × tipos_trabajo. Como el enunciado dice explícitamente que el mismo mecánico puede repetir tipo de trabajo en la misma orden en días distintos, la ternaria no se puede identificar por la terna: necesita clave sustituta o incluir la fecha.

piezas N:M intervenciones con atributos (cantidad, precio_unitario).

Paso 2 — Diagrama ER

erDiagram
    CLIENTES  ||--o{ VEHICULOS : posee
    VEHICULOS ||--o| COCHES     : "es un"
    VEHICULOS ||--o| MOTOS      : "es un"
    VEHICULOS ||--o| FURGONETAS : "es un"
    VEHICULOS ||--o{ ORDENES    : genera
    ORDENES   ||--o{ INTERVENCIONES : incluye
    MECANICOS ||--o{ INTERVENCIONES : realiza
    TIPOS_TRABAJO ||--o{ INTERVENCIONES : "se aplica en"
    INTERVENCIONES ||--o{ INTERVENCION_PIEZAS : consume
    PIEZAS         ||--o{ INTERVENCION_PIEZAS : "se usa en"

    CLIENTES  { int cliente_id PK
                text nombre
                text nif UK
                text telefono }
    VEHICULOS { int  vehiculo_id PK
                text matricula UK
                text tipo
                int  cliente_id FK
                text marca
                text modelo
                int  anio }
    COCHES     { int vehiculo_id PK_FK
                 int plazas
                 text combustible }
    MOTOS      { int vehiculo_id PK_FK
                 int cilindrada }
    FURGONETAS { int vehiculo_id PK_FK
                 int carga_max_kg
                 bool tacografo }
    ORDENES   { int  orden_id PK
                int  vehiculo_id FK
                date fecha_entrada
                int  kilometraje
                text descripcion
                text estado }
    MECANICOS { int mecanico_id PK
                text nombre
                text especialidad }
    TIPOS_TRABAJO { int tipo_trabajo_id PK
                    text codigo UK
                    text nombre
                    numeric precio_hora }
    INTERVENCIONES { int  intervencion_id PK
                     int  orden_id FK
                     int  mecanico_id FK
                     int  tipo_trabajo_id FK
                     date fecha
                     numeric horas }
    PIEZAS    { int piezas_id PK
                text referencia UK
                text descripcion
                numeric precio_actual }
    INTERVENCION_PIEZAS { int intervencion_id PK_FK
                          int pieza_id PK_FK
                          int cantidad
                          numeric precio_unitario }

Paso 3 — CREATE TABLE

CREATE TABLE clientes (
    cliente_id SERIAL PRIMARY KEY,
    nombre     VARCHAR(120) NOT NULL,
    nif        VARCHAR(12) NOT NULL UNIQUE,
    telefono   VARCHAR(15)
);

CREATE TABLE vehiculos (
    vehiculo_id SERIAL PRIMARY KEY,
    matricula   VARCHAR(10) NOT NULL UNIQUE,
    tipo        VARCHAR(10) NOT NULL,          -- discriminante
    cliente_id  INTEGER NOT NULL REFERENCES clientes(cliente_id) ON DELETE RESTRICT,
    marca       VARCHAR(40) NOT NULL,
    modelo      VARCHAR(60) NOT NULL,
    anio        SMALLINT NOT NULL,
    CONSTRAINT ck_veh_tipo CHECK (tipo IN ('coche','moto','furgoneta')),
    -- Truco para que la subtabla solo pueda enlazar con su tipo:
    CONSTRAINT uq_veh_tipo UNIQUE (vehiculo_id, tipo)
);

CREATE TABLE coches (
    vehiculo_id INTEGER PRIMARY KEY,
    tipo        VARCHAR(10) NOT NULL DEFAULT 'coche',
    plazas      SMALLINT NOT NULL,
    combustible VARCHAR(12) NOT NULL,
    CONSTRAINT ck_coches_tipo CHECK (tipo = 'coche'),
    CONSTRAINT fk_coches_veh FOREIGN KEY (vehiculo_id, tipo)
        REFERENCES vehiculos (vehiculo_id, tipo) ON DELETE CASCADE,
    CONSTRAINT ck_coches_plazas CHECK (plazas BETWEEN 1 AND 9),
    CONSTRAINT ck_coches_comb CHECK (combustible IN ('gasolina','diesel','hibrido','electrico','glp'))
);

CREATE TABLE motos (
    vehiculo_id INTEGER PRIMARY KEY,
    tipo        VARCHAR(10) NOT NULL DEFAULT 'moto',
    cilindrada  SMALLINT NOT NULL,
    CONSTRAINT ck_motos_tipo CHECK (tipo = 'moto'),
    CONSTRAINT fk_motos_veh FOREIGN KEY (vehiculo_id, tipo)
        REFERENCES vehiculos (vehiculo_id, tipo) ON DELETE CASCADE,
    CONSTRAINT ck_motos_cc CHECK (cilindrada BETWEEN 49 AND 2500)
);

CREATE TABLE furgonetas (
    vehiculo_id  INTEGER PRIMARY KEY,
    tipo         VARCHAR(10) NOT NULL DEFAULT 'furgoneta',
    carga_max_kg INTEGER NOT NULL,
    tacografo    BOOLEAN NOT NULL DEFAULT FALSE,
    CONSTRAINT ck_furg_tipo CHECK (tipo = 'furgoneta'),
    CONSTRAINT fk_furg_veh FOREIGN KEY (vehiculo_id, tipo)
        REFERENCES vehiculos (vehiculo_id, tipo) ON DELETE CASCADE,
    CONSTRAINT ck_furg_carga CHECK (carga_max_kg BETWEEN 100 AND 5000)
);

CREATE TABLE ordenes (
    orden_id      SERIAL PRIMARY KEY,
    vehiculo_id   INTEGER NOT NULL REFERENCES vehiculos(vehiculo_id) ON DELETE RESTRICT,
    fecha_entrada DATE NOT NULL DEFAULT CURRENT_DATE,
    fecha_salida  DATE,
    kilometraje   INTEGER NOT NULL,
    descripcion   TEXT NOT NULL,
    estado        VARCHAR(12) NOT NULL DEFAULT 'abierta',
    CONSTRAINT ck_ord_estado CHECK (estado IN ('abierta','en_curso','cerrada','facturada')),
    CONSTRAINT ck_ord_km     CHECK (kilometraje >= 0),
    CONSTRAINT ck_ord_salida CHECK (fecha_salida IS NULL OR fecha_salida >= fecha_entrada)
);

CREATE TABLE mecanicos (
    mecanico_id  SERIAL PRIMARY KEY,
    nombre       VARCHAR(100) NOT NULL,
    especialidad VARCHAR(40),
    activo       BOOLEAN NOT NULL DEFAULT TRUE
);

CREATE TABLE tipos_trabajo (
    tipo_trabajo_id SERIAL PRIMARY KEY,
    codigo          VARCHAR(10) NOT NULL UNIQUE,
    nombre          VARCHAR(80) NOT NULL,
    precio_hora     NUMERIC(7,2) NOT NULL CHECK (precio_hora > 0)
);

-- La relación ternaria, con clave sustituta
CREATE TABLE intervenciones (
    intervencion_id SERIAL PRIMARY KEY,
    orden_id        INTEGER NOT NULL REFERENCES ordenes(orden_id)             ON DELETE CASCADE,
    mecanico_id     INTEGER NOT NULL REFERENCES mecanicos(mecanico_id)        ON DELETE RESTRICT,
    tipo_trabajo_id INTEGER NOT NULL REFERENCES tipos_trabajo(tipo_trabajo_id)ON DELETE RESTRICT,
    fecha           DATE NOT NULL DEFAULT CURRENT_DATE,
    horas           NUMERIC(5,2) NOT NULL,
    CONSTRAINT ck_int_horas CHECK (horas > 0 AND horas <= 24),
    CONSTRAINT uq_intervencion UNIQUE (orden_id, mecanico_id, tipo_trabajo_id, fecha)
);

CREATE TABLE piezas (
    pieza_id       SERIAL PRIMARY KEY,
    referencia     VARCHAR(30) NOT NULL UNIQUE,
    descripcion    VARCHAR(150) NOT NULL,
    precio_actual  NUMERIC(8,2) NOT NULL CHECK (precio_actual >= 0)
);

CREATE TABLE intervencion_piezas (
    intervencion_id INTEGER NOT NULL REFERENCES intervenciones(intervencion_id) ON DELETE CASCADE,
    pieza_id        INTEGER NOT NULL REFERENCES piezas(pieza_id)                ON DELETE RESTRICT,
    cantidad        SMALLINT NOT NULL CHECK (cantidad > 0),
    precio_unitario NUMERIC(8,2) NOT NULL CHECK (precio_unitario >= 0),
    PRIMARY KEY (intervencion_id, pieza_id)
);

Paso 4 — La restricción que faltaba

-- Coherencia de kilometraje: una orden posterior no puede tener menos km
-- (regla de conjunto: no se puede expresar con CHECK; disparador o aplicación)

-- Sí es declarativa esta: la orden no puede cerrarse sin ninguna intervención
-- → tampoco es un CHECK. Se documenta y se implementa con disparador AFTER.

-- Lo que sí queda garantizado por el motor:
--  * un vehículo pertenece exactamente a un subtipo (por la FK compuesta)
--  * no hay dos intervenciones idénticas el mismo día (uq_intervencion)
--  * los precios y horas son positivos

Resultado esperado

Doce tablas. Comprobación de que la jerarquía está bien cerrada:

INSERT INTO clientes (nombre, nif) VALUES ('Nerea Solans','44112233X');
INSERT INTO vehiculos (matricula, tipo, cliente_id, marca, modelo, anio)
VALUES ('4471 KLM','moto',1,'Yamaha','MT-07',2021);

-- Correcto: la moto va a su subtabla
INSERT INTO motos (vehiculo_id, cilindrada) VALUES (1, 689);

-- Incorrecto: intentar meter esa misma moto como coche
INSERT INTO coches (vehiculo_id, plazas, combustible) VALUES (1, 5, 'gasolina');
-- ERROR: insert or update on table "coches" violates foreign key constraint "fk_coches_veh"

Explicación y decisiones discutibles

Las tres formas de transformar una jerarquía, y por qué he elegido esta. La lección 04-03 daba tres opciones:

Opción Cómo Cuándo conviene Aquí
Tabla única Una tabla vehiculos con todas las columnas de los tres subtipos, la mayoría nulas Pocos atributos específicos, consultas siempre sobre el conjunto Descartada: 5 columnas nulables y ningún NOT NULL posible en cilindrada
Tabla por subtipo (la elegida) Superentidad + una tabla por subtipo con vehiculo_id como PK y FK Atributos específicos que deben ser obligatorios; generalización total y disjunta Elegida
Solo subtipos Tres tablas independientes sin superentidad Los subtipos no comparten relaciones Descartada: ordenes necesita apuntar a un vehículo cualquiera, y con tres tablas haría falta una FK polimórfica

La opción tercera es la que rompe el diseño en cuanto aparece ordenes: hay que poder referenciar «un vehículo» sin saber su tipo.

El truco de la clave ajena compuesta (vehiculo_id, tipo) merece atención porque es elegante y poco conocido. El problema que resuelve: con una clave ajena normal coches.vehiculo_id → vehiculos.vehiculo_id, nada impide meter en coches un vehículo cuyo tipo sea 'moto'. La solución tiene tres piezas que solo funcionan juntas:

  1. UNIQUE (vehiculo_id, tipo) en vehiculos —redundante con la clave primaria, pero necesario para que la FK compuesta tenga a qué apuntar.
  2. Una columna tipo en la subtabla, con CHECK (tipo = 'coche') y DEFAULT.
  3. La clave ajena compuesta de las dos columnas.

El resultado es que el motor impide meter una moto en la tabla de coches. Sin el truco, esa regla quedaría en manos de la aplicación. Lo que sigue sin quedar garantizado es la totalidad de la generalización (que todo vehículo tenga fila en alguna subtabla): eso requiere restricciones diferidas o un disparador.

La ternaria: por qué no basta con la terna como clave. La tentación es PRIMARY KEY (orden_id, mecanico_id, tipo_trabajo_id). El enunciado la desmonta: «el mismo tipo de trabajo lo pueden hacer mecánicos distintos en la misma orden en días distintos». Y de hecho el mismo mecánico puede repetir el mismo trabajo dos días seguidos. Por eso la solución usa clave sustituta y añade UNIQUE (orden_id, mecanico_id, tipo_trabajo_id, fecha), que sí es la clave alternativa real. Si mañana admiten dos intervenciones iguales el mismo día (mañana y tarde), habría que sustituir fecha por momento TIMESTAMPTZ o eliminar el UNIQUE.

La clave sustituta tiene además una ventaja decisiva: intervencion_piezas necesita apuntar a la intervención, y con clave compuesta de cuatro columnas la tabla de piezas tendría seis columnas de clave.

precio_unitario duplica piezas.precio_actual, y está bien. Es la desnormalización más justificada que existe: el precio de una pieza cambia con el tiempo y la orden ya facturada debe conservar el precio que se aplicó. piezas.precio_actual es el precio de hoy, intervencion_piezas.precio_unitario es el precio de aquel día. No son el mismo dato, aunque coincidan en el momento de insertar. El ejercicio 5 lleva esta idea hasta el final.

Alternativa razonable: modelar intervenciones sin tipos_trabajo, poniendo el nombre del trabajo como texto libre. Sería más simple y sería un error: el enunciado habla de un «catálogo», y el precio por hora vive en él.


Ejercicio 5: Tarifas con vigencia temporal

Dificultad: Avanzado

Enunciado. Una empresa municipal de aparcamientos de Vallmar:

«Tenemos cuatro aparcamientos y cada uno aplica tarifas que cambian con el tiempo. Una tarifa dice: para este aparcamiento y este tipo de usuario (residente, general, comercial), el precio de la primera hora, el de cada hora adicional y el máximo diario. Las tarifas se aprueban en pleno y entran en vigor una fecha concreta; la anterior deja de aplicarse ese mismo día. Necesitamos conservar todas las tarifas históricas, porque hay reclamaciones de hace tres años y tenemos que poder decir qué precio estaba vigente el 14 de marzo de 2024. A veces se aprueba una tarifa con meses de antelación, así que puede haber tarifas futuras cargadas. Nunca puede haber dos tarifas vigentes a la vez para el mismo aparcamiento y tipo de usuario. También registramos las estancias: entrada, salida, matrícula y el importe cobrado.»

Pista. Piensa en la clave como «qué + desde cuándo», y busca la restricción de PostgreSQL que impide que dos intervalos se solapen.

Solución

Paso 1 — Entidades y relaciones

  • aparcamientos: entidad fuerte, estable.
  • tipos_usuario: catálogo pequeño.
  • tarifas: la entidad temporal. Su identidad no es «el aparcamiento y el tipo», sino «el aparcamiento, el tipo y el periodo de vigencia».
  • estancias: los hechos. Cada estancia se cobra con la tarifa vigente en su momento.

La relación clave: aparcamientos × tipos_usuario 1:N tarifas, donde el discriminante que completa la identidad es el intervalo de vigencia.

Paso 2 — Diagrama ER

erDiagram
    APARCAMIENTOS ||--o{ TARIFAS   : "aplica"
    TIPOS_USUARIO ||--o{ TARIFAS   : "para"
    APARCAMIENTOS ||--o{ ESTANCIAS : "aloja"
    TIPOS_USUARIO ||--o{ ESTANCIAS : "clasifica"
    TARIFAS       ||--o{ ESTANCIAS : "cobra según"

    APARCAMIENTOS { int aparcamiento_id PK
                    text nombre UK
                    text direccion
                    int  plazas }
    TIPOS_USUARIO { int tipo_usuario_id PK
                    text codigo UK
                    text nombre }
    TARIFAS { int     tarifa_id PK
              int     aparcamiento_id FK
              int     tipo_usuario_id FK
              date    vigente_desde
              date    vigente_hasta "nulo = vigente"
              numeric precio_primera_hora
              numeric precio_hora_extra
              numeric maximo_diario
              text    acuerdo_pleno }
    ESTANCIAS { int       estancia_id PK
                int       aparcamiento_id FK
                int       tipo_usuario_id FK
                int       tarifa_id FK
                text      matricula
                timestamp entrada
                timestamp salida
                numeric   importe }

Paso 3 — CREATE TABLE

CREATE EXTENSION IF NOT EXISTS btree_gist;

CREATE TABLE aparcamientos (
    aparcamiento_id SERIAL PRIMARY KEY,
    nombre          VARCHAR(60) NOT NULL UNIQUE,
    direccion       VARCHAR(120) NOT NULL,
    plazas          SMALLINT NOT NULL CHECK (plazas > 0)
);

CREATE TABLE tipos_usuario (
    tipo_usuario_id SERIAL PRIMARY KEY,
    codigo          VARCHAR(12) NOT NULL UNIQUE,
    nombre          VARCHAR(40) NOT NULL
);

CREATE TABLE tarifas (
    tarifa_id           SERIAL PRIMARY KEY,
    aparcamiento_id     INTEGER NOT NULL REFERENCES aparcamientos(aparcamiento_id) ON DELETE RESTRICT,
    tipo_usuario_id     INTEGER NOT NULL REFERENCES tipos_usuario(tipo_usuario_id) ON DELETE RESTRICT,
    vigente_desde       DATE NOT NULL,
    vigente_hasta       DATE,                    -- NULL = vigente indefinidamente
    precio_primera_hora NUMERIC(6,2) NOT NULL,
    precio_hora_extra   NUMERIC(6,2) NOT NULL,
    maximo_diario       NUMERIC(6,2) NOT NULL,
    acuerdo_pleno       VARCHAR(40),
    CONSTRAINT ck_tar_periodo CHECK (vigente_hasta IS NULL OR vigente_hasta > vigente_desde),
    CONSTRAINT ck_tar_precios CHECK (precio_primera_hora >= 0
                                 AND precio_hora_extra  >= 0
                                 AND maximo_diario      >= precio_primera_hora)
);

CREATE TABLE estancias (
    estancia_id     SERIAL PRIMARY KEY,
    aparcamiento_id INTEGER NOT NULL REFERENCES aparcamientos(aparcamiento_id) ON DELETE RESTRICT,
    tipo_usuario_id INTEGER NOT NULL REFERENCES tipos_usuario(tipo_usuario_id) ON DELETE RESTRICT,
    tarifa_id       INTEGER NOT NULL REFERENCES tarifas(tarifa_id)             ON DELETE RESTRICT,
    matricula       VARCHAR(10) NOT NULL,
    entrada         TIMESTAMPTZ NOT NULL,
    salida          TIMESTAMPTZ,
    importe         NUMERIC(8,2),
    CONSTRAINT ck_est_salida CHECK (salida IS NULL OR salida > entrada),
    CONSTRAINT ck_est_importe CHECK (importe IS NULL OR importe >= 0)
);

Paso 4 — La restricción de no solapamiento

-- La regla del enunciado: nunca dos tarifas vigentes a la vez
-- para el mismo aparcamiento y tipo de usuario.
ALTER TABLE tarifas ADD CONSTRAINT ex_tarifas_no_solapan
    EXCLUDE USING gist (
        aparcamiento_id WITH =,
        tipo_usuario_id WITH =,
        daterange(vigente_desde, vigente_hasta, '[)') WITH &&
    );

-- Índice para la consulta más frecuente: ¿qué tarifa aplicaba el día X?
CREATE INDEX idx_tarifas_vigencia
    ON tarifas (aparcamiento_id, tipo_usuario_id, vigente_desde DESC);

Resultado esperado

Carga de ejemplo con dos tramos históricos y uno futuro:

INSERT INTO aparcamientos (nombre, direccion, plazas)
VALUES ('P1 Plaza Mayor','Plaza Mayor s/n',240);
INSERT INTO tipos_usuario (codigo, nombre) VALUES ('RES','Residente'),('GEN','General');

INSERT INTO tarifas (aparcamiento_id, tipo_usuario_id, vigente_desde, vigente_hasta,
                     precio_primera_hora, precio_hora_extra, maximo_diario, acuerdo_pleno)
VALUES (1,2,'2023-01-01','2024-07-01', 1.80, 1.20, 14.00, 'PLE-2022/114'),
       (1,2,'2024-07-01','2026-01-01', 2.00, 1.35, 16.00, 'PLE-2024/037'),
       (1,2,'2026-01-01', NULL,        2.20, 1.50, 18.00, 'PLE-2025/206');

Consulta de reclamación: «¿qué precio se aplicaba el 14 de marzo de 2024?»

SELECT precio_primera_hora, precio_hora_extra, maximo_diario, acuerdo_pleno
FROM tarifas
WHERE aparcamiento_id = 1
  AND tipo_usuario_id = 2
  AND vigente_desde <= DATE '2024-03-14'
  AND (vigente_hasta IS NULL OR vigente_hasta > DATE '2024-03-14');
precio_primera_hora precio_hora_extra maximo_diario acuerdo_pleno
1.80 1.20 14.00 PLE-2022/114

Y la comprobación de que el motor bloquea un solapamiento:

INSERT INTO tarifas (aparcamiento_id, tipo_usuario_id, vigente_desde, vigente_hasta,
                     precio_primera_hora, precio_hora_extra, maximo_diario)
VALUES (1, 2, '2025-06-01', '2025-12-01', 2.10, 1.40, 17.00);
-- ERROR: conflicting key value violates exclusion constraint "ex_tarifas_no_solapan"

Explicación y decisiones discutibles

La decisión central: no hay UPDATE de precios, hay filas nuevas. El instinto de un principiante es UPDATE tarifas SET precio_primera_hora = 2.20 WHERE .... Ese UPDATE destruye la información que el enunciado pide conservar: en cuanto se ejecuta, ya no hay forma de responder a la reclamación de 2024. Cuando un requisito dice «histórico», la operación de negocio «cambiar el precio» se traduce en un INSERT, no en un UPDATE.

Intervalo cerrado-abierto [desde, hasta). El enunciado dice que la nueva tarifa entra en vigor «una fecha concreta» y la anterior deja de aplicarse «ese mismo día». Eso es exactamente un intervalo cerrado por la izquierda y abierto por la derecha: la tarifa antigua vale hasta el 30 de junio incluido y la nueva desde el 1 de julio, y ambas se escriben con 2024-07-01 como frontera. La alternativa —vigente_hasta = '2024-06-30' con intervalo cerrado por ambos lados— también funciona, pero obliga a hacer aritmética de fechas cada vez que se encadena un tramo y falla en cuanto la granularidad pasa de días a horas. Cerrado-abierto es la convención que hay que adoptar por defecto en datos temporales.

vigente_hasta IS NULL significa «todavía vigente». Es un uso legítimo del nulo y encaja con daterange(desde, hasta), que interpreta el NULL superior como infinito. La alternativa es poner '9999-12-31'; simplifica las consultas (BETWEEN funciona sin OR ... IS NULL) a cambio de meter una fecha mágica que algún día alguien mostrará en pantalla.

Tres formas de garantizar el no solapamiento:

Opción Portabilidad Fuerza
EXCLUDE USING gist con daterange Solo PostgreSQL Total: el motor lo garantiza, incluso con concurrencia
Índice único parcial (aparcamiento_id, tipo_usuario_id) WHERE vigente_hasta IS NULL PostgreSQL y SQLite Parcial: garantiza una sola tarifa abierta, pero no impide solapes entre tramos cerrados
Disparador que consulta antes de insertar Cualquiera Depende del nivel de aislamiento; con READ COMMITTED dos sesiones simultáneas pueden colarse

La primera es superior y por eso es la de la solución; la segunda es un buen segundo premio y merece la pena conocerla porque cubre el 90 % de los casos con sintaxis estándar.

estancias.tarifa_id guarda la tarifa aplicada, y es imprescindible. Se podría deducir a partir de entrada y la tabla de tarifas, pero fijarla en la estancia tiene dos ventajas: la factura queda inmutable aunque alguien corrija a posteriori una fecha de vigencia mal cargada, y la consulta de facturación no necesita el JOIN por rango, que es mucho más caro que un JOIN por igualdad. Es la misma lógica que intervencion_piezas.precio_unitario en el ejercicio 4.

Alternativa razonable que no he tomado: dos tablas, tarifas_vigentes y tarifas_historico. Es un patrón muy extendido y tiene una ventaja clara (la tabla caliente se mantiene diminuta) y dos inconvenientes serios: las consultas que cruzan la frontera necesitan un UNION, y el paso de una tabla a otra es una operación que hay que escribir bien y que puede fallar. Con una sola tabla y un índice adecuado, PostgreSQL aguanta millones de tramos sin despeinarse.


Errores Comunes y Consejos

1. Confundir la obra con el objeto físico. Película/copia, material/ejemplar, modelo/unidad. Si dos cosas pueden estar en sitios distintos y en estados distintos, son dos entidades.

2. Atributos multivaluados escondidos. «A veces son dos géneros», «los teléfonos», «los idiomas de los subtítulos». Cada vez que el cliente diga «a veces son varios», ahí hay una tabla.

3. Claves ajenas polimórficas. Una columna objeto_tipo + objeto_id que apunta a tablas distintas según el tipo. El motor no puede declarar esa clave ajena, así que la integridad queda sin proteger. Usa columnas nulables excluyentes con CHECK, o una jerarquía.

4. Poner todas las claves ajenas con ON DELETE CASCADE «por si acaso». CASCADE está bien para lo que no existe sin su padre (líneas de un pedido, historial de una petición). Para el histórico contable —préstamos, alquileres, facturas— la acción correcta casi siempre es RESTRICT, y la baja se hace con una columna activo.

5. Olvidar que un CHECK solo ve su propia fila. «Máximo tres peticiones activas», «al menos una opción correcta», «la suma de las líneas debe cuadrar con el total»: ninguna es expresable con CHECK. Reconócelo en el diseño y decide dónde va (disparador, aplicación o restricción diferida).

6. Clave primaria compuesta en entidades que se van a referenciar mucho. Es técnicamente correcto y prácticamente incómodo: cada tabla hija arrastra todas las columnas. Clave sustituta + UNIQUE sobre la clave natural es casi siempre mejor compromiso.

7. Precios y tarifas sin congelar. Un importe facturado que se lee de la tabla de precios actual cambia solo cuando cambian los precios. Copia el precio aplicado en la línea del documento.

8. Nomenclatura inconsistente. peliculaID, id_genero, SocioId en el mismo esquema. Elige una convención (snake_case, singular o plural, sufijo _id) y no la rompas. Es lo primero que ve quien herede tu esquema.

9. Diseñar para consultas que nadie ha pedido. Cada tabla que añades es una tabla que hay que mantener. Si el enunciado no menciona una necesidad, apúntala como posible extensión, no la construyas.

Consejo de método. Antes de escribir el primer CREATE TABLE, escribe la lista de preguntas que el esquema debe responder y déjala a la vista. Al terminar, comprueba una por una que puedes escribir la consulta. Ese es el criterio de «terminado»; no lo es que el CREATE TABLE se ejecute sin error.

Ejercicios

Sin pistas. Cada uno pide los cuatro pasos completos.

Ejercicio A: Gimnasio municipal

Diseña el esquema de un gimnasio: socios con cuota mensual, clases dirigidas con horario semanal fijo (día de la semana y hora), monitores, salas con aforo, y reservas de plaza de los socios para una sesión concreta de una clase en una fecha concreta. Un socio no puede reservar dos veces la misma sesión, y una sesión no puede admitir más reservas que el aforo de su sala. Además, dos clases no pueden ocupar la misma sala a la misma hora.

Ejercicio B: Ampliación de BiblioRed — donaciones y expurgo

Amplía BiblioRed para gestionar el origen y el final de vida de los ejemplares: de dónde vino cada ejemplar (compra a un proveedor con factura, donación de un particular o de una entidad, o intercambio con otra biblioteca) y, cuando se retira, por qué motivo, con qué fecha y qué destino tuvo (venta benéfica, reciclaje, cesión). No puedes modificar la tabla ejemplares salvo para añadir columnas, y ninguna consulta existente puede romperse.

Soluciones

Solución A — Gimnasio

Entidades: socios, salas, monitores, clases (la definición: nombre, nivel, duración), sesiones (una ocurrencia concreta: clase + fecha + hora + sala + monitor), reservas (socio × sesión).

La distinción clave es clase frente a sesión: «Pilates de los martes a las 19:00» es la clase; «el Pilates del martes 4 de agosto de 2026 a las 19:00 en la Sala 2 con Aitor» es la sesión. Las reservas se hacen sobre sesiones.

CREATE TABLE salas (
    sala_id SERIAL PRIMARY KEY,
    nombre  VARCHAR(40) NOT NULL UNIQUE,
    aforo   SMALLINT NOT NULL CHECK (aforo > 0)
);

CREATE TABLE monitores (
    monitor_id SERIAL PRIMARY KEY,
    nombre     VARCHAR(100) NOT NULL,
    activo     BOOLEAN NOT NULL DEFAULT TRUE
);

CREATE TABLE clases (
    clase_id     SERIAL PRIMARY KEY,
    nombre       VARCHAR(60) NOT NULL UNIQUE,
    nivel        VARCHAR(15) NOT NULL,
    duracion_min SMALLINT NOT NULL CHECK (duracion_min BETWEEN 15 AND 180)
);

CREATE TABLE socios (
    socio_id  SERIAL PRIMARY KEY,
    nombre    VARCHAR(100) NOT NULL,
    email     VARCHAR(120) NOT NULL UNIQUE,
    cuota_mes NUMERIC(6,2) NOT NULL CHECK (cuota_mes >= 0),
    activo    BOOLEAN NOT NULL DEFAULT TRUE
);

CREATE TABLE sesiones (
    sesion_id  SERIAL PRIMARY KEY,
    clase_id   INTEGER NOT NULL REFERENCES clases(clase_id)     ON DELETE RESTRICT,
    sala_id    INTEGER NOT NULL REFERENCES salas(sala_id)       ON DELETE RESTRICT,
    monitor_id INTEGER NOT NULL REFERENCES monitores(monitor_id)ON DELETE RESTRICT,
    inicio     TIMESTAMPTZ NOT NULL,
    fin        TIMESTAMPTZ NOT NULL,
    plazas     SMALLINT NOT NULL CHECK (plazas > 0),
    CONSTRAINT ck_ses_horario CHECK (fin > inicio)
);

CREATE TABLE reservas (
    sesion_id INTEGER NOT NULL REFERENCES sesiones(sesion_id) ON DELETE CASCADE,
    socio_id  INTEGER NOT NULL REFERENCES socios(socio_id)    ON DELETE RESTRICT,
    momento   TIMESTAMPTZ NOT NULL DEFAULT now(),
    estado    VARCHAR(12) NOT NULL DEFAULT 'confirmada'
                CHECK (estado IN ('confirmada','cancelada','asistida')),
    PRIMARY KEY (sesion_id, socio_id)     -- un socio, una reserva por sesión
);

-- Dos clases no pueden ocupar la misma sala a la misma hora
CREATE EXTENSION IF NOT EXISTS btree_gist;
ALTER TABLE sesiones ADD CONSTRAINT ex_sala_ocupada
    EXCLUDE USING gist (sala_id WITH =, tstzrange(inicio, fin) WITH &&);

-- Un monitor tampoco puede estar en dos sitios a la vez
ALTER TABLE sesiones ADD CONSTRAINT ex_monitor_ocupado
    EXCLUDE USING gist (monitor_id WITH =, tstzrange(inicio, fin) WITH &&);

Dos comentarios sobre las decisiones:

  • PRIMARY KEY (sesion_id, socio_id) resuelve gratis la regla «un socio no puede reservar dos veces la misma sesión». Es el caso ideal: una regla de negocio que se convierte en la clave primaria.
  • El aforo no es un CHECK. «No más reservas que plazas» exige contar filas, y un CHECK no puede. sesiones.plazas copia el aforo de la sala en el momento de programarla (para poder ofrecer menos plazas que el aforo real), y el control se hace en la transacción de reserva con SELECT ... FOR UPDATE sobre la sesión —exactamente el problema de la última plaza que resolverás en 07-04.
  • Las dos restricciones EXCLUDE son el mismo patrón del ejercicio 5 aplicado a intervalos de tiempo en lugar de a vigencias.

Solución B — Donaciones y expurgo en BiblioRed

CREATE TABLE proveedores (
    proveedor_id SERIAL PRIMARY KEY,
    nombre       VARCHAR(120) NOT NULL,
    nif          VARCHAR(12) UNIQUE,
    contacto     VARCHAR(120)
);

CREATE TABLE donantes (
    donante_id SERIAL PRIMARY KEY,
    tipo       VARCHAR(12) NOT NULL CHECK (tipo IN ('particular','entidad')),
    nombre     VARCHAR(150) NOT NULL,
    email      VARCHAR(120),
    anonimo    BOOLEAN NOT NULL DEFAULT FALSE
);

CREATE TABLE adquisiciones (
    adquisicion_id SERIAL PRIMARY KEY,
    ejemplar_id    INTEGER NOT NULL UNIQUE REFERENCES ejemplares(ejemplar_id) ON DELETE CASCADE,
    via            VARCHAR(12) NOT NULL,
    fecha          DATE NOT NULL,
    proveedor_id   INTEGER REFERENCES proveedores(proveedor_id) ON DELETE RESTRICT,
    donante_id     INTEGER REFERENCES donantes(donante_id)      ON DELETE RESTRICT,
    biblioteca_origen VARCHAR(150),
    num_factura    VARCHAR(30),
    coste          NUMERIC(8,2),
    CONSTRAINT ck_adq_via CHECK (via IN ('compra','donacion','intercambio')),
    CONSTRAINT ck_adq_coherencia CHECK (
        (via = 'compra'      AND proveedor_id IS NOT NULL AND donante_id IS NULL
                             AND num_factura IS NOT NULL AND coste IS NOT NULL)
     OR (via = 'donacion'    AND donante_id IS NOT NULL AND proveedor_id IS NULL)
     OR (via = 'intercambio' AND biblioteca_origen IS NOT NULL
                             AND proveedor_id IS NULL AND donante_id IS NULL)
    )
);

CREATE TABLE expurgos (
    expurgo_id  SERIAL PRIMARY KEY,
    ejemplar_id INTEGER NOT NULL UNIQUE REFERENCES ejemplares(ejemplar_id) ON DELETE RESTRICT,
    fecha       DATE NOT NULL DEFAULT CURRENT_DATE,
    motivo      VARCHAR(20) NOT NULL
                  CHECK (motivo IN ('deterioro','obsoleto','duplicado','perdida','baja_demanda')),
    destino     VARCHAR(20) NOT NULL
                  CHECK (destino IN ('venta_benefica','reciclaje','cesion','destruccion')),
    autorizado_por VARCHAR(80) NOT NULL,
    observaciones  TEXT
);

CREATE INDEX idx_adq_via   ON adquisiciones (via, fecha);
CREATE INDEX idx_exp_fecha ON expurgos (fecha DESC);

Las decisiones que hay que saber defender:

  • UNIQUE (ejemplar_id) en las dos tablas convierte la relación en 1:1 opcional: un ejemplar tiene como mucho un origen registrado y como mucho un expurgo. Sin ese UNIQUE sería 1:N y un ejemplar podría aparecer donado dos veces.
  • El CHECK de coherencia por vía es la pieza de diseño más valiosa: hace imposible una compra sin factura o una donación con proveedor. La alternativa —tres tablas hijas adquisiciones_compra, adquisiciones_donacion, adquisiciones_intercambio con el patrón de jerarquía del ejercicio 4— es más limpia conceptualmente y más pesada de consultar. Con tres subtipos de dos o tres columnas cada uno, el CHECK gana.
  • expurgos → ejemplares es RESTRICT y adquisiciones → ejemplares es CASCADE. No es una incoherencia: si se borra el ejemplar de la base de datos, su procedencia deja de importar, pero un expurgo es un acto administrativo que debe sobrevivir e impedir el borrado.
  • No se modifica ejemplares. El estado retirado ya existía; lo único que aporta el expurgo es la documentación de por qué. Cualquier consulta anterior sigue funcionando palabra por palabra.

Rúbrica de Autoevaluación

Puntúa tu diseño de cada ejercicio con esta lista. Un diseño correcto cumple los ocho primeros puntos; los dos últimos separan un diseño correcto de un buen diseño.

# Criterio Cómo comprobarlo
1 Todas las preguntas del enunciado son respondibles Escribe la consulta de cada pregunta. Si alguna necesita un dato que no está en ninguna tabla, el diseño está incompleto
2 Ningún atributo multivaluado Busca columnas que puedan contener «varios» valores: listas con comas, telefono1/telefono2, campos de texto con separadores
3 Toda tabla tiene clave primaria Sin excepciones, incluidas las tablas de reunión N:M
4 Toda relación está materializada con clave ajena declarada No vale «la aplicación lo controla». Si el motor no lo declara, no está garantizado
5 Cada clave ajena tiene una acción ON DELETE decidida a conciencia Recorre la lista y justifica una por una: CASCADE, RESTRICT, SET NULL, SET DEFAULT o NO ACTION
6 Cada regla de negocio del enunciado tiene su restricción... o su nota Haz la lista de reglas del enunciado y marca junto a cada una: CHECK, UNIQUE, índice parcial, EXCLUDE, disparador o «responsabilidad de la aplicación»
7 Los conjuntos cerrados de valores están restringidos Todo estado, tipo o motivo debe tener CHECK IN (...), un dominio o una tabla de catálogo
8 Nomenclatura consistente Mismo idioma, mismo número (singular/plural), mismo estilo de sufijo _id, mismo estilo de nombre de restricción
9 Los datos históricos no se pueden destruir con un UPDATE Precios, tarifas e importes facturados congelados en la fila que los usó; vigencias como filas nuevas, no como modificaciones
10 Hay índices para las consultas frecuentes del enunciado Al menos las claves ajenas que se usan en JOIN y las columnas de los filtros habituales

Cómo puntuar. Si fallas el punto 1, vuelve al enunciado: el diseño no sirve. Si fallas el 2, el 3 o el 4, tienes un problema estructural. Los puntos 5 a 8 son los que separan un esquema de estudiante de uno de producción. Los puntos 9 y 10 solo se aplican a los enunciados que los mencionan.

Conclusión

Has diseñado cinco esquemas completos desde cero: un videoclub con dos relaciones N:M —una de ellas con atributo propio—, una ampliación de BiblioRed que tuvo que encajar con un esquema en producción sin tocarlo, una plataforma de cursos con jerarquía de contenidos y autorreferencia, un taller mecánico con jerarquía de generalización y relación ternaria, y un sistema de tarifas donde la clave incluye el tiempo. En los cinco, el trabajo real no estuvo en el CREATE TABLE sino en las dos decisiones que lo preceden: qué es una entidad y qué es un atributo, y qué regla de negocio puede garantizar el motor y cuál no.

Repasa la lista de herramientas que has usado para codificar reglas: CHECK de una columna y de varias, UNIQUE compuesto, índice único parcial (una copia alquilada, una matrícula activa, una opción correcta), clave ajena compuesta con discriminante para cerrar una jerarquía, EXCLUDE USING gist para intervalos que no se solapan, y dominios para conjuntos de valores reutilizables. Y recuerda las tres reglas que ninguna de ellas pudo expresar —el máximo de peticiones activas, el aforo de una sesión y la totalidad de una jerarquía—, porque saber dónde acaba lo declarativo es tan importante como saber usarlo.

En 07-03, Ejercicios de Normalización, el trabajo se invierte. En lugar de partir de requisitos y llegar a tablas, partirás de tablas que ya existen y que están mal: listados planos con datos repetidos, históricos con el nombre del socio copiado en cada fila, tablas donde cambiar un teléfono obliga a tocar catorce registros. Tendrás que escribir sus dependencias funcionales, calcular cierres, encontrar todas las claves candidatas, decir en qué forma normal están y por qué, y descomponerlas paso a paso con el SQL que hace la migración. Nada de requisitos: solo tablas, datos y el análisis formal que revela lo que esconden.

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