Terminamos la lección anterior con un esquema completo y una confesión: los tipos eran provisionales y las diez reglas de negocio del documento de requisitos no estaban en ninguna parte. Hoy, en el esquema tal como quedó, es perfectamente posible insertar una multa de −40 €, un evento que termina antes de empezar, una sala con aforo cero, una inscripción con estado 'confimada' y un socio con cuarenta teléfonos. El esquema es estructuralmente correcto y semánticamente indefenso.

Esta lección lo blinda. Tiene dos bloques y ambos son igual de importantes.

El primero es la elección razonada de tipos de datos. No es la lista de tipos de PostgreSQL —eso está en el manual—, sino las decisiones que hay detrás: cuándo un entero se queda corto, por qué el dinero de las multas de BiblioRed nunca puede ir en coma flotante (con una demostración ejecutable cuyo resultado sorprende), qué diferencia hay entre TIMESTAMP y TIMESTAMPTZ y por qué esa diferencia arruina agendas de eventos, si limitar la longitud de un texto sirve de algo, y por qué la colación decide si "Àngels" aparece antes o después de "Angel" cuando un socio busca en el catálogo.

El segundo es el catálogo de restricciones: NOT NULL, DEFAULT, UNIQUE con su comportamiento sorprendente ante los NULL, CHECK de una y de varias columnas, columnas generadas, dominios reutilizables, cómo nombrarlas para que los errores de producción sean legibles y cómo añadirlas a una tabla que ya tiene datos sin bloquear el servicio.

El entregable es la versión definitiva y blindada del esquema de BiblioRed ampliado, y con ella se cierra el módulo 4.

Contenido

  1. Por qué el tipo de dato es una decisión de diseño
  2. Enteros: SMALLINT, INTEGER, BIGINT y el día que se acaban
  3. NUMERIC frente a REAL: por qué el dinero nunca va en coma flotante
  4. Texto: CHAR, VARCHAR(n) y TEXT
  5. Fechas y horas: TIMESTAMP frente a TIMESTAMPTZ, e INTERVAL
  6. BOOLEAN y las trampas de 'S'/'N'
  7. UUID frente a entero secuencial como clave
  8. Conjuntos cerrados de valores: ENUM, tabla de catálogo o CHECK
  9. JSONB y arrays como escapes controlados
  10. BYTEA y por qué las portadas no van en la base
  11. Codificación y colación: buscar títulos en español y catalán
  12. Los tipos permisivos de SQLite frente a los estrictos de PostgreSQL
  13. NOT NULL: la decisión de permitir ausencias
  14. DEFAULT: valores por omisión
  15. UNIQUE simple y compuesto, y su relación con NULL
  16. PRIMARY KEY como combinación de las anteriores
  17. CHECK: codificar reglas de negocio en el esquema
  18. Columnas generadas y dominios
  19. Nombrar las restricciones y leer los errores de producción
  20. Añadir restricciones a una tabla que ya tiene datos
  21. Qué reglas van en la base de datos y cuáles en la aplicación
  22. Entregable: el esquema definitivo de BiblioRed ampliado
  23. Errores Comunes y Consejos
  24. Ejercicios
  25. Conclusión

  1. Por qué el tipo de dato es una decisión de diseño

Elegir un tipo parece trámite. En realidad se están decidiendo cuatro cosas a la vez:

Se decide Consecuencia
Qué valores son posibles Es la primera línea de defensa de la integridad: DATE hace imposible el 31 de febrero
Qué operaciones tienen sentido Sobre DATE se puede restar y obtener días; sobre el texto '2026-05-14' no
Cómo se ordena y se compara '10' va antes que '9' en texto y después en número
Cuánto ocupa y cuánto cuesta Multiplicado por millones de filas y por los índices que se construyan sobre ellas

Un tipo mal elegido no da error: da resultados incorrectos en silencio, que es peor. Una fecha guardada como VARCHAR funciona perfectamente hasta el día en que alguien ordena la agenda de eventos y aparece diciembre antes que febrero.

  1. Enteros: SMALLINT, INTEGER, BIGINT y el día que se acaban

Tipo Bytes Rango Uso típico en BiblioRed
SMALLINT 2 −32.768 a 32.767 aforo, planta, acompanantes, num_paginas, anio_publicacion, duracion_min
INTEGER 4 ±2.147.483.647 Casi todas las claves primarias
BIGINT 8 ±9,2 × 10¹⁸ Claves de tablas de altísimo volumen (registros de auditoría, eventos de log)

Cuándo un identificador se queda corto

La pregunta correcta no es "¿cuántas filas habrá?", sino "¿cuántas veces se incrementará el contador?", y son cosas distintas: las filas borradas consumen valores que no se reutilizan.

Los volúmenes de BiblioRed a tres años (apartado D del documento de requisitos) son 55.000 ejemplares, 30.000 inscripciones y 9.000 multas. INTEGER da margen para 2.147 millones. Aunque BiblioRed multiplicara su tamaño por mil, no se acercaría. INTEGER es correcto para todas las claves de este esquema.

El caso en que no lo es tiene una señal clara: tablas donde se insertan filas por eventos automáticos, no por acciones humanas. Un registro de accesos a la web con 500 inserciones por segundo agota un INTEGER en unos cincuenta días. La regla práctica:

Si las filas las genera una persona, INTEGER sobra. Si las genera una máquina, calcula.

Y una advertencia real: cambiar de INTEGER a BIGINT en una tabla grande y muy referenciada es una de las migraciones más dolorosas que existen, porque reescribe la tabla, todos sus índices y todas las columnas de las claves ajenas que la referencian. Si hay una duda razonable, BIGINT desde el principio cuesta 4 bytes por fila.

Por qué usar SMALLINT donde encaja

No es por ahorrar bytes: es por documentar la intención. Un aforo SMALLINT dice a quien lee el esquema que ninguna sala municipal albergará 40.000 personas. Es una restricción débil pero gratuita, y complementa al CHECK que le pondremos después.

-- Provisional en 04-03
aforo INTEGER NOT NULL

-- Definitivo
aforo SMALLINT NOT NULL   -- + CHECK (aforo > 0 AND aforo <= 2000)

  1. NUMERIC frente a REAL: por qué el dinero nunca va en coma flotante

Este es el apartado más importante de la lección y el que más veces se ignora en proyectos reales.

Tipo Familia Precisión Uso
REAL / FLOAT4 Coma flotante binaria ~6 dígitos Magnitudes físicas aproximadas
DOUBLE PRECISION / FLOAT8 Coma flotante binaria ~15 dígitos Cálculo científico
NUMERIC(p,s) / DECIMAL(p,s) Decimal exacta Exacta hasta p dígitos Dinero, cantidades exactas

La demostración

Los tipos de coma flotante representan los números en binario. Y hay decimales sencillos que en binario son periódicos infinitos, igual que 1/3 lo es en decimal. 0,1 es uno de ellos. Lo que se guarda no es 0,1: es lo más cercano a 0,1 que cabe en 64 bits.

Tres multas de BiblioRed: 3,10 €, 2,20 € y 4,30 €. El total debería ser exactamente 9,60 €.

SELECT 3.10::double precision
     + 2.20::double precision
     + 4.30::double precision  AS total_flotante,
       3.10::numeric
     + 2.20::numeric
     + 4.30::numeric           AS total_numeric;
   total_flotante    | total_numeric
---------------------+---------------
   9.600000000000001 |          9.60
(1 fila)

No es un error de PostgreSQL: es cómo funciona el binario, y ocurre igual en Java, Python, JavaScript y C. Ahora las consecuencias prácticas:

SELECT (3.10::double precision + 2.20::double precision + 4.30::double precision) = 9.60
           AS coincide_flotante,
       (3.10::numeric + 2.20::numeric + 4.30::numeric) = 9.60
           AS coincide_numeric;
 coincide_flotante | coincide_numeric
-------------------+------------------
 f                 | t
(1 fila)

Una multa pagada íntegramente aparecería como impagada. La comprobación de RN5 ("la suma de los pagos nunca supera el importe") y la consulta C7 ("socios con deuda superior a 20 €") darían resultados aleatorios.

El error se agrava al acumular. Los recargos de BiblioRed son de 0,20 €/día:

SELECT SUM(0.20::real)          AS total_real,
       SUM(0.20::numeric(6,2))  AS total_numeric
  FROM generate_series(1, 1000);
 total_real | total_numeric
------------+---------------
  200.00003 |        200.00
(1 fila)

Tres céntimas de diferencia en mil operaciones. En un cierre contable municipal, eso es una incidencia que alguien tiene que investigar. (El valor exacto puede variar ligeramente según la plataforma; lo invariable es que no sea exacto.)

La regla

Todo lo que se cuenta en dinero, todo lo que se factura y todo lo que se compara por igualdad va en NUMERIC(p,s). La coma flotante es para magnitudes físicas donde un error de 10⁻¹⁵ es irrelevante.

En BiblioRed:

Columna Tipo definitivo Motivo
multas.importe NUMERIC(6,2) Dinero. Hasta 9.999,99 €
pagos.importe NUMERIC(6,2) Dinero
participaciones.honorarios NUMERIC(8,2) Dinero, con más margen
prestamos.recargo NUMERIC(6,2) Ya estaba bien
informes_evento.valoracion_media NUMERIC(3,2) 0,00 a 5,00. Se compara y se muestra exacta

Sobre NUMERIC(6,2): 6 es el total de dígitos (precisión) y 2 los decimales (escala), así que la parte entera admite cuatro dígitos. El gestor redondea al insertar y rechaza lo que desborda:

INSERT INTO multas (socio_id, motivo, importe) VALUES (15, 'retraso', 12.348);
SELECT importe FROM multas WHERE socio_id = 15;
 importe
---------
   12.35
INSERT INTO multas (socio_id, motivo, importe) VALUES (15, 'perdida', 25000.00);
ERROR:  numeric field overflow
DETALLE:  A field with precision 6, scale 2 must round to an absolute value less than 10^4.

El desbordamiento avisa; el redondeo no. Si BiblioRed llegara a emitir multas por pérdida de material de más de 10.000 €, habría que ampliar a NUMERIC(8,2).

Nota sobre MONEY: PostgreSQL tiene un tipo MONEY, pero depende de la configuración regional del servidor y no lleva la moneda dentro. Se desaconseja: NUMERIC es la opción portable.

  1. Texto: CHAR, VARCHAR(n) y TEXT

Tipo Comportamiento Cuándo usarlo
CHAR(n) Rellena con espacios hasta n Prácticamente nunca
VARCHAR(n) Longitud variable con tope Cuando el tope es una regla real
TEXT Longitud variable sin tope Todo lo demás

El problema de CHAR(n)

SELECT 'ca'::char(5) = 'ca'::text        AS iguales,
       length('ca'::char(5))             AS longitud,
       '[' || 'ca'::char(5) || ']'       AS visualizacion;
 iguales | longitud | visualizacion
---------+----------+---------------
 t       |        2 | [ca   ]
(1 fila)

El valor lleva tres espacios de relleno que aparecen al concatenar, al exportar a CSV y al comparar desde una aplicación que no aplica las reglas de SQL. Es una fuente de errores desproporcionada al beneficio, que en PostgreSQL es ninguno: CHAR(n) no es más rápido ni ocupa menos.

¿Sirve de algo limitar la longitud?

En PostgreSQL, VARCHAR(n) y TEXT se almacenan exactamente igual. VARCHAR(200) no reserva 200 bytes; el n es solo una restricción de validación. Así que la pregunta es si esa validación aporta.

Sí aporta cuando el límite es una regla de negocio real:

Columna Tipo Motivo
materiales_libro.isbn VARCHAR(13) Un ISBN tiene 13 dígitos por definición
materiales_revista.issn VARCHAR(9) Formato normalizado NNNN-NNNN
sucursales.dir_codigo_postal CHAR(5) o VARCHAR(5) Cinco dígitos en España
materiales.idioma VARCHAR(5) Códigos ISO 639-1 y variantes (es, ca, pt-BR)
telefonos_socio.numero VARCHAR(20) Con prefijo internacional

No aporta cuando el número es inventado. titulo VARCHAR(200) no responde a ninguna regla: es un número que alguien puso porque había que poner uno. Y tiene un coste real, porque el día que llegue un título de 214 caracteres —los hay— la inserción fallará en producción, con un error que el equipo tendrá que diagnosticar de madrugada:

INSERT INTO materiales (tipo_material, titulo, idioma)
VALUES ('libro', 'Historia general y natural de las Indias, islas y tierra firme del mar océano, con las anotaciones y notas del ilustrísimo señor cronista de la Corona de Castilla, edición conmemorativa del quinto centenario', 'es');
ERROR:  value too long for type character varying(200)

