Las dos lecciones anteriores nos han dado el vocabulario y el catálogo. Sabemos escribir dependencias funcionales, calcular cierres, encontrar claves candidatas y decidir si una relación está en una forma normal concreta. Lo que todavía no hemos hecho es normalizar algo de verdad, de principio a fin.
Y hay una diferencia sustancial entre las dos cosas. Los ejemplos de la lección 05-02 estaban construidos para ilustrar una forma normal aislada: tres o cuatro columnas, una dependencia problemática, la corrección evidente. La realidad no se presenta así. La realidad se presenta como la hoja de cálculo de préstamos de BiblioRed: trece columnas, once dependencias funcionales, valores no atómicos, dependencias parciales y transitivas simultáneas, ochenta y cuatro mil filas con errores tipográficos acumulados desde 2018, y un mostrador que atiende a socios todos los días y no se puede parar.
Esta lección es el procedimiento. Seis pasos, aplicados a esa hoja de principio a fin, con los datos delante en cada etapa y el SQL que descompone y migra. Después, las dos propiedades que toda descomposición debe cumplir para ser correcta —y el contraejemplo, con datos, de una que no las cumple e inventa filas al reunir—. Y por último, la parte que menos se enseña y más se necesita: cómo se normaliza una base de datos que ya está en producción, y el examen tabla por tabla del esquema ampliado que construimos en el módulo 4.
Un aviso sobre el alcance: aquí no se redefine ninguna forma normal. Se aplican. Si en algún punto dudas de qué prohíbe exactamente la 2FN o de por qué la FNBC es más exigente que la 3FN, vuelve a 05-02.
Contenido
- El procedimiento en seis pasos
- Paso 1: reunir las reglas de negocio y escribir las dependencias
- Paso 2: determinar las claves candidatas
- Paso 3a: comprobar y alcanzar la 1FN
- Paso 3b y 4a: comprobar y alcanzar la 2FN
- Paso 3c y 4b: comprobar y alcanzar la 3FN
- El resultado final y el reencuentro con el esquema del módulo 2
- Descomposición sin pérdida de información y la condición de Heath
- El contraejemplo: una descomposición que inventa filas
- Conservación de las dependencias
- La cobertura mínima y el algoritmo de síntesis 3FN
- Paso 5: verificación con consultas de control
- Paso 6: reponer las claves ajenas
- Normalizar en la vida real: diseño nuevo frente a producción
- Examen del esquema ampliado del módulo 4
- El procedimiento en seis pasos
Este es el guion completo. Es el mismo tanto si diseñas desde cero como si rescatas un esquema existente; lo que cambia es la cantidad de trabajo en el paso 1 y el riesgo del paso 4.
| Paso | Qué se hace | Herramienta |
|---|---|---|
| 1 | Reunir las reglas de negocio y escribir el conjunto F de dependencias funcionales |
Entrevistas, documentación, SELECT de refutación |
| 2 | Determinar las claves candidatas de la relación | Clasificación de atributos + algoritmo del cierre X⁺ |
| 3 | Comprobar 1FN, luego 2FN, luego 3FN/FNBC — en ese orden y parando en el primer fallo | Definiciones de 05-02 |
| 4 | Descomponer: sacar cada dependencia infractora a su propia tabla | CREATE TABLE + INSERT ... SELECT DISTINCT |
| 5 | Verificar: sin pérdida, dependencias conservadas, recuentos que cuadran | JOIN de reconstrucción, COUNT, EXCEPT |
| 6 | Reponer las claves ajenas y las restricciones | ALTER TABLE ... ADD CONSTRAINT |
Dos observaciones antes de empezar.
El orden del paso 3 no es negociable. Se comprueba de abajo arriba porque las formas normales son acumulativas: no tiene sentido buscar dependencias transitivas en una tabla que todavía tiene listas dentro de las celdas. Y en cuanto una comprobación falla, se descompone (paso 4) y se vuelve al paso 2 —porque las tablas nuevas tienen claves nuevas— antes de seguir subiendo.
El paso 5 es el que la gente se salta y el que más caro sale. Una descomposición puede parecer impecable sobre el papel y estar perdiendo información o dejando una regla de negocio sin quien la vigile. Las secciones 8 a 12 son enteramente sobre esto.
flowchart TD
P1["1 · Reglas de negocio → F"]
P2["2 · Claves candidatas (cierre)"]
P3A{"3a · ¿1FN?"}
P3B{"3b · ¿2FN?"}
P3C{"3c · ¿3FN / FNBC?"}
P4["4 · Descomponer"]
P5["5 · Verificar"]
P6["6 · Claves ajenas y restricciones"]
P1 --> P2 --> P3A
P3A -->|No| P4
P3A -->|Sí| P3B
P3B -->|No| P4
P3B -->|Sí| P3C
P3C -->|No| P4
P3C -->|Sí| P5
P4 --> P2
P5 --> P6
- Paso 1: reunir las reglas de negocio y escribir las dependencias
Partimos de la hoja de cálculo tal como está. Y aquí hay que hacer una corrección respecto de la lección 05-01: allí trabajamos con una versión simplificada de la tabla para poder razonar sobre la estructura. El fichero real, más allá de las cinco filas de muestra que vimos en 01-01, tiene dos columnas más y un problema adicional.
La bibliotecaria de la sucursal Norte lo explica así: "la columna de teléfonos la fuimos ampliando, porque muchos socios nos dan el móvil y el fijo de casa y los apuntábamos los dos separados por una barra. Y en la columna de autor, cuando un libro tiene dos autores, los ponemos con coma."
Esta es, entonces, la tabla de partida completa:
prestamos_hoja — el punto de partida real
| ejemplar_cod | fecha_prestamo | fecha_devolucion | socio_email | socio_nombre | socio_telefonos | isbn | titulo | autores | autor_nac | sucursal_nombre | sucursal_ciudad | sucursal_cp |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| EJ-3081 | 2026-03-02 | 2026-03-16 | [email protected] | Marta Alsina | 600111222 / 938880011 | 9788401339097 | El mapa del tiempo | Félix J. Palma | española | Norte | Vallmar | 08110 |
| EJ-3081 | 2026-04-05 | (NULL) | [email protected] | M. Alsina | 600111222 | 9788401339097 | El mapa del tiempo | Félix J. Palma | española | norte | Vallmar | 08110 |
| EJ-3090 | 2026-04-07 | 2026-04-21 | [email protected] | Iván Pereda | 600333444 | 9788401337208 | Los pilares de la Tierra | Ken Follet | británica | Norte | Vallmar | 08110 |
| EJ-3082 | 2026-04-09 | (NULL) | [email protected] | Marta Alsina | 600111222 / 938880011 | 978840133909 | El mapa del tiempo | Félix J. Palma | española | Norte | Vallmar | 08110 |
| EJ-3095 | 2026-04-10 | (NULL) | [email protected] | Nuria Bastos | 600555666 | 9788401337208 | Los pilares de la Tierra | Ken Follett | británica | Sur | Vallmar de Mar | 08130 |
| EJ-3093 | 2026-04-12 | (NULL) | [email protected] | Nuria Bastos | 600555666 | 9788432234118 | Manual de horticultura urbana | Rosa Vinyals, Pere Coll | española | Sur | Vallmar de Mar | 08130 |
Seis filas. Fíjate en que ahora aparecen los defectos originales del diagnóstico de 01-01: M. Alsina, norte, Ken Follet, [email protected], el ISBN truncado. Vamos a tener que ocuparnos de ellos, pero más adelante y por separado: la normalización es un trabajo sobre la estructura y la limpieza es un trabajo sobre los datos. Confundirlos es la mejor forma de no terminar ninguno de los dos.
Las entrevistas
Estas son las reglas que BiblioRed confirma, cada una con la dependencia que produce:
| # | Regla de negocio confirmada | Dependencia |
|---|---|---|
| RN1 | Un ejemplar físico no puede estar prestado dos veces el mismo día | clave {ejemplar_cod, fecha_prestamo} |
| RN2 | Cada préstamo lo hace un socio y se devuelve (o no) en una fecha | {ejemplar_cod, fecha_prestamo} → {socio_email, fecha_devolucion} |
| RN3 | Cada ejemplar es una copia de una obra concreta y vive en una sucursal | ejemplar_cod → {isbn, sucursal_nombre} |
| RN4 | Un ISBN identifica una edición: un título y una lista de autores | isbn → titulo |
| RN5 | Un autor tiene una nacionalidad | autor → autor_nac |
| RN6 | El correo identifica a un socio, con su nombre | socio_email → socio_nombre |
| RN7 | Cada sucursal está en una dirección con su código postal | sucursal_nombre → sucursal_cp |
| RN8 | Un código postal pertenece a un solo municipio | sucursal_cp → sucursal_ciudad |
| RN9 | Un socio puede tener varios teléfonos | multivaluado (no funcional) |
| RN10 | Una obra puede tener varios autores, y un autor varias obras | multivaluado (no funcional) |
Y estas son las no-reglas, igual de importantes, que salieron al preguntar por las excepciones:
- "¿Puede haber dos socios con el mismo teléfono?" → Sí, familias que dan el fijo de casa. Luego no existe
socio_telefono → socio_email, por mucho que los datos de muestra parezcan sugerirlo. - "¿Puede haber dos ediciones distintas con el mismo título?" → Sí, y de hecho las hay. Luego no existe
titulo → isbn. - "¿Un ejemplar cambia de sucursal alguna vez?" → Sí, en traslados, pero el ejemplar está en una sola sucursal en cada momento. La dependencia
ejemplar_cod → sucursal_nombrese mantiene, pero anota que el dato es mutable.
El conjunto F
F = {
f1: {ejemplar_cod, fecha_prestamo} → socio_email
f2: {ejemplar_cod, fecha_prestamo} → fecha_devolucion
f3: ejemplar_cod → isbn
f4: ejemplar_cod → sucursal_nombre
f5: isbn → titulo
f6: autor → autor_nac
f7: socio_email → socio_nombre
f8: sucursal_nombre → sucursal_cp
f9: sucursal_cp → sucursal_ciudad
}Escrito en forma desagregada (un solo atributo a la derecha), que es como conviene tenerlo para trabajar.
Comprobar que los datos no refutan las dependencias
Antes de construir nada sobre F, vale la pena lanzar una consulta de refutación por cada dependencia. Recuerda de 05-01: los datos no pueden confirmar una dependencia, pero sí refutarla, y si la refutan es que hay datos sucios o que la regla no es la que nos han contado. Las dos cosas hay que saberlas antes de migrar.
-- Plantilla general de refutación: para X → Y,
-- buscar valores de X con más de un valor de Y.
-- ¿isbn → titulo?
SELECT isbn, COUNT(DISTINCT titulo) AS n
FROM prestamos_hoja GROUP BY isbn HAVING COUNT(DISTINCT titulo) > 1;
-- ¿socio_email → socio_nombre?
SELECT socio_email, COUNT(DISTINCT socio_nombre) AS n
FROM prestamos_hoja GROUP BY socio_email HAVING COUNT(DISTINCT socio_nombre) > 1;
-- ¿sucursal_nombre → sucursal_cp?
SELECT sucursal_nombre, COUNT(DISTINCT sucursal_cp) AS n
FROM prestamos_hoja GROUP BY sucursal_nombre HAVING COUNT(DISTINCT sucursal_cp) > 1;Resultado de la segunda:
socio_email | n ------------------------+--- [email protected] | 2
Ahí está: dos nombres (Marta Alsina y M. Alsina) para el mismo correo. Esto no refuta la regla de negocio: refuta la calidad de los datos. La regla sigue siendo cierta —un socio tiene un nombre— y lo que la consulta ha encontrado es el defecto 3 del diagnóstico de 01-01, localizado con precisión quirúrgica.
Este es uno de los beneficios menos anunciados de normalizar: las consultas de refutación son el mejor detector de datos sucios que existe, porque encuentran exactamente las contradicciones que el nuevo esquema va a rechazar. Ejecútalas todas y haz la lista antes de empezar a migrar; después es tarde.
- Paso 2: determinar las claves candidatas
Aplicamos el método de 05-01, sección 13.
Clasificación de atributos. Solo a la izquierda: ejemplar_cod, fecha_prestamo. Solo a la derecha: fecha_devolucion, socio_nombre, titulo, autor_nac, sucursal_ciudad. En ambos lados: socio_email, isbn, sucursal_nombre, sucursal_cp. Sin aparecer en F: socio_telefonos, autores (son multivaluados y no producen dependencias funcionales).
Núcleo obligatorio. Los "solo izquierda" y los "que no aparecen" están en toda clave candidata:
Este resultado es en sí mismo un diagnóstico. Que socio_telefonos y autores tengan que formar parte de la clave es absurdo desde el punto de vista del negocio —nadie identifica un préstamo por la lista de teléfonos del socio— y es la señal formal de que esas columnas no encajan en el modelo relacional tal como están. Es la violación de 1FN que trataremos en el paso siguiente.
Para poder avanzar, dejemos esas dos columnas apartadas de momento y calculemos sobre las once restantes:
{ejemplar_cod, fecha_prestamo}⁺:
| Pasada | Dependencia | Se añade |
|---|---|---|
| 1 | f1 | socio_email |
| 1 | f2 | fecha_devolucion |
| 1 | f3 | isbn |
| 1 | f4 | sucursal_nombre |
| 1 | f5 | titulo |
| 1 | f7 | socio_nombre |
| 1 | f8 | sucursal_cp |
| 1 | f9 | sucursal_ciudad |
| 2 | — | (nada nuevo) |
Contiene los once atributos considerados. Es superclave. Y ya comprobamos en 05-01 que es mínima ({ejemplar_cod}⁺ no llega y {fecha_prestamo}⁺ no crece). Clave candidata única: {ejemplar_cod, fecha_prestamo}.
Atributos primos: ejemplar_cod, fecha_prestamo. Todos los demás, no primos.
Nota que autor_nac no ha entrado en el cierre: f6 es autor → autor_nac, y autor (en singular) ni siquiera es una columna de la tabla, porque la columna se llama autores y contiene una lista. La dependencia f6 no se puede evaluar en esta tabla. Es otra manifestación del mismo problema de 1FN.
- Paso 3a: comprobar y alcanzar la 1FN
Comprobación. ¿Todos los valores son atómicos?
socio_telefonos='600111222 / 938880011'→ No. Dos valores en una celda.autores='Rosa Vinyals, Pere Coll'→ No. Dos valores en una celda.
La tabla no está en 1FN. No tiene sentido seguir comprobando nada más hasta arreglarlo.
Descomposición. Cada atributo multivaluado sale a su propia tabla, con la clave de la entidad propietaria más el valor como clave primaria (regla 3 de transformación de 04-03, que ahora sabemos que es la corrección estándar de la 1FN).
Aquí aparece un detalle importante: para sacar los teléfonos hace falta saber de qué socio son, y el identificador del socio en esta tabla es socio_email. Igual con los autores: hacen falta colgados del isbn. Así que la descomposición de 1FN ya produce tres tablas.
-- 1. La tabla principal, sin las columnas multivaluadas
CREATE TABLE p1_prestamos (
ejemplar_cod VARCHAR(10) NOT NULL,
fecha_prestamo DATE NOT NULL,
fecha_devolucion DATE,
socio_email VARCHAR(120) NOT NULL,
socio_nombre VARCHAR(120) NOT NULL,
isbn VARCHAR(13) NOT NULL,
titulo VARCHAR(200) NOT NULL,
sucursal_nombre VARCHAR(60) NOT NULL,
sucursal_ciudad VARCHAR(60) NOT NULL,
sucursal_cp VARCHAR(5) NOT NULL,
CONSTRAINT pk_p1_prestamos PRIMARY KEY (ejemplar_cod, fecha_prestamo)
);
-- 2. Los teléfonos, uno por fila
CREATE TABLE p1_telefonos (
socio_email VARCHAR(120) NOT NULL,
numero VARCHAR(20) NOT NULL,
CONSTRAINT pk_p1_telefonos PRIMARY KEY (socio_email, numero)
);
-- 3. Los autores de cada obra, uno por fila
CREATE TABLE p1_autores_obra (
isbn VARCHAR(13) NOT NULL,
autor VARCHAR(120) NOT NULL,
autor_nac VARCHAR(40),
CONSTRAINT pk_p1_autores_obra PRIMARY KEY (isbn, autor)
);Migración de los datos. Esto es lo que se hace de verdad cuando hay ochenta y cuatro mil filas y no se pueden teclear a mano. PostgreSQL tiene funciones para partir cadenas, y son la herramienta exacta para deshacer una violación de 1FN:
-- La tabla principal: una fila por préstamo, quitando las columnas de lista
INSERT INTO p1_prestamos (ejemplar_cod, fecha_prestamo, fecha_devolucion,
socio_email, socio_nombre, isbn, titulo,
sucursal_nombre, sucursal_ciudad, sucursal_cp)
SELECT ejemplar_cod, fecha_prestamo, fecha_devolucion,
socio_email, socio_nombre, isbn, titulo,
sucursal_nombre, sucursal_ciudad, sucursal_cp
FROM prestamos_hoja;
-- Los teléfonos: se parte la cadena por '/' y se genera una fila por trozo.
-- unnest(string_to_array(...)) convierte una lista en filas: es la operación
-- inversa exacta de la violación de 1FN.
INSERT INTO p1_telefonos (socio_email, numero)
SELECT DISTINCT
h.socio_email,
btrim(t.numero) -- btrim quita los espacios sobrantes
FROM prestamos_hoja h
CROSS JOIN LATERAL unnest(string_to_array(h.socio_telefonos, '/')) AS t(numero)
WHERE btrim(t.numero) <> '';
-- Los autores: igual, partiendo por ','
INSERT INTO p1_autores_obra (isbn, autor, autor_nac)
SELECT DISTINCT
h.isbn,
btrim(a.autor),
h.autor_nac
FROM prestamos_hoja h
CROSS JOIN LATERAL unnest(string_to_array(h.autores, ',')) AS a(autor)
WHERE btrim(a.autor) <> '';El DISTINCT es imprescindible: Marta Alsina aparece en tres préstamos y sus dos teléfonos saldrían nueve veces sin él. Es la primera aparición de un patrón que se repetirá en todo el proceso: INSERT ... SELECT DISTINCT es la forma canónica de migrar datos al descomponer, porque la tabla de destino contiene cada hecho una vez y la de origen lo tiene repetido.
Resultado:
p1_telefonos
| socio_email | numero |
|---|---|
| [email protected] | 600111222 |
| [email protected] | 938880011 |
| [email protected] | 600111222 |
| [email protected] | 938880011 |
| [email protected] | 600333444 |
| [email protected] | 600555666 |
p1_autores_obra
| isbn | autor | autor_nac |
|---|---|---|
| 9788401339097 | Félix J. Palma | española |
| 9788401337208 | Ken Follet | británica |
| 9788401337208 | Ken Follett | británica |
| 978840133909 | Félix J. Palma | española |
| 9788432234118 | Rosa Vinyals | española |
| 9788432234118 | Pere Coll | española |
Y aquí los datos sucios saltan a la vista con una claridad que en la hoja original no tenían: [email protected] genera un socio fantasma con teléfonos duplicados; Ken Follet y Ken Follett son dos autores distintos para el mismo ISBN; el ISBN truncado 978840133909 crea una obra inexistente. La normalización no ha creado estos problemas: los ha hecho visibles. Antes estaban repartidos entre seis filas anchas y no se podían contar; ahora son filas de más que se pueden listar y corregir.
Anótalos en la lista de limpieza y sigamos con la estructura.
Nota sobre la nacionalidad. Al sacar
autor_nacap1_autores_obrahemos puesto la nacionalidad junto a cada pareja obra-autor, con lo que sigue repetida una vez por obra del mismo autor. Es una dependencia transitiva que arrastraremos hasta el paso de la 3FN. Es normal: cada forma normal arregla su parte, y las descomposiciones intermedias no son el resultado final.
- Paso 3b y 4a: comprobar y alcanzar la 2FN
Volvemos al paso 2 con p1_prestamos, que tiene clave {ejemplar_cod, fecha_prestamo}.
Comprobación de 2FN. ¿Hay atributos no primos que dependan de una parte de la clave? Calculamos el cierre de cada parte:
{ejemplar_cod}⁺={ejemplar_cod, isbn, titulo, sucursal_nombre, sucursal_cp, sucursal_ciudad}. Contiene cinco atributos no primos. Hay dependencias parciales.{fecha_prestamo}⁺={fecha_prestamo}. No aporta nada.
p1_prestamos no está en 2FN. Las dependencias infractoras son f3 (ejemplar_cod → isbn) y f4 (ejemplar_cod → sucursal_nombre), y con ellas se arrastra todo lo que cuelga: titulo, sucursal_cp, sucursal_ciudad.
Antes de la descomposición, esta es la redundancia que vamos a eliminar. Fíjate en la fila 1 y la 2: son el mismo ejemplar prestado dos veces, y las nueve columnas de la derecha son idénticas:
| ejemplar_cod | fecha_prestamo | socio_email | isbn | titulo | sucursal_nombre | sucursal_cp | sucursal_ciudad |
|---|---|---|---|---|---|---|---|
| EJ-3081 | 2026-03-02 | [email protected] | 9788401339097 | El mapa del tiempo | Norte | 08110 | Vallmar |
| EJ-3081 | 2026-04-05 | [email protected] | 9788401339097 | El mapa del tiempo | norte | 08110 | Vallmar |
| EJ-3090 | 2026-04-07 | [email protected] | 9788401337208 | Los pilares de la Tierra | Norte | 08110 | Vallmar |
| EJ-3082 | 2026-04-09 | [email protected] | 978840133909 | El mapa del tiempo | Norte | 08110 | Vallmar |
| EJ-3095 | 2026-04-10 | [email protected] | 9788401337208 | Los pilares de la Tierra | Sur | 08130 | Vallmar de Mar |
| EJ-3093 | 2026-04-12 | [email protected] | 9788432234118 | Manual de horticultura urbana | Sur | 08130 | Vallmar de Mar |
Descomposición. Todo lo que depende de ejemplar_cod se va a una tabla con ejemplar_cod como clave primaria; en la tabla de préstamos queda ejemplar_cod como referencia.
-- Lo que depende de la clave COMPLETA: el préstamo en sí
CREATE TABLE p2_prestamos (
ejemplar_cod VARCHAR(10) NOT NULL,
fecha_prestamo DATE NOT NULL,
fecha_devolucion DATE,
socio_email VARCHAR(120) NOT NULL,
socio_nombre VARCHAR(120) NOT NULL,
CONSTRAINT pk_p2_prestamos PRIMARY KEY (ejemplar_cod, fecha_prestamo)
);
-- Lo que depende de ejemplar_cod solo
CREATE TABLE p2_ejemplares (
ejemplar_cod VARCHAR(10) NOT NULL,
isbn VARCHAR(13) NOT NULL,
titulo VARCHAR(200) NOT NULL,
sucursal_nombre VARCHAR(60) NOT NULL,
sucursal_ciudad VARCHAR(60) NOT NULL,
sucursal_cp VARCHAR(5) NOT NULL,
CONSTRAINT pk_p2_ejemplares PRIMARY KEY (ejemplar_cod)
);INSERT INTO p2_prestamos (ejemplar_cod, fecha_prestamo, fecha_devolucion,
socio_email, socio_nombre)
SELECT ejemplar_cod, fecha_prestamo, fecha_devolucion, socio_email, socio_nombre
FROM p1_prestamos;
-- Aquí el DISTINCT hace el trabajo: de las 6 filas de préstamos
-- salen solo las 5 combinaciones distintas de ejemplar.
INSERT INTO p2_ejemplares (ejemplar_cod, isbn, titulo,
sucursal_nombre, sucursal_ciudad, sucursal_cp)
SELECT DISTINCT ejemplar_cod, isbn, titulo,
sucursal_nombre, sucursal_ciudad, sucursal_cp
FROM p1_prestamos;Un aviso sobre este DISTINCT. Si los datos estuvieran sucios de una forma concreta —el mismo ejemplar_cod con dos sucursales distintas por un error de tecleo, o con Norte y norte— el SELECT DISTINCT devolvería dos filas para el mismo ejemplar y el INSERT fallaría con violación de clave primaria. Y eso es exactamente lo que ocurre aquí:
ERROR: llave duplicada viola restricción de unicidad «pk_p2_ejemplares» DETALLE: Ya existe la llave (ejemplar_cod)=(EJ-3081).
Porque EJ-3081 aparece con Norte en una fila y con norte en otra. El error no es un fallo de la migración: es la migración funcionando. El esquema nuevo está rechazando una contradicción que el viejo permitía. Es el momento de aplicar la limpieza, que en este caso es trivial:
-- Limpieza previa: normalizar mayúsculas de la sucursal
UPDATE p1_prestamos SET sucursal_nombre = initcap(lower(sucursal_nombre));Y volver a lanzar el INSERT. Este ciclo —migrar, fallar, limpiar, reintentar— es el ritmo normal de una normalización sobre datos históricos, y por eso se hace siempre sobre una copia y dentro de una transacción.
Resultado después de la limpieza:
p2_ejemplares
| ejemplar_cod | isbn | titulo | sucursal_nombre | sucursal_ciudad | sucursal_cp |
|---|---|---|---|---|---|
| EJ-3081 | 9788401339097 | El mapa del tiempo | Norte | Vallmar | 08110 |
| EJ-3082 | 978840133909 | El mapa del tiempo | Norte | Vallmar | 08110 |
| EJ-3090 | 9788401337208 | Los pilares de la Tierra | Norte | Vallmar | 08110 |
| EJ-3093 | 9788432234118 | Manual de horticultura urbana | Sur | Vallmar de Mar | 08130 |
| EJ-3095 | 9788401337208 | Los pilares de la Tierra | Sur | Vallmar de Mar | 08130 |
p2_prestamos
| ejemplar_cod | fecha_prestamo | fecha_devolucion | socio_email | socio_nombre |
|---|---|---|---|---|
| EJ-3081 | 2026-03-02 | 2026-03-16 | [email protected] | Marta Alsina |
| EJ-3081 | 2026-04-05 | (NULL) | [email protected] | M. Alsina |
| EJ-3090 | 2026-04-07 | 2026-04-21 | [email protected] | Iván Pereda |
| EJ-3082 | 2026-04-09 | (NULL) | [email protected] | Marta Alsina |
| EJ-3095 | 2026-04-10 | (NULL) | [email protected] | Nuria Bastos |
| EJ-3093 | 2026-04-12 | (NULL) | [email protected] | Nuria Bastos |
Ya no hay ninguna fila donde el título de "El mapa del tiempo" esté repetido por culpa del préstamo. Con seis filas el ahorro es modesto; con ochenta y cuatro mil préstamos sobre cuarenta mil ejemplares, la columna titulo pasa de ochenta y cuatro mil valores a cuarenta mil, y —lo que importa de verdad— de ochenta y cuatro mil oportunidades de escribirlo mal a cuarenta mil.
- Paso 3c y 4b: comprobar y alcanzar la 3FN
Ahora hay tres tablas que comprobar. Vamos con las dos que tienen candidatos a dependencia transitiva.
6.1 p2_prestamos
Clave: {ejemplar_cod, fecha_prestamo}. Dependencias que se cumplen aquí: f1, f2 y f7 (socio_email → socio_nombre).
Aplicamos la comprobación mecánica de 3FN a f7:
- ¿Es
socio_emailsuperclave?{socio_email}⁺={socio_email, socio_nombre}. No contiene la clave. No. - ¿Es
socio_nombreprimo? Los primos sonejemplar_codyfecha_prestamo. No.
Viola la 3FN. Es la dependencia transitiva {ejemplar_cod, fecha_prestamo} → socio_email → socio_nombre, y su síntoma en los datos es que Marta Alsina aparece dos veces y Nuria Bastos otras dos.
CREATE TABLE p3_prestamos (
ejemplar_cod VARCHAR(10) NOT NULL,
fecha_prestamo DATE NOT NULL,
fecha_devolucion DATE,
socio_email VARCHAR(120) NOT NULL,
CONSTRAINT pk_p3_prestamos PRIMARY KEY (ejemplar_cod, fecha_prestamo)
);
CREATE TABLE p3_socios (
socio_email VARCHAR(120) NOT NULL,
socio_nombre VARCHAR(120) NOT NULL,
CONSTRAINT pk_p3_socios PRIMARY KEY (socio_email)
);
INSERT INTO p3_prestamos SELECT ejemplar_cod, fecha_prestamo, fecha_devolucion, socio_email
FROM p2_prestamos;
INSERT INTO p3_socios SELECT DISTINCT socio_email, socio_nombre FROM p2_prestamos;Y otra vez:
ERROR: llave duplicada viola restricción de unicidad «pk_p3_socios» DETALLE: Ya existe la llave (socio_email)=([email protected]).
Marta Alsina y M. Alsina. El esquema nuevo no admite que un socio tenga dos nombres, que es precisamente lo que queríamos. Limpieza y reintento:
UPDATE p2_prestamos SET socio_nombre = 'Marta Alsina' WHERE socio_nombre = 'M. Alsina';
UPDATE p2_prestamos SET socio_email = '[email protected]'
WHERE socio_email = '[email protected]';p3_socios
| socio_email | socio_nombre |
|---|---|
| [email protected] | Marta Alsina |
| [email protected] | Iván Pereda |
| [email protected] | Nuria Bastos |
Tres socios. Antes de normalizar, un COUNT(DISTINCT socio_nombre) sobre la hoja daba cinco. Ese es el defecto 3 de 01-01, resuelto.
6.2 p2_ejemplares
Clave: {ejemplar_cod}. Dependencias que se cumplen: f3, f4, f5 (isbn → titulo), f8 (sucursal_nombre → sucursal_cp) y f9 (sucursal_cp → sucursal_ciudad).
Tres violaciones de 3FN encadenadas:
| Dependencia | ¿Determinante superclave? | ¿Determinado primo? | Veredicto |
|---|---|---|---|
isbn → titulo |
No ({isbn}⁺ no incluye ejemplar_cod) |
No | Viola |
sucursal_nombre → sucursal_cp |
No | No | Viola |
sucursal_cp → sucursal_ciudad |
No | No | Viola |
Se sacan las tres. Nota que sucursal_nombre → sucursal_cp → sucursal_ciudad es una cadena de dos saltos y produce dos tablas, no una: la de sucursales y la de códigos postales. Es el caso que vimos en 05-02, sección 6.
CREATE TABLE p3_ejemplares (
ejemplar_cod VARCHAR(10) NOT NULL,
isbn VARCHAR(13) NOT NULL,
sucursal_nombre VARCHAR(60) NOT NULL,
CONSTRAINT pk_p3_ejemplares PRIMARY KEY (ejemplar_cod)
);
CREATE TABLE p3_obras (
isbn VARCHAR(13) NOT NULL,
titulo VARCHAR(200) NOT NULL,
CONSTRAINT pk_p3_obras PRIMARY KEY (isbn)
);
CREATE TABLE p3_sucursales (
sucursal_nombre VARCHAR(60) NOT NULL,
sucursal_cp VARCHAR(5) NOT NULL,
CONSTRAINT pk_p3_sucursales PRIMARY KEY (sucursal_nombre)
);
CREATE TABLE p3_codigos_postales (
sucursal_cp VARCHAR(5) NOT NULL,
sucursal_ciudad VARCHAR(60) NOT NULL,
CONSTRAINT pk_p3_codigos_postales PRIMARY KEY (sucursal_cp)
);
INSERT INTO p3_ejemplares
SELECT ejemplar_cod, isbn, sucursal_nombre FROM p2_ejemplares;
INSERT INTO p3_obras
SELECT DISTINCT isbn, titulo FROM p2_ejemplares;
INSERT INTO p3_sucursales
SELECT DISTINCT sucursal_nombre, sucursal_cp FROM p2_ejemplares;
INSERT INTO p3_codigos_postales
SELECT DISTINCT sucursal_cp, sucursal_ciudad FROM p2_ejemplares;p3_obras
| isbn | titulo |
|---|---|
| 9788401339097 | El mapa del tiempo |
| 978840133909 | El mapa del tiempo |
| 9788401337208 | Los pilares de la Tierra |
| 9788432234118 | Manual de horticultura urbana |
Cuatro obras donde hay tres: el ISBN truncado sigue ahí. Esta vez el INSERT no falla, porque técnicamente 978840133909 y 9788401339097 son claves distintas. Es un aviso importante: la normalización solo detecta automáticamente los errores que producen contradicciones; un identificador mal escrito que no choca con ningún otro pasa desapercibido. Aquí hace falta una validación de dominio —el dígito de control del ISBN, un CHECK de longitud— que es lo que aprendimos a poner en 04-04.
Después de la limpieza manual del ISBN:
p3_obras
| isbn | titulo |
|---|---|
| 9788401339097 | El mapa del tiempo |
| 9788401337208 | Los pilares de la Tierra |
| 9788432234118 | Manual de horticultura urbana |
p3_sucursales
| sucursal_nombre | sucursal_cp |
|---|---|
| Norte | 08110 |
| Sur | 08130 |
p3_codigos_postales
| sucursal_cp | sucursal_ciudad |
|---|---|
| 08110 | Vallmar |
| 08130 | Vallmar de Mar |
6.3 p1_autores_obra
Clave: {isbn, autor}. Se cumple f6: autor → autor_nac.
- ¿Es
autorsuperclave? No, es media clave. - Y como es media clave, la dependencia es parcial: viola la 2FN, no solo la 3FN.
Se descompone igual:
CREATE TABLE p3_autores (
autor VARCHAR(120) NOT NULL,
autor_nac VARCHAR(40),
CONSTRAINT pk_p3_autores PRIMARY KEY (autor)
);
CREATE TABLE p3_obras_autores (
isbn VARCHAR(13) NOT NULL,
autor VARCHAR(120) NOT NULL,
CONSTRAINT pk_p3_obras_autores PRIMARY KEY (isbn, autor)
);
INSERT INTO p3_autores SELECT DISTINCT autor, autor_nac FROM p1_autores_obra;
INSERT INTO p3_obras_autores SELECT DISTINCT isbn, autor FROM p1_autores_obra;Tras corregir Ken Follet → Ken Follett:
p3_autores
| autor | autor_nac |
|---|---|
| Félix J. Palma | española |
| Ken Follett | británica |
| Rosa Vinyals | española |
| Pere Coll | española |
6.4 p1_telefonos
Clave: {socio_email, numero}. Los dos atributos son primos y no hay ningún otro. Como vimos en 05-02, toda tabla de dos atributos cuya clave es la pareja está en FNBC por construcción. Nada que hacer, más allá de reemitirla con los correos ya corregidos.
- El resultado final y el reencuentro con el esquema del módulo 2
Este es el esquema al que hemos llegado, partiendo de una tabla de trece columnas:
| Tabla | Atributos | Clave primaria | Forma normal |
|---|---|---|---|
p3_prestamos |
ejemplar_cod, fecha_prestamo, fecha_devolucion, socio_email |
{ejemplar_cod, fecha_prestamo} |
FNBC |
p3_ejemplares |
ejemplar_cod, isbn, sucursal_nombre |
{ejemplar_cod} |
FNBC |
p3_obras |
isbn, titulo |
{isbn} |
FNBC |
p3_obras_autores |
isbn, autor |
{isbn, autor} |
FNBC |
p3_autores |
autor, autor_nac |
{autor} |
FNBC |
p3_socios |
socio_email, socio_nombre |
{socio_email} |
FNBC |
p3_telefonos |
socio_email, numero |
{socio_email, numero} |
FNBC |
p3_sucursales |
sucursal_nombre, sucursal_cp |
{sucursal_nombre} |
FNBC |
p3_codigos_postales |
sucursal_cp, sucursal_ciudad |
{sucursal_cp} |
FNBC |
Nueve tablas. Y todas en FNBC, no solo en 3FN: como cada una tiene una única clave candidata y está en 3FN, la regla de 05-02 se aplica automáticamente.
flowchart LR
CP["p3_codigos_postales<br/>cp · ciudad"] --> SUC["p3_sucursales<br/>nombre · cp"]
SUC --> EJ["p3_ejemplares<br/>cod · isbn · sucursal"]
OB["p3_obras<br/>isbn · titulo"] --> EJ
OB --> OA["p3_obras_autores<br/>isbn · autor"]
AU["p3_autores<br/>autor · nac"] --> OA
EJ --> PR["p3_prestamos<br/>ejemplar · fecha · devolución · socio"]
SO["p3_socios<br/>email · nombre"] --> PR
SO --> TEL["p3_telefonos<br/>email · numero"]
Ahora compáralo con el diagrama que dibujamos en la lección 01-01 —socios, prestamos, ejemplares, libros, sucursales— y con el esquema que escribimos en el módulo 2 por comprensión del dominio. Son el mismo esquema. La normalización formal ha llegado, por un camino completamente distinto y sin consultar el resultado anterior, a la misma estructura a la que llegó el diseño intuitivo.
Esto no es una casualidad ni una trampa didáctica: es el resultado esperado, y es la mejor justificación posible de la teoría. Un diseñador experimentado llega a un esquema en 3FN sin pensar en dependencias funcionales, porque ha interiorizado las mismas restricciones. La normalización sirve para tres cosas que la intuición no da: verificar que el diseño intuitivo es correcto, arbitrar cuando dos personas discrepan, y resolver los casos raros en que la intuición se equivoca.
Las diferencias con el esquema del módulo 2 son de detalle y todas apuntan hacia allí:
- Nuestras tablas usan claves naturales (
socio_email,ejemplar_cod,isbn,autor). El esquema real usa claves subrogadas (socio_id,ejemplar_id,libro_id,autor_id), por las razones de 04-01: el correo cambia, el nombre del autor se escribe de varias formas, y una clave que cambia se propaga a todas las tablas que la referencian. La normalización no obliga a usar claves naturales; nosotros lo hicimos porque en la hoja no había otra cosa. Reemplazarlas por subrogadas es el paso siguiente y no altera la forma normal. - El esquema real tiene
libroscomo vista sobremateriales+materiales_libro, resultado de la jerarquía de generalización de 04-02 y 04-03. Nuestrap3_obrases la versión sin jerarquía. - El esquema real guarda
dir_ciudadensucursalesen lugar de tenercodigos_postales. Es una desnormalización deliberada sobre una tabla de cuatro filas, y la discutiremos en 05-04.
- Descomposición sin pérdida de información y la condición de Heath
Hemos descompuesto ocho veces. ¿Cómo sabemos que no hemos perdido nada por el camino?
Definición. Una descomposición de una relación
RenR1yR2es sin pérdida de información (o de reunión no aditiva) si, al hacer el JOIN natural deR1yR2, se obtiene exactamenteR: ni una fila de menos ni una fila de más.
El nombre despista un poco, porque el problema no suele ser perder filas: es ganarlas. Una descomposición mal hecha produce, al reunir, filas que nunca existieron. Y esas filas son mentiras: combinaciones de datos que la base de datos afirma y que no ocurrieron nunca.
La condición que lo garantiza es sencilla y tiene nombre propio:
Condición de Heath. Sea
Runa relación con atributos divididos en tres gruposX,YyZ. Si se cumple la dependencia funcionalX → Y, entonces descomponerRenR1(X, Y)yR2(X, Z)es sin pérdida de información.
En lenguaje llano: descompón siempre por un determinante. Si el atributo (o conjunto de atributos) que queda en las dos tablas —el que sirve de "pegamento" para el JOIN— es clave de al menos una de ellas, no puedes perder ni inventar nada.
La razón intuitiva es esta: si X es clave de R1, entonces cada valor de X aparece una sola vez en R1. Al hacer el JOIN, cada fila de R2 encuentra exactamente una pareja, así que el número de filas del resultado es el número de filas de R2, que era el original. No hay multiplicación posible.
Comprobémoslo en nuestra descomposición
Miremos el paso de la 2FN. Partimos p1_prestamos en:
p2_prestamos(ejemplar_cod, fecha_prestamo, fecha_devolucion, socio_email, socio_nombre)p2_ejemplares(ejemplar_cod, isbn, titulo, sucursal_nombre, sucursal_ciudad, sucursal_cp)
El atributo compartido es ejemplar_cod, y es clave primaria de p2_ejemplares. Condición de Heath cumplida: la descomposición es sin pérdida.
Y se puede verificar empíricamente, que es lo que hay que hacer siempre:
-- Reconstruir el original y compararlo fila a fila.
-- EXCEPT devuelve las filas de la primera consulta que NO están en la segunda.
-- Si las dos direcciones dan cero filas, las tablas son idénticas.
WITH reconstruido AS (
SELECT p.ejemplar_cod, p.fecha_prestamo, p.fecha_devolucion,
p.socio_email, p.socio_nombre,
e.isbn, e.titulo, e.sucursal_nombre, e.sucursal_ciudad, e.sucursal_cp
FROM p2_prestamos p
JOIN p2_ejemplares e ON e.ejemplar_cod = p.ejemplar_cod
)
SELECT 'sobran en reconstruido' AS problema, * FROM (
SELECT * FROM reconstruido EXCEPT SELECT * FROM p1_prestamos
) s
UNION ALL
SELECT 'faltan en reconstruido', * FROM (
SELECT * FROM p1_prestamos EXCEPT SELECT * FROM reconstruido
) f;Cero filas en ambas direcciones: reconstrucción exacta.
- El contraejemplo: una descomposición que inventa filas
Para que se vea qué se está evitando, hagamos deliberadamente una descomposición mala.
Tomemos tres columnas de la hoja: socio_email, titulo y sucursal_nombre. Estos son los datos reales (una fila por préstamo, quitando duplicados exactos):
R original
| socio_email | titulo | sucursal_nombre |
|---|---|---|
| [email protected] | El mapa del tiempo | Norte |
| [email protected] | Los pilares de la Tierra | Sur |
| [email protected] | Manual de horticultura urbana | Sur |
| [email protected] | Los pilares de la Tierra | Norte |
Ahora alguien decide, razonando "un socio tiene sus títulos y un socio tiene sus sucursales", descomponer así:
R1(socio_email, titulo)
| socio_email | titulo |
|---|---|
| [email protected] | El mapa del tiempo |
| [email protected] | Los pilares de la Tierra |
| [email protected] | Manual de horticultura urbana |
| [email protected] | Los pilares de la Tierra |
R2(socio_email, sucursal_nombre)
| socio_email | sucursal_nombre |
|---|---|
| [email protected] | Norte |
| [email protected] | Sur |
| [email protected] | Norte |
Parece razonable. Ahora reunamos:
SELECT r1.socio_email, r1.titulo, r2.sucursal_nombre
FROM r1 JOIN r2 ON r2.socio_email = r1.socio_email;Con estos datos concretos el resultado casualmente coincide, porque cada socio solo tiene una sucursal. Añadamos un préstamo más, perfectamente normal: Nuria Bastos se lleva "Los pilares de la Tierra" también de la sucursal Norte, un día que pasaba por allí.
R original (5 filas)
| socio_email | titulo | sucursal_nombre |
|---|---|---|
| [email protected] | El mapa del tiempo | Norte |
| [email protected] | Los pilares de la Tierra | Sur |
| [email protected] | Manual de horticultura urbana | Sur |
| [email protected] | Los pilares de la Tierra | Norte |
| [email protected] | Los pilares de la Tierra | Norte |
R2 pasa a tener dos filas para Nuria: (n.bastos, Sur) y (n.bastos, Norte). Y ahora el JOIN:
Resultado del JOIN (7 filas)
| socio_email | titulo | sucursal_nombre | ¿Existía? |
|---|---|---|---|
| [email protected] | El mapa del tiempo | Norte | Sí |
| [email protected] | Los pilares de la Tierra | Sur | Sí |
| [email protected] | Los pilares de la Tierra | Norte | Sí |
| [email protected] | Manual de horticultura urbana | Sur | Sí |
| [email protected] | Manual de horticultura urbana | Norte | NO |
| [email protected] | Los pilares de la Tierra | Norte | Sí |
La quinta fila es falsa. Nuria Bastos nunca sacó el "Manual de horticultura urbana" de la sucursal Norte; lo sacó de Sur. El JOIN la ha inventado combinando sus dos títulos con sus dos sucursales.
Y aquí está lo peor: esa fila es indistinguible de las verdaderas. No hay ninguna marca que la señale. Cualquier informe sobre qué se presta en cada sucursal saldrá mal, y nadie sabrá por qué.
¿Por qué ha fallado? Porque el atributo compartido, socio_email, no es clave de ninguna de las dos tablas: un socio tiene varios títulos y varias sucursales. No se cumple la condición de Heath, y por tanto la descomposición no está garantizada.
La descomposición correcta de estos tres atributos sería por un determinante real. Como el título depende del ejemplar y el ejemplar de la sucursal, la ruta correcta pasa por ejemplar_cod, que es exactamente lo que hicimos en la sección 5.
Regla de bolsillo: antes de partir una tabla, pregúntate "¿la columna por la que voy a unirlas después es clave primaria de al menos una de las dos?". Si la respuesta es no, no partas: estás a punto de inventar datos.
- Conservación de las dependencias
La segunda propiedad. Ya la conocimos en 05-02 al hablar de la FNBC; aquí la formalizamos y la comprobamos sobre nuestra descomposición.
Definición. Una descomposición conserva las dependencias si toda dependencia del conjunto original
Fse puede comprobar dentro de una sola de las tablas resultantes, sin necesidad de reunirlas.
Por qué importa, en términos prácticos: una dependencia que vive dentro de una tabla se garantiza con una PRIMARY KEY o un UNIQUE, y el SGBD la vigila en cada INSERT sin que nadie tenga que acordarse. Una dependencia repartida entre dos tablas necesita un disparador, código de aplicación o una consulta periódica de auditoría: es decir, algo que se puede olvidar, desactivar o ejecutar tarde.
Comprobación sobre el resultado de BiblioRed
| Dependencia | ¿En qué tabla vive? | ¿Cómo se garantiza? |
|---|---|---|
{ejemplar_cod, fecha_prestamo} → socio_email |
p3_prestamos |
Clave primaria |
{ejemplar_cod, fecha_prestamo} → fecha_devolucion |
p3_prestamos |
Clave primaria |
ejemplar_cod → isbn |
p3_ejemplares |
Clave primaria |
ejemplar_cod → sucursal_nombre |
p3_ejemplares |
Clave primaria |
isbn → titulo |
p3_obras |
Clave primaria |
autor → autor_nac |
p3_autores |
Clave primaria |
socio_email → socio_nombre |
p3_socios |
Clave primaria |
sucursal_nombre → sucursal_cp |
p3_sucursales |
Clave primaria |
sucursal_cp → sucursal_ciudad |
p3_codigos_postales |
Clave primaria |
Las nueve dependencias están conservadas, y las nueve se garantizan con una clave primaria. Ni un disparador, ni una línea de código de aplicación.
Esto no es casualidad. Cuando la descomposición se hace sacando cada dependencia a una tabla cuyo determinante es la clave primaria, la conservación viene de regalo: la dependencia X → Y se convierte literalmente en "X es la clave primaria de la tabla que contiene Y", y eso es lo que una clave primaria significa.
El caso problemático es el que vimos en 05-02, sección 8: cuando hay claves candidatas solapadas y se fuerza la FNBC, alguna dependencia puede quedar repartida. Aquí no ha ocurrido porque cada tabla tiene una sola clave candidata.
Cuando una dependencia se pierde: qué hacer
Si al terminar una descomposición hay una dependencia que no vive en ninguna tabla, tienes tres salidas, en orden de preferencia:
| Opción | Cuándo | Coste |
|---|---|---|
| Retroceder a 3FN | Si la dependencia perdida es una regla crítica y la redundancia que se acepta es pequeña | Redundancia controlada, anomalías de actualización posibles |
| Añadir un disparador | Si la FNBC compensa y la regla se puede comprobar en un BEFORE INSERT/UPDATE |
Código que mantener; coste en cada escritura |
| Auditoría periódica | Si la violación es tolerable durante horas y se puede corregir después | La base de datos puede estar temporalmente inconsistente |
Lo que no es una opción es no darse cuenta. Haz siempre la tabla de la sección anterior: una fila por dependencia, una columna con la tabla donde vive. Si alguna celda queda vacía, decide conscientemente.
- La cobertura mínima y el algoritmo de síntesis 3FN
Todo lo que hemos hecho ha sido por descomposición: partir de una tabla grande e ir partiéndola. Existe el camino contrario, que se llama síntesis: partir del conjunto de dependencias y construir las tablas directamente. Lo presentamos a nivel de idea, porque conviene saber que existe.
Cobertura mínima
Una cobertura mínima (o cubrimiento canónico) de un conjunto de dependencias
Fes otro conjuntoFcque determina exactamente lo mismo queF—tiene el mismo cierre— pero está reducido al mínimo: cada dependencia tiene un solo atributo a la derecha, ningún atributo del lado izquierdo es superfluo, y ninguna dependencia entera es superflua.
Se calcula en tres pasos:
- Desagregar los lados derechos, usando la regla de descomposición de 05-01.
- Quitar atributos superfluos de la izquierda: para cada
{A, B} → C, comprobar siA → Cya se deduce del resto; si sí,Bsobraba. - Quitar dependencias redundantes: para cada
X → Y, quitarla del conjunto y comprobar con el cierre siY ⊆ X⁺sigue cumpliéndose usando solo las demás; si sí, sobraba.
Nuestro F de BiblioRed ya está prácticamente en cobertura mínima: está desagregado, ningún lado izquierdo tiene atributos de sobra, y ninguna dependencia se deduce de las otras. Solo hay que vigilar las que se deducen por transitividad. Por ejemplo, si alguien hubiera añadido ejemplar_cod → titulo a la lista, sería redundante, porque ya se deduce de ejemplar_cod → isbn e isbn → titulo. Incluirla llevaría a crear una tabla de más.
El algoritmo de síntesis 3FN
Con la cobertura mínima calculada, el algoritmo es sorprendentemente directo:
1. Calcular la cobertura mínima Fc de F. 2. Agrupar las dependencias de Fc que tengan el MISMO lado izquierdo. Crear una tabla por cada grupo, con los atributos del lado izquierdo (clave primaria) más todos los derechos del grupo. 3. Si ninguna de las tablas creadas contiene una clave candidata de la relación original, añadir una tabla más formada por una clave candidata. 4. Eliminar las tablas cuyos atributos estén contenidos en otra.
Aplicado a nuestro F, agrupando por lado izquierdo:
| Lado izquierdo | Dependencias | Tabla resultante |
|---|---|---|
{ejemplar_cod, fecha_prestamo} |
f1, f2 | (ejemplar_cod, fecha_prestamo, socio_email, fecha_devolucion) |
ejemplar_cod |
f3, f4 | (ejemplar_cod, isbn, sucursal_nombre) |
isbn |
f5 | (isbn, titulo) |
autor |
f6 | (autor, autor_nac) |
socio_email |
f7 | (socio_email, socio_nombre) |
sucursal_nombre |
f8 | (sucursal_nombre, sucursal_cp) |
sucursal_cp |
f9 | (sucursal_cp, sucursal_ciudad) |
Siete tablas, y la primera contiene la clave candidata, así que el paso 3 no añade nada. Son exactamente las siete tablas a las que llegamos por descomposición (las otras dos, p3_telefonos y p3_obras_autores, salieron de los atributos multivaluados, que no producen dependencias funcionales y por tanto quedan fuera de este algoritmo).
Dos cosas hay que saber de este algoritmo:
- Garantiza 3FN, conservación de dependencias y descomposición sin pérdida. Las tres a la vez. Es un resultado fuerte y es la razón por la que la 3FN se considera el objetivo por defecto: siempre es alcanzable sin sacrificar nada.
- No garantiza FNBC. Ya sabemos por qué: puede no existir una descomposición a FNBC que conserve las dependencias.
En la práctica casi nadie ejecuta este algoritmo a mano en un proyecto real —se diseña por comprensión del dominio y se verifica con normalización—, pero conocerlo cambia cómo miras un esquema: cada tabla bien diseñada corresponde a un grupo de dependencias con el mismo determinante, y ese determinante es su clave primaria. Si encuentras una tabla que no encaja en esa descripción, tienes algo que revisar.
- Paso 5: verificación con consultas de control
Terminada la descomposición y la migración, hay que demostrar que el resultado es correcto. No basta con mirarlo: hay que ejecutar comprobaciones. Estas son las cuatro que no deben faltar en ninguna migración.
12.1 El recuento de la tabla de hechos
La tabla principal debe tener exactamente las mismas filas que el original:
SELECT (SELECT COUNT(*) FROM prestamos_hoja) AS origen,
(SELECT COUNT(*) FROM p3_prestamos) AS destino;Si el destino tiene menos, se han perdido préstamos (probablemente por duplicados exactos eliminados por un DISTINCT mal puesto). Si tiene más, algo se ha multiplicado.
12.2 Los recuentos de las tablas de catálogo
Cada tabla nueva debe tener tantas filas como valores distintos había en el original después de la limpieza:
SELECT 'socios' AS tabla,
(SELECT COUNT(DISTINCT socio_email) FROM prestamos_hoja) AS esperado,
(SELECT COUNT(*) FROM p3_socios) AS real_
UNION ALL
SELECT 'obras',
(SELECT COUNT(DISTINCT isbn) FROM prestamos_hoja),
(SELECT COUNT(*) FROM p3_obras)
UNION ALL
SELECT 'ejemplares',
(SELECT COUNT(DISTINCT ejemplar_cod) FROM prestamos_hoja),
(SELECT COUNT(*) FROM p3_ejemplares)
UNION ALL
SELECT 'sucursales',
(SELECT COUNT(DISTINCT sucursal_nombre) FROM prestamos_hoja),
(SELECT COUNT(*) FROM p3_sucursales);tabla | esperado | real_ ------------+----------+------- socios | 4 | 3 obras | 4 | 3 ejemplares | 5 | 5 sucursales | 3 | 2
Las discrepancias no son errores: son el registro de la limpieza. Cuatro correos distintos daban tres socios (se fusionó [email protected]); cuatro ISBN daban tres obras (se corrigió el truncado); tres nombres de sucursal daban dos (Norte/norte). Cada diferencia debe estar justificada y anotada. Una diferencia que no sepas explicar es un error.
12.3 La reconstrucción completa
La prueba definitiva: reunir todo y comparar con el original.
CREATE OR REPLACE VIEW v_prestamos_reconstruido AS
SELECT p.ejemplar_cod,
p.fecha_prestamo,
p.fecha_devolucion,
p.socio_email,
s.socio_nombre,
e.isbn,
o.titulo,
e.sucursal_nombre,
cp.sucursal_ciudad,
su.sucursal_cp
FROM p3_prestamos p
JOIN p3_socios s ON s.socio_email = p.socio_email
JOIN p3_ejemplares e ON e.ejemplar_cod = p.ejemplar_cod
JOIN p3_obras o ON o.isbn = e.isbn
JOIN p3_sucursales su ON su.sucursal_nombre = e.sucursal_nombre
JOIN p3_codigos_postales cp ON cp.sucursal_cp = su.sucursal_cp;
-- Debe devolver el mismo número de filas que el original
SELECT COUNT(*) FROM v_prestamos_reconstruido;Seis filas, las mismas que había. Ni una de más: no hemos inventado nada.
Fíjate en una cosa: usar JOIN y no LEFT JOIN es parte de la prueba. Si algún préstamo apuntara a un socio, un ejemplar o una obra inexistente, el JOIN interno lo dejaría fuera y el recuento bajaría. Un recuento que cuadra con JOIN interno demuestra a la vez que no falta nada y que la integridad referencial se cumple.
12.4 Las mismas consultas dan las mismas respuestas
Por último, y esto es lo que convence a quien paga: las consultas que el negocio hacía sobre la hoja tienen que seguir funcionando y dar el mismo resultado.
-- "¿Cuántos préstamos hizo cada socio?" — sobre la hoja original
SELECT socio_email, COUNT(*) FROM prestamos_hoja GROUP BY socio_email;
-- La misma pregunta, sobre el esquema normalizado
SELECT s.socio_email, s.socio_nombre, COUNT(*) AS prestamos
FROM p3_prestamos p
JOIN p3_socios s ON s.socio_email = p.socio_email
GROUP BY s.socio_email, s.socio_nombre
ORDER BY prestamos DESC;socio_email | socio_nombre | prestamos ----------------------+---------------+----------- [email protected] | Marta Alsina | 3 [email protected] | Nuria Bastos | 2 [email protected] | Iván Pereda | 1
Sobre la hoja original, esa consulta daba cuatro filas y le atribuía a Marta solo dos préstamos, porque el tercero estaba bajo el correo mal escrito. El esquema normalizado no solo da la misma respuesta: da la respuesta correcta, que la hoja no daba.
- Paso 6: reponer las claves ajenas
La descomposición ha dejado columnas que apuntan a otras tablas sin declararlo. Hay que decírselo al SGBD, porque hasta que no lo hagas nada impide un préstamo de un socio inexistente.
ALTER TABLE p3_prestamos
ADD CONSTRAINT fk_prestamos_ejemplar FOREIGN KEY (ejemplar_cod)
REFERENCES p3_ejemplares (ejemplar_cod) ON DELETE RESTRICT ON UPDATE CASCADE,
ADD CONSTRAINT fk_prestamos_socio FOREIGN KEY (socio_email)
REFERENCES p3_socios (socio_email) ON DELETE RESTRICT ON UPDATE CASCADE,
ADD CONSTRAINT chk_prestamos_fechas
CHECK (fecha_devolucion IS NULL OR fecha_devolucion >= fecha_prestamo);
ALTER TABLE p3_ejemplares
ADD CONSTRAINT fk_ejemplares_obra FOREIGN KEY (isbn)
REFERENCES p3_obras (isbn) ON UPDATE CASCADE,
ADD CONSTRAINT fk_ejemplares_sucursal FOREIGN KEY (sucursal_nombre)
REFERENCES p3_sucursales (sucursal_nombre) ON UPDATE CASCADE;
ALTER TABLE p3_sucursales
ADD CONSTRAINT fk_sucursales_cp FOREIGN KEY (sucursal_cp)
REFERENCES p3_codigos_postales (sucursal_cp) ON UPDATE CASCADE;
ALTER TABLE p3_obras_autores
ADD CONSTRAINT fk_oa_obra FOREIGN KEY (isbn) REFERENCES p3_obras (isbn)
ON DELETE CASCADE ON UPDATE CASCADE,
ADD CONSTRAINT fk_oa_autor FOREIGN KEY (autor) REFERENCES p3_autores (autor)
ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE p3_telefonos
ADD CONSTRAINT fk_telefonos_socio FOREIGN KEY (socio_email)
REFERENCES p3_socios (socio_email) ON DELETE CASCADE ON UPDATE CASCADE;Y con esto, la restricción del defecto 6 del diagnóstico de 01-01 —la devolución anterior al préstamo de Iván Pereda— queda impedida por el CHECK. Si algún dato histórico la incumple, el ALTER TABLE fallará y tendrás que decidir: corregir el dato o añadir la restricción como NOT VALID, como vimos en 04-04.
Los criterios para elegir CASCADE, RESTRICT o SET NULL en cada clave ajena son los de la lección 02-06; aquí solo se aplican.
El resultado final: de una hoja con ocho defectos catalogados, seis han quedado estructuralmente imposibles (redundancia de socio, redundancia de libro, inconsistencia de nombre, inconsistencia de autor, formato incoherente de sucursal, fragmentación entre ficheros), uno lo impide ahora un CHECK (la fecha imposible), y uno —el ISBN truncado— requiere una validación de dominio, que es trabajo de 04-04 y no de normalización.
- Normalizar en la vida real: diseño nuevo frente a producción
Todo lo anterior se ha hecho sobre una tabla parada. En la realidad hay dos situaciones muy distintas.
Caso A: diseño nuevo
Es el caso fácil y el más frecuente en un proyecto que empieza. No se normaliza al final: se diseña normalizado desde el principio. Se hace el modelo ER (04-02), se transforma (04-03), se eligen tipos y restricciones (04-04), y la normalización se usa como lista de verificación antes de escribir la primera línea de código de aplicación.
La revisión es rápida cuando el diseño está bien hecho: tabla por tabla, escribes sus dependencias, calculas su clave candidata y compruebas la 3FN. Media hora para un esquema de veinte tablas. Y lo que encuentra suele ser poco pero valioso: una columna que se coló donde no tocaba, una regla de negocio que nadie había escrito.
Caso B: base de datos en producción
Aquí el problema no es la teoría: es que hay ochenta mil filas, cuarenta consultas escritas contra el esquema viejo y un mostrador que abre mañana a las nueve. La normalización correcta se sabe hacer; lo difícil es aplicarla sin cortar el servicio.
La técnica estándar es la migración por fases con expansión y contracción:
Fase 1 — Expandir (sin romper nada). Se crean las tablas nuevas junto a la vieja, vacías. Nada las usa todavía. La aplicación sigue funcionando exactamente igual.
Fase 2 — Rellenar en segundo plano. Se migran los datos históricos con los INSERT ... SELECT DISTINCT que hemos visto, por lotes, en horas de poco tráfico, y con las consultas de refutación ejecutadas antes para saber qué se va a romper.
-- Migración por lotes: no bloquea la tabla durante horas
INSERT INTO socios (email, nombre)
SELECT DISTINCT socio_email, socio_nombre
FROM prestamos_hoja
WHERE fecha_prestamo BETWEEN '2018-01-01' AND '2018-12-31'
ON CONFLICT (email) DO NOTHING;Fase 3 — Doble escritura. Se modifica la aplicación para que escriba en las dos estructuras a la vez, la vieja y la nueva, dentro de la misma transacción. Sigue leyendo de la vieja. Este es el punto de no retorno más suave posible: si algo va mal, se desactiva la escritura nueva y no ha pasado nada.
Fase 4 — Cambiar las lecturas. Se van moviendo las consultas al esquema nuevo, una a una, empezando por las menos críticas. Una vista con el nombre de la tabla vieja que lea del esquema nuevo permite mover muchas consultas sin tocar el código:
-- La aplicación sigue haciendo SELECT ... FROM prestamos_hoja
-- pero ahora lee del esquema normalizado.
CREATE VIEW prestamos_hoja AS
SELECT p.ejemplar_cod, p.fecha_prestamo, p.fecha_devolucion,
s.email AS socio_email, s.nombre AS socio_nombre, ...
FROM prestamos p JOIN socios s ON ...;Fase 5 — Contraer. Cuando ninguna consulta usa ya la estructura vieja, se retira la doble escritura y se borra la tabla antigua. Antes de borrarla, se guarda una copia: siempre.
Cinco reglas que no hay que saltarse:
- Todo se ensaya primero en una copia de producción. Con el volumen real, no con seis filas.
- Las consultas de refutación se ejecutan antes de empezar, para tener la lista de datos sucios y decidir qué hacer con cada caso. No se descubre a mitad de la migración.
- Cada fase es reversible por sí sola. Si la fase 4 sale mal, se vuelve a leer de la vieja.
- Las consultas de control de la sección 12 se ejecutan después de cada fase, no solo al final.
- La limpieza de datos se documenta. Cada fila fusionada, cada valor corregido, con su criterio. Alguien preguntará dentro de dos años por qué Marta Alsina tiene tres préstamos y no dos.
- Examen del esquema ampliado del módulo 4
Y llegamos a la prueba prometida al cerrar el módulo 4: someter el esquema de la ampliación de BiblioRed al instrumental formal. Vamos tabla por tabla con las cuatro que el enunciado del módulo señalaba como las más comprometidas.
15.1 eventos
evento_id → {titulo, descripcion, tipo_evento_id, sala_id, inicio, fin,
plazas_ofertadas, estado, publicado, duracion_min}
{inicio, fin} → duracion_minClave candidata única: {evento_id}, de un solo atributo. 2FN garantizada.
Comprobación de 3FN sobre la segunda dependencia:
- ¿Es
{inicio, fin}superclave? No: dos eventos distintos pueden empezar y acabar a la vez en salas distintas. - ¿Es
duracion_minprimo? No.
Formalmente, eventos viola la 3FN. duracion_min es una dependencia transitiva: depende de inicio y fin, que no son clave.
Y sin embargo la dejamos. La razón está en el CREATE TABLE de 04-04:
Es una columna generada, y la palabra clave es ALWAYS: PostgreSQL calcula el valor en cada INSERT y en cada UPDATE, y no permite escribirlo a mano. La contradicción que la 3FN existe para evitar —que duracion_min diga una cosa y las fechas digan otra— es físicamente imposible.
Criterio general: una columna generada por el SGBD es una desnormalización con garantía. Viola la forma normal en la letra, no en el espíritu, porque el riesgo que la forma normal previene está eliminado por otro mecanismo. Anótala como tal en la documentación del esquema y sigue adelante.
Veredicto: 3FN a efectos prácticos. Desnormalización documentada y garantizada por el SGBD.
Hay un segundo punto más discutible: la columna estado, que puede valer 'completo'. Ese valor se puede deducir comparando plazas_ofertadas con la suma de plazas_ocupadas de las inscripciones. Es información derivada de otra tabla, y no la garantiza nada. Aquí no hay dependencia funcional que lo capture —la teoría de la normalización no habla entre tablas—, pero es redundancia igual, y del tipo peligroso: nada impide que estado = 'completo' con plazas libres. Es un caso de manual para el tratamiento de la lección 05-04.
15.2 inscripciones
{evento_id, socio_id} → {fecha_inscripcion, estado, acompanantes, plazas_ocupadas}
acompanantes → plazas_ocupadasClave candidata: {evento_id, socio_id}, compuesta. Hay que comprobar la 2FN con cuidado.
{evento_id}⁺={evento_id}. Ninguna dependencia arranca conevento_idsolo. Sin dependencias parciales por ahí.{socio_id}⁺={socio_id}. Igual. Sin dependencias parciales.
Está en 2FN. Y es un buen resultado: significa que fecha_inscripcion, estado y acompanantes son genuinamente hechos sobre la inscripción, no sobre el evento ni sobre el socio. Si alguien hubiera metido aquí socio_nombre o evento_titulo —la tentación de siempre— habría dependencias parciales inmediatas.
Para la 3FN, el único candidato es acompanantes → plazas_ocupadas, y es exactamente el mismo caso que duracion_min: una columna generada ALWAYS AS (1 + acompanantes) STORED. Misma conclusión.
Veredicto: FNBC a efectos prácticos (clave candidata única + 3FN ⟹ FNBC), con una desnormalización garantizada por el SGBD.
15.3 multas
Aquí está el hallazgo del examen. Las dependencias:
multa_id → {socio_id, prestamo_id, motivo, importe, fecha_emision, estado}
prestamo_id → socio_id ← ¡ATENCIÓN!La segunda sale de una regla que no habíamos escrito nunca pero que es evidente: un préstamo lo hizo un socio concreto. Si la multa 900 está asociada al préstamo 5001, y el préstamo 5001 lo hizo el socio 14, entonces el socio de la multa 900 tiene que ser el 14. No hay elección.
Comprobación de 3FN:
- ¿Es
prestamo_idsuperclave demultas? No: un préstamo puede generar dos multas (una por retraso y otra por deterioro; de hecho la restricciónuq_multas_prestamo_motivolo contempla explícitamente). - ¿Es
socio_idprimo? La única clave candidata es{multa_id}. No.
multas viola la 3FN. Es una dependencia transitiva multa_id → prestamo_id → socio_id, y la anomalía es real y grave:
-- Nada impide esto: una multa asociada al préstamo 5001 (del socio 14)
-- pero atribuida al socio 16.
INSERT INTO multas (socio_id, prestamo_id, motivo, importe)
VALUES (16, 5001, 'retraso', 3.50);El SGBD la acepta. Las dos claves ajenas se cumplen —el socio 16 existe, el préstamo 5001 existe— pero el dato es falso: acabamos de multar a Nuria Bastos por un retraso de Marta Alsina. Y en un sistema de multas, eso no es un detalle académico: es una reclamación.
La consulta que lo detecta:
SELECT m.multa_id, m.socio_id AS socio_multa, p.socio_id AS socio_prestamo
FROM multas m
JOIN prestamos p ON p.prestamo_id = m.prestamo_id
WHERE m.socio_id <> p.socio_id;¿Cuál es la corrección? Hay tres opciones y las tres son defendibles según el caso:
| Opción | Cómo | Ventaja | Inconveniente |
|---|---|---|---|
A. Eliminar socio_id |
Quitar la columna; obtener el socio por JOIN con prestamos |
3FN pura, imposible contradecirse | No funciona: hay multas sin préstamo (prestamo_id es opcional, decisión D6 de 04-03) — pérdida de carné, deterioro de una sala |
| B. Restricción cruzada | Mantener socio_id y añadir un disparador que lo compare con el del préstamo |
Conserva ambos casos y garantiza la coherencia | Un disparador que mantener; coste en cada escritura |
| C. Clave ajena compuesta | Añadir UNIQUE (prestamo_id, socio_id) en prestamos y una FK compuesta (prestamo_id, socio_id) desde multas |
Lo garantiza el SGBD, sin código | Requiere un UNIQUE redundante en prestamos |
La opción C es la más elegante y la que hay que conocer, porque es un truco de diseño que resuelve muchos casos de este tipo:
-- 1. Una clave alternativa "redundante" en prestamos que incluya el socio
ALTER TABLE prestamos
ADD CONSTRAINT uq_prestamos_id_socio UNIQUE (prestamo_id, socio_id);
-- 2. La clave ajena de multas apunta a la pareja, no solo al préstamo
ALTER TABLE multas
DROP CONSTRAINT fk_multas_prestamo,
ADD CONSTRAINT fk_multas_prestamo_socio
FOREIGN KEY (prestamo_id, socio_id)
REFERENCES prestamos (prestamo_id, socio_id)
ON DELETE RESTRICT ON UPDATE CASCADE;Ahora el INSERT falso de antes es imposible:
ERROR: inserción o actualización en la tabla «multas» viola la llave foránea
«fk_multas_prestamo_socio»
DETALLE: La llave (prestamo_id, socio_id)=(5001, 16) no está presente
en la tabla «prestamos».Y cuando prestamo_id es NULL —multa sin préstamo— la clave ajena no se comprueba (comportamiento MATCH SIMPLE, el predeterminado en SQL), así que esos casos siguen funcionando. Lo mejor de las dos opciones.
Veredicto: multas no estaba en 3FN. Corregida con una clave ajena compuesta, la dependencia queda garantizada por el SGBD. Este es el tipo de hallazgo que justifica todo el módulo: es un fallo real, con consecuencias reales, que el diseño por intuición del módulo 4 no vio y que el análisis formal encuentra en dos minutos.
Un segundo punto sobre multas, más sutil. Si BiblioRed tuviera una tarifa fija por motivo —motivo → importe— sería otra violación de 3FN y la tarifa debería estar en una tabla tarifas_multa. Pero el importe no se deduce del motivo: depende de los días de retraso, y sobre todo tiene que quedar congelado con el valor que tuvo el día de la emisión, aunque la ordenanza cambie después. Guardarlo en multas es correcto y necesario. Es un caso de duplicación histórica congelada, exactamente igual que el que vimos en 03-03 para el modelado documental, y su tratamiento es materia de 05-04.
15.4 pagos
Clave candidata única: {pago_id}. 2FN garantizada.
¿Hay dependencias transitivas? Repasemos las columnas: fecha_pago, importe, metodo y referencia son todos hechos sobre el pago concreto. metodo → referencia no se cumple (varios pagos con tarjeta tienen referencias distintas). importe → nada. multa_id → nada más dentro de esta tabla.
Veredicto: pagos está en FNBC. Sin observaciones.
Aunque conviene señalar la trampa que no cayó: si alguien hubiera añadido socio_id a pagos "para no tener que hacer dos JOIN", tendríamos multa_id → socio_id y exactamente el mismo problema que en multas. Y si hubiera añadido importe_multa para poder comparar, tendríamos multa_id → importe_multa. Las dos son tentaciones habituales y las dos son violaciones de 3FN. Que no estén ahí es mérito del diseño de 04-03.
Resumen del examen
| Tabla | Forma normal | Observaciones |
|---|---|---|
eventos |
3FN* | duracion_min es columna generada ALWAYS: desnormalización garantizada. estado='completo' es información derivada de inscripciones sin garantía: revisar en 05-04 |
inscripciones |
FNBC* | plazas_ocupadas es columna generada ALWAYS: desnormalización garantizada |
multas |
Violaba 3FN | prestamo_id → socio_id. Corregido con clave ajena compuesta (prestamo_id, socio_id) |
pagos |
FNBC | Sin observaciones |
(El asterisco marca las tablas cuya única desviación formal es una columna generada por el SGBD.)
Conclusión del examen: el esquema del módulo 4 aguanta. De once tablas nuevas, diez estaban correctas y una tenía un fallo real que ahora está corregido. Es un buen resultado para un diseño hecho por comprensión del dominio, y a la vez la demostración de que la verificación formal no sobra: ese fallo estaba ahí y nadie lo había visto.
Errores Comunes y Consejos
Saltarse el paso 1 y deducir las dependencias de los datos. Ya lo advertimos en 05-01 y aquí se paga el doble, porque una dependencia inventada produce una descomposición que rechazará datos legítimos en producción. Las consultas de refutación sirven para detectar contradicciones, no para descubrir reglas.
Descomponer sin comprobar la condición de Heath. Es la causa del contraejemplo de la sección 9, y su síntoma es que el JOIN de reconstrucción devuelve más filas que el original. Antes de cada CREATE TABLE, pregúntate cuál será la columna de unión y si es clave primaria de alguna de las dos tablas.
Olvidar el DISTINCT en la migración. INSERT INTO socios SELECT socio_email, socio_nombre FROM prestamos_hoja fallará con violación de clave primaria en cuanto un socio tenga dos préstamos. El DISTINCT no es una optimización: es parte del significado de la migración.
Interpretar un error de clave duplicada como un fallo de la migración. Casi siempre es lo contrario: es el esquema nuevo rechazando una contradicción que el viejo permitía. Antes de tocar el INSERT, mira qué filas chocan; ahí está tu lista de datos sucios.
Mezclar limpieza de datos y normalización en el mismo paso. Son dos trabajos con criterios distintos y hay que separarlos: primero la estructura, y cuando la estructura rechace algo, se anota, se decide el criterio de limpieza con el negocio y se aplica. Corregir sobre la marcha produce decisiones improvisadas que nadie documenta.
Dar por terminada la migración sin las consultas de control. El recuento de la tabla de hechos, los recuentos de catálogo, la reconstrucción con JOIN interno y la comparación de las consultas del negocio. Cuatro consultas, quince minutos, y son la diferencia entre "creo que está bien" y "está bien".
Normalizar en producción de golpe. Nunca. Expandir, rellenar, doble escritura, cambiar lecturas, contraer. Cada fase reversible, cada fase verificada.
No documentar por qué una tabla se queda como está. El caso de duracion_min es el ejemplo perfecto: alguien que audite el esquema dentro de dos años verá una violación de 3FN y la "arreglará". Escribe al lado que es una columna generada ALWAYS y que la decisión es consciente. La documentación del esquema de 04-01 es el sitio.
Ejercicios
Ejercicio 1: Normalizar hasta 3FN
BiblioRed recibe de una biblioteca vecina que se integra en la red esta tabla plana:
donaciones(donante_nif, donante_nombre, donante_ciudad, ciudad_provincia, material_isbn, material_titulo, fecha_donacion, estado_conservacion)
Reglas de negocio confirmadas:
- Un donante puede donar el mismo material en fechas distintas.
- El NIF identifica al donante, con su nombre y su ciudad.
- Cada ciudad pertenece a una provincia.
- El ISBN identifica el material y su título.
- El estado de conservación se anota en el momento de cada donación concreta.
Se pide:
- a) Escribir el conjunto
Fde dependencias funcionales. - b) Determinar la clave candidata calculando el cierre.
- c) Identificar las violaciones de 2FN y de 3FN.
- d) Escribir el
CREATE TABLEde las tablas resultantes en 3FN y elINSERT ... SELECT DISTINCTque migraría los datos.
Ejercicio 2: Detectar una descomposición con pérdida
Un compañero propone descomponer la tabla participaciones(evento_id, ponente_id, rol) de BiblioRed en dos:
R1(evento_id, ponente_id)R2(evento_id, rol)
con el argumento de que "así separamos quién viene de qué se hace".
Con estos datos:
| evento_id | ponente_id | rol |
|---|---|---|
| 210 | 7 | moderador |
| 210 | 9 | tallerista |
| 211 | 7 | tallerista |
Se pide:
- a) Construir
R1yR2y hacer el JOIN natural porevento_id. - b) Decir cuántas filas salen y cuáles son falsas.
- c) Explicar con la condición de Heath por qué falla.
- d) Decir qué información se ha destruido irreversiblemente.
Ejercicio 3: Examinar una tabla del esquema real
BiblioRed quiere añadir al esquema una tabla para las reservas anticipadas de salas por parte de entidades externas:
CREATE TABLE cesiones_sala (
cesion_id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
sala_id INTEGER NOT NULL REFERENCES salas (sala_id),
sala_aforo SMALLINT NOT NULL,
sucursal_id INTEGER NOT NULL REFERENCES sucursales (sucursal_id),
entidad_nif VARCHAR(9) NOT NULL,
entidad_nombre VARCHAR(120) NOT NULL,
inicio TIMESTAMPTZ NOT NULL,
fin TIMESTAMPTZ NOT NULL,
tarifa_hora NUMERIC(6,2) NOT NULL,
importe_total NUMERIC(8,2) NOT NULL
);Sabiendo que cada sala tiene un aforo y pertenece a una sucursal, que el NIF identifica a la entidad, que la tarifa por hora depende de la sucursal, y que el importe total es la tarifa por las horas de cesión:
- a) Escribir las dependencias funcionales.
- b) Determinar en qué forma normal está y qué dependencias la violan.
- c) Proponer la corrección, indicando qué columna conviene no eliminar aunque sea redundante, y por qué.
Soluciones
Solución 1
a) Dependencias funcionales:
g1: {donante_nif, material_isbn, fecha_donacion} → estado_conservacion
g2: donante_nif → donante_nombre
g3: donante_nif → donante_ciudad
g4: donante_ciudad → ciudad_provincia
g5: material_isbn → material_titulob) Clave candidata. Atributos solo a la izquierda: fecha_donacion. Atributos en ambos lados: donante_nif, material_isbn, donante_ciudad. Solo a la derecha: el resto.
El núcleo obligatorio incluye fecha_donacion. Probamos {donante_nif, material_isbn, fecha_donacion}:
| Pasada | Dependencia | Se añade |
|---|---|---|
| 1 | g1 | estado_conservacion |
| 1 | g2 | donante_nombre |
| 1 | g3 | donante_ciudad |
| 1 | g5 | material_titulo |
| 2 | g4 (ahora donante_ciudad está) |
ciudad_provincia |
Los ocho atributos. Es superclave. Y es mínima: sin fecha_donacion no se determina estado_conservacion (el mismo donante puede donar el mismo material dos veces con estados distintos); sin donante_nif no se llega a los datos del donante; sin material_isbn no se llega al título.
Clave candidata: {donante_nif, material_isbn, fecha_donacion}. Primos: esos tres. No primos: los otros cinco.
c) Violaciones.
2FN — dependencias parciales (partes de la clave que determinan atributos no primos):
g2yg3:donante_nifes un tercio de la clave y determinadonante_nombreydonante_ciudad. Parcial.g5:material_isbnes un tercio de la clave y determinamaterial_titulo. Parcial.
3FN — dependencia transitiva:
g4:donante_ciudad → ciudad_provincia, condonante_ciudadno superclave yciudad_provinciano primo. Transitiva.
d) Esquema resultante:
CREATE TABLE provincias_ciudad (
ciudad VARCHAR(60) NOT NULL,
provincia VARCHAR(60) NOT NULL,
CONSTRAINT pk_provincias_ciudad PRIMARY KEY (ciudad)
);
CREATE TABLE donantes (
nif VARCHAR(9) NOT NULL,
nombre VARCHAR(120) NOT NULL,
ciudad VARCHAR(60) NOT NULL,
CONSTRAINT pk_donantes PRIMARY KEY (nif),
CONSTRAINT fk_donantes_ciudad FOREIGN KEY (ciudad)
REFERENCES provincias_ciudad (ciudad) ON UPDATE CASCADE
);
CREATE TABLE materiales_donados (
isbn VARCHAR(13) NOT NULL,
titulo VARCHAR(200) NOT NULL,
CONSTRAINT pk_materiales_donados PRIMARY KEY (isbn)
);
CREATE TABLE donaciones (
donante_nif VARCHAR(9) NOT NULL,
material_isbn VARCHAR(13) NOT NULL,
fecha_donacion DATE NOT NULL,
estado_conservacion VARCHAR(20) NOT NULL,
CONSTRAINT pk_donaciones PRIMARY KEY (donante_nif, material_isbn, fecha_donacion),
CONSTRAINT fk_donaciones_donante FOREIGN KEY (donante_nif)
REFERENCES donantes (nif) ON UPDATE CASCADE,
CONSTRAINT fk_donaciones_material FOREIGN KEY (material_isbn)
REFERENCES materiales_donados (isbn) ON UPDATE CASCADE
);Migración, en orden de dependencia (primero las tablas referenciadas):
INSERT INTO provincias_ciudad (ciudad, provincia)
SELECT DISTINCT donante_ciudad, ciudad_provincia FROM donaciones_hoja;
INSERT INTO donantes (nif, nombre, ciudad)
SELECT DISTINCT donante_nif, donante_nombre, donante_ciudad FROM donaciones_hoja;
INSERT INTO materiales_donados (isbn, titulo)
SELECT DISTINCT material_isbn, material_titulo FROM donaciones_hoja;
INSERT INTO donaciones (donante_nif, material_isbn, fecha_donacion, estado_conservacion)
SELECT donante_nif, material_isbn, fecha_donacion, estado_conservacion
FROM donaciones_hoja;Nótese que solo la última no lleva DISTINCT: es la tabla de hechos y debe conservar exactamente las mismas filas que el original.
Solución 2
a) Las dos proyecciones:
R1(evento_id, ponente_id)
| evento_id | ponente_id |
|---|---|
| 210 | 7 |
| 210 | 9 |
| 211 | 7 |
R2(evento_id, rol)
| evento_id | rol |
|---|---|
| 210 | moderador |
| 210 | tallerista |
| 211 | tallerista |
JOIN natural por evento_id:
| evento_id | ponente_id | rol | ¿Existía? |
|---|---|---|---|
| 210 | 7 | moderador | Sí |
| 210 | 7 | tallerista | NO |
| 210 | 9 | moderador | NO |
| 210 | 9 | tallerista | Sí |
| 211 | 7 | tallerista | Sí |
b) Cinco filas donde había tres. Dos son falsas: dicen que el ponente 7 fue tallerista en el evento 210 y que el ponente 9 fue moderador, cuando fue justo al revés.
c) Falla la condición de Heath. El atributo compartido es evento_id, y no es clave primaria de ninguna de las dos tablas: el evento 210 aparece dos veces en R1 y dos veces en R2. Al reunir, esas dos filas por lado se combinan entre sí y producen 2 × 2 = 4 filas donde había 2. Es el mismo mecanismo del contraejemplo de la sección 9.
Para que la descomposición fuera sin pérdida haría falta una dependencia evento_id → ponente_id o evento_id → rol, y ninguna se cumple: un evento tiene varios ponentes y varios roles.
d) Se ha destruido la asociación entre ponente y rol. Ese es el hecho que la tabla existía para guardar: no "en el evento 210 participaron el 7 y el 9" ni "en el evento 210 hubo un moderador y un tallerista", sino "el 7 fue el moderador y el 9 el tallerista". Esa información no está en ninguna de las dos proyecciones y no hay forma de recuperarla.
Es también, de paso, la respuesta al ejercicio 3b de la lección 05-02: participaciones es una relación ternaria legítima y no una violación de 4FN, precisamente porque rol depende de la pareja evento-ponente y no del evento solo.
Solución 3
a) Dependencias funcionales:
c1: cesion_id → {sala_id, entidad_nif, inicio, fin}
c2: sala_id → {sala_aforo, sucursal_id}
c3: entidad_nif → entidad_nombre
c4: sucursal_id → tarifa_hora
c5: {tarifa_hora, inicio, fin} → importe_totalb) La clave candidata es {cesion_id}, de un solo atributo, así que la 2FN está garantizada. Las violaciones son todas de 3FN, y hay cuatro dependencias transitivas encadenadas:
| Dependencia | ¿Determinante superclave? | ¿Determinado primo? | Veredicto |
|---|---|---|---|
sala_id → sala_aforo |
No | No | Viola 3FN |
sala_id → sucursal_id |
No | No | Viola 3FN |
sucursal_id → tarifa_hora |
No | No | Viola 3FN |
entidad_nif → entidad_nombre |
No | No | Viola 3FN |
La tabla está en 2FN y no llega a 3FN. La cadena completa es cesion_id → sala_id → sucursal_id → tarifa_hora, tres saltos.
Las anomalías son las esperables: si se reforma una sala y cambia su aforo, hay que actualizar todas sus cesiones históricas; si el ayuntamiento sube la tarifa de una sucursal, hay que tocar todas las cesiones de todas sus salas; y si una entidad cambia de nombre, hay que buscarla por todas partes.
c) Corrección. Se eliminan las columnas cuya información ya vive en otra tabla y se llega a ellas por clave ajena:
CREATE TABLE entidades (
nif VARCHAR(9) NOT NULL,
nombre VARCHAR(120) NOT NULL,
CONSTRAINT pk_entidades PRIMARY KEY (nif)
);
-- tarifa_hora se añade a sucursales, que es de quien depende
ALTER TABLE sucursales ADD COLUMN tarifa_cesion_hora NUMERIC(6,2) NOT NULL DEFAULT 0;
CREATE TABLE cesiones_sala (
cesion_id INTEGER GENERATED BY DEFAULT AS IDENTITY,
sala_id INTEGER NOT NULL,
entidad_nif VARCHAR(9) NOT NULL,
inicio TIMESTAMPTZ NOT NULL,
fin TIMESTAMPTZ NOT NULL,
tarifa_hora NUMERIC(6,2) NOT NULL, -- ← SE QUEDA. Ver justificación
importe_total NUMERIC(8,2) NOT NULL, -- ← SE QUEDA. Ver justificación
CONSTRAINT pk_cesiones_sala PRIMARY KEY (cesion_id),
CONSTRAINT chk_cesiones_fin CHECK (fin > inicio),
CONSTRAINT fk_cesiones_sala FOREIGN KEY (sala_id)
REFERENCES salas (sala_id) ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT fk_cesiones_entidad FOREIGN KEY (entidad_nif)
REFERENCES entidades (nif) ON DELETE RESTRICT ON UPDATE CASCADE
);Desaparecen sala_aforo, sucursal_id y entidad_nombre: se obtienen con un JOIN a salas y entidades, y así no pueden contradecirse.
Se quedan tarifa_hora e importe_total, y esta es la parte interesante del ejercicio. Formalmente son redundantes: la tarifa está en sucursales y el importe se calcula. Pero son datos históricos que deben quedar congelados: la cesión del 12 de marzo se facturó a 18,00 €/hora, y si el ayuntamiento sube la tarifa a 22,00 €/hora en abril, esa factura de marzo no puede cambiar. Si tarifa_hora se obtuviera por JOIN a sucursales, cualquier consulta sobre cesiones pasadas devolvería importes que no coinciden con las facturas emitidas.
Es exactamente el mismo caso que el importe de multas que analizamos en la sección 15.3, y la misma "duplicación histórica congelada" que vimos en el modelado documental de 03-03. Es una desnormalización deliberada, correcta y necesaria, y hay que documentarla como tal para que nadie la "arregle" después. Cómo se toman y se justifican estas decisiones es el contenido íntegro de la lección siguiente.
(Detalle final: importe_total no puede ser una columna generada ALWAYS AS, aunque lo parezca, precisamente porque debe conservar el valor histórico y no recalcularse si la tarifa cambia. Es la diferencia entre duracion_min —derivada de columnas de la propia fila, que no cambian— y un importe derivado de un dato externo que sí cambia.)
Conclusión
Hemos hecho el trabajo completo. De una hoja de cálculo con trece columnas, seis filas de muestra y ocho defectos catalogados desde la primera lección del curso, a nueve tablas en forma normal de Boyce-Codd, con sus claves ajenas, sus restricciones y sus datos migrados y verificados.
El procedimiento son seis pasos: reunir las reglas de negocio y escribir las dependencias; determinar las claves candidatas con el cierre; comprobar 1FN, 2FN y 3FN/FNBC en ese orden y parando en el primer fallo; descomponer sacando cada dependencia infractora a una tabla cuyo determinante sea la clave primaria; verificar; y reponer las claves ajenas. Volver al paso 2 después de cada descomposición, porque las tablas nuevas tienen claves nuevas.
Las dos propiedades que toda descomposición debe cumplir son innegociables. Sin pérdida de información: el JOIN debe devolver exactamente lo original, y la condición de Heath lo garantiza si descompones siempre por un determinante —si la columna de unión es clave primaria de al menos una de las dos tablas—. Vimos con datos qué pasa cuando no se cumple: una descomposición que parecía razonable inventó una fila afirmando que Nuria Bastos sacó un libro de una sucursal donde nunca estuvo, y esa fila era indistinguible de las verdaderas. Conservación de las dependencias: cada regla de negocio debe poder comprobarse dentro de una sola tabla, y cuando eso no es posible hay que decidir conscientemente entre retroceder a 3FN, poner un disparador o auditar periódicamente.
Aprendimos también algunas cosas que no salen en los libros de teoría. Que INSERT ... SELECT DISTINCT es la forma canónica de migrar al descomponer. Que un error de clave duplicada durante la migración casi nunca es un fallo del script: es el esquema nuevo rechazando una contradicción que el viejo permitía, y ahí está tu lista de datos sucios. Que la normalización no limpia los datos, los hace visibles: Marta Alsina y M. Alsina estaban escondidas entre seis filas anchas y aparecieron en cuanto la clave primaria de socios se negó a admitirlas. Y que en producción no se normaliza de golpe: se expande, se rellena, se escribe por duplicado, se cambian las lecturas y se contrae, con las consultas de control ejecutadas después de cada fase.
Y sometimos a examen el esquema del módulo 4. Aguantó, que era lo que había que comprobar: eventos e inscripciones en 3FN y FNBC respectivamente, con la única salvedad de sus columnas generadas ALWAYS, que son desnormalizaciones con garantía del SGBD; pagos en FNBC sin observaciones. Y un hallazgo real: multas violaba la 3FN por la dependencia prestamo_id → socio_id, lo que permitía atribuir a un socio la multa del retraso de otro. La corrección —una clave ajena compuesta (prestamo_id, socio_id) apoyada en un UNIQUE redundante en prestamos— deja la regla garantizada por el SGBD sin necesidad de disparadores. Un fallo que el diseño por intuición no vio y que el análisis formal encontró en dos minutos: eso es exactamente para lo que sirve este módulo.
Y sin embargo, tres veces a lo largo de la lección nos hemos encontrado con lo mismo, y las tres hemos decidido no normalizar: duracion_min en eventos, plazas_ocupadas en inscripciones, el importe congelado de multas y la tarifa_hora de las cesiones. Las cuatro son redundancias. Las cuatro violan la letra de la tercera forma normal. Y las cuatro son correctas.
Eso no es una contradicción ni una excepción incómoda: es la otra mitad del oficio. En la lección 05-04, Desnormalización y sus Usos, se estudia la decisión inversa con el mismo rigor con que hemos estudiado esta. Qué se gana y qué se paga exactamente al desnormalizar; cuándo está justificado y cuándo es simple pereza; las técnicas una a una —columnas calculadas, tablas de resumen, vistas materializadas, el esquema en estrella de los almacenes analíticos—; cómo se mantiene la coherencia de lo que se ha duplicado a propósito; y la regla de oro que ordena todo el módulo: primero normaliza, después desnormaliza a propósito, midiendo, y nunca al revés.
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
