Todo lo que hemos construido en este módulo —el esquema, los datos, los JOIN, los informes— se apoya en una suposición que hasta ahora hemos dado por buena: que cuando prestamos dice socio_id = 14, existe un socio 14. Si esa suposición falla, los JOIN pierden filas en silencio, los recuentos mienten y los informes de la lección anterior dejan de ser fiables sin que nadie se dé cuenta.
La integridad referencial es la garantía de que eso no ocurra. Es la tercera regla del modelo relacional, la enunciamos en la lección 02-01 y la hemos estado usando de forma implícita desde que escribimos REFERENCES en el CREATE TABLE. Ahora toca dominarla: cómo se declara, qué comprueba exactamente el gestor, qué debe pasar cuando se borra la fila de la que otras dependen, cómo detectar el daño que ya existe y —una advertencia que puede ahorrarte meses de desconcierto— por qué SQLite no protege absolutamente nada si no se lo pides.
Con esta lección cerramos el módulo 2. Al terminarla, BiblioRed no solo tendrá datos correctos: tendrá un esquema que impide activamente que dejen de serlo.
Contenido
- Filas huérfanas: de dónde vienen y qué rompen
- Declarar una clave ajena
- Qué comprueba el gestor y cuándo
- Las acciones referenciales
ON DELETEyON UPDATE - Las cinco opciones, comparadas
- Elegir la acción correcta en BiblioRed
- El esquema final de BiblioRed con sus acciones referenciales
- Claves ajenas compuestas
- Restricciones diferibles
- SQLite:
PRAGMA foreign_keys = ON - Detectar y limpiar filas huérfanas
- ¿Validar en la aplicación o en la base de datos?
- Errores comunes y consejos
- Ejercicios
- Conclusión
- Filas huérfanas: de dónde vienen y qué rompen
Una fila huérfana es una fila cuya clave ajena apunta a algo que no existe. En la hoja de cálculo de BiblioRed que diagnosticamos en la lección 01-01 había tres formas de crearlas, y las tres ocurrían a diario:
- Teclear un número de socio inventado. Nadie comprobaba nada: si el bibliotecario escribía
77en lugar de17, la fila se guardaba tan contenta. - Borrar una fila de la que dependían otras. Cuando un socio se daba de baja, alguien eliminaba su línea de la pestaña "Socios"; sus quince préstamos históricos seguían en la pestaña "Préstamos", apuntando al vacío.
- Renumerar. Al reordenar la hoja de socios, los números cambiaban y todas las referencias anteriores pasaban a señalar a otra persona. Este es el peor de los tres, porque no deja huella: la fila no queda huérfana, queda mal adoptada.
Qué rompe exactamente una fila huérfana:
- Los
INNER JOINla eliminan sin avisar. El informe de préstamos por sucursal de la lección 02-05 simplemente devolvería menos de lo que hay. Y como no hay error, nadie lo investiga. - Los
LEFT JOINla conservan conNULL, lo que produce informes con huecos que alguien tendrá que explicar. - Los totales no cuadran entre sí.
COUNT(*) FROM prestamosda 4.312 y la suma de los préstamos por socio da 4.298. Y a partir de ahí, la confianza en la base de datos se evapora.
La integridad referencial convierte esas tres formas de crear huérfanos en errores inmediatos, en el momento exacto en que se intentan. Ese es su valor: el problema aparece cuando se puede arreglar, no seis meses después.
- Declarar una clave ajena
Ya lo hicimos en la lección 02-02; ahora con detalle.
Forma de columna
CREATE TABLE socios (
socio_id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
nombre VARCHAR(60) NOT NULL,
sucursal_id INTEGER NOT NULL REFERENCES sucursales (sucursal_id)
);Compacta y suficiente para casos simples. El gestor genera un nombre automático para la restricción, del estilo socios_sucursal_id_fkey.
Forma de tabla, con nombre propio
CREATE TABLE socios (
socio_id INTEGER GENERATED BY DEFAULT AS IDENTITY,
nombre VARCHAR(60) NOT NULL,
sucursal_id INTEGER NOT NULL,
CONSTRAINT pk_socios PRIMARY KEY (socio_id),
CONSTRAINT fk_socios_sucursal
FOREIGN KEY (sucursal_id) REFERENCES sucursales (sucursal_id)
);Es la que usamos en BiblioRed, por tres razones:
- Es obligatoria para claves ajenas compuestas (apartado 8).
- Permite nombrarla. Cuando salte el error, el mensaje dirá
fk_socios_sucursal, nosocios_sucursal_id_fkey. - Permite eliminarla y recrearla con
ALTER TABLE ... DROP CONSTRAINT fk_socios_sucursal, que es justo lo que haremos en el apartado 7.
Añadirla después
ALTER TABLE socios
ADD CONSTRAINT fk_socios_sucursal
FOREIGN KEY (sucursal_id) REFERENCES sucursales (sucursal_id);Al ejecutarlo, PostgreSQL valida todas las filas existentes. Si alguna es huérfana, la operación falla entera:
ERROR: no se pudo crear la restricción de llave foránea «fk_socios_sucursal»
DETALLE: La llave (sucursal_id)=(99) no está presente en la tabla «sucursales».Es exactamente lo que quieres: la restricción no se activa hasta que los datos estén limpios. El apartado 11 enseña a limpiarlos.
Requisitos de la columna referenciada
La columna a la que apunta una clave ajena debe tener clave primaria o una restricción UNIQUE. No se puede referenciar una columna cualquiera:
Y tiene sentido: si la columna referenciada pudiera repetirse, "apuntar a la fila con apellido Alsina" sería ambiguo. Por eso ejemplares.libro_id puede apuntar tanto a libros.libro_id (clave primaria) como a libros.isbn (clave alternativa UNIQUE), aunque lo primero es lo sensato.
- Qué comprueba el gestor y cuándo
Una clave ajena impone comprobaciones a las dos tablas, no solo a la hija. Este es el cuadro completo:
| Operación | Tabla | Qué comprueba el gestor |
|---|---|---|
INSERT en la hija |
prestamos |
Que el valor de socio_id exista en socios (o sea NULL) |
UPDATE de la clave ajena en la hija |
prestamos |
Lo mismo: el nuevo valor debe existir |
DELETE en la padre |
socios |
Que no queden filas hijas apuntando a la fila borrada |
UPDATE de la clave primaria en la padre |
socios |
Lo mismo: que no queden hijas apuntando al valor antiguo |
INSERT en la padre |
socios |
Nada: añadir un socio nunca rompe nada |
Comprobémoslo sobre biblioredb. Primero, desde el lado hijo:
INSERT INTO prestamos (socio_id, ejemplar_id, fecha_prestamo, fecha_devolucion_prevista)
VALUES (77, 1, '2026-08-02', '2026-08-23');ERROR: inserción o actualización en la tabla «prestamos» viola la llave foránea
«fk_prestamos_socio»
DETALLE: La llave (socio_id)=(77) no está presente en la tabla «socios».Ahora desde el lado padre:
ERROR: update or delete on table "socios" violates foreign key constraint
"fk_prestamos_socio" on table "prestamos"
DETALLE: La llave (socio_id)=(14) todavía es referida desde la tabla «prestamos».Y el caso del NULL, que sí se permite:
-- libros.autor_id NO es NOT NULL, así que un libro sin autor es legal
INSERT INTO libros (libro_id, isbn, titulo, autor_id, editorial, anio_publicacion, idioma)
VALUES (340, NULL, 'Bandos municipales de 1912', NULL, 'Ayuntamiento de Vallmar', 1912, 'es');Una clave ajena nula no apunta a nada, y eso es válido. La regla de integridad referencial habla de valores no nulos. Si quieres que la referencia sea obligatoria, hay que añadir NOT NULL: es una decisión de diseño independiente. En BiblioRed, prestamos.socio_id es NOT NULL (no existe un préstamo sin socio) mientras que libros.autor_id no lo es (sí existen obras sin autor catalogado).
Deshacemos la inserción de prueba:
- Las acciones referenciales
ON DELETE y ON UPDATE
ON DELETE y ON UPDATEHasta aquí, el gestor se ha limitado a prohibir. Pero prohibir no siempre es lo que quieres. Si BiblioRed retira un título entero del catálogo, tener que borrar antes sus ejemplares uno a uno es absurdo: lo natural es que se vayan con él.
Las acciones referenciales dicen qué debe hacer el gestor con las filas hijas cuando la fila padre se borra o cambia de clave:
CONSTRAINT fk_ejemplares_libro
FOREIGN KEY (libro_id) REFERENCES libros (libro_id)
ON DELETE CASCADE
ON UPDATE CASCADESon dos cláusulas independientes:
ON DELETE: qué hacer cuando se borra la fila padre.ON UPDATE: qué hacer cuando cambia el valor de la clave primaria de la fila padre.
Si no escribes ninguna, se aplica NO ACTION, el comportamiento por defecto del estándar (el que hemos visto en el apartado anterior).
Un laboratorio para probarlas
Para experimentar sin tocar BiblioRed, creamos dos tablas de usar y tirar:
CREATE TABLE demo_categorias (
categoria_id INTEGER PRIMARY KEY,
nombre VARCHAR(40) NOT NULL
);
CREATE TABLE demo_items (
item_id INTEGER PRIMARY KEY,
nombre VARCHAR(40) NOT NULL,
categoria_id INTEGER,
CONSTRAINT fk_demo FOREIGN KEY (categoria_id)
REFERENCES demo_categorias (categoria_id) ON DELETE CASCADE
);
INSERT INTO demo_categorias VALUES (1, 'Narrativa'), (2, 'Técnica');
INSERT INTO demo_items VALUES (10, 'Item A', 1), (11, 'Item B', 1), (12, 'Item C', 2);Estado inicial: tres ítems, dos categorías.
| item_id | nombre | categoria_id |
|---|---|---|
| 12 | Item C | 2 |
Los ítems A y B han desaparecido. El DELETE sobre una fila ha borrado tres filas en total, y solo la primera aparece en el mensaje. Esa es la naturaleza del CASCADE: es potente y es silencioso.
Ahora probemos SET NULL:
ALTER TABLE demo_items DROP CONSTRAINT fk_demo;
ALTER TABLE demo_items ADD CONSTRAINT fk_demo
FOREIGN KEY (categoria_id) REFERENCES demo_categorias (categoria_id) ON DELETE SET NULL;
DELETE FROM demo_categorias WHERE categoria_id = 2;
SELECT * FROM demo_items;| item_id | nombre | categoria_id |
|---|---|---|
| 12 | Item C | (NULL) |
El ítem sobrevive; solo pierde su referencia. Limpiamos el laboratorio:
- Las cinco opciones, comparadas
| Acción | Qué hace al borrar/actualizar la fila padre | Requisito | Riesgo |
|---|---|---|---|
NO ACTION |
Rechaza la operación si quedan hijas. Es el valor por defecto. La comprobación se hace al final de la instrucción, lo que permite que un desencadenante intermedio arregle la situación | Ninguno | Ninguno |
RESTRICT |
Rechaza la operación si quedan hijas. La comprobación es inmediata y no se puede diferir | Ninguno | Ninguno |
CASCADE |
Propaga: borra las filas hijas (ON DELETE) o actualiza su clave ajena (ON UPDATE) |
Ninguno | Alto: un DELETE puede llevarse miles de filas encadenadas |
SET NULL |
Pone la clave ajena de las hijas a NULL |
La columna no puede ser NOT NULL |
Medio: quedan filas sin referencia |
SET DEFAULT |
Pone la clave ajena al valor DEFAULT de la columna |
La columna debe tener DEFAULT, y ese valor debe existir en la tabla padre |
Medio: si el valor por defecto no existe, la operación falla |
NO ACTION y RESTRICT: la diferencia real
En el 99 % de los casos se comportan igual: ambas impiden la operación. La diferencia es cuándo se comprueba:
RESTRICTcomprueba inmediatamente, en cuanto se ejecuta la fila delDELETE.NO ACTIONcomprueba al final de la instrucción, y además puede diferirse hasta elCOMMITsi la restricción se declaróDEFERRABLE(apartado 9).
Consecuencia práctica: RESTRICT no se puede diferir nunca. Si prevés necesitar restricciones diferibles, usa NO ACTION. En BiblioRed usaremos RESTRICT donde queremos una prohibición explícita y visible en el CREATE TABLE, porque documenta la intención mejor que dejar el hueco vacío.
SET DEFAULT: la que casi nunca se usa
-- Requiere que la columna tenga DEFAULT y que ese valor exista en la tabla padre
sucursal_id INTEGER DEFAULT 1 REFERENCES sucursales (sucursal_id) ON DELETE SET DEFAULTLa idea es "si desaparece la sucursal de este ejemplar, asígnalo a la sucursal 1". El problema es evidente: si algún día alguien borra la sucursal 1, la acción falla y el DELETE se bloquea de forma difícil de diagnosticar. Existe, hay que conocerla, y en la práctica se usa muy poco. Nota: las cláusulas DEFAULT se estudian a fondo en la lección 04-04.
- Elegir la acción correcta en BiblioRed
La pregunta que hay que hacerse para cada clave ajena es siempre la misma:
Si desaparece la fila padre, ¿la fila hija sigue teniendo sentido por sí sola?
- Si no tiene sentido y no vale nada →
CASCADE. - Si no tiene sentido pero es valiosa (histórico, contabilidad, auditoría) →
RESTRICT. - Si sí tiene sentido sin la referencia →
SET NULL.
Apliquémoslo a las ocho claves ajenas de BiblioRed.
prestamos.socio_id → RESTRICT
Por qué borrar un socio no debe arrastrar su histórico de préstamos. Un préstamo es un hecho ocurrido: el 5 de marzo de 2026 salió un ejemplar por la puerta y volvió el 2 de abril con 1,40 € de recargo. Ese hecho no deja de haber ocurrido porque la persona se dé de baja.
Si pusiéramos CASCADE, dar de baja a un socio destruiría estadísticas históricas —préstamos por año, títulos más prestados, recaudación— que la dirección usa para decidir compras. Es destrucción de información contable disfrazada de limpieza.
Con RESTRICT, el intento de borrado falla, y eso obliga a resolver la pregunta de negocio de verdad: no se borran los socios, se marcan como inactivos (activo = FALSE, como Ramón Etxebarri). Es lo que se llama borrado lógico, y es la práctica correcta para cualquier entidad con historial.
ejemplares.libro_id → CASCADE
Por qué borrar un libro sí debe arrastrar sus ejemplares. Aquí la relación es de composición: un ejemplar es una copia física de un libro. EJ-3081 no es "un objeto que casualmente está asociado a El mapa del tiempo"; es un ejemplar de El mapa del tiempo. Sin el libro, la fila no significa nada: sería un objeto sin título, sin autor y sin ISBN.
Si el título desaparece del catálogo, mantener sus quince ejemplares sería conservar basura referencial. CASCADE es la acción correcta.
Y aquí ocurre algo interesante. Intentemos borrar un libro que sí tiene préstamos:
El CASCADE de ejemplares intenta borrar los ejemplares 1, 2 y 3… pero el RESTRICT de prestamos.ejemplar_id lo impide:
ERROR: update or delete on table "ejemplares" violates foreign key constraint
"fk_prestamos_ejemplar" on table "prestamos"La cascada se detiene al chocar con una restricción. Es exactamente el comportamiento deseado: se puede retirar del catálogo un título que nunca se prestó, pero no uno con historial. El esquema hace cumplir una regla de negocio real sin que nadie la haya programado en ninguna aplicación.
El cuadro completo
| Clave ajena | ON DELETE |
ON UPDATE |
Razonamiento |
|---|---|---|---|
socios.sucursal_id → sucursales |
RESTRICT |
CASCADE |
No se cierra una sucursal sin reasignar antes a sus socios |
libros.autor_id → autores |
SET NULL |
CASCADE |
El libro sigue existiendo aunque se depure la ficha del autor: queda como obra sin autor catalogado, igual que "Memoria del Ensanche" |
ejemplares.libro_id → libros |
CASCADE |
CASCADE |
Composición: el ejemplar no existe sin su obra |
ejemplares.sucursal_id → sucursales |
RESTRICT |
CASCADE |
Los ejemplares hay que trasladarlos físicamente, no borrarlos |
prestamos.socio_id → socios |
RESTRICT |
CASCADE |
Histórico: no se destruye |
prestamos.ejemplar_id → ejemplares |
RESTRICT |
CASCADE |
Histórico: no se destruye |
reservas.socio_id → socios |
CASCADE |
CASCADE |
Una reserva es una intención futura, no un hecho contable: sin socio no significa nada |
reservas.libro_id → libros |
CASCADE |
CASCADE |
Ídem: sin el título, la reserva es inútil |
La asimetría entre prestamos (RESTRICT) y reservas (CASCADE) es el corazón del razonamiento: el préstamo es historia y la reserva es futuro. La historia se conserva; el futuro que ya no puede ocurrir se descarta.
Sobre el ON UPDATE CASCADE generalizado
Todas nuestras claves primarias son subrogadas y, por definición, nunca cambian de valor (lección 02-01). Así que ON UPDATE CASCADE no se va a activar jamás. ¿Por qué ponerlo entonces?
Es un seguro barato. Si algún día hay que renumerar identificadores durante una migración o una fusión de dos catálogos, la propagación será automática en lugar de un guion manual propenso a errores. No cuesta nada y evita un desastre improbable pero grave. En un esquema con claves naturales (donde la clave sí puede corregirse) el ON UPDATE CASCADE deja de ser un seguro y pasa a ser imprescindible.
- El esquema final de BiblioRed con sus acciones referenciales
Este es el entregable de la lección. Sustituimos las ocho claves ajenas que creamos en 02-02 por sus versiones con acciones referenciales. Ejecútalo sobre tu biblioredb:
-- ============================================================
-- BiblioRed - Acciones referenciales
-- Módulo 2, lección 02-06. Dialecto: PostgreSQL
-- ============================================================
-- socios → sucursales
ALTER TABLE socios DROP CONSTRAINT fk_socios_sucursal;
ALTER TABLE socios ADD CONSTRAINT fk_socios_sucursal
FOREIGN KEY (sucursal_id) REFERENCES sucursales (sucursal_id)
ON DELETE RESTRICT ON UPDATE CASCADE;
-- libros → autores (SET NULL: la obra sobrevive a la ficha del autor)
ALTER TABLE libros DROP CONSTRAINT fk_libros_autor;
ALTER TABLE libros ADD CONSTRAINT fk_libros_autor
FOREIGN KEY (autor_id) REFERENCES autores (autor_id)
ON DELETE SET NULL ON UPDATE CASCADE;
-- ejemplares → libros (CASCADE: composición)
ALTER TABLE ejemplares DROP CONSTRAINT fk_ejemplares_libro;
ALTER TABLE ejemplares ADD CONSTRAINT fk_ejemplares_libro
FOREIGN KEY (libro_id) REFERENCES libros (libro_id)
ON DELETE CASCADE ON UPDATE CASCADE;
-- ejemplares → sucursales
ALTER TABLE ejemplares DROP CONSTRAINT fk_ejemplares_sucursal;
ALTER TABLE ejemplares ADD CONSTRAINT fk_ejemplares_sucursal
FOREIGN KEY (sucursal_id) REFERENCES sucursales (sucursal_id)
ON DELETE RESTRICT ON UPDATE CASCADE;
-- prestamos → socios (RESTRICT: el histórico no se destruye)
ALTER TABLE prestamos DROP CONSTRAINT fk_prestamos_socio;
ALTER TABLE prestamos ADD CONSTRAINT fk_prestamos_socio
FOREIGN KEY (socio_id) REFERENCES socios (socio_id)
ON DELETE RESTRICT ON UPDATE CASCADE;
-- prestamos → ejemplares (RESTRICT: detiene la cascada de libros)
ALTER TABLE prestamos DROP CONSTRAINT fk_prestamos_ejemplar;
ALTER TABLE prestamos ADD CONSTRAINT fk_prestamos_ejemplar
FOREIGN KEY (ejemplar_id) REFERENCES ejemplares (ejemplar_id)
ON DELETE RESTRICT ON UPDATE CASCADE;
-- reservas → socios (CASCADE: intención futura, no hecho contable)
ALTER TABLE reservas DROP CONSTRAINT fk_reservas_socio;
ALTER TABLE reservas ADD CONSTRAINT fk_reservas_socio
FOREIGN KEY (socio_id) REFERENCES socios (socio_id)
ON DELETE CASCADE ON UPDATE CASCADE;
-- reservas → libros
ALTER TABLE reservas DROP CONSTRAINT fk_reservas_libro;
ALTER TABLE reservas ADD CONSTRAINT fk_reservas_libro
FOREIGN KEY (libro_id) REFERENCES libros (libro_id)
ON DELETE CASCADE ON UPDATE CASCADE;Comprobación:
Restricciones de llave foránea:
"fk_prestamos_ejemplar" FOREIGN KEY (ejemplar_id) REFERENCES ejemplares(ejemplar_id)
ON UPDATE CASCADE ON DELETE RESTRICT
"fk_prestamos_socio" FOREIGN KEY (socio_id) REFERENCES socios(socio_id)
ON UPDATE CASCADE ON DELETE RESTRICTY una comprobación de que las reglas se aplican de verdad:
-- El libro 338 ("Manual de jardinería urbana") tiene 2 ejemplares
-- y CERO préstamos. La cascada debería funcionar.
SELECT COUNT(*) FROM ejemplares WHERE libro_id = 338; -- 2
-- Probamos... y deshacemos con una transacción (lección 06-01)
BEGIN;
DELETE FROM libros WHERE libro_id = 338;
SELECT COUNT(*) FROM ejemplares WHERE libro_id = 338; -- 0: la cascada actuó
ROLLBACK;
SELECT COUNT(*) FROM ejemplares WHERE libro_id = 338; -- 2: todo restauradoAquí aparece por primera vez una utilidad práctica de las transacciones: probar una operación destructiva y deshacerla. BEGIN abre la transacción, ROLLBACK la anula por completo. Es el contenido de la lección 06-01; de momento, úsalo como red de seguridad.
La versión SQLite
SQLite no admite ALTER TABLE ... ADD CONSTRAINT. Para añadir acciones referenciales hay que recrear las tablas con la definición completa. En un CREATE TABLE de SQLite se escribe igual:
CREATE TABLE prestamos (
prestamo_id INTEGER PRIMARY KEY,
socio_id INTEGER NOT NULL
REFERENCES socios (socio_id) ON DELETE RESTRICT ON UPDATE CASCADE,
ejemplar_id INTEGER NOT NULL
REFERENCES ejemplares (ejemplar_id) ON DELETE RESTRICT ON UPDATE CASCADE,
fecha_prestamo TEXT NOT NULL,
fecha_devolucion_prevista TEXT NOT NULL,
fecha_devolucion TEXT,
recargo NUMERIC
);Y siempre, siempre, PRAGMA foreign_keys = ON;. Lo vemos en el apartado 10.
- Claves ajenas compuestas
Si la clave primaria de la tabla padre es compuesta (varias columnas), la clave ajena que la referencia también tiene que serlo, con las columnas en el mismo orden.
Imagina que BiblioRed decide catalogar la ubicación física exacta de cada ejemplar. Los estantes se numeran dentro de cada sucursal: hay un estante A-12 en Centro y otro A-12 en Norte, y no son el mismo. La clave primaria natural es entonces la pareja:
CREATE TABLE estantes (
sucursal_id INTEGER NOT NULL,
codigo_estante VARCHAR(10) NOT NULL,
sala VARCHAR(40),
CONSTRAINT pk_estantes PRIMARY KEY (sucursal_id, codigo_estante),
CONSTRAINT fk_estantes_sucursal
FOREIGN KEY (sucursal_id) REFERENCES sucursales (sucursal_id)
ON DELETE RESTRICT ON UPDATE CASCADE
);Y ejemplares la referenciaría así:
ALTER TABLE ejemplares ADD COLUMN codigo_estante VARCHAR(10);
ALTER TABLE ejemplares ADD CONSTRAINT fk_ejemplares_estante
FOREIGN KEY (sucursal_id, codigo_estante)
REFERENCES estantes (sucursal_id, codigo_estante)
ON DELETE SET NULL ON UPDATE CASCADE;Tres reglas que hay que conocer:
- El orden importa.
FOREIGN KEY (a, b) REFERENCES t (x, y)emparejaaconxybcony. Invertirlos crea una restricción distinta y probablemente absurda. - La forma de tabla es obligatoria. Una restricción que afecta a dos columnas no cabe en la declaración de una sola.
- Cuidado con los
NULLparciales. Por defecto (MATCH SIMPLE, el comportamiento estándar), si alguna de las columnas esNULL, la restricción no se comprueba en absoluto. Es decir: un ejemplar consucursal_id = 2ycodigo_estante = NULLpasaría la validación aunque no exista tal estante. Si quieres exigir que estén las dos o ninguna, hay que escribirMATCH FULL.
Una observación de diseño: en el ejemplo anterior hay un ON DELETE SET NULL sobre una clave ajena compuesta de la que sucursal_id forma parte… y sucursal_id es NOT NULL en ejemplares. Eso hace que la acción falle en la práctica. Es un buen recordatorio de que las claves ajenas compuestas son más delicadas de lo que parecen, y una de las razones de peso a favor de las claves subrogadas simples. Estas decisiones de modelado se tratan a fondo en el módulo 4.
Estas tablas son ilustrativas: no las crees en biblioredb, no forman parte del esquema del curso.
- Restricciones diferibles
Por defecto, las claves ajenas se comprueban inmediatamente, al ejecutar cada instrucción. Eso plantea un problema en tres situaciones reales:
- Referencias circulares. Si la tabla A referencia a B y B referencia a A, no se puede insertar la primera fila de ninguna de las dos.
- Cargas masivas en orden arbitrario. Un volcado de datos que inserta
prestamosantes quesociosfallará, aunque al terminar todo sea coherente. - Intercambios. Cambiar dos filas de identificador entre sí pasa por un estado intermedio inválido.
La solución del estándar es declarar la restricción diferible: sus comprobaciones se posponen hasta el COMMIT de la transacción.
ALTER TABLE prestamos DROP CONSTRAINT fk_prestamos_socio;
ALTER TABLE prestamos ADD CONSTRAINT fk_prestamos_socio
FOREIGN KEY (socio_id) REFERENCES socios (socio_id)
ON DELETE NO ACTION ON UPDATE CASCADE
DEFERRABLE INITIALLY DEFERRED;Tres modos posibles:
| Declaración | Comportamiento |
|---|---|
(nada) o NOT DEFERRABLE |
Comprobación inmediata, siempre. El valor por defecto. |
DEFERRABLE INITIALLY IMMEDIATE |
Inmediata por defecto, pero se puede diferir en una transacción concreta con SET CONSTRAINTS ... DEFERRED |
DEFERRABLE INITIALLY DEFERRED |
Diferida al COMMIT por defecto |
Con la restricción diferida, esto funciona:
BEGIN;
-- Insertamos el préstamo ANTES que el socio: estado intermedio inválido
INSERT INTO prestamos (socio_id, ejemplar_id, fecha_prestamo, fecha_devolucion_prevista)
VALUES (21, 13, '2026-08-02', '2026-08-23');
INSERT INTO socios (socio_id, nombre, apellidos, fecha_alta, sucursal_id, activo)
VALUES (21, 'Berta', 'Colomer', '2026-08-02', 3, TRUE);
COMMIT; -- aquí se comprueba todo: correctoSi al llegar al COMMIT la coherencia no se hubiera restablecido, la transacción entera se anula.
Cuatro advertencias:
RESTRICTnunca se puede diferir. SoloNO ACTIONadmiteDEFERRABLE. Es la diferencia práctica entre ambas que anunciábamos en el apartado 5.- Las restricciones diferidas consumen más memoria, porque el gestor debe recordar todas las comprobaciones pendientes hasta el
COMMIT. - El error aparece al confirmar, no en la instrucción culpable, lo que dificulta el diagnóstico.
- SQLite solo admite
DEFERRABLE INITIALLY DEFERRED, y únicamente si las claves ajenas están activadas.
Consejo: no las uses por defecto. Son una herramienta para casos concretos —cargas masivas, migraciones, referencias circulares—, no una comodidad general. Deshaz el experimento completo si lo has probado, para que biblioredb vuelva a su estado del apartado 7:
-- 1) Borrar los datos de prueba (primero la hija, después la padre)
DELETE FROM prestamos WHERE socio_id = 21;
DELETE FROM socios WHERE socio_id = 21;
-- 2) Restaurar la restricción no diferible
ALTER TABLE prestamos DROP CONSTRAINT fk_prestamos_socio;
ALTER TABLE prestamos ADD CONSTRAINT fk_prestamos_socio
FOREIGN KEY (socio_id) REFERENCES socios (socio_id)
ON DELETE RESTRICT ON UPDATE CASCADE;
SELECT COUNT(*) FROM socios; -- 10
SELECT COUNT(*) FROM prestamos; -- 12
- SQLite:
PRAGMA foreign_keys = ON
PRAGMA foreign_keys = ONEsta es probablemente la advertencia más importante de la lección, y la más fácil de pasar por alto.
SQLite acepta la sintaxis
REFERENCES, la almacena en el esquema, la muestra en.schema… y NO LA APLICA, salvo que se active explícitamente en cada conexión.
Por compatibilidad con versiones antiguas, las claves ajenas están desactivadas por defecto. El resultado es un esquema que parece protegido y no lo está:
sqlite> PRAGMA foreign_keys;
0
sqlite> INSERT INTO prestamos (socio_id, ejemplar_id, fecha_prestamo, fecha_devolucion_prevista)
...> VALUES (77, 1, '2026-08-02', '2026-08-23');
sqlite>Ni error, ni aviso: una fila huérfana recién creada, referida a un socio 77 que no existe. Ahora con la comprobación activada:
sqlite> PRAGMA foreign_keys = ON;
sqlite> INSERT INTO prestamos (socio_id, ejemplar_id, fecha_prestamo, fecha_devolucion_prevista)
...> VALUES (77, 1, '2026-08-02', '2026-08-23');
Error: FOREIGN KEY constraint failedLo que hay que saber:
- Es por conexión, no por base de datos. Cada vez que abres
sqlite3o cada vez que tu aplicación abre una conexión, hay que ejecutarlo de nuevo. No se guarda en el fichero. - No se puede activar dentro de una transacción: si lo intentas, se ignora en silencio.
- Las bibliotecas de acceso a datos no siempre lo hacen por ti. Algunos ORM y controladores lo activan automáticamente; otros no. Compruébalo tú.
- Ponlo como primera línea de todos tus guiones
.sql.
Y una herramienta de diagnóstico específica, muy útil cuando heredas un fichero:
Cada línea es una violación existente: tabla, rowid de la fila culpable, tabla padre e índice de la clave ajena. Sin argumentos revisa toda la base de datos. Es lo primero que hay que ejecutar al recibir una base SQLite ajena.
- Detectar y limpiar filas huérfanas
Activar las claves ajenas impide crear huérfanos nuevos. No arregla los que ya existen: de hecho, PostgreSQL se negará a crear la restricción mientras queden. Necesitamos detectarlos y limpiarlos primero.
Reproduzcamos el escenario real: BiblioRed importa los préstamos históricos de la hoja de cálculo a una tabla intermedia sin restricciones, que es como se hacen todas las migraciones.
CREATE TABLE prestamos_import (
fila INTEGER,
socio_id INTEGER,
codigo_ejemplar VARCHAR(10),
fecha_prestamo DATE
);
INSERT INTO prestamos_import (fila, socio_id, codigo_ejemplar, fecha_prestamo) VALUES
(1, 14, 'EJ-3081', '2026-02-03'),
(2, 77, 'EJ-3085', '2026-02-05'), -- socio inexistente
(3, 16, 'EJ-9999', '2026-02-08'), -- ejemplar inexistente
(4, 15, 'EJ-3084', '2026-02-11'),
(5, NULL, 'EJ-3082', '2026-02-14'), -- socio sin identificar
(6, 77, 'EJ-3090', '2026-02-19'); -- socio inexistente, otra vezDetección con LEFT JOIN ... IS NULL
Es el patrón anti-join de la lección 02-04, aplicado a la auditoría de datos.
-- Filas cuyo socio no existe (excluimos los NULL: son otra categoría de problema)
SELECT i.fila, i.socio_id, i.codigo_ejemplar, i.fecha_prestamo
FROM prestamos_import i
LEFT JOIN socios s ON s.socio_id = i.socio_id
WHERE i.socio_id IS NOT NULL
AND s.socio_id IS NULL
ORDER BY i.fila;| fila | socio_id | codigo_ejemplar | fecha_prestamo |
|---|---|---|---|
| 2 | 77 | EJ-3085 | 2026-02-05 |
| 6 | 77 | EJ-3090 | 2026-02-19 |
-- Filas cuyo ejemplar no existe
SELECT i.fila, i.socio_id, i.codigo_ejemplar
FROM prestamos_import i
LEFT JOIN ejemplares e ON e.codigo = i.codigo_ejemplar
WHERE e.ejemplar_id IS NULL
ORDER BY i.fila;| fila | socio_id | codigo_ejemplar |
|---|---|---|
| 3 | 16 | EJ-9999 |
La misma pregunta con NOT EXISTS, que es igual de válida y algo más legible:
SELECT i.fila, i.socio_id
FROM prestamos_import i
WHERE i.socio_id IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM socios s WHERE s.socio_id = i.socio_id);Un informe de auditoría completo
SELECT i.fila,
i.socio_id,
i.codigo_ejemplar,
CASE WHEN i.socio_id IS NULL THEN 'socio sin identificar'
WHEN s.socio_id IS NULL THEN 'socio inexistente'
WHEN e.ejemplar_id IS NULL THEN 'ejemplar inexistente'
ELSE 'correcta'
END AS diagnostico
FROM prestamos_import i
LEFT JOIN socios s ON s.socio_id = i.socio_id
LEFT JOIN ejemplares e ON e.codigo = i.codigo_ejemplar
ORDER BY i.fila;| fila | socio_id | codigo_ejemplar | diagnostico |
|---|---|---|---|
| 1 | 14 | EJ-3081 | correcta |
| 2 | 77 | EJ-3085 | socio inexistente |
| 3 | 16 | EJ-9999 | ejemplar inexistente |
| 4 | 15 | EJ-3084 | correcta |
| 5 | (NULL) | EJ-3082 | socio sin identificar |
| 6 | 77 | EJ-3090 | socio inexistente |
Y el resumen para la reunión de proyecto, con lo aprendido en 02-05:
SELECT CASE WHEN i.socio_id IS NULL THEN 'socio sin identificar'
WHEN s.socio_id IS NULL THEN 'socio inexistente'
WHEN e.ejemplar_id IS NULL THEN 'ejemplar inexistente'
ELSE 'correcta' END AS diagnostico,
COUNT(*) AS filas
FROM prestamos_import i
LEFT JOIN socios s ON s.socio_id = i.socio_id
LEFT JOIN ejemplares e ON e.codigo = i.codigo_ejemplar
GROUP BY 1
ORDER BY filas DESC;| diagnostico | filas |
|---|---|
| socio inexistente | 2 |
| correcta | 2 |
| ejemplar inexistente | 1 |
| socio sin identificar | 1 |
Dos tercios de las filas tienen problemas. Ese es el dato con el que se va a hablar con la dirección.
Las cuatro estrategias de limpieza
| Estrategia | Cuándo | Cómo |
|---|---|---|
| Corregir | El valor correcto es deducible (77 era 17, un error de tecleo) |
UPDATE fila a fila, con criterio humano |
Poner a NULL |
La columna lo admite y "desconocido" es aceptable | UPDATE ... SET socio_id = NULL WHERE ... |
| Crear la fila padre | El padre existía de verdad y se perdió en la migración | INSERT en la tabla padre |
| Descartar | La fila no se puede salvar | Moverla a una tabla de cuarentena y borrarla |
La regla de oro: nunca borres huérfanos sin guardarlos antes. Puede que la información que falta esté en otro sitio, y una vez borrada no vuelve.
-- 1) Cuarentena: guardamos lo que no se puede importar
CREATE TABLE prestamos_import_rechazados AS
SELECT i.*
FROM prestamos_import i
LEFT JOIN socios s ON s.socio_id = i.socio_id
LEFT JOIN ejemplares e ON e.codigo = i.codigo_ejemplar
WHERE i.socio_id IS NULL OR s.socio_id IS NULL OR e.ejemplar_id IS NULL;
SELECT COUNT(*) FROM prestamos_import_rechazados; -- 4
-- 2) Importar solo lo válido a la tabla real
INSERT INTO prestamos (socio_id, ejemplar_id, fecha_prestamo, fecha_devolucion_prevista)
SELECT i.socio_id, e.ejemplar_id, i.fecha_prestamo, i.fecha_prestamo + 21
FROM prestamos_import i
INNER JOIN socios s ON s.socio_id = i.socio_id
INNER JOIN ejemplares e ON e.codigo = i.codigo_ejemplar;Fíjate en el detalle: el INSERT ... SELECT con INNER JOIN importa únicamente las filas que emparejan. Las huérfanas se quedan fuera por construcción, sin necesidad de un WHERE que filtrarlas. Es el uso deliberado de una propiedad del INNER JOIN que en otros contextos era un peligro.
Ahora limpiamos el experimento para dejar biblioredb como estaba (los dos préstamos importados tendrían los identificadores 13 y 14):
DELETE FROM prestamos WHERE prestamo_id > 12;
DROP TABLE prestamos_import_rechazados;
DROP TABLE prestamos_import;
SELECT COUNT(*) FROM prestamos; -- 12
- ¿Validar en la aplicación o en la base de datos?
Es una discusión recurrente en los equipos de desarrollo, y merece una respuesta razonada.
El argumento a favor de la aplicación: los mensajes de error son mejores ("Ese socio no existe, ¿quieres darlo de alta?" en lugar de un texto en inglés sobre fk_prestamos_socio), se valida antes de llegar a la base de datos, y con un ORM las relaciones ya están descritas en el código.
El argumento a favor de la base de datos, que es el decisivo:
- La base de datos no es solo tuya. A lo largo de su vida,
biblioredbrecibirá escrituras de la aplicación web, del proceso nocturno de importación, del guion que alguien ejecuta desdepsqla las once de la noche, de la herramienta de administración gráfica y de la migración de dentro de tres años. Cada uno de esos caminos tendría que reimplementar las mismas validaciones. Uno de ellos no lo hará. - Los datos duran más que el código. Las aplicaciones se reescriben cada cinco o siete años; los datos se conservan décadas. Una regla que solo vive en el código desaparece con él.
- La concurrencia. Comprobar en la aplicación "¿existe el socio 14?" y después insertar el préstamo deja una ventana entre ambas operaciones. Si en ese intervalo otra sesión borra al socio 14, la comprobación era inútil. La base de datos verifica dentro de la operación, con los bloqueos adecuados. Este razonamiento se desarrolla en las lecciones 06-01 y 06-02.
- Las operaciones masivas no pasan por la aplicación. Un
UPDATEde 40.000 filas se ejecuta en SQL. Ninguna validación del código lo ve.
La conclusión no es "una u otra", sino las dos, en capas:
flowchart TD
A["Interfaz de usuario<br/>validación inmediata, mensajes claros"] --> B["Lógica de aplicación<br/>reglas de negocio complejas"]
B --> C["Base de datos<br/>claves ajenas, UNIQUE, NOT NULL, CHECK"]
C --> D[("Datos<br/>siempre coherentes")]
A -.->|"puede saltarse"| C
B -.->|"puede saltarse"| C
style C fill:#2d6a4f,color:#ffffff
La aplicación valida para la persona: mensajes útiles, formularios que guían, errores detectados antes de enviar. La base de datos valida para los datos: es la última línea de defensa, la que nadie puede saltarse, la que sigue ahí cuando la aplicación cambie.
Un caso práctico: BiblioRed valida en el formulario web que el correo tenga formato de correo (eso la base de datos no lo comprueba bien), y la base de datos garantiza que sea UNIQUE y que el sucursal_id exista (eso la aplicación no puede garantizarlo de forma fiable). Cada capa hace lo que sabe hacer.
Nota final: las restricciones CHECK —que verifican condiciones sobre los valores, como "el estado debe ser uno de estos cuatro" o "la fecha de devolución no puede ser anterior a la de préstamo"— son la otra gran herramienta de validación en la base de datos. Se tratan a fondo en la lección 04-04, junto con DEFAULT y los criterios de elección de tipos.
Errores Comunes y Consejos
- Olvidar
PRAGMA foreign_keys = ONen SQLite. El esquema parece correcto y no protege nada. Es la trampa número uno de esta lección, y hay que repetirlo en cada conexión. - Poner
ON DELETE CASCADEpor comodidad. "Así no da errores al borrar" es la peor razón posible. UnDELETEsobre una fila puede llevarse miles encadenadas, sin aviso y sin papelera. - Borrar entidades con historial. Los socios, los clientes, los productos vendidos no se borran: se marcan como inactivos.
RESTRICTes tu aliado precisamente porque te obliga a plantearte esa decisión. - Confundir
RESTRICTconNO ACTION. Se comportan igual salvo en un detalle decisivo:RESTRICTno admite diferimiento. - Usar
SET NULLsobre una columnaNOT NULL. PostgreSQL rechaza la definición; en otros gestores el error aparece más tarde, al intentar el borrado. - Creer que una clave ajena nula es un error. No lo es:
NULLsignifica "no apunta a nadie". Si la referencia debe ser obligatoria, añadeNOT NULL. - Dejar las restricciones sin nombre. El día que tengas que hacer
DROP CONSTRAINT, tendrás que ir a buscar el nombre automático en el catálogo. - Referenciar una columna sin
UNIQUE. El gestor lo rechaza, y con razón: la referencia sería ambigua. - Borrar huérfanos sin guardarlos. Muévelos siempre a una tabla de cuarentena antes. Lo que se borra no vuelve.
- Confiar solo en la validación de la aplicación. Habrá otro camino de escritura. Siempre lo hay.
- Consejo: documenta la acción referencial elegida con un comentario en el
CREATE TABLE. Dentro de dos años, "¿por qué esto esRESTRICTy aquelloCASCADE?" será una pregunta real, y la respuesta es una decisión de negocio, no técnica. - Consejo: cuando heredes una base de datos, ejecuta antes que nada la auditoría de huérfanos de cada clave ajena (o
PRAGMA foreign_key_checken SQLite). Te dirá en treinta segundos con qué calidad de datos estás trabajando.
Ejercicios
Ejercicio 1: Decidir la acción referencial
BiblioRed quiere añadir tres tablas nuevas. Para cada clave ajena, decide ON DELETE y justifica la elección en una frase.
resenas (resena_id, socio_id, libro_id, texto, puntuacion, fecha): reseñas escritas por los socios sobre los libros.multas (multa_id, prestamo_id, importe, fecha_emision, pagada): sanciones económicas derivadas de un préstamo.eventos (evento_id, sucursal_id, titulo, fecha): actividades culturales organizadas por cada sucursal.inscripciones (inscripcion_id, evento_id, socio_id, fecha_inscripcion): socios apuntados a esos eventos.
Ejercicio 2: Predecir el efecto de un borrado
Con el esquema final del apartado 7 y el juego de datos del curso, di qué ocurre con cada instrucción y cuántas filas se ven afectadas en total.
DELETE FROM autores WHERE autor_id = 8;(Marina Escolá, sin obras)DELETE FROM autores WHERE autor_id = 4;(Óscar Barreda, dos obras)DELETE FROM libros WHERE libro_id = 339;(Memoria del Ensanche, 1 ejemplar sin préstamos)DELETE FROM libros WHERE libro_id = 331;(El mapa del tiempo, 3 ejemplares, 4 préstamos, 2 reservas)DELETE FROM socios WHERE socio_id = 20;(Elena Roig, sin préstamos ni reservas)DELETE FROM socios WHERE socio_id = 16;(Nuria Bastos, 2 préstamos, 1 reserva)DELETE FROM sucursales WHERE sucursal_id = 4;(Este, 1 socio, 2 ejemplares)
Ejercicio 3: Auditoría y limpieza
BiblioRed ha recibido de otra biblioteca un fichero con socios para incorporar. Créalo como tabla intermedia:
CREATE TABLE socios_import (
fila INTEGER,
nombre VARCHAR(60),
apellidos VARCHAR(80),
email VARCHAR(120),
sucursal_id INTEGER
);
INSERT INTO socios_import (fila, nombre, apellidos, email, sucursal_id) VALUES
(1, 'Rosa', 'Cabanes', '[email protected]', 2),
(2, 'Teo', 'Ninot', '[email protected]', 9),
(3, 'Amina', 'Bakri', '[email protected]', 1),
(4, 'Lluc', 'Ferrer', '[email protected]', 3),
(5, 'Selma', 'Duarte', NULL, 7),
(6, 'Jordi', 'Pons', '[email protected]', NULL);- Escribe una consulta que detecte las filas cuya
sucursal_idno existe. - Escribe una consulta que detecte las filas cuyo
emailya está en uso por un socio actual. - Escribe un informe de auditoría con una columna
diagnosticoque clasifique cada fila. - Importa únicamente las filas válidas y comprueba cuántas entraron. Después, deja
biblioredbcomo estaba.
Soluciones
Solución 1
| Tabla | Clave ajena | ON DELETE |
Justificación |
|---|---|---|---|
resenas |
socio_id → socios |
SET NULL |
La reseña tiene valor para los demás lectores aunque su autor se dé de baja: pasa a ser anónima. (CASCADE sería defendible si la política de privacidad exigiera borrar todo rastro del socio; es una decisión legal, no técnica.) |
resenas |
libro_id → libros |
CASCADE |
Una reseña de un libro que ya no está en el catálogo no tiene lector posible. |
multas |
prestamo_id → prestamos |
RESTRICT |
Es un registro económico. Además, prestamos.socio_id ya es RESTRICT, así que la protección es coherente en toda la cadena. |
eventos |
sucursal_id → sucursales |
RESTRICT (o SET NULL) |
Los eventos pasados son historial de actividad; borrarlos al cerrar una sucursal destruiría las estadísticas anuales. |
inscripciones |
evento_id → eventos |
CASCADE |
Sin evento, la inscripción no significa nada. |
inscripciones |
socio_id → socios |
CASCADE |
Igual que las reservas: es una intención futura, no un hecho contable. |
El patrón que emerge: hechos económicos e históricos → RESTRICT; intenciones y elementos accesorios → CASCADE; contenido con valor propio → SET NULL.
Solución 2
| # | Qué ocurre | Filas afectadas |
|---|---|---|
| 1 | Éxito. Marina Escolá no tiene obras, así que el SET NULL no toca nada. |
1 (el autor) |
| 2 | Éxito con SET NULL. Los libros 334 y 338 sobreviven con autor_id = NULL; sus ejemplares y préstamos quedan intactos. |
3 (1 autor + 2 libros modificados) |
| 3 | Éxito con CASCADE. Se borra el libro y, en cascada, su ejemplar EJ-3095, que no tiene préstamos. No hay reservas del 339. |
2 (1 libro + 1 ejemplar) |
| 4 | ERROR. La cascada intenta borrar los ejemplares 1, 2 y 3, pero el RESTRICT de fk_prestamos_ejemplar lo impide: hay 4 préstamos apuntando a ellos. Nada se borra. |
0 |
| 5 | Éxito. Elena Roig no tiene nada asociado. | 1 |
| 6 | ERROR. El RESTRICT de fk_prestamos_socio bloquea el borrado por sus 2 préstamos. La reserva se habría borrado en cascada, pero la operación entera se anula. |
0 |
| 7 | ERROR. El RESTRICT de fk_socios_sucursal (Lucía Vendrell está dada de alta en Este) y el de fk_ejemplares_sucursal (2 ejemplares) lo impiden. Hay que reasignar antes socios y ejemplares. |
0 |
Observación importante sobre los casos 4, 6 y 7: el fallo es atómico. Aunque la cascada hubiera empezado a borrar filas antes de chocar con el RESTRICT, todo se deshace: la instrucción es una unidad. Eso lo garantizan las transacciones, tema de la lección 06-01.
Solución 3
-- 1 Sucursal inexistente (excluyendo los NULL, que son otro caso)
SELECT i.fila, i.apellidos, i.sucursal_id
FROM socios_import i
LEFT JOIN sucursales su ON su.sucursal_id = i.sucursal_id
WHERE i.sucursal_id IS NOT NULL
AND su.sucursal_id IS NULL
ORDER BY i.fila;| fila | apellidos | sucursal_id |
|---|---|---|
| 2 | Ninot | 9 |
| 5 | Duarte | 7 |
-- 2 Correo ya en uso: violaría uq_socios_email
SELECT i.fila, i.apellidos, i.email
FROM socios_import i
INNER JOIN socios s ON s.email = i.email
ORDER BY i.fila;| fila | apellidos | |
|---|---|---|
| 4 | Ferrer | [email protected] |
-- 3 Informe de auditoría
SELECT i.fila,
i.nombre || ' ' || i.apellidos AS socio,
CASE WHEN i.sucursal_id IS NULL THEN 'sin sucursal asignada'
WHEN su.sucursal_id IS NULL THEN 'sucursal inexistente'
WHEN s.socio_id IS NOT NULL THEN 'correo duplicado'
ELSE 'correcta'
END AS diagnostico
FROM socios_import i
LEFT JOIN sucursales su ON su.sucursal_id = i.sucursal_id
LEFT JOIN socios s ON s.email = i.email
ORDER BY i.fila;| fila | socio | diagnostico |
|---|---|---|
| 1 | Rosa Cabanes | correcta |
| 2 | Teo Ninot | sucursal inexistente |
| 3 | Amina Bakri | correcta |
| 4 | Lluc Ferrer | correo duplicado |
| 5 | Selma Duarte | sucursal inexistente |
| 6 | Jordi Pons | sin sucursal asignada |
Nota: la fila 6 no viola ninguna clave ajena —NULL es una referencia válida— pero sí violaría el NOT NULL de socios.sucursal_id. Son dos restricciones distintas y ambas hay que auditarlas.
-- 4 Importar solo lo válido
INSERT INTO socios (nombre, apellidos, email, fecha_alta, sucursal_id, activo)
SELECT i.nombre, i.apellidos, i.email, '2026-08-02', i.sucursal_id, TRUE
FROM socios_import i
INNER JOIN sucursales su ON su.sucursal_id = i.sucursal_id
WHERE NOT EXISTS (SELECT 1 FROM socios s WHERE s.email = i.email);Entran Rosa Cabanes y Amina Bakri. El INNER JOIN con sucursales descarta por construcción las filas 2, 5 y 6, y el NOT EXISTS descarta la 4.
-- Comprobación y limpieza
SELECT COUNT(*) FROM socios; -- 12
DELETE FROM socios WHERE apellidos IN ('Cabanes', 'Bakri');
DROP TABLE socios_import;
SELECT COUNT(*) FROM socios; -- 10Conclusión
Cerramos el módulo con las garantías que hacen que todo lo anterior siga siendo cierto con el paso del tiempo:
- Una fila huérfana es una clave ajena que apunta a algo inexistente. Nacía a diario en la hoja de cálculo de BiblioRed por tecleo, por borrados y por renumeraciones, y su daño es silencioso: los
INNER JOINla eliminan sin avisar y los totales dejan de cuadrar. - Una clave ajena se declara en forma de columna o, mejor, de tabla con nombre propio (
CONSTRAINT fk_...), y solo puede referenciar columnas conPRIMARY KEYoUNIQUE. Al añadirla conALTER TABLE, el gestor valida todas las filas existentes. - El gestor comprueba en el
INSERTy elUPDATEde la tabla hija, y en elDELETEy elUPDATEde la clave primaria de la tabla padre. Una clave ajena nula es legal: si la referencia debe ser obligatoria, hay que añadirNOT NULL. - Las cinco acciones referenciales —
NO ACTION,RESTRICT,CASCADE,SET NULLySET DEFAULT— definen qué ocurre con las hijas cuando desaparece el padre.RESTRICTno admite diferimiento; esa es su única diferencia real conNO ACTION. - El criterio de elección en BiblioRed: el préstamo es historia (
RESTRICT) y la reserva es futuro (CASCADE); el ejemplar es una parte del libro (CASCADE) y el libro sobrevive a la ficha de su autor (SET NULL). Un socio no se borra: se marca como inactivo. - Las claves ajenas compuestas exigen la forma de tabla, respetan el orden de las columnas y, con
MATCH SIMPLE, no se comprueban si alguna columna es nula. - Las restricciones diferibles (
DEFERRABLE INITIALLY DEFERRED) posponen la comprobación alCOMMIT, y sirven para referencias circulares, cargas masivas e intercambios. No son una comodidad general. - SQLite no aplica las claves ajenas si no se activa
PRAGMA foreign_keys = ON, en cada conexión.PRAGMA foreign_key_checkaudita una base entera. - Los huérfanos que ya existen se detectan con el patrón
LEFT JOIN ... IS NULL(oNOT EXISTS), se clasifican conCASE WHEN, se guardan en cuarentena y solo entonces se descartan. UnINSERT ... SELECTconINNER JOINimporta por construcción únicamente lo válido. - Y la conclusión de fondo: se valida en las dos capas. La aplicación valida para la persona; la base de datos es la última línea de defensa, porque habrá otros caminos de escritura, porque los datos duran más que el código y porque solo el gestor puede verificar dentro de la operación, sin ventanas de concurrencia.
Con esto termina el módulo 2. Has recorrido el camino completo del mundo relacional: la teoría del modelo y sus reglas de integridad, el lenguaje SQL y la creación del esquema, el CRUD sobre una tabla, la reunión de varias tablas con JOIN y subconsultas, el resumen con agregados y agrupaciones, y las garantías referenciales que lo sostienen. biblioredb ya no es una base vacía: es un sistema de información con siete tablas, datos coherentes, informes de gestión y defensas propias. En el módulo 3, Bases de Datos No Relacionales, cambiamos de mundo: veremos qué es NoSQL, qué familias existen, cómo se modelan los datos cuando no hay esquema fijo ni claves ajenas que los protejan —y qué se gana y qué se pierde en ese trato—. BiblioRed viene con nosotros: sus reseñas y su registro de actividad son, como decidimos en la lección 01-02, el caso de uso perfecto para MongoDB.
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