Criterio de BiblioRed: VARCHAR(n) solo donde n sale de una norma externa. TEXT para títulos, descripciones, observaciones, biografías y nombres. Donde el negocio quiera un tope orientativo pero no crítico, se pone con un CHECK nombrado, que se puede relajar sin reescribir la tabla:

titulo TEXT NOT NULL,
CONSTRAINT chk_materiales_titulo_longitud CHECK (char_length(titulo) BETWEEN 1 AND 300)

Ventaja no obvia: ampliar VARCHAR(200) a VARCHAR(300) es un ALTER TABLE que en versiones antiguas reescribía la tabla; relajar un CHECK es soltar y volver a crear una restricción.

  1. Fechas y horas: TIMESTAMP frente a TIMESTAMPTZ, e INTERVAL

Tipo Guarda Zona horaria
DATE Solo fecha No aplica
TIME Solo hora No
TIMESTAMP Fecha y hora No: guarda literalmente lo que le des
TIMESTAMPTZ Fecha y hora : normaliza a UTC y convierte al leer
INTERVAL Una duración No aplica

La diferencia que rompe agendas

TIMESTAMPTZ no almacena la zona horaria. Almacena el instante absoluto (UTC) y lo convierte a la zona de la sesión al leerlo. TIMESTAMP guarda una pared de reloj sin contexto.

CREATE TABLE prueba_horas (
    con_tz  TIMESTAMPTZ,
    sin_tz  TIMESTAMP
);

SET TIME ZONE 'Europe/Madrid';
INSERT INTO prueba_horas VALUES ('2026-05-14 18:00:00', '2026-05-14 18:00:00');

SELECT * FROM prueba_horas;
         con_tz         |       sin_tz
------------------------+---------------------
 2026-05-14 18:00:00+02 | 2026-05-14 18:00:00

Ahora un socio consulta la agenda desde el extranjero, o un proceso automático se ejecuta con otra configuración:

SET TIME ZONE 'America/Bogota';
SELECT * FROM prueba_horas;
         con_tz         |       sin_tz
------------------------+---------------------
 2026-05-14 11:00:00-05 | 2026-05-14 18:00:00

con_tz dice correctamente que el club de lectura de las 18:00 en Vallmar son las 11:00 en Bogotá: es el mismo instante. sin_tz dice 18:00 en las dos, lo cual es falso en una de ellas y no hay forma de saber en cuál.

Y hay un caso donde el daño ocurre sin salir de Vallmar: el cambio de hora. La madrugada del último domingo de octubre, las 02:30 existe dos veces. Un TIMESTAMP sin zona no puede distinguirlas; un TIMESTAMPTZ sí, porque internamente son dos instantes UTC distintos.

Regla: todo instante de un hecho —cuándo empieza un evento, cuándo se hizo una inscripción, cuándo se registró un pago— va en TIMESTAMPTZ. Úsalo por defecto y justifica cuando no lo hagas.

TIMESTAMP sin zona tiene un uso legítimo y estrecho: horarios recurrentes que son "hora local" por definición, como "la biblioteca abre a las 9:00" independientemente de la estación. Ahí lo correcto suele ser TIME, no TIMESTAMP.

DATE cuando la hora no existe

No todo necesita hora. fecha_prestamo, fecha_devolucion_prevista, fecha_emision de una multa y fecha_alta de un material son fechas: la biblioteca no cobra por horas. Usar TIMESTAMPTZ ahí obliga a arrastrar un 00:00:00 que confunde y complica las comparaciones de rango.

Columna Tipo Motivo
eventos.inicio, eventos.fin TIMESTAMPTZ Instantes concretos, con hora
inscripciones.fecha_inscripcion TIMESTAMPTZ Instante de un hecho; el orden importa para la lista de espera
pagos.fecha_pago TIMESTAMPTZ Instante contable
multas.fecha_emision DATE La ordenanza cuenta días
prestamos.fecha_prestamo DATE Ya estaba bien
informes_evento.fecha_redaccion DATE Basta el día

INTERVAL para calcular vencimientos

INTERVAL representa una duración y se suma directamente a fechas:

SELECT p.prestamo_id,
       p.fecha_prestamo,
       p.fecha_prestamo + INTERVAL '21 days' AS vencimiento_calculado,
       p.fecha_devolucion_prevista,
       CURRENT_DATE - p.fecha_devolucion_prevista AS dias_retraso
  FROM prestamos p
 WHERE p.fecha_devolucion IS NULL
   AND p.fecha_devolucion_prevista < CURRENT_DATE
 ORDER BY dias_retraso DESC;
 prestamo_id | fecha_prestamo | vencimiento_calculado | fecha_devolucion_prevista | dias_retraso
-------------+----------------+-----------------------+---------------------------+--------------
        4188 | 2026-06-05     | 2026-06-26 00:00:00   | 2026-06-26                |           37
        4201 | 2026-06-14     | 2026-07-05 00:00:00   | 2026-07-05                |           28
(2 filas)

Detalle a notar: DATE − DATE da un entero de días, no un intervalo, que es justo lo que hace falta para calcular el recargo de R10 a 0,20 €/día. En cambio TIMESTAMPTZ − TIMESTAMPTZ sí devuelve un INTERVAL, y lo usaremos en el apartado 18 para la duración de los eventos.

  1. BOOLEAN y las trampas de 'S'/'N'

PostgreSQL tiene BOOLEAN con tres valores posibles: TRUE, FALSE y NULL (lógica trivalente de 02-01).

SELECT nombre, apellidos FROM socios WHERE activo;         -- sin '= TRUE'
SELECT nombre, apellidos FROM socios WHERE NOT activo;

Comparémoslo con la costumbre heredada de sistemas antiguos de usar CHAR(1) con 'S'/'N':

Problema de 'S'/'N' Con BOOLEAN
Admite 's', 'S', 'Y', '1', 'X', ' ' y '' Solo tres valores posibles
Necesita un CHECK para restringirlo Restringido por el tipo
No funciona con AND/OR/NOT directamente
No lo agrega count(*) FILTER (WHERE ...) sin conversión Directo
Depende del idioma: 'S' en español, 'Y' en inglés Universal
Ocupa 1 byte + cabecera de texto 1 byte

En BiblioRed son BOOLEAN: socios.activo, salas.accesible, eventos.publicado, ponentes.externo.

Una precisión que evita un error frecuente: un booleano NULL significa "no se sabe", y eso a veces es legítimo (accesible de una sala que aún no se ha inspeccionado) y a veces es un descuido. Si el tercer valor no tiene sentido en el dominio, declara NOT NULL DEFAULT y elimina la duda. Todos los booleanos de BiblioRed son NOT NULL.

  1. UUID frente a entero secuencial como clave

Entero secuencial (IDENTITY) UUID
Tamaño 4-8 bytes 16 bytes
Legible SELECT * FROM socios WHERE socio_id = 14 ...= 'f47ac10b-58cc-4372-a567-0e02b2c3d479'
Se puede generar en el cliente No: hace falta ir al servidor , sin coordinación
Filtra información : revela cuántos socios hay y en qué orden se dieron de alta No
Fusionar datos de varias bases Colisiona No colisiona
Localidad en índices Excelente (valores crecientes) Mala en UUIDv4; buena en UUIDv7

Cuándo compensa UUID: identificadores que viajan en URLs públicas, sistemas distribuidos que generan filas sin conexión, fusión de bases de datos independientes.

El caso de BiblioRed: claves internas de una base centralizada con volúmenes moderados. Entero secuencial, sin discusión. Los socio_id y evento_id no aparecen en URLs públicas; si mañana la web expusiera /eventos/47, la solución no es cambiar la clave primaria sino añadir un identificador público aparte:

ALTER TABLE eventos ADD COLUMN slug_publico UUID NOT NULL DEFAULT gen_random_uuid();
ALTER TABLE eventos ADD CONSTRAINT uq_eventos_slug UNIQUE (slug_publico);

Así la clave primaria sigue siendo compacta para las claves ajenas y los índices, y la exposición pública no filtra el volumen del negocio. Es la mejor de las dos opciones y casi nadie la considera.

  1. Conjuntos cerrados de valores: ENUM, tabla de catálogo o CHECK

BiblioRed tiene ocho columnas con un conjunto cerrado: materiales.tipo_material, ejemplares.estado, reservas.estado, eventos.estado, inscripciones.estado, multas.motivo, multas.estado, pagos.metodo, telefonos_socio.tipo, participaciones.rol y eventos_materiales.papel. Hay tres formas de implementarlo.

Opción A — Tipo enumerado de PostgreSQL

CREATE TYPE estado_multa AS ENUM ('pendiente', 'pagada', 'condonada', 'anulada');
ALTER TABLE multas ALTER COLUMN estado TYPE estado_multa USING estado::estado_multa;
INSERT INTO multas (socio_id, motivo, importe, estado)
VALUES (15, 'retraso', 4.00, 'pagadaa');
ERROR:  invalid input value for enum estado_multa: "pagadaa"
LÍNEA 2: VALUES (15, 'retraso', 4.00, 'pagadaa');

Compacto (4 bytes), ordenación natural según el orden de declaración, error clarísimo. Sus dos inconvenientes son reales: no se pueden eliminar valores (solo añadir, y desde PostgreSQL 12 con ADD VALUE), y no admite atributos: no se puede guardar la descripción de cada estado ni su orden de presentación.

Opción B — Tabla de catálogo

CREATE TABLE estados_multa (
    codigo      VARCHAR(15) PRIMARY KEY,
    nombre      TEXT NOT NULL,
    es_final    BOOLEAN NOT NULL DEFAULT FALSE,
    orden       SMALLINT NOT NULL
);
ALTER TABLE multas
    ADD CONSTRAINT fk_multas_estado FOREIGN KEY (estado) REFERENCES estados_multa (codigo);

Los valores se gestionan con INSERT/UPDATE sin tocar el esquema, admiten atributos y se pueden traducir. El precio es un JOIN cada vez que se quiere el nombre legible, y una tabla más.

Opción C — CHECK sobre una columna de texto

ALTER TABLE multas ADD CONSTRAINT chk_multas_estado
    CHECK (estado IN ('pendiente','pagada','condonada','anulada'));
INSERT INTO multas (socio_id, motivo, importe, estado)
VALUES (15, 'retraso', 4.00, 'pagadaa');
ERROR:  new row for relation "multas" violates check constraint "chk_multas_estado"
DETALLE:  Failing row contains (312, 15, null, retraso, 4.00, 2026-08-02, pagadaa).

Sin tipos nuevos, sin tablas nuevas, y modificar la lista es soltar y recrear la restricción.

Comparativa

Criterio ENUM Tabla de catálogo CHECK
Rechaza valores inválidos
Añadir un valor ALTER TYPE INSERT Recrear el CHECK
Quitar un valor Muy difícil DELETE (si no hay uso) Recrear el CHECK
El usuario final puede gestionarlo No No
Admite atributos (descripción, orden, traducción) No No
Listar los valores válidos desde la app Consulta al catálogo del sistema SELECT normal Analizar el texto de la restricción
Espacio 4 bytes Tamaño del código + índice de la FK Tamaño del texto
Portable a SQLite/MySQL No
Coste de leer con el nombre bonito Ninguno Un JOIN Ninguno

La recomendación, aplicada a BiblioRed

Si el conjunto lo gestiona el usuario, tabla de catálogo. Si lo gestiona el desarrollador y cambia poco, CHECK. ENUM solo cuando el conjunto sea inmutable de verdad.

Columna Decisión Motivo
eventos.tipo_evento_id Tabla de catálogo (tipos_evento) R5 lo pedía explícitamente: el ayuntamiento añade tipos
multas.estado, eventos.estado, inscripciones.estado CHECK Estados del flujo de la aplicación; cambiarlos implica cambiar código de todos modos
multas.motivo CHECK Fijado por la ordenanza municipal
pagos.metodo CHECK Tres métodos; añadir uno implica integrar una pasarela, o sea, código
materiales.tipo_material CHECK Añadir un tipo implica crear una subtabla (04-03, regla 10), o sea, código
telefonos_socio.tipo, participaciones.rol, eventos_materiales.papel CHECK Listas cortas y estables

Ninguna columna usa ENUM. La razón es la portabilidad —el esquema debe poder cargarse en SQLite para pruebas— y la rigidez de eliminar valores. Es una decisión defendible, no la única posible.

  1. JSONB y arrays como escapes controlados

En 03-03 modelamos documentos en MongoDB y en 03-04 vimos que PostgreSQL guarda documentos con jsonb. Aquí toca la pregunta de diseño: ¿cuándo es legítimo usarlos en un esquema relacional?

Arrays

-- Alternativa a la tabla subtitulos_dvd
ALTER TABLE materiales_dvd ADD COLUMN subtitulos TEXT[];
UPDATE materiales_dvd SET subtitulos = ARRAY['es','ca','en'] WHERE material_id = 1204;

