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:
- 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.
- Dibujar el diagrama ER con notación de pata de gallo (puedes usar
mermaid, papel o cualquier herramienta). - Escribir el
CREATE TABLEaplicando las diez reglas de transformación de la lección 04-03, con sus claves ajenas y sus accionesON DELETE/ON UPDATE. - Añadir las restricciones —
NOT 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,UNIQUEsobre varias columnas,UNIQUEparcial mediante índice, dominios (CREATE DOMAIN), columnas generadas yEXCLUDEconbtree_gist. - Una sesión
psqlpara ejecutar tusCREATE 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:
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
- Ejercicio 1 — Básico: videoclub de barrio
- Ejercicio 2 — Intermedio: préstamo interbibliotecario en BiblioRed
- Ejercicio 3 — Intermedio: plataforma de cursos en línea
- Ejercicio 4 — Avanzado: taller mecánico con jerarquía y relación ternaria
- Ejercicio 5 — Avanzado: tarifas con vigencia temporal
- Errores comunes y consejos
- Ejercicios de refuerzo
- 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:
peliculasN:Mgeneros→ tabla intermediapeliculas_generos.peliculasN:Mactores, con atributopersonaje→ tabla intermediareparto.peliculas1:Ncopias(una película tiene entre 1 y 6 copias).socios1:Nalquileres,copias1:Nalquileres.
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:
socios1:Npeticiones_interbib(quién pide).sucursales1:Npeticiones_interbibcomo origen (dónde lo recoge).sucursales1:Npeticiones_interbibcomo destino opcional (a quién se pide).bibliotecas_externas1:Npeticiones_interbibcomo destino opcional.materiales1:Npeticiones_interbib(qué se pide).ejemplares1:Npeticiones_interbib(qué ejemplar se asignó, nulo hasta la aceptación).peticiones_interbib1:Npeticiones_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 positivosResultado 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:
UNIQUE (vehiculo_id, tipo)envehiculos—redundante con la clave primaria, pero necesario para que la FK compuesta tenga a qué apuntar.- Una columna
tipoen la subtabla, conCHECK (tipo = 'coche')yDEFAULT. - 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 unCHECKno puede.sesiones.plazascopia 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 conSELECT ... FOR UPDATEsobre la sesión —exactamente el problema de la última plaza que resolverás en 07-04. - Las dos restricciones
EXCLUDEson 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 eseUNIQUEsería 1:N y un ejemplar podría aparecer donado dos veces.- El
CHECKde 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 hijasadquisiciones_compra,adquisiciones_donacion,adquisiciones_intercambiocon 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, elCHECKgana. expurgos → ejemplaresesRESTRICTyadquisiciones → ejemplaresesCASCADE. 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 estadoretiradoya 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
- Conceptos Básicos de Bases de Datos
- Tipos de Bases de Datos
- Historia y Evolución de las Bases de Datos
- Sistemas Gestores de Bases de Datos y Arquitectura
Módulo 2: Bases de Datos Relacionales
- Modelo Relacional
- Lenguaje SQL
- Operaciones Básicas en SQL
- Consultas Multitabla: JOIN y Subconsultas
- Agregación y Agrupación de Datos
- Integridad Referencial
Módulo 3: Bases de Datos No Relacionales
- Introducción a NoSQL
- Tipos de Bases de Datos NoSQL
- Modelado de Datos en NoSQL
- Comparación entre Bases de Datos Relacionales y No Relacionales
Módulo 4: Diseño de Esquemas
- Principios de Diseño de Esquemas
- Diagramas Entidad-Relación (ER)
- Transformación de Diagramas ER a Esquemas Relacionales
- Tipos de Datos y Restricciones
Módulo 5: Normalización
Módulo 6: Transacciones, Rendimiento y Seguridad
- Transacciones y Propiedades ACID
- Concurrencia y Niveles de Aislamiento
- Índices y Optimización de Consultas
- Seguridad, Permisos y Copias de Seguridad
Módulo 7: Ejercicios Prácticos
- Ejercicios de SQL
- Ejercicios de Diseño de Esquemas
- Ejercicios de Normalización
- Ejercicios de Consultas Avanzadas y Transacciones
Módulo 8: Casos de Estudio
- Caso de Estudio: Base de Datos Relacional
- Caso de Estudio: Base de Datos No Relacional
- Caso de Estudio: Persistencia Políglota
