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
- Por qué el tipo de dato es una decisión de diseño
- Enteros:
SMALLINT,INTEGER,BIGINTy el día que se acaban NUMERICfrente aREAL: por qué el dinero nunca va en coma flotante- Texto:
CHAR,VARCHAR(n)yTEXT - Fechas y horas:
TIMESTAMPfrente aTIMESTAMPTZ, eINTERVAL BOOLEANy las trampas de'S'/'N'UUIDfrente a entero secuencial como clave- Conjuntos cerrados de valores:
ENUM, tabla de catálogo oCHECK JSONBy arrays como escapes controladosBYTEAy por qué las portadas no van en la base- Codificación y colación: buscar títulos en español y catalán
- Los tipos permisivos de SQLite frente a los estrictos de PostgreSQL
NOT NULL: la decisión de permitir ausenciasDEFAULT: valores por omisiónUNIQUEsimple y compuesto, y su relación conNULLPRIMARY KEYcomo combinación de las anterioresCHECK: codificar reglas de negocio en el esquema- Columnas generadas y dominios
- Nombrar las restricciones y leer los errores de producción
- Añadir restricciones a una tabla que ya tiene datos
- Qué reglas van en la base de datos y cuáles en la aplicación
- Entregable: el esquema definitivo de BiblioRed ampliado
- Errores Comunes y Consejos
- Ejercicios
- Conclusión
- 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.
- Enteros:
SMALLINT, INTEGER, BIGINT y el día que se acaban
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,
INTEGERsobra. 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)
NUMERIC frente a REAL: por qué el dinero nunca va en coma flotante
NUMERIC frente a REAL: por qué el dinero nunca va en coma flotanteEste 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;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);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;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 tipoMONEY, pero depende de la configuración regional del servidor y no lleva la moneda dentro. Se desaconseja:NUMERICes la opción portable.
- Texto:
CHAR, VARCHAR(n) y TEXT
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;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');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.
- Fechas y horas:
TIMESTAMP frente a TIMESTAMPTZ, e INTERVAL
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 | Sí: 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:
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.
BOOLEAN y las trampas de 'S'/'N'
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 |
Sí |
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.
UUID frente a entero secuencial como clave
UUID frente a entero secuencial como claveEntero 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 | Sí, sin coordinación |
| Filtra información | Sí: 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.
- Conjuntos cerrados de valores:
ENUM, tabla de catálogo o CHECK
ENUM, tabla de catálogo o CHECKBiblioRed 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;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'));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 | Sí | Sí | Sí |
| 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 | Sí | No |
| Admite atributos (descripción, orden, traducción) | No | Sí | 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 | Sí | Sí |
| 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.ENUMsolo 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.
JSONB y arrays como escapes controlados
JSONB y arrays como escapes controladosEn 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'];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}';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;
JSONBsi la forma la decide alguien ajeno al esquema. Y si acabas escribiendo unCHECKcomplejo sobre una clave del JSON, esa clave quería ser una columna.
BYTEA y por qué las portadas no van en la base
BYTEA y por qué las portadas no van en la baseBYTEA 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 duplicadosLas 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—.
- 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
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";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";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%';-- Insensible a acentos: extensión unaccent
CREATE EXTENSION IF NOT EXISTS unaccent;
SELECT titulo FROM materiales
WHERE unaccent(titulo) ILIKE unaccent('%angels%');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.
- 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;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 |
Sí | No: REAL de coma flotante. El dinero exige guardar céntimos como enteros |
VARCHAR(n) |
Valida n |
Ignora n por completo |
CHECK |
Sí | Sí |
| 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');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.
NOT NULL: la decisión de permitir ausencias
NOT NULL: la decisión de permitir ausenciasEmpieza 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 |
Sí | Un evento sin título no es un evento |
eventos.sala_id |
No | Decisión D4: eventos al aire libre |
eventos.fin |
Sí | 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 |
Sí | Una multa sin importe no tiene sentido |
ponentes.email |
No | Hay externos de los que solo se tiene el teléfono |
inscripciones.acompanantes |
Sí, con DEFAULT 0 |
"Ninguno" es 0, no "no se sabe" |
informes_evento.valoracion_media |
No | Puede no haber encuestas |
informes_evento.asistentes_reales |
Sí | 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 NULLy 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.
DEFAULT: valores por omisión
DEFAULT: valores por omisiónfecha_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 0Un 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);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 comoDEFAULT.
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.
UNIQUE simple y compuesto, y su relación con NULL
UNIQUE simple y compuesto, y su relación con NULLUNIQUE 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);Dos salas con el mismo nombre en sucursales distintas: correcto según R3.
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;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);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);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
UNIQUEsobre columnas anulables no garantiza lo que parece garantizar. Cada vez que declares uno, comprueba si alguna de sus columnas admiteNULLy decide conscientemente.
PRIMARY KEY como combinación de las anteriores
PRIMARY KEY como combinación de las anterioresPRIMARY 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:
- Solo puede haber una clave primaria por tabla; claves
UNIQUE, las que hagan falta. REFERENCES tablasin columna apunta implícitamente a la clave primaria.- 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.
CHECK: codificar reglas de negocio en el esquema
CHECK: codificar reglas de negocio en el esquemaAquí 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:
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).
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');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.
- 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; evento_id | inicio | fin | duracion_min
-----------+------------------------+------------------------+--------------
47 | 2026-09-17 18:00:00+02 | 2026-09-17 20:00:00+02 | 120ERROR: 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');| 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.
- 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:
Parece un detalle estético. No lo es, por tres razones concretas:
- El equipo de soporte lee el mensaje.
chk_salas_aforo_positivose entiende sin abrir el esquema;salas_aforo_checkobliga a investigar. Con dosCHECKen la misma columna, el nombre autogenerado essalas_aforo_check1, y ahí ya no hay nada que hacer. - 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.
- Las migraciones necesitan el nombre.
ALTER TABLE ... DROP CONSTRAINTexige 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.
- 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:
Ocurren dos cosas. Si hay datos que la incumplen:
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:
Paso 2 — Corregir los datos existentes:
Paso 3 — Añadir la restricción con NOT VALID y validarla después:
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:
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';
NOT VALIDfunciona 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 NULLes la excepción: no admiteNOT VALIDhasta PostgreSQL 18; el rodeo clásico es añadir primero unCHECK (col IS NOT NULL) NOT VALID, validarlo y convertirlo después.
- 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.
- 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.
salas.metros_cuadrados— superficie de la sala, con un decimal.eventos.aforo_reducido_covid— porcentaje entero de aforo permitido (0-100).pagos.referencia_pasarela— identificador que devuelve la pasarela del ayuntamiento, cadena alfanumérica de 32 caracteres.socios.fecha_nacimiento— para estadísticas por franja de edad.materiales.veces_prestado— número total de préstamos históricos del material.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.
- La valoración media de un informe está entre 0 y 5.
- Un evento cancelado no puede estar publicado.
- Un ejemplar en estado
'baja'no puede prestarse. - El código de un ejemplar empieza siempre por
EJ-. - Una sala no puede tener dos eventos solapados.
- Un socio no puede tener más de tres teléfonos.
- 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 | Sí, CHECK de una columna |
Ya está: chk_informes_valoracion |
| 2 | Cancelado no publicado | Sí, 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- |
Sí, CHECK con expresión regular |
Ya está: chk_ejemplares_codigo |
| 5 | Sin solapes en una sala | Sí, 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:
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:
SMALLINTdocumenta la intención,INTEGERsobra para todo lo que genera una persona,BIGINTpara lo que genera una máquina. Migrar deINTEGERaBIGINTen una tabla grande y referenciada es de las peores migraciones que existen. NUMERICfrente a coma flotante es el apartado que hay que recordar:3.10 + 2.20 + 4.30da9.600000000000001enDOUBLE PRECISION, y por eso una multa pagada íntegramente puede aparecer como impagada. Todo lo que es dinero va enNUMERIC.- Texto:
CHAR(n)casi nunca;VARCHAR(n)solo cuandonsale de una norma externa (ISBN, ISSN, código postal);TEXTpara todo lo demás, conCHECKde longitud si el negocio quiere un tope. - Fechas:
TIMESTAMPTZpor defecto para los instantes de hechos, porque guarda el momento absoluto y sobrevive a las zonas horarias y al cambio de hora;DATEcuando la hora no existe en el dominio;INTERVALpara calcular vencimientos. BOOLEANen lugar de'S'/'N', yNOT NULLcuando el tercer valor no significa nada.UUIDsolo 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,
CHECKsi los gestiona el desarrollador,ENUMsolo si son inmutables de verdad. JSONBy 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-icuconunaccentpara buscar. - SQLite usa afinidad de tipos: acepta texto en una columna
INTEGER. ConSTRICT,PRAGMA foreign_keys = ONy los importes en céntimos enteros, es una herramienta excelente; sin eso, es una trampa.
Sobre las restricciones:
NOT NULLpor 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.DEFAULTvive en la base para que lo respeten todas las vías de escritura, no solo la aplicación web. No rescata unNULLexplícito.UNIQUEsobre columnas anulables no garantiza lo que parece: variosNULLconviven, y eso abrió un agujero real enuq_multas_prestamo_motivoque se cerró conNULLS NOT DISTINCT.CHECKacepta la fila cuando la expresión daTRUEoNULL; solo la rechaza conFALSE. 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 CONSTRAINTes 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
- 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