SELECT material_id FROM materiales_dvd WHERE subtitulos @> ARRAY['ca'];
 material_id
-------------
        1204
(1 fila)

Un array no es una lista con comas: conserva la estructura, tiene operadores propios (@> contiene, && se solapa) y se puede indexar con GIN (06-03). Pero comparado con la tabla subtitulos_dvd de 04-03 pierde tres cosas: no puede tener clave ajena a un catálogo de idiomas, no admite atributos por elemento (¿son subtítulos para sordos?) y las agregaciones ("¿cuántos DVD por idioma?") requieren unnest.

Decisión de BiblioRed: la tabla. El array es la opción correcta cuando los elementos son etiquetas sin estructura y solo se consultan por pertenencia.

JSONB

El uso legítimo es datos genuinamente variables cuya estructura no se conoce en tiempo de diseño. En BiblioRed hay un caso: las respuestas a las encuestas de satisfacción de los eventos (R9). Cada tipo de evento tiene su cuestionario, el número y tipo de preguntas cambia, y se añaden preguntas sin avisar.

ALTER TABLE informes_evento ADD COLUMN respuestas_encuesta JSONB;

UPDATE informes_evento
   SET respuestas_encuesta = '{
        "version_cuestionario": 3,
        "num_respuestas": 18,
        "preguntas": [
          {"id": "p1", "texto": "Valoración general",      "media": 4.3},
          {"id": "p2", "texto": "Adecuación de la sala",   "media": 3.8},
          {"id": "p5", "texto": "¿Repetirías?",            "si": 16, "no": 2}
        ]}'::jsonb
 WHERE evento_id = 47;

SELECT evento_id, respuestas_encuesta -> 'num_respuestas' AS n
  FROM informes_evento
 WHERE respuestas_encuesta @> '{"version_cuestionario": 3}';
 evento_id | n
-----------+----
        47 | 18
(1 fila)

La línea que no hay que cruzar: JSONB no es un sitio donde meter columnas para no diseñarlas. Si un dato se consulta siempre, se filtra, se agrega o tiene una regla de negocio asociada, es una columna. Meter asistentes_reales dentro del JSON sería el anti-patrón EAV de 04-01 con sintaxis moderna.

Criterio: columna si el dato tiene nombre conocido, tipo conocido y reglas; JSONB si la forma la decide alguien ajeno al esquema. Y si acabas escribiendo un CHECK complejo sobre una clave del JSON, esa clave quería ser una columna.

  1. BYTEA y por qué las portadas no van en la base

BYTEA guarda datos binarios. La tentación es guardar dentro de la base las portadas de los libros y los PDF de los informes. No lo hagas, salvo con muy buenas razones:

Problema Detalle
Copias de seguridad 40.000 portadas de 300 KB son 12 GB. La copia diaria pasa de 2 minutos a 40, y la restauración igual
Memoria compartida El caché del gestor se llena de imágenes en lugar de índices y filas consultadas
Replicación Cada imagen viaja por el registro de transacciones a todas las réplicas
No hay servicio directo El servidor web no puede servirlas: hay que leerlas, transferirlas y reenviarlas
Sin CDN ni caché HTTP Se pierde toda la infraestructura de distribución de estáticos

Lo correcto: guardar el fichero en el sistema de archivos o en almacenamiento de objetos, y en la base la ruta y los metadatos:

ALTER TABLE materiales ADD COLUMN portada_url  TEXT;
ALTER TABLE materiales ADD COLUMN portada_hash CHAR(64);   -- SHA-256, para detectar duplicados

Las excepciones legítimas son pocas y reconocibles: binarios pequeños (menos de unos pocos KB), poco numerosos, que deban participar de la transacción y del control de acceso de la base —una firma digital, un sello de tiempo—.

  1. Codificación y colación: buscar títulos en español y catalán

BiblioRed maneja títulos en castellano y catalán. Esto convierte dos parámetros normalmente invisibles en decisiones de diseño.

Codificación: UTF-8, siempre

CREATE DATABASE biblioredb ENCODING 'UTF8' LC_COLLATE 'es_ES.UTF-8' LC_CTYPE 'es_ES.UTF-8';

UTF-8 representa cualquier carácter de cualquier idioma: ñ, ç, à, ï, · (el punto volado catalán de "col·lecció"), comillas tipográficas y emojis. Cualquier codificación de un solo byte (LATIN1) romperá algo tarde o temprano, y la migración posterior es laboriosa. No hay decisión que tomar aquí: UTF-8.

Colación: cómo se ordena y se compara

La colación son las reglas de ordenación y comparación de cadenas. No es un detalle cosmético: decide el resultado de ORDER BY, de < y >, y de los índices que se apoyen en ese orden.

SELECT titulo FROM (VALUES ('Ángeles'),('Antología'),('Àngels'),('anatomía'),('Zoo'))
    AS t(titulo)
 ORDER BY titulo COLLATE "C";
  titulo
------------
 Antología
 Zoo
 anatomía
 Ángeles
 Àngels
(5 filas)

La colación "C" ordena por el valor byte a byte: todas las mayúsculas antes que todas las minúsculas, y los acentuados al final. Es rápida y completamente inaceptable para un catálogo bibliotecario.

SELECT titulo FROM (VALUES ('Ángeles'),('Antología'),('Àngels'),('anatomía'),('Zoo'))
    AS t(titulo)
 ORDER BY titulo COLLATE "es-ES-x-icu";
  titulo
------------
 anatomía
 Ángeles
 Àngels
 Antología
 Zoo
(5 filas)

Este es el orden que espera cualquier persona: los acentos y las mayúsculas no alteran la posición alfabética.

Búsqueda insensible a mayúsculas y acentos

Un socio que busca "mapa del tiempo" debe encontrar "El Mapa del Tiempo", y quien busca "angels" debe encontrar "Àngels". Dos piezas:

-- Insensible a mayúsculas: ILIKE
SELECT titulo FROM materiales WHERE titulo ILIKE '%mapa del tiempo%';
       titulo
---------------------
 El mapa del tiempo
(1 fila)
-- Insensible a acentos: extensión unaccent
CREATE EXTENSION IF NOT EXISTS unaccent;
SELECT titulo FROM materiales
 WHERE unaccent(titulo) ILIKE unaccent('%angels%');
      titulo
-------------------
 Àngels de paper
(1 fila)

Desde PostgreSQL 12 existe además la opción más limpia: una colación no determinista que ignora acentos y mayúsculas para la comparación, aplicable a una columna concreta.

CREATE COLLATION busqueda_es (
    provider = icu,
    locale = 'es-ES-u-ks-level1',   -- nivel 1: ignora acentos y mayúsculas
    deterministic = false
);

Decisión de BiblioRed: colación por defecto es-ES-x-icu en la base (ordenación correcta) y búsqueda con unaccent + ILIKE en las consultas del catálogo. Las columnas de códigos (isbn, codigo de ejemplar, idioma) llevan colación "C" explícita: son códigos, no texto, y comparar byte a byte es lo correcto y lo más rápido.

Advertencia operativa: cambiar la colación de una base con datos invalida los índices construidos sobre columnas de texto, porque su orden deja de ser válido. Hay que reconstruirlos (REINDEX). Es una decisión que se toma al crear la base y no se cambia a la ligera.

  1. Los tipos permisivos de SQLite frente a los estrictos de PostgreSQL

En 01-04 instalamos SQLite como alternativa ligera. Su sistema de tipos funciona de una manera que sorprende a quien viene de PostgreSQL, y hay que conocerla porque es una fuente clásica de datos corruptos.

SQLite usa afinidad de tipos: el tipo declarado es una preferencia, no una restricción.

-- En SQLite
CREATE TABLE prueba (id INTEGER, importe NUMERIC, fecha DATE);
INSERT INTO prueba VALUES ('hola', 'mucho dinero', 'el jueves');
SELECT * FROM prueba;
id    importe       fecha
----  ------------  ----------
hola  mucho dinero  el jueves

Ningún error. La misma sentencia en PostgreSQL:

ERROR:  invalid input syntax for type integer: "hola"
LÍNEA 1: INSERT INTO prueba VALUES ('hola', 'mucho dinero', 'el jueves');

Las diferencias que más afectan al diseño:

Aspecto PostgreSQL SQLite
Tipos Estrictos: valor inválido, error Afinidad: convierte si puede, guarda tal cual si no
Tipos disponibles Más de 40, más los definidos por el usuario Cinco clases de almacenamiento
BOOLEAN Tipo real INTEGER 0/1
Fechas y horas DATE, TIMESTAMPTZ, INTERVAL No existen: texto ISO-8601, número o entero Unix
NUMERIC exacto No: REAL de coma flotante. El dinero exige guardar céntimos como enteros
VARCHAR(n) Valida n Ignora n por completo
CHECK
Claves ajenas Activas siempre Desactivadas por defecto (PRAGMA foreign_keys = ON, 02-06)
Colación Completa vía ICU Tres colaciones básicas; sin reglas de idioma

Tablas STRICT

Desde SQLite 3.37 (2021) existe una solución parcial:

CREATE TABLE prueba (id INTEGER, importe REAL, fecha TEXT) STRICT;
INSERT INTO prueba VALUES ('hola', 1.0, '2026-05-14');
Runtime error: cannot store TEXT value in INTEGER column prueba.id

Recomendación para BiblioRed: PostgreSQL es la referencia. Si se usa SQLite para pruebas locales, hay que declarar las tablas STRICT, activar PRAGMA foreign_keys = ON y no guardar dinero en REAL —los importes se almacenan como enteros de céntimos—. Sabiendo eso, SQLite es una herramienta excelente; sin saberlo, es una trampa.


  1. NOT NULL: la decisión de permitir ausencias

Empieza el segundo bloque. NOT NULL es la restricción más simple y la que más se decide por inercia.

En 02-01 vimos que NULL no es cero ni cadena vacía: es "no se sabe" o "no aplica". Permitirlo en una columna es afirmar que ese caso existe en el negocio. La pregunta correcta es: "¿existe una fila legítima en la que esto no se sepa?"

Columna ¿NOT NULL? Razonamiento
eventos.titulo Un evento sin título no es un evento
eventos.sala_id No Decisión D4: eventos al aire libre
eventos.fin Siempre se conoce al programarlo
prestamos.fecha_devolucion No NULL significa "aún no devuelto", y es información valiosa
multas.prestamo_id No Decisión D6: multas por pérdida en sala
multas.importe Una multa sin importe no tiene sentido
ponentes.email No Hay externos de los que solo se tiene el teléfono
inscripciones.acompanantes , con DEFAULT 0 "Ninguno" es 0, no "no se sabe"
informes_evento.valoracion_media No Puede no haber encuestas
informes_evento.asistentes_reales Si se redacta el informe, se cuenta

La fila de acompanantes es la más instructiva. Es la distinción entre cero y desconocido, y confundirla es el error más común con NULL. Un socio que va solo tiene 0 acompañantes, no un número desconocido de acompañantes. La combinación NOT NULL DEFAULT 0 lo expresa exactamente y además hace que SUM(1 + acompanantes) funcione sin COALESCE.

Consejo práctico: empieza declarando todo NOT NULL y quita la restricción solo donde puedas nombrar la fila legítima que la incumple. El sentido por defecto opuesto —permitir nulos y restringir después— produce esquemas donde nadie sabe qué columnas pueden faltar.

  1. DEFAULT: valores por omisión

fecha_alta   DATE        NOT NULL DEFAULT CURRENT_DATE,
fecha_pago   TIMESTAMPTZ NOT NULL DEFAULT now(),
estado       TEXT        NOT NULL DEFAULT 'programado',
publicado    BOOLEAN     NOT NULL DEFAULT FALSE,
acompanantes SMALLINT    NOT NULL DEFAULT 0,
honorarios   NUMERIC(8,2) NOT NULL DEFAULT 0

Un DEFAULT se aplica cuando la columna se omite en el INSERT, no cuando se le pasa NULL explícitamente:

INSERT INTO eventos (titulo, tipo_evento_id, inicio, fin, plazas_ofertadas)
VALUES ('Cuentacuentos de otoño', 4, '2026-10-03 17:30+02', '2026-10-03 18:30+02', 30);

SELECT titulo, estado, publicado FROM eventos WHERE titulo = 'Cuentacuentos de otoño';
         titulo         |   estado    | publicado
------------------------+-------------+-----------
 Cuentacuentos de otoño | programado  | f
INSERT INTO eventos (titulo, tipo_evento_id, inicio, fin, plazas_ofertadas, estado)
VALUES ('Taller de haiku', 2, '2026-10-10 18:00+02', '2026-10-10 20:00+02', 15, NULL);
ERROR:  null value in column "estado" of relation "eventos" violates not-null constraint

El DEFAULT no rescata un NULL explícito. Es un comportamiento correcto y sorprende a mucha gente.

now() frente a CURRENT_DATE

  • CURRENT_DATE → fecha del día.
  • now() / CURRENT_TIMESTAMP → instante de inicio de la transacción, no de la sentencia. Todas las filas insertadas en la misma transacción llevan la misma marca, lo cual suele ser lo deseable.
  • clock_timestamp() → instante real de cada llamada. Se usa para medir, casi nunca como DEFAULT.

DEFAULT frente a la aplicación

Poner el valor por defecto en la base garantiza que cualquier vía de inserción lo respete: la aplicación web, un script de importación, una carga masiva o un INSERT manual desde psql. Si el valor por defecto solo vive en el código de la aplicación, la primera carga masiva lo saltará. Es el mismo argumento que cierra el apartado 21.

  1. UNIQUE simple y compuesto, y su relación con NULL

UNIQUE garantiza que no haya dos filas con el mismo valor. En BiblioRed, cada clave natural del apartado 10 de 04-01 lleva la suya:

CONSTRAINT uq_materiales_libro_isbn      UNIQUE (isbn),
CONSTRAINT uq_ejemplares_codigo          UNIQUE (codigo),
CONSTRAINT uq_ponentes_email             UNIQUE (email),
CONSTRAINT uq_salas_sucursal_nombre      UNIQUE (sucursal_id, nombre),
CONSTRAINT uq_materiales_revista_issn_num UNIQUE (issn, numero)

El compuesto aplica a la combinación, no a cada columna:

INSERT INTO salas (sucursal_id, nombre, aforo) VALUES (1, 'Sala Polivalente', 60);
INSERT INTO salas (sucursal_id, nombre, aforo) VALUES (2, 'Sala Polivalente', 45);
INSERT 0 1
INSERT 0 1

Dos salas con el mismo nombre en sucursales distintas: correcto según R3.

INSERT INTO salas (sucursal_id, nombre, aforo) VALUES (1, 'Sala Polivalente', 60);
ERROR:  duplicate key value violates unique constraint "uq_salas_sucursal_nombre"
DETALLE:  Key (sucursal_id, nombre)=(1, Sala Polivalente) already exists.

UNIQUE y NULL: el comportamiento que sorprende

En SQL estándar, NULL no es igual a NULL (lógica trivalente, 02-01). Como UNIQUE prohíbe valores iguales y dos NULL no lo son, una columna UNIQUE admite tantos NULL como quieras:

INSERT INTO ponentes (nombre, apellidos, email) VALUES ('Elena', 'Roig', NULL);
INSERT INTO ponentes (nombre, apellidos, email) VALUES ('Marc',  'Duran', NULL);
INSERT INTO ponentes (nombre, apellidos, email) VALUES ('Aina',  'Ferrer', NULL);
SELECT count(*) FROM ponentes WHERE email IS NULL;
 count
-------
     3

Tres ponentes sin correo, con UNIQUE (email). No es un fallo: es lo que permite combinar "el correo es opcional" con "dos ponentes no comparten correo", que era justo lo que necesitábamos en 04-03.

El caso donde ese comportamiento hace daño en BiblioRed

Recuerda la restricción uq_multas_prestamo_motivo UNIQUE (prestamo_id, motivo), que implementaba R10 ("un préstamo genera como mucho una multa de cada motivo"). Como prestamo_id es anulable por la decisión D6, tiene un agujero:

INSERT INTO multas (socio_id, prestamo_id, motivo, importe) VALUES (16, NULL, 'perdida', 18.00);
INSERT INTO multas (socio_id, prestamo_id, motivo, importe) VALUES (16, NULL, 'perdida', 18.00);
INSERT INTO multas (socio_id, prestamo_id, motivo, importe) VALUES (16, NULL, 'perdida', 18.00);
INSERT 0 1
INSERT 0 1
INSERT 0 1

Tres multas idénticas por la misma pérdida, y el UNIQUE no ha dicho nada porque cada (NULL, 'perdida') es distinto de los demás. Es un duplicado real en producción.

Desde PostgreSQL 15 hay una solución declarativa:

ALTER TABLE multas DROP CONSTRAINT uq_multas_prestamo_motivo;
ALTER TABLE multas ADD CONSTRAINT uq_multas_prestamo_motivo
    UNIQUE NULLS NOT DISTINCT (prestamo_id, motivo);
INSERT INTO multas (socio_id, prestamo_id, motivo, importe) VALUES (16, NULL, 'perdida', 18.00);
ERROR:  duplicate key value violates unique constraint "uq_multas_prestamo_motivo"
DETALLE:  Key (prestamo_id, motivo)=(null, perdida) already exists.

Con NULLS NOT DISTINCT, los NULL se consideran iguales entre sí a efectos de unicidad. En versiones anteriores a la 15 se resolvía con un índice único parcial, que es materia de 06-03.

La lección de fondo: un UNIQUE sobre columnas anulables no garantiza lo que parece garantizar. Cada vez que declares uno, comprueba si alguna de sus columnas admite NULL y decide conscientemente.

  1. PRIMARY KEY como combinación de las anteriores

PRIMARY KEY no es una restricción nueva: equivale a UNIQUE + NOT NULL, más el papel de identificador por defecto de las claves ajenas.

-- Estas dos declaraciones son casi equivalentes
CONSTRAINT pk_salas PRIMARY KEY (sala_id)

sala_id INTEGER NOT NULL,
CONSTRAINT uq_salas_id UNIQUE (sala_id)

Las tres diferencias que sí importan:

  1. Solo puede haber una clave primaria por tabla; claves UNIQUE, las que hagan falta.
  2. REFERENCES tabla sin columna apunta implícitamente a la clave primaria.
  3. Herramientas, ORM y clientes gráficos la usan para identificar la fila.

En BiblioRed conviven los dos usos: materiales tiene PRIMARY KEY (material_id) y además UNIQUE (material_id, tipo_material), esta última existiendo únicamente para que las subtablas puedan referenciarla con la clave ajena compuesta de la regla 10.

  1. CHECK: codificar reglas de negocio en el esquema

Aquí es donde las diez reglas de negocio de 04-01 entran por fin en el esquema.

CHECK de una columna

CONSTRAINT chk_multas_importe_no_negativo CHECK (importe >= 0),          -- RN5
CONSTRAINT chk_salas_aforo_positivo       CHECK (aforo > 0),
CONSTRAINT chk_inscripciones_acompanantes CHECK (acompanantes BETWEEN 0 AND 3),  -- R6
CONSTRAINT chk_multas_motivo   CHECK (motivo IN ('retraso','deterioro','perdida')),
CONSTRAINT chk_multas_estado   CHECK (estado IN ('pendiente','pagada','condonada','anulada'))

Comprobación:

INSERT INTO multas (socio_id, motivo, importe) VALUES (14, 'retraso', -40.00);
ERROR:  new row for relation "multas" violates check constraint "chk_multas_importe_no_negativo"
DETALLE:  Failing row contains (318, 14, null, retraso, -40.00, 2026-08-02, pendiente).
INSERT INTO inscripciones (evento_id, socio_id, acompanantes) VALUES (47, 16, 7);
ERROR:  new row for relation "inscripciones" violates check constraint "chk_inscripciones_acompanantes"
DETALLE:  Failing row contains (47, 16, 2026-08-02 12:14:03.221+02, confirmada, 7).

CHECK de varias columnas

Un CHECK declarado a nivel de tabla puede referirse a varias columnas de la misma fila:

-- RN3: el evento no puede terminar antes de empezar
CONSTRAINT chk_eventos_fin_posterior CHECK (fin > inicio),

-- El préstamo no se devuelve antes de prestarse
CONSTRAINT chk_prestamos_devolucion CHECK (fecha_devolucion IS NULL
                                        OR fecha_devolucion >= fecha_prestamo),

-- RN8: un evento publicado necesita sala y plazas
CONSTRAINT chk_eventos_publicado CHECK (NOT publicado
                                     OR (sala_id IS NOT NULL AND plazas_ofertadas > 0))
INSERT INTO eventos (titulo, tipo_evento_id, inicio, fin, plazas_ofertadas)
VALUES ('Charla mal programada', 5, '2026-09-10 19:00+02', '2026-09-10 18:00+02', 40);
ERROR:  new row for relation "eventos" violates check constraint "chk_eventos_fin_posterior"
DETALLE:  Failing row contains (52, Charla mal programada, null, 5, null, 2026-09-10 19:00:00+02, 2026-09-10 18:00:00+02, 40, programado, f).

Fíjate en la construcción de chk_prestamos_devolucion: incluye el caso NULL explícitamente. Es imprescindible saber cómo trata CHECK a los nulos.

CHECK y NULL: la regla que hay que memorizar

Un CHECK acepta la fila cuando la expresión da TRUE o NULL. Solo la rechaza cuando da FALSE.

-- CHECK (fecha_devolucion >= fecha_prestamo), sin contemplar NULL
INSERT INTO prestamos (socio_id, ejemplar_id, fecha_prestamo, fecha_devolucion_prevista)
VALUES (14, 3081, '2026-07-01', '2026-07-22');
INSERT 0 1

Se acepta porque fecha_devolucion es NULL y NULL >= '2026-07-01' da NULL, no FALSE. En este caso concreto es lo que queríamos —un préstamo no devuelto es válido—, pero el comportamiento es fácil de confundir. Si quieres rechazar los nulos, hazlo explícito con NOT NULL o con IS NOT NULL dentro del CHECK.

Lo que un CHECK NO puede hacer

Un CHECK solo ve la fila que se está insertando o modificando. No puede consultar otras tablas ni otras filas. Esto deja fuera cuatro de las diez reglas de negocio:

Regla Por qué no cabe en un CHECK Dónde vive
RN1: plazas ≤ aforo de la sala El aforo está en salas Disparador o aplicación
RN2: inscripciones ≤ plazas ofertadas Requiere agregar inscripciones Disparador o aplicación (con bloqueo)
RN4: dos eventos no se solapan en la misma sala Requiere consultar otras filas Restricción de exclusión (ver abajo)
RN10: no reservar un material sin ejemplares Requiere contar ejemplares Disparador o aplicación
R12: máximo 3 teléfonos por socio Requiere contar telefonos_socio Disparador o aplicación

Para RN4, PostgreSQL ofrece una herramienta específica y poco conocida, la restricción de exclusión, que generaliza UNIQUE a cualquier operador:

CREATE EXTENSION IF NOT EXISTS btree_gist;

ALTER TABLE eventos ADD CONSTRAINT excl_eventos_solape_sala
    EXCLUDE USING gist (
        sala_id WITH =,
        tstzrange(inicio, fin) WITH &&
    ) WHERE (estado <> 'cancelado' AND sala_id IS NOT NULL);

Se lee: no pueden existir dos filas no canceladas con la misma sala_id cuyos rangos de tiempo se solapen.

INSERT INTO eventos (titulo, tipo_evento_id, sala_id, inicio, fin, plazas_ofertadas)
VALUES ('Club de lectura', 1, 3, '2026-09-17 18:00+02', '2026-09-17 20:00+02', 20);

INSERT INTO eventos (titulo, tipo_evento_id, sala_id, inicio, fin, plazas_ofertadas)
VALUES ('Presentación', 3, 3, '2026-09-17 19:00+02', '2026-09-17 21:00+02', 50);
INSERT 0 1
ERROR:  conflicting key value violates exclusion constraint "excl_eventos_solape_sala"
DETALLE:  Key (sala_id, tstzrange(inicio, fin))=(3, ["2026-09-17 19:00:00+02","2026-09-17 21:00:00+02")) conflicts with existing key (sala_id, tstzrange(inicio, fin))=(3, ["2026-09-17 18:00:00+02","2026-09-17 20:00:00+02")).

RN4 garantizada por el servidor, con concurrencia correcta y sin una línea de código de aplicación. Es de lo mejor que ofrece PostgreSQL y no tiene equivalente en SQLite.

  1. Columnas generadas y dominios

Columnas generadas

Una columna generada se calcula a partir de otras de la misma fila. Es la manera correcta de materializar un derivado sencillo sin riesgo de desincronización, porque el gestor la mantiene y nadie puede escribirla.

ALTER TABLE inscripciones ADD COLUMN plazas_ocupadas SMALLINT
    GENERATED ALWAYS AS (1 + acompanantes) STORED;

ALTER TABLE eventos ADD COLUMN duracion_min INTEGER
    GENERATED ALWAYS AS (EXTRACT(EPOCH FROM (fin - inicio)) / 60) STORED;
SELECT evento_id, inicio, fin, duracion_min FROM eventos WHERE evento_id = 47;
 evento_id |         inicio         |          fin           | duracion_min
-----------+------------------------+------------------------+--------------
        47 | 2026-09-17 18:00:00+02 | 2026-09-17 20:00:00+02 |          120
UPDATE eventos SET duracion_min = 999 WHERE evento_id = 47;
ERROR:  column "duracion_min" can only be updated to DEFAULT
DETALLE:  Column "duracion_min" is a generated column.

No se puede mentir. Esa es la diferencia esencial con la desnormalización manual de 05-04: aquí el gestor garantiza la coherencia.

Dos límites: en PostgreSQL solo existe STORED (se almacena; no hay columnas virtuales calculadas al vuelo), y la expresión debe ser inmutable y referirse solo a columnas de la misma fila. Por eso plazas_libres de un evento —que agrega otra tabla— no puede ser una columna generada y sigue siendo la vista v_eventos_ocupacion de 04-03.

Dominios

Un dominio es un tipo propio construido sobre otro, con restricciones incorporadas. Sirve para no repetir la misma validación en quince sitios y para que el esquema exprese el vocabulario del negocio.

CREATE DOMAIN dom_email AS TEXT
    CHECK (VALUE ~ '^[^@[:space:]]+@[^@[:space:]]+\.[A-Za-z]{2,}$');

CREATE DOMAIN dom_importe_eur AS NUMERIC(8,2)
    CHECK (VALUE >= 0);

CREATE DOMAIN dom_idioma AS VARCHAR(5)
    CHECK (VALUE ~ '^[a-z]{2}(-[A-Z]{2})?$');

CREATE DOMAIN dom_codigo_postal AS CHAR(5)
    CHECK (VALUE ~ '^[0-9]{5}$');

Y se usan como si fueran tipos:

CREATE TABLE ponentes (
    ponente_id INTEGER GENERATED BY DEFAULT AS IDENTITY,
    email      dom_email,
    ...
);

INSERT INTO ponentes (nombre, apellidos, email)
VALUES ('Elena', 'Roig', 'elena.roig-arroba-example.org');
ERROR:  value for domain dom_email violates check constraint "dom_email_check"
Ventaja Detalle
Una sola definición La validación del correo está en un sitio, para socios y ponentes
Cambio centralizado ALTER DOMAIN ... ADD CONSTRAINT afecta a todas las columnas
Documentación email dom_email dice más que email TEXT
Menos errores de copia No hay quince CHECK que puedan divergir

Su inconveniente es la portabilidad: los dominios son de SQL estándar pero SQLite no los tiene, así que un esquema con dominios necesita una versión alternativa para las pruebas locales.

  1. Nombrar las restricciones y leer los errores de producción

Si no nombras una restricción, PostgreSQL le pone un nombre automático. Compara los dos mensajes.

Sin nombre:

CREATE TABLE salas_sin_nombres (
    sala_id     INTEGER PRIMARY KEY,
    sucursal_id INTEGER NOT NULL REFERENCES sucursales(sucursal_id),
    nombre      TEXT NOT NULL,
    aforo       SMALLINT NOT NULL CHECK (aforo > 0),
    UNIQUE (sucursal_id, nombre)
);

INSERT INTO salas_sin_nombres VALUES (1, 1, 'Sala Polivalente', 0);
ERROR:  new row for relation "salas_sin_nombres" violates check constraint "salas_sin_nombres_aforo_check"

Con nombre:

INSERT INTO salas VALUES (DEFAULT, 1, 'Sala Polivalente', 0);
ERROR:  new row for relation "salas" violates check constraint "chk_salas_aforo_positivo"

Parece un detalle estético. No lo es, por tres razones concretas:

  1. El equipo de soporte lee el mensaje. chk_salas_aforo_positivo se entiende sin abrir el esquema; salas_aforo_check obliga a investigar. Con dos CHECK en la misma columna, el nombre autogenerado es salas_aforo_check1, y ahí ya no hay nada que hacer.
  2. La aplicación puede reaccionar al nombre. El código captura el error, lee el nombre de la restricción y muestra el mensaje adecuado al usuario. Con nombres autogenerados, esa lógica se rompe en cuanto alguien recrea una tabla y los sufijos cambian de orden.
  3. Las migraciones necesitan el nombre. ALTER TABLE ... DROP CONSTRAINT exige saberlo, y consultar el catálogo cada vez es fricción innecesaria.

Convención de BiblioRed:

Prefijo Tipo Ejemplo
pk_ Clave primaria pk_inscripciones
uq_ Unicidad uq_salas_sucursal_nombre
fk_ Clave ajena fk_inscripciones_evento
chk_ Comprobación chk_eventos_fin_posterior
excl_ Exclusión excl_eventos_solape_sala
dom_ Dominio dom_importe_eur

Y la forma del nombre: <prefijo>_<tabla>_<qué comprueba>. Los nombres son globales por esquema, así que incluir la tabla evita colisiones.

  1. Añadir restricciones a una tabla que ya tiene datos

El caso real: prestamos tiene 4.312 filas y ninguna restricción sobre el recargo. Si intentas añadirla directamente:

ALTER TABLE prestamos ADD CONSTRAINT chk_prestamos_recargo CHECK (recargo >= 0);

Ocurren dos cosas. Si hay datos que la incumplen:

ERROR:  check constraint "chk_prestamos_recargo" of relation "prestamos" is violated by some row

Y si no los hay, PostgreSQL recorre la tabla entera para verificarlo, manteniendo un bloqueo que impide leer y escribir. Con 4.312 filas es instantáneo; con diez millones, es una parada de servicio.

El procedimiento correcto en tres pasos

Paso 1 — Averiguar cuántas filas incumplen:

SELECT count(*) FROM prestamos WHERE recargo < 0;
 count
-------
     3

Paso 2 — Corregir los datos existentes:

UPDATE prestamos SET recargo = 0 WHERE recargo < 0;
UPDATE 3

Paso 3 — Añadir la restricción con NOT VALID y validarla después:

ALTER TABLE prestamos
    ADD CONSTRAINT chk_prestamos_recargo CHECK (recargo >= 0) NOT VALID;
ALTER TABLE

NOT VALID significa: "la restricción se aplica desde ahora a toda fila nueva o modificada, pero no compruebes las que ya están". Es instantáneo y toma un bloqueo mucho más ligero. La base queda protegida de inmediato contra datos nuevos incorrectos.

Después, en una ventana tranquila:

ALTER TABLE prestamos VALIDATE CONSTRAINT chk_prestamos_recargo;
ALTER TABLE

VALIDATE CONSTRAINT recorre la tabla, pero con un bloqueo que permite lecturas y escrituras concurrentes. Si encuentra una fila que incumple, falla y la restricción se queda en NOT VALID; no se pierde nada.

Se puede consultar el estado en el catálogo:

SELECT conname, convalidated FROM pg_constraint
 WHERE conrelid = 'prestamos'::regclass AND contype = 'c';
        conname         | convalidated
------------------------+--------------
 chk_prestamos_recargo  | t

NOT VALID funciona igual con claves ajenas y es la técnica estándar para añadir integridad referencial a una base grande sin parar el servicio. NOT NULL es la excepción: no admite NOT VALID hasta PostgreSQL 18; el rodeo clásico es añadir primero un CHECK (col IS NOT NULL) NOT VALID, validarlo y convertirlo después.

  1. Qué reglas van en la base de datos y cuáles en la aplicación

En 02-06 defendimos la integridad referencial en el servidor. Ahora toca el criterio general, porque no todo cabe en el esquema.

El argumento de fondo

La base de datos es el único punto por el que pasan todos los caminos. La aplicación web, la aplicación móvil, el script nocturno de importación del catálogo, la carga masiva del proveedor, el becario con psql y el proceso de migración: todos escriben en la misma base. Una regla que vive solo en la aplicación web la incumplen los otros cinco.

Además, las aplicaciones se reescriben; los datos permanecen. Y una regla en la base se aplica retroactivamente en el sentido de que impide que el problema vuelva a aparecer, mientras que corregir el código no arregla los datos ya corrompidos.

La tabla de decisión

Tipo de regla Dónde Motivo
Formato y rango de un valor (importe >= 0) Base (CHECK, dominio) Barata, universal, imposible de saltar
Unicidad Base (UNIQUE) La aplicación no puede garantizarla con concurrencia
Integridad referencial Base (FOREIGN KEY, 02-06) Igual
Coherencia entre columnas de una fila (fin > inicio) Base (CHECK) Barata
No solapamiento (RN4) Base (EXCLUDE) Requiere concurrencia correcta
Agregados sobre otras tablas (RN2: aforo) Base con disparador, o aplicación con bloqueo No cabe en CHECK; el disparador es más seguro pero más opaco
Flujo de estados (de programado a abierto, no a celebrado) Aplicación Es lógica de proceso, cambia a menudo
Cálculo del importe de una multa según la ordenanza Aplicación Cambia con las tarifas; el resultado se congela en la base
Bloqueo por deuda > 20 € (R14) Aplicación Depende de un umbral político que cambia y requiere agregar varias tablas
Validación de forma para dar mejor mensaje al usuario Ambas La aplicación por experiencia de uso, la base por garantía

La duplicación deliberada

La última fila incomoda a mucha gente: ¿no es duplicar trabajo? No, porque cumplen funciones distintas:

  • La aplicación valida para dar una buena experiencia: un mensaje claro junto al campo, antes de enviar el formulario, en el idioma del usuario.
  • La base valida para dar una garantía: nada entra mal, venga por donde venga.

La primera se puede saltar; la segunda no. Duplicar la validación de formato en los dos sitios es correcto y esperado. Lo que no es correcto es tenerla solo en la aplicación.

La regla que resume el apartado: si el dato incorrecto causaría un problema aunque nadie lo mirara nunca por pantalla, la regla va en la base.

  1. Entregable: el esquema definitivo de BiblioRed ampliado

Aquí está el resultado del módulo 4 completo. Es la migración V005, que revisa los tipos provisionales de 04-03 y añade el blindaje.

-- =====================================================================
-- BiblioRed · Migración V005: tipos definitivos y restricciones
-- Cierra el módulo 4. Referencias R1..R14 y RN1..RN10 del documento v1.0
-- =====================================================================

-- ---------------------------------------------------------------------
-- 0 · Dominios reutilizables
-- ---------------------------------------------------------------------
CREATE DOMAIN dom_email AS TEXT
    CHECK (VALUE ~ '^[^@[:space:]]+@[^@[:space:]]+\.[A-Za-z]{2,}$');

CREATE DOMAIN dom_importe_eur AS NUMERIC(8,2)
    CHECK (VALUE >= 0);                                          -- RN5

CREATE DOMAIN dom_idioma AS VARCHAR(5)
    CHECK (VALUE ~ '^[a-z]{2}(-[A-Z]{2})?$');

CREATE DOMAIN dom_codigo_postal AS CHAR(5)
    CHECK (VALUE ~ '^[0-9]{5}$');

CREATE EXTENSION IF NOT EXISTS btree_gist;
CREATE EXTENSION IF NOT EXISTS unaccent;

-- ---------------------------------------------------------------------
-- 1 · Catálogo de materiales
-- ---------------------------------------------------------------------
CREATE TABLE materiales (
    material_id      INTEGER  GENERATED BY DEFAULT AS IDENTITY,
    tipo_material    VARCHAR(15) NOT NULL,
    titulo           TEXT        NOT NULL,
    autor_id         INTEGER,
    editorial        TEXT,
    anio_publicacion SMALLINT,
    idioma           dom_idioma  NOT NULL,
    fecha_alta       DATE        NOT NULL DEFAULT CURRENT_DATE,
    portada_url      TEXT,
    CONSTRAINT pk_materiales           PRIMARY KEY (material_id),
    CONSTRAINT uq_materiales_id_tipo   UNIQUE (material_id, tipo_material),
    CONSTRAINT chk_materiales_tipo
        CHECK (tipo_material IN ('libro','dvd','revista','audiolibro')),
    CONSTRAINT chk_materiales_titulo    CHECK (char_length(titulo) BETWEEN 1 AND 300),
    CONSTRAINT chk_materiales_anio      CHECK (anio_publicacion BETWEEN 1450 AND 2100),
    CONSTRAINT fk_materiales_autor      FOREIGN KEY (autor_id)
        REFERENCES autores (autor_id) ON DELETE SET NULL ON UPDATE CASCADE
);

CREATE TABLE materiales_libro (
    material_id    INTEGER     NOT NULL,
    tipo_material  VARCHAR(15) NOT NULL DEFAULT 'libro',
    isbn           VARCHAR(13) COLLATE "C" NOT NULL,
    num_paginas    SMALLINT,
    encuadernacion VARCHAR(20),
    CONSTRAINT pk_materiales_libro       PRIMARY KEY (material_id),
    CONSTRAINT uq_materiales_libro_isbn  UNIQUE (isbn),                   -- RN9
    CONSTRAINT chk_materiales_libro_tipo CHECK (tipo_material = 'libro'),
    CONSTRAINT chk_materiales_libro_isbn CHECK (isbn ~ '^[0-9]{13}$'),
    CONSTRAINT chk_materiales_libro_pags CHECK (num_paginas IS NULL OR num_paginas > 0),
    CONSTRAINT chk_materiales_libro_enc
        CHECK (encuadernacion IS NULL
               OR encuadernacion IN ('tapa_dura','tapa_blanda','espiral','bolsillo')),
    CONSTRAINT fk_materiales_libro_material FOREIGN KEY (material_id, tipo_material)
        REFERENCES materiales (material_id, tipo_material)
        ON DELETE CASCADE ON UPDATE CASCADE
);

CREATE TABLE materiales_dvd (
    material_id   INTEGER     NOT NULL,
    tipo_material VARCHAR(15) NOT NULL DEFAULT 'dvd',
    duracion_min  SMALLINT    NOT NULL,
    formato_video VARCHAR(15),
    codigo_region SMALLINT,
    CONSTRAINT pk_materiales_dvd       PRIMARY KEY (material_id),
    CONSTRAINT chk_materiales_dvd_tipo CHECK (tipo_material = 'dvd'),
    CONSTRAINT chk_materiales_dvd_dur  CHECK (duracion_min > 0 AND duracion_min < 1000),
    CONSTRAINT chk_materiales_dvd_reg  CHECK (codigo_region IS NULL
                                              OR codigo_region BETWEEN 0 AND 8),
    CONSTRAINT fk_materiales_dvd_material FOREIGN KEY (material_id, tipo_material)
        REFERENCES materiales (material_id, tipo_material)
        ON DELETE CASCADE ON UPDATE CASCADE
);

CREATE TABLE materiales_revista (
    material_id   INTEGER     NOT NULL,
    tipo_material VARCHAR(15) NOT NULL DEFAULT 'revista',
    issn          VARCHAR(9)  COLLATE "C" NOT NULL,
    numero        VARCHAR(20) NOT NULL,
    periodicidad  VARCHAR(20),
    CONSTRAINT pk_materiales_revista        PRIMARY KEY (material_id),
    CONSTRAINT uq_materiales_revista_issn   UNIQUE (issn, numero),        -- RN9
    CONSTRAINT chk_materiales_revista_tipo  CHECK (tipo_material = 'revista'),
    CONSTRAINT chk_materiales_revista_issn  CHECK (issn ~ '^[0-9]{4}-[0-9]{3}[0-9X]$'),
    CONSTRAINT chk_materiales_revista_per
        CHECK (periodicidad IS NULL
               OR periodicidad IN ('diaria','semanal','quincenal','mensual',
                                   'bimestral','trimestral','semestral','anual')),
    CONSTRAINT fk_materiales_revista_material FOREIGN KEY (material_id, tipo_material)
        REFERENCES materiales (material_id, tipo_material)
        ON DELETE CASCADE ON UPDATE CASCADE
);

CREATE TABLE materiales_audiolibro (
    material_id   INTEGER     NOT NULL,
    tipo_material VARCHAR(15) NOT NULL DEFAULT 'audiolibro',
    duracion_min  SMALLINT    NOT NULL,
    narrador      TEXT,
    formato_audio VARCHAR(15),
    CONSTRAINT pk_materiales_audiolibro       PRIMARY KEY (material_id),
    CONSTRAINT chk_materiales_audio_tipo      CHECK (tipo_material = 'audiolibro'),
    CONSTRAINT chk_materiales_audio_dur       CHECK (duracion_min > 0),
    CONSTRAINT chk_materiales_audio_formato
        CHECK (formato_audio IS NULL OR formato_audio IN ('mp3','m4b','flac','ogg')),
    CONSTRAINT fk_materiales_audio_material FOREIGN KEY (material_id, tipo_material)
        REFERENCES materiales (material_id, tipo_material)
        ON DELETE CASCADE ON UPDATE CASCADE
);

CREATE TABLE subtitulos_dvd (
    material_id INTEGER    NOT NULL,
    idioma      dom_idioma NOT NULL,
    CONSTRAINT pk_subtitulos_dvd PRIMARY KEY (material_id, idioma),
    CONSTRAINT fk_subtitulos_dvd_material FOREIGN KEY (material_id)
        REFERENCES materiales_dvd (material_id) ON DELETE CASCADE ON UPDATE CASCADE
);

-- ---------------------------------------------------------------------
-- 2 · Socios: teléfonos (R12) y direcciones de sucursal (R13)
-- ---------------------------------------------------------------------
CREATE TABLE telefonos_socio (
    socio_id INTEGER     NOT NULL,
    numero   VARCHAR(20) NOT NULL,
    tipo     VARCHAR(10) NOT NULL DEFAULT 'movil',
    CONSTRAINT pk_telefonos_socio  PRIMARY KEY (socio_id, numero),
    CONSTRAINT chk_telefonos_tipo  CHECK (tipo IN ('movil','fijo','trabajo')),
    CONSTRAINT chk_telefonos_num   CHECK (numero ~ '^\+?[0-9 ]{6,20}$'),
    CONSTRAINT fk_telefonos_socio  FOREIGN KEY (socio_id)
        REFERENCES socios (socio_id) ON DELETE CASCADE ON UPDATE CASCADE
);
-- R12 (máximo 3 por socio): no expresable en CHECK. Disparador o aplicación.

ALTER TABLE sucursales
    ALTER COLUMN dir_codigo_postal TYPE dom_codigo_postal,
    ALTER COLUMN dir_calle  SET NOT NULL,
    ALTER COLUMN dir_ciudad SET NOT NULL;

ALTER TABLE socios ALTER COLUMN email TYPE dom_email;

-- ---------------------------------------------------------------------
-- 3 · Salas y eventos (R3, R4, R5)
-- ---------------------------------------------------------------------
CREATE TABLE salas (
    sala_id     INTEGER  GENERATED BY DEFAULT AS IDENTITY,
    sucursal_id INTEGER  NOT NULL,
    nombre      TEXT     NOT NULL,
    aforo       SMALLINT NOT NULL,
    planta      SMALLINT NOT NULL DEFAULT 0,
    accesible   BOOLEAN  NOT NULL DEFAULT TRUE,
    CONSTRAINT pk_salas                 PRIMARY KEY (sala_id),
    CONSTRAINT uq_salas_sucursal_nombre UNIQUE (sucursal_id, nombre),      -- R3
    CONSTRAINT chk_salas_aforo_positivo CHECK (aforo > 0 AND aforo <= 2000),
    CONSTRAINT chk_salas_planta         CHECK (planta BETWEEN -3 AND 20),
    CONSTRAINT chk_salas_nombre         CHECK (char_length(nombre) BETWEEN 1 AND 80),
    CONSTRAINT fk_salas_sucursal        FOREIGN KEY (sucursal_id)
        REFERENCES sucursales (sucursal_id) ON DELETE RESTRICT ON UPDATE CASCADE
);

CREATE TABLE tipos_evento (
    tipo_evento_id        INTEGER     GENERATED BY DEFAULT AS IDENTITY,
    codigo                VARCHAR(30) COLLATE "C" NOT NULL,
    nombre                TEXT        NOT NULL,
    descripcion           TEXT,
    duracion_estandar_min SMALLINT,
    activo                BOOLEAN     NOT NULL DEFAULT TRUE,
    CONSTRAINT pk_tipos_evento        PRIMARY KEY (tipo_evento_id),
    CONSTRAINT uq_tipos_evento_codigo UNIQUE (codigo),
    CONSTRAINT chk_tipos_evento_codigo CHECK (codigo ~ '^[a-z][a-z0-9_]{2,29}$'),
    CONSTRAINT chk_tipos_evento_dur
        CHECK (duracion_estandar_min IS NULL OR duracion_estandar_min > 0)
);

CREATE TABLE eventos (
    evento_id        INTEGER     GENERATED BY DEFAULT AS IDENTITY,
    titulo           TEXT        NOT NULL,
    descripcion      TEXT,
    tipo_evento_id   INTEGER     NOT NULL,
    sala_id          INTEGER,                                   -- D4: opcional
    inicio           TIMESTAMPTZ NOT NULL,
    fin              TIMESTAMPTZ NOT NULL,
    plazas_ofertadas SMALLINT    NOT NULL,
    estado           VARCHAR(15) NOT NULL DEFAULT 'programado',
    publicado        BOOLEAN     NOT NULL DEFAULT FALSE,
    duracion_min     INTEGER     GENERATED ALWAYS AS
                         (EXTRACT(EPOCH FROM (fin - inicio)) / 60) STORED,
    CONSTRAINT pk_eventos            PRIMARY KEY (evento_id),
    CONSTRAINT chk_eventos_titulo    CHECK (char_length(titulo) BETWEEN 3 AND 200),
    CONSTRAINT chk_eventos_fin_posterior CHECK (fin > inicio),             -- RN3
    CONSTRAINT chk_eventos_plazas    CHECK (plazas_ofertadas >= 0 AND plazas_ofertadas <= 2000),
    CONSTRAINT chk_eventos_estado
        CHECK (estado IN ('programado','abierto','completo','celebrado','cancelado')),
    CONSTRAINT chk_eventos_publicado                                        -- RN8
        CHECK (NOT publicado OR (sala_id IS NOT NULL AND plazas_ofertadas > 0)),
    CONSTRAINT fk_eventos_tipo FOREIGN KEY (tipo_evento_id)
        REFERENCES tipos_evento (tipo_evento_id) ON DELETE RESTRICT ON UPDATE CASCADE,
    CONSTRAINT fk_eventos_sala FOREIGN KEY (sala_id)
        REFERENCES salas (sala_id) ON DELETE RESTRICT ON UPDATE CASCADE
);

ALTER TABLE eventos ADD CONSTRAINT excl_eventos_solape_sala                 -- RN4
    EXCLUDE USING gist (sala_id WITH =, tstzrange(inicio, fin) WITH &&)
    WHERE (estado <> 'cancelado' AND sala_id IS NOT NULL);

CREATE TABLE ponentes (
    ponente_id INTEGER   GENERATED BY DEFAULT AS IDENTITY,
    nombre     TEXT      NOT NULL,
    apellidos  TEXT      NOT NULL,
    email      dom_email,
    biografia  TEXT,
    externo    BOOLEAN   NOT NULL DEFAULT TRUE,
    CONSTRAINT pk_ponentes        PRIMARY KEY (ponente_id),
    CONSTRAINT uq_ponentes_email  UNIQUE (email),      -- varios NULL permitidos
    CONSTRAINT chk_ponentes_nombre CHECK (char_length(nombre) > 0
                                      AND char_length(apellidos) > 0)
);

-- ---------------------------------------------------------------------
-- 4 · Inscripciones, informes, participaciones y materiales del evento
-- ---------------------------------------------------------------------
CREATE TABLE inscripciones (
    evento_id         INTEGER     NOT NULL,
    socio_id          INTEGER     NOT NULL,
    fecha_inscripcion TIMESTAMPTZ NOT NULL DEFAULT now(),
    estado            VARCHAR(15) NOT NULL DEFAULT 'confirmada',
    acompanantes      SMALLINT    NOT NULL DEFAULT 0,
    plazas_ocupadas   SMALLINT    GENERATED ALWAYS AS (1 + acompanantes) STORED,
    CONSTRAINT pk_inscripciones     PRIMARY KEY (evento_id, socio_id),      -- R6
    CONSTRAINT chk_inscripciones_acompanantes CHECK (acompanantes BETWEEN 0 AND 3),
    CONSTRAINT chk_inscripciones_estado
        CHECK (estado IN ('confirmada','lista_espera','cancelada','asistida')),
    CONSTRAINT fk_inscripciones_evento FOREIGN KEY (evento_id)
        REFERENCES eventos (evento_id) ON DELETE CASCADE  ON UPDATE CASCADE,
    CONSTRAINT fk_inscripciones_socio  FOREIGN KEY (socio_id)
        REFERENCES socios (socio_id)   ON DELETE RESTRICT ON UPDATE CASCADE
);
-- RN2 (aforo), RN6 (socio activo) y RN7 (fecha < inicio): disparador o aplicación.

CREATE TABLE informes_evento (
    evento_id           INTEGER      NOT NULL,
    asistentes_reales   SMALLINT     NOT NULL,
    valoracion_media    NUMERIC(3,2),
    observaciones       TEXT,
    respuestas_encuesta JSONB,
    fecha_redaccion     DATE         NOT NULL DEFAULT CURRENT_DATE,
    CONSTRAINT pk_informes_evento   PRIMARY KEY (evento_id),                -- R9, 1:1
    CONSTRAINT chk_informes_asistentes CHECK (asistentes_reales >= 0),
    CONSTRAINT chk_informes_valoracion
        CHECK (valoracion_media IS NULL OR valoracion_media BETWEEN 0 AND 5),
    CONSTRAINT fk_informes_evento   FOREIGN KEY (evento_id)
        REFERENCES eventos (evento_id) ON DELETE CASCADE ON UPDATE CASCADE
);

CREATE TABLE participaciones (
    evento_id  INTEGER         NOT NULL,
    ponente_id INTEGER         NOT NULL,
    rol        VARCHAR(25)     NOT NULL,
    honorarios dom_importe_eur NOT NULL DEFAULT 0,
    CONSTRAINT pk_participaciones PRIMARY KEY (evento_id, ponente_id, rol),  -- R7
    CONSTRAINT chk_participaciones_rol
        CHECK (rol IN ('moderador','tallerista','autor_invitado','presentador')),
    CONSTRAINT fk_participaciones_evento  FOREIGN KEY (evento_id)
        REFERENCES eventos (evento_id)   ON DELETE CASCADE  ON UPDATE CASCADE,
    CONSTRAINT fk_participaciones_ponente FOREIGN KEY (ponente_id)
        REFERENCES ponentes (ponente_id) ON DELETE RESTRICT ON UPDATE CASCADE
);

CREATE TABLE eventos_materiales (
    evento_id   INTEGER     NOT NULL,
    material_id INTEGER     NOT NULL,
    papel       VARCHAR(15) NOT NULL DEFAULT 'recomendado',
    CONSTRAINT pk_eventos_materiales PRIMARY KEY (evento_id, material_id),   -- R8
    CONSTRAINT chk_eventos_materiales_papel
        CHECK (papel IN ('principal','recomendado')),
    CONSTRAINT fk_eventos_materiales_evento   FOREIGN KEY (evento_id)
        REFERENCES eventos (evento_id)     ON DELETE CASCADE ON UPDATE CASCADE,
    CONSTRAINT fk_eventos_materiales_material FOREIGN KEY (material_id)
        REFERENCES materiales (material_id) ON DELETE CASCADE ON UPDATE CASCADE
);

-- ---------------------------------------------------------------------
-- 5 · Multas y pagos (R10, R11)
-- ---------------------------------------------------------------------
CREATE TABLE multas (
    multa_id      INTEGER         GENERATED BY DEFAULT AS IDENTITY,
    socio_id      INTEGER         NOT NULL,
    prestamo_id   INTEGER,                                     -- D6: opcional
    motivo        VARCHAR(15)     NOT NULL,
    importe       dom_importe_eur NOT NULL,                    -- RN5
    fecha_emision DATE            NOT NULL DEFAULT CURRENT_DATE,
    estado        VARCHAR(15)     NOT NULL DEFAULT 'pendiente',
    CONSTRAINT pk_multas PRIMARY KEY (multa_id),
    CONSTRAINT uq_multas_prestamo_motivo                               -- R10
        UNIQUE NULLS NOT DISTINCT (prestamo_id, motivo),
    CONSTRAINT chk_multas_motivo CHECK (motivo IN ('retraso','deterioro','perdida')),
    CONSTRAINT chk_multas_estado
        CHECK (estado IN ('pendiente','pagada','condonada','anulada')),
    CONSTRAINT chk_multas_importe_max CHECK (importe <= 500),
    CONSTRAINT fk_multas_socio    FOREIGN KEY (socio_id)
        REFERENCES socios (socio_id)       ON DELETE RESTRICT ON UPDATE CASCADE,
    CONSTRAINT fk_multas_prestamo FOREIGN KEY (prestamo_id)
        REFERENCES prestamos (prestamo_id) ON DELETE RESTRICT ON UPDATE CASCADE
);

CREATE TABLE pagos (
    pago_id    INTEGER         GENERATED BY DEFAULT AS IDENTITY,
    multa_id   INTEGER         NOT NULL,
    fecha_pago TIMESTAMPTZ     NOT NULL DEFAULT now(),
    importe    dom_importe_eur NOT NULL,
    metodo     VARCHAR(15)     NOT NULL,
    referencia VARCHAR(50),
    CONSTRAINT pk_pagos          PRIMARY KEY (pago_id),
    CONSTRAINT chk_pagos_importe CHECK (importe > 0),
    CONSTRAINT chk_pagos_metodo  CHECK (metodo IN ('efectivo','tarjeta','pasarela')),
    CONSTRAINT chk_pagos_referencia
        CHECK (metodo = 'efectivo' OR referencia IS NOT NULL),
    CONSTRAINT fk_pagos_multa    FOREIGN KEY (multa_id)
        REFERENCES multas (multa_id) ON DELETE RESTRICT ON UPDATE CASCADE
);
-- RN5 (suma de pagos <= importe de la multa): disparador o aplicación.

-- ---------------------------------------------------------------------
-- 6 · Blindaje de las tablas preexistentes
-- ---------------------------------------------------------------------
ALTER TABLE prestamos
    ALTER COLUMN recargo TYPE NUMERIC(6,2),
    ALTER COLUMN recargo SET DEFAULT 0,
    ADD CONSTRAINT chk_prestamos_recargo CHECK (recargo >= 0) NOT VALID,
    ADD CONSTRAINT chk_prestamos_devolucion
        CHECK (fecha_devolucion IS NULL OR fecha_devolucion >= fecha_prestamo) NOT VALID,
    ADD CONSTRAINT chk_prestamos_prevista
        CHECK (fecha_devolucion_prevista > fecha_prestamo) NOT VALID;

ALTER TABLE prestamos VALIDATE CONSTRAINT chk_prestamos_recargo;
ALTER TABLE prestamos VALIDATE CONSTRAINT chk_prestamos_devolucion;
ALTER TABLE prestamos VALIDATE CONSTRAINT chk_prestamos_prevista;

ALTER TABLE ejemplares
    ALTER COLUMN num_ejemplar TYPE SMALLINT,
    ALTER COLUMN codigo TYPE VARCHAR(15) COLLATE "C",
    ADD CONSTRAINT chk_ejemplares_estado
        CHECK (estado IN ('disponible','prestado','reservado','reparacion','baja')),
    ADD CONSTRAINT chk_ejemplares_num CHECK (num_ejemplar > 0),
    ADD CONSTRAINT chk_ejemplares_codigo CHECK (codigo ~ '^EJ-[0-9]{4,6}$');

ALTER TABLE reservas
    ADD CONSTRAINT chk_reservas_estado
        CHECK (estado IN ('activa','disponible','recogida','expirada','cancelada')),
    ADD CONSTRAINT chk_reservas_expiracion CHECK (fecha_expiracion > fecha_reserva);

-- ---------------------------------------------------------------------
-- 7 · Documentación en el catálogo
-- ---------------------------------------------------------------------
COMMENT ON TABLE  materiales IS
    'Superclase de la jerarquía de catálogo (R1). Estrategia: tabla por subclase.';
COMMENT ON COLUMN multas.importe IS
    'Importe en euros congelado en el momento de la emisión (R10). No recalcular.';
COMMENT ON COLUMN prestamos.recargo IS
    'OBSOLETA. Histórico anterior a la ampliación; la verdad vive en multas.importe.';
COMMENT ON CONSTRAINT excl_eventos_solape_sala ON eventos IS
    'RN4: dos eventos no cancelados no pueden solaparse en la misma sala.';

Estado final de las diez reglas de negocio

Regla Dónde queda garantizada
RN1 plazas ≤ aforo de la sala Disparador o aplicación (referencia a otra tabla)
RN2 inscripciones ≤ plazas ofertadas Disparador o aplicación (agregado con bloqueo)
RN3 fin > inicio chk_eventos_fin_posterior
RN4 sin solape de eventos en una sala excl_eventos_solape_sala
RN5 importe ≥ 0 dom_importe_eur; la suma de pagos ≤ importe, en disparador
RN6 solo socios activos Aplicación
RN7 inscripción anterior al inicio Disparador o aplicación
RN8 evento publicado con sala y plazas chk_eventos_publicado
RN9 ISBN e ISSN+número únicos uq_materiales_libro_isbn, uq_materiales_revista_issn
RN10 no reservar material sin ejemplares Disparador o aplicación

Cinco de diez en el esquema, con garantía absoluta. Las otras cinco requieren consultar otras tablas o agregar filas, y quedan documentadas con su ubicación decidida. Eso también es diseño: saber exactamente dónde vive cada regla y por qué.

Errores Comunes y Consejos

Guardar dinero en REAL o DOUBLE PRECISION. El error más caro de esta lección y el más frecuente en proyectos reales. Los importes no cuadran, las sumas fallan por céntimos y nadie encuentra el motivo durante semanas. NUMERIC, siempre.

Usar TIMESTAMP sin zona horaria "porque solo operamos en España". Basta un servidor en otra zona, un contenedor con UTC por defecto o un cambio de hora para que la agenda de eventos deje de ser fiable. TIMESTAMPTZ por defecto.

Guardar fechas como texto. '14/05/2026' ordena mal, no permite restar, no valida y depende del formato regional. Una fecha es un DATE.

Poner VARCHAR(n) con un n inventado. El error aparece en producción con el primer valor largo, y en el peor momento. TEXT más un CHECK de longitud si hace falta.

Declarar UNIQUE sobre columnas anulables sin pensarlo. Los NULL no se consideran iguales: la unicidad que creías tener no existe. Es el agujero de uq_multas_prestamo_motivo, y se cierra con NULLS NOT DISTINCT.

Confundir cero con desconocido. acompanantes = 0 significa que va solo; acompanantes IS NULL significa que no lo sabemos. Si el negocio no admite el segundo caso, declara NOT NULL DEFAULT 0.

Olvidar que CHECK acepta NULL. CHECK (importe > 0) no impide importe IS NULL. Si el valor es obligatorio, hace falta también NOT NULL.

No nombrar las restricciones. El día que el error salga en producción a las tres de la madrugada, alguien tendrá que averiguar qué significa eventos_check2.

Añadir una restricción a una tabla grande sin NOT VALID. Bloquea la tabla durante la verificación completa. Con NOT VALID la protección es inmediata y la verificación se hace después sin cortar el servicio.

Meter en JSONB lo que quería ser una columna. Si el dato se filtra, se agrega o tiene reglas, es una columna. JSONB es para estructuras genuinamente variables.

Consejo: escribe primero la restricción y luego intenta violarla. Un CHECK que nunca has visto fallar puede estar mal escrito. Los mensajes de error de este capítulo salieron todos de ejecutar el INSERT que debía fallar.

Consejo: repasa el esquema columna por columna preguntando "¿qué valor absurdo cabe aquí?". Aforo cero, importe negativo, evento de duración negativa, estado con errata, código postal de tres cifras. Cada respuesta es una restricción que falta.

Consejo: los dominios se pagan solos a partir de la tercera columna igual. Tres correos, cuatro importes, cinco códigos de idioma: si repites el mismo CHECK, ya es un dominio.

Ejercicios

Ejercicio 1 — Elegir el tipo definitivo

Para cada columna, elige el tipo definitivo y justifícalo en una o dos frases. Indica también si debe llevar NOT NULL y con qué DEFAULT.

  1. salas.metros_cuadrados — superficie de la sala, con un decimal.
  2. eventos.aforo_reducido_covid — porcentaje entero de aforo permitido (0-100).
  3. pagos.referencia_pasarela — identificador que devuelve la pasarela del ayuntamiento, cadena alfanumérica de 32 caracteres.
  4. socios.fecha_nacimiento — para estadísticas por franja de edad.
  5. materiales.veces_prestado — número total de préstamos históricos del material.
  6. inscripciones.recordatorio_enviado_en — cuándo se envió el recordatorio, si se envió.

Ejercicio 2 — Traducir reglas de negocio a restricciones

Para cada regla, decide si se puede expresar con una restricción declarativa. Si es que sí, escribe el ALTER TABLE ... ADD CONSTRAINT con nombre; si es que no, explica por qué y dónde debería vivir.

  1. La valoración media de un informe está entre 0 y 5.
  2. Un evento cancelado no puede estar publicado.
  3. Un ejemplar en estado 'baja' no puede prestarse.
  4. El código de un ejemplar empieza siempre por EJ-.
  5. Una sala no puede tener dos eventos solapados.
  6. Un socio no puede tener más de tres teléfonos.
  7. Los honorarios de un ponente no externo son siempre 0.

Ejercicio 3 — Diagnóstico de un esquema mal tipado

Un equipo externo entrega esta tabla para gestionar las cuotas anuales de los socios. Localiza al menos siete problemas de tipo o de restricción, explica el daño concreto en BiblioRed y escribe la versión corregida.

CREATE TABLE cuotas (
    id            VARCHAR(50),
    socio         INTEGER,
    anio          VARCHAR(4),
    importe       FLOAT,
    pagada        CHAR(1) DEFAULT 'N',
    fecha_pago    VARCHAR(20),
    metodo        VARCHAR(50),
    descuento_pct FLOAT,
    observaciones CHAR(500)
);

Soluciones

Solución al Ejercicio 1

# Columna Tipo definitivo Justificación
1 metros_cuadrados NUMERIC(6,1), nulo permitido Es una medida que se muestra y a veces se suma en informes de patrimonio; NUMERIC evita sorpresas al agregar. Nulo permitido porque puede no estar medida. CHECK (metros_cuadrados > 0)
2 aforo_reducido_covid SMALLINT NOT NULL DEFAULT 100 Entero pequeño de 0 a 100. DEFAULT 100 porque la situación normal es sin reducción, y NOT NULL porque "sin dato" no aporta nada. CHECK (BETWEEN 0 AND 100)
3 referencia_pasarela VARCHAR(32) COLLATE "C", nulo permitido La longitud sale de una norma externa, así que el tope es legítimo. Colación "C" porque es un código: comparar byte a byte es correcto y rápido. Nulo permitido porque los pagos en efectivo no la tienen
4 fecha_nacimiento DATE, nulo permitido No hay hora. Nulo permitido porque no es un dato obligatorio para darse de alta. CHECK (fecha_nacimiento < CURRENT_DATE) no es válido: CURRENT_DATE no es inmutable en un CHECK, así que la validación va en la aplicación
5 veces_prestado Ninguno: es un atributo derivado Se cuenta sobre prestamos (regla 4 de 04-03). Almacenarlo crea la posibilidad de que mienta. Si el rendimiento lo exigiera, sería desnormalización deliberada con disparador, y eso es 05-04
6 recordatorio_enviado_en TIMESTAMPTZ, nulo permitido Instante de un hecho. El NULL es informativo: significa "no se ha enviado", y así el anti-join WHERE recordatorio_enviado_en IS NULL da la lista de pendientes sin necesidad de una columna booleana adicional

La 5 es la respuesta clave: la pregunta pedía un tipo y la respuesta correcta es que no debe existir la columna.

Solución al Ejercicio 2

# Regla ¿Declarativa? Solución
1 Valoración 0-5 , CHECK de una columna Ya está: chk_informes_valoracion
2 Cancelado no publicado , CHECK de dos columnas de la misma fila Ver abajo
3 Ejemplar de baja no prestable No: implica dos tablas (ejemplares y prestamos) Disparador o aplicación. Un CHECK en prestamos no puede leer ejemplares.estado
4 Código empieza por EJ- , CHECK con expresión regular Ya está: chk_ejemplares_codigo
5 Sin solapes en una sala , restricción de exclusión Ya está: excl_eventos_solape_sala
6 Máximo tres teléfonos No: requiere contar filas de la propia tabla Disparador (BEFORE INSERT que cuente) o aplicación
7 Honorarios 0 si no es externo No directamente: externo está en ponentes y honorarios en participaciones Disparador, o desnormalizar copiando externo a participaciones, o comprobarlo en la aplicación
-- 2
ALTER TABLE eventos ADD CONSTRAINT chk_eventos_cancelado_no_publicado
    CHECK (estado <> 'cancelado' OR NOT publicado);

Verificación:

UPDATE eventos SET estado = 'cancelado' WHERE evento_id = 47 AND publicado;
ERROR:  new row for relation "eventos" violates check constraint "chk_eventos_cancelado_no_publicado"

Comentario sobre la 7: la tentación es "pues copio externo a participaciones". Es redundancia y crea la posibilidad de que las dos copias discrepen. La respuesta correcta en la v1.0 es la aplicación, y anotarlo en el diccionario de datos.

Solución al Ejercicio 3

# Problema Daño concreto
1 id VARCHAR(50) sin PRIMARY KEY La tabla admite duplicados exactos, no se puede referenciar y no se puede actualizar fila a fila con seguridad. Además, una clave de texto de 50 caracteres para un contador es un desperdicio
2 socio sin FOREIGN KEY ni NOT NULL Cuotas huérfanas apuntando a socios inexistentes (02-06). El nombre además incumple la convención socio_id
3 anio VARCHAR(4) No se puede sumar ni comparar por rango con seguridad, admite 'ayer' y '20226', y ordena mal en cuanto aparezca un valor de otra longitud
4 importe FLOAT El error grave. Coma flotante para dinero: las sumas de recaudación no cuadran, la comparación con el importe esperado falla
5 pagada CHAR(1) DEFAULT 'N' Admite 'S', 's', 'Y', '1', 'X'; no se puede usar en WHERE pagada; el CHAR(1) añade relleno
6 fecha_pago VARCHAR(20) No ordena, no resta, no valida. La consulta C6 (recaudación por mes) se vuelve imposible sin conversiones
7 metodo VARCHAR(50) sin CHECK Convivirán 'Tarjeta', 'tarjeta', 'TPV' y 'targeta'; agrupar por método da cuatro filas para lo mismo
8 descuento_pct FLOAT sin rango Descuentos del 500 % o negativos
9 observaciones CHAR(500) Rellena con espacios hasta 500 caracteres cada fila
10 Falta UNIQUE (socio_id, anio) Un socio puede tener quince cuotas del mismo año
11 Ninguna restricción nombrada Errores ilegibles en producción
12 Sin NOT NULL en nada Cuotas sin socio, sin año y sin importe

Versión corregida:

CREATE TABLE cuotas (
    cuota_id      INTEGER         GENERATED BY DEFAULT AS IDENTITY,
    socio_id      INTEGER         NOT NULL,
    anio          SMALLINT        NOT NULL,
    importe       dom_importe_eur NOT NULL,
    descuento_pct SMALLINT        NOT NULL DEFAULT 0,
    pagada        BOOLEAN         NOT NULL DEFAULT FALSE,
    fecha_pago    DATE,
    metodo        VARCHAR(15),
    observaciones TEXT,
    importe_final NUMERIC(8,2)    GENERATED ALWAYS AS
                      (ROUND(importe * (100 - descuento_pct) / 100.0, 2)) STORED,
    CONSTRAINT pk_cuotas            PRIMARY KEY (cuota_id),
    CONSTRAINT uq_cuotas_socio_anio UNIQUE (socio_id, anio),
    CONSTRAINT chk_cuotas_anio      CHECK (anio BETWEEN 2000 AND 2100),
    CONSTRAINT chk_cuotas_descuento CHECK (descuento_pct BETWEEN 0 AND 100),
    CONSTRAINT chk_cuotas_metodo
        CHECK (metodo IS NULL OR metodo IN ('efectivo','tarjeta','pasarela','domiciliacion')),
    CONSTRAINT chk_cuotas_coherencia_pago
        CHECK ((pagada     AND fecha_pago IS NOT NULL AND metodo IS NOT NULL)
            OR (NOT pagada AND fecha_pago IS NULL     AND metodo IS NULL)),
    CONSTRAINT fk_cuotas_socio FOREIGN KEY (socio_id)
        REFERENCES socios (socio_id) ON DELETE RESTRICT ON UPDATE CASCADE
);

Las dos mejoras que van más allá de corregir tipos son chk_cuotas_coherencia_pago —que impide el estado incoherente "pagada sin fecha de pago", un CHECK de tres columnas— y la columna generada importe_final, que garantiza que el descuento aplicado nunca pueda desincronizarse del importe base.

Conclusión

Esta lección ha convertido un esquema estructuralmente correcto en un esquema que se defiende solo, y con ella se cierra el módulo 4.

Sobre los tipos:

  • El tipo decide cuatro cosas a la vez: qué valores caben, qué operaciones tienen sentido, cómo se ordena y cuánto cuesta. Un tipo mal elegido no da error: da resultados incorrectos en silencio.
  • Enteros: SMALLINT documenta la intención, INTEGER sobra para todo lo que genera una persona, BIGINT para lo que genera una máquina. Migrar de INTEGER a BIGINT en una tabla grande y referenciada es de las peores migraciones que existen.
  • NUMERIC frente a coma flotante es el apartado que hay que recordar: 3.10 + 2.20 + 4.30 da 9.600000000000001 en DOUBLE PRECISION, y por eso una multa pagada íntegramente puede aparecer como impagada. Todo lo que es dinero va en NUMERIC.
  • Texto: CHAR(n) casi nunca; VARCHAR(n) solo cuando n sale de una norma externa (ISBN, ISSN, código postal); TEXT para todo lo demás, con CHECK de longitud si el negocio quiere un tope.
  • Fechas: TIMESTAMPTZ por defecto para los instantes de hechos, porque guarda el momento absoluto y sobrevive a las zonas horarias y al cambio de hora; DATE cuando la hora no existe en el dominio; INTERVAL para calcular vencimientos.
  • BOOLEAN en lugar de 'S'/'N', y NOT NULL cuando el tercer valor no significa nada.
  • UUID solo si los identificadores viajan por URLs públicas o hay generación distribuida; para BiblioRed, entero secuencial, y si hace falta exponer algo, un identificador público adicional.
  • Conjuntos cerrados: tabla de catálogo si los gestiona el usuario, CHECK si los gestiona el desarrollador, ENUM solo si son inmutables de verdad.
  • JSONB y arrays son escapes legítimos para estructuras genuinamente variables —las encuestas de los eventos—, no un sitio donde meter columnas sin diseñar.
  • BYTEA: las portadas no van en la base; van en almacenamiento de objetos y en la base su ruta y su hash.
  • Codificación y colación: UTF-8 sin discusión, y la colación decide si "Àngels" se ordena donde una persona espera. Los códigos llevan colación "C"; el texto de catálogo, es-ES-x-icu con unaccent para buscar.
  • SQLite usa afinidad de tipos: acepta texto en una columna INTEGER. Con STRICT, PRAGMA foreign_keys = ON y los importes en céntimos enteros, es una herramienta excelente; sin eso, es una trampa.

Sobre las restricciones:

  • NOT NULL por defecto, y quitarlo solo donde puedas nombrar la fila legítima que lo incumple. La distinción entre cero y desconocido es el error más común.
  • DEFAULT vive en la base para que lo respeten todas las vías de escritura, no solo la aplicación web. No rescata un NULL explícito.
  • UNIQUE sobre columnas anulables no garantiza lo que parece: varios NULL conviven, y eso abrió un agujero real en uq_multas_prestamo_motivo que se cerró con NULLS NOT DISTINCT.
  • CHECK acepta la fila cuando la expresión da TRUE o NULL; solo la rechaza con FALSE. Y no puede mirar otras filas ni otras tablas, lo que deja fuera cinco de las diez reglas de negocio. Para el no solapamiento existe la restricción de exclusión, que garantiza RN4 en el servidor con concurrencia correcta.
  • Columnas generadas materializan derivados de la misma fila con garantía del gestor: no se pueden escribir a mano, así que no pueden mentir.
  • Dominios centralizan una validación repetida y hacen que el esquema hable el idioma del negocio.
  • Nombrar las restricciones convierte un error de producción ilegible en un diagnóstico inmediato, permite que la aplicación reaccione al nombre y hace posibles las migraciones.
  • NOT VALID + VALIDATE CONSTRAINT es la forma de añadir integridad a una tabla grande sin parar el servicio: protege de inmediato lo nuevo y verifica lo viejo después.
  • El criterio de fondo: si el dato incorrecto causaría un problema aunque nadie lo mirara nunca por pantalla, la regla va en la base. La aplicación valida para dar buena experiencia; la base valida para dar garantía. Duplicarlo es correcto; tenerlo solo en la aplicación, no.

Con esto se cierra el módulo 4, Diseño de Esquemas, y la ampliación de BiblioRed está terminada de principio a fin: partimos de una frase del ayuntamiento, la convertimos en un documento de requisitos con catorce puntos, diez reglas de negocio y doce consultas; lo dibujamos como un modelo conceptual con veintiuna entidades y nueve decisiones razonadas; lo transformamos en tablas aplicando diez reglas mecánicas; y lo hemos blindado con tipos elegidos uno a uno y restricciones que hacen imposibles los estados inválidos. El esquema que tenemos ahora no es el que habríamos escrito el primer día abriendo el editor, y esa diferencia es exactamente lo que enseña este módulo. En el módulo 5, Normalización, sometemos ese esquema a un examen que hasta ahora hemos evitado deliberadamente: dejamos la intuición de "una cosa, un sitio" y pasamos al instrumental formal —dependencias funcionales, primera, segunda y tercera forma normal, Boyce-Codd— para comprobar con matemáticas si el diseño de BiblioRed aguanta, corregir lo que no aguante, y entender después por qué a veces conviene romper esas reglas a propósito.

Fundamentos de Bases de Datos

Módulo 1: Introducción a las Bases de Datos

Módulo 2: Bases de Datos Relacionales

Módulo 3: Bases de Datos No Relacionales

Módulo 4: Diseño de Esquemas

Módulo 5: Normalización

Módulo 6: Transacciones, Rendimiento y Seguridad

Módulo 7: Ejercicios Prácticos

Módulo 8: Casos de Estudio

Módulo 9: Recursos Adicionales

© Copyright 2026. Todos los derechos reservados