Semana 7 — Dependencias funcionales y normalización
Objetivos
- Reconocer en una tabla las anomalías de actualización, inserción y borrado que produce la redundancia, y explicarlas con dependencias funcionales (functional dependencies).
- Decidir si una descomposición es sin pérdida (lossless) y si preserva dependencias (dependency-preserving), y descomponer un esquema hasta BCNF.
- Usar los axiomas de Armstrong y la clausura (closure) de un conjunto de atributos para encontrar claves y decidir si una dependencia se deduce de otras; calcular una cubierta canónica (canonical cover).
- Sintetizar un esquema en 3NF y explicar cuándo conviene 3NF en lugar de BCNF. ✂️ Reconocer dependencias multivaluadas y la 4NF.
Sesión A — Dependencias funcionales y BCNF
Lecturas:
- Silberschatz, secs. 7.1 (Features of Good Relational Designs), 7.2 (Decomposition Using Functional Dependencies) y 7.3 (Normal Forms), esta última hasta BCNF.
- Berkeley CS186, Note 13 DB Design: secciones FDs and Normalization, Lossless Decomposition y BCNF Decomposition.
Lecturas complementarias:
- Silberschatz, sec. 7.8 (Atomic Domains and First Normal Form).
- Diapositivas de los autores del libro, capítulo 7: db-book.com, Slides.
- Wikipedia, Boyce–Codd normal form.
Temas:
- La intuición: una tabla que junta envíos, clientes y agencias repite el nombre de un cliente en cada envío suyo. Anomalía de actualización: se cambia el apellido en una fila y no en las otras. De inserción: no se puede registrar un cliente que todavía no envió nada. De borrado: al borrar el último envío de un cliente se pierde el cliente.
- Dependencia funcional
X → Y: dos filas que coinciden enXcoinciden enY. En Chasqui,dni → first_name, last_name, cityyplate → kind, capacity_kg. Dependencias triviales (Y ⊆ X). Una superclave es un conjuntoXtal queX → R. - De dónde salen las dependencias: de los requisitos del negocio (“cada agencia está en una sola ciudad”), no de los datos. Los datos de un momento dado pueden mostrar que una dependencia no se cumple, nunca demostrar que se cumple: con diez clientes, quizá ningún apellido se repite, y aun así
last_name → dnies falsa. - Error común: deducir dependencias mirando unas filas. Con SQL se puede refutar una dependencia:
SELECT dni FROM planilla GROUP BY dni HAVING count(DISTINCT last_name) > 1devuelve los contraejemplos dedni → last_name. Si no devuelve nada, solo sabemos que los datos de hoy no la contradicen. - Descomposición: reemplazar
RporR1yR2cuyos atributos cubren los deR. Es sin pérdida si el join naturalR1 ⋈ R2devuelve exactamenteR. Ejemplo de descomposición con pérdida: separar envíos y clientes por la ciudad en vez de por el DNI; el join produce filas falsas (spurious tuples), un envío aparece con todos los clientes de esa ciudad. - Criterio con dependencias: la descomposición en
R1yR2es sin pérdida siR1 ∩ R2 → R1oR1 ∩ R2 → R2, es decir, si los atributos comunes son superclave de una de las partes. - BCNF: para toda dependencia no trivial
X → Yque se cumple enR,Xes superclave deR. Intuición: todo determinante de una dependencia no trivial es superclave; si un atributo determina otros sin ser clave, esa información se repite y tiene que ir en su propia tabla. - Algoritmo de descomposición en BCNF: mientras haya una dependencia
X → Yque viola BCNF, partirRenX ∪ YyR − (Y − X). Cada paso es sin pérdida porqueXes la parte común y determinaX ∪ Y. Ejemplo resuelto en clase con una planilla de envíos hasta llegar a tablas parecidas ashipment,customer,agencyyvehicle. - 1NF y dominios atómicos (sec. 7.8): una columna con
'Lima, Cusco'o una lista de teléfonos no es atómica. La normalización con dependencias funcionales supone 1NF.
Sesión B — Teoría de dependencias: clausura, cubierta canónica, 3NF · ✂️ 4NF
Lecturas:
- Silberschatz, secs. 7.3 (Normal Forms; la parte de 3NF), 7.4 (Functional-Dependency Theory) y 7.5 (Algorithms for Decomposition Using Functional Dependencies).
- ✂️ Silberschatz, sec. 7.6 (Decomposition Using Multivalued Dependencies).
- Berkeley CS186, Note 13 DB Design: secciones FDs and Normalization (axiomas de Armstrong y clausura de atributos) y Dependency Preserving Decompositions.
Lecturas complementarias:
- Wikipedia, Armstrong’s axioms, Third normal form y ✂️ Fourth normal form.
- Ejercicios de práctica resueltos de los autores, capítulo 7: db-book.com, Practice Exercises.
Temas:
- Clausura de un conjunto de dependencias
F⁺: todas las que se deducen deF. Axiomas de Armstrong: reflexividad (siY ⊆ X, entoncesX → Y), aumento (siX → Y, entoncesXZ → YZ) y transitividad. Son correctos (no deducen nada falso) y completos (deducen todo). Reglas derivadas: unión, descomposición y pseudotransitividad, demostradas con los axiomas. - Clausura de atributos
X⁺: empezar conXy agregarYcada vez que hayaW → YconW ⊆ X⁺. Sirve para tres cosas: decidir siXes superclave (X⁺contiene todoR), decidir siX → Yestá enF⁺(Y ⊆ X⁺) y encontrar las claves candidatas. Ejemplo en la planilla:{shipment_id}⁺,{sender_dni}⁺. - Cubierta canónica
Fc: un conjunto equivalente aFsin atributos extraños (extraneous) a la izquierda ni a la derecha y sin dos dependencias con el mismo lado izquierdo. Cómo probar si un atributo es extraño usando la clausura. La cubierta canónica no es única. - Preservación de dependencias: una descomposición preserva dependencias si la unión de las dependencias que se pueden verificar en cada tabla por separado implica todo
F(su clausura esF⁺). Basta que cada dependencia deFquepa en una sola tabla, pero no hace falta: conA → B,B → CyA → C, la descomposición en(A, B)y(B, C)preservaA → Caunque ninguna tabla la contenga. Importa porque cada dependencia que no se preserva es una regla que la base de datos ya no hace cumplir con una clave. - Error común: creer que BCNF siempre preserva dependencias. Contraejemplo en Chasqui:
attention(customer_id, agency_id, executive_id), donde cada cliente tiene un ejecutivo por agencia (customer_id, agency_id → executive_id) y cada ejecutivo trabaja en una sola agencia (executive_id → agency_id).executive_idno es superclave, así que no está en BCNF. La descomposición en(executive_id, agency_id)y(customer_id, executive_id)es sin pérdida y está en BCNF, pero la primera dependencia ya no se puede verificar en ninguna de las dos tablas. - 3NF: para toda
X → Yno trivial,Xes superclave o cada atributo deY − Xes parte de alguna clave candidata.attentionestá en 3NF porqueagency_ides parte de la clave{customer_id, agency_id}. 3NF admite algo de redundancia a cambio de preservar siempre las dependencias. - Algoritmo de síntesis en 3NF: calcular
Fc; crear una tabla por cada dependenciaX → YdeFccon atributosXY; si ninguna contiene una clave candidata deR, agregar una con una clave; quitar las tablas contenidas en otras. El resultado es sin pérdida y preserva dependencias. - BCNF o 3NF: se prefiere BCNF; se acepta 3NF cuando la descomposición en BCNF pierde una dependencia importante. En la práctica, también se puede quedar en BCNF y verificar la dependencia perdida con un trigger.
- ✂️ Dependencias multivaluadas y 4NF: una agencia tiene varios teléfonos y ofrece varios servicios (recojo a domicilio, embalaje), independientes entre sí. En
agency_info(agency_id, phone, service)no hay dependencias funcionales no triviales y está en BCNF, pero cada teléfono se repite con cada servicio.agency_id ↠ phonees una dependencia multivaluada; la 4NF la elimina separando(agency_id, phone)y(agency_id, service).
Laboratorio — Normalizar un esquema
Una agencia llevaba sus envíos en una hoja de cálculo y se importó tal cual a una tabla planilla. Trabajen con la base de datos chasqui cargada (make -C datasets/chasqui). Referencia: PostgreSQL, Combining Queries (EXCEPT).
- Construir la planilla a partir de los datos reales:
CREATE TABLE planilla AS SELECT ...con un join deshipment,customer(remitente),agency(origen y destino),tripyvehicle. Columnas:shipment_id,created_at,weight_kg,price,status,sender_dni,sender_first_name,sender_last_name,sender_city,origin_agency_id,origin_agency,origin_city,origin_region,dest_agency_id,dest_agency,dest_city,dest_region,plate,vehicle_kind. Como en la hoja de cálculo, la agencia aparece con su código y su nombre. UsarLEFT JOINparatripyvehicle: hay envíos sin viaje asignado. - Simular los errores de la hoja de cálculo: con dos o tres
UPDATE, cambiar el apellido de un remitente en solo una de sus filas y la región de una ciudad en una fila. Esa es la anomalía de actualización hecha realidad. - Escribir, a partir de los requisitos (un DNI identifica a una persona; un código de agencia identifica a una agencia, que está en una ciudad; cada ciudad está en una región; cada placa es de un vehículo), el conjunto
Fde dependencias funcionales. Verificar conGROUP BY ... HAVING count(DISTINCT ...) > 1cuáles contradicen los datos y encontrar las filas alteradas en el paso 2. - Calcular a mano
{shipment_id}⁺y{sender_dni}⁺. ¿Cuál es la clave deplanilla? ¿Qué dependencias violan BCNF? ¿origin_city → origin_regionviola también 3NF? - Descomponer hasta BCNF y crear las tablas nuevas con
CREATE TABLE ... AS SELECT DISTINCT .... Intentar agregar la clave primaria a la tabla de clientes conALTER TABLE ... ADD PRIMARY KEY (dni): falla, porque el DNI alterado aparece en dos filas. - Antes de corregir nada, reconstruir la planilla con joins de las tablas nuevas y compararla con la original:
(SELECT ... FROM planilla) EXCEPT (SELECT ... reconstruida)y la diferencia en el otro sentido. La reconstruida tiene filas de más (spurious tuples): el DNI alterado se une con sus dos versiones. La garantía de descomposición sin pérdida vale solo si los datos cumplen las dependencias. Ojo conplate: conNULL, un join interno pierde filas y hace faltaLEFT JOIN. - Corregir las filas alteradas en
planilla(no en las tablas nuevas), volver a crear las tablas descompuestas, agregar las claves primarias y repetir la comparación: ahora las dos diferencias deben devolver cero filas. - Para cerrar: ¿la descomposición preserva todas las dependencias de
F? Comparar las tablas obtenidas con las del dataset original y anotar las diferencias.
Entregas
Sin entregas esta semana. P2 (Diseño y aplicación) está en curso: el esquema normalizado y justificado con dependencias funcionales es parte de lo que se entrega en la semana 9. Ver projects/README.md.
Preguntas de repaso
- Con la planilla del laboratorio, den un ejemplo concreto de anomalía de actualización, de inserción y de borrado.
R(A, B, C, D, E)conF = {A → B, BC → D, D → E}. Calculen{A, C}⁺. ¿EsACclave candidata? ¿EstáRen BCNF? Descompónganla.- ¿Por qué no basta con revisar los datos actuales para afirmar que
city → regionse cumple? ¿Qué sí pueden concluir con una consultaGROUP BY ... HAVING? - Expliquen con el ejemplo de
attention(customer_id, agency_id, executive_id)por qué una descomposición en BCNF puede no preservar dependencias. ¿Qué forma normal elegirían y por qué? - Calculen una cubierta canónica de
F = {A → BD, D → B, AD → C, A → D}y muestren qué atributo es extraño en cada paso.
Código de la semana
Compilar y probar dentro del contenedor del curso con make y make test.
Ejemplos (7)Pruebas (2)Compilación (1)
Ejemplos
Los programas que usan los apuntes y la clase.
-- attention(customer_id, agency_id, executive_id): cada cliente tiene un
-- ejecutivo por agencia y cada ejecutivo trabaja en una sola agencia.
-- customer_id, agency_id → executive_id
-- executive_id → agency_id
-- Primero la descomposición en BCNF, que pierde la primera dependencia;
-- después el diseño en 3NF, que la conserva con una clave. Todo va en una
-- transacción que termina en ROLLBACK. En psql: \i attention.sql.
-- name: begin
BEGIN;
-- name: bcnf_tables
CREATE TABLE executive_agency (
executive_id integer PRIMARY KEY,
agency_id integer NOT NULL REFERENCES agency
);
CREATE TABLE customer_executive (
customer_id integer REFERENCES customer,
executive_id integer REFERENCES executive_agency,
PRIMARY KEY (customer_id, executive_id)
);
-- Los ejecutivos 101 y 102 trabajan en Cusco - Wanchaq (agencia 6).
-- name: bcnf_insert
INSERT INTO executive_agency VALUES (101, 6), (102, 6), (103, 1);
INSERT INTO customer_executive VALUES (1, 101), (1, 102), (1, 103);
-- Cada tabla cumple su clave, pero el cliente 1 quedó con dos ejecutivos en
-- la misma agencia: customer_id, agency_id → executive_id ya no se cumple.
-- name: bcnf_lost
SELECT ce.customer_id, ea.agency_id,
array_agg(ce.executive_id ORDER BY ce.executive_id) AS ejecutivos
FROM customer_executive ce
JOIN executive_agency ea USING (executive_id)
GROUP BY ce.customer_id, ea.agency_id
HAVING count(DISTINCT ce.executive_id) > 1;
-- En 3NF: attention con la clave (customer_id, agency_id). executive_id →
-- agency_id se verifica con una clave foránea compuesta hacia executive.
-- name: three_nf_tables
CREATE TABLE executive (
executive_id integer PRIMARY KEY,
agency_id integer NOT NULL REFERENCES agency,
UNIQUE (executive_id, agency_id)
);
CREATE TABLE attention (
customer_id integer REFERENCES customer,
agency_id integer,
executive_id integer NOT NULL,
CONSTRAINT attention_pk PRIMARY KEY (customer_id, agency_id),
CONSTRAINT attention_executive_fk FOREIGN KEY (executive_id, agency_id)
REFERENCES executive (executive_id, agency_id)
);
INSERT INTO executive VALUES (101, 6), (102, 6), (103, 1);
INSERT INTO attention VALUES (1, 6, 101), (1, 1, 103);
-- Las dos sentencias que siguen fallan a propósito; el SAVEPOINT permite
-- seguir dentro de la transacción después de cada error.
-- name: savepoint
SAVEPOINT s;
-- Un segundo ejecutivo para el cliente 1 en la agencia 6: la clave lo impide.
-- name: three_nf_dup
INSERT INTO attention VALUES (1, 6, 102);
-- name: back
ROLLBACK TO SAVEPOINT s;
-- El ejecutivo 101 atendiendo en la agencia 1: la clave foránea lo impide.
-- name: three_nf_wrong_agency
INSERT INTO attention VALUES (2, 1, 101);
-- name: back2
ROLLBACK TO SAVEPOINT s;
-- name: rollback
ROLLBACK;-- La descomposición en BCNF de la hoja de viajes, hecha con SQL: una tabla
-- por esquema, con sus claves, y la prueba de que el join devuelve la hoja.
-- Las tablas son temporales (desaparecen al cerrar la sesión). En psql:
-- \i decompose.sql. Las pruebas corren cada parte por su nombre.
-- name: create
CREATE TEMP TABLE s_origin AS
SELECT DISTINCT origin_agency, origin_city FROM trip_sheet;
CREATE TEMP TABLE s_dest AS
SELECT DISTINCT dest_agency, dest_city FROM trip_sheet;
CREATE TEMP TABLE s_route AS
SELECT DISTINCT route_id, origin_agency, dest_agency, distance_km, base_fare FROM trip_sheet;
CREATE TEMP TABLE s_vehicle AS
SELECT DISTINCT plate, kind, capacity_kg FROM trip_sheet;
CREATE TEMP TABLE s_trip AS
SELECT DISTINCT trip_id, departure_at, route_id, plate FROM trip_sheet;
-- Cada lado izquierdo de F es la clave de su tabla. Si los datos no
-- cumplieran la dependencia, el ALTER TABLE fallaría.
-- name: keys
ALTER TABLE s_origin ADD PRIMARY KEY (origin_agency);
ALTER TABLE s_dest ADD PRIMARY KEY (dest_agency);
ALTER TABLE s_route ADD PRIMARY KEY (route_id);
ALTER TABLE s_route ADD UNIQUE (origin_agency, dest_agency);
ALTER TABLE s_vehicle ADD PRIMARY KEY (plate);
ALTER TABLE s_trip ADD PRIMARY KEY (trip_id);
-- name: sizes
SELECT 's_origin' AS tabla, count(*) AS filas FROM s_origin
UNION ALL SELECT 's_dest', count(*) FROM s_dest
UNION ALL SELECT 's_route', count(*) FROM s_route
UNION ALL SELECT 's_vehicle', count(*) FROM s_vehicle
UNION ALL SELECT 's_trip', count(*) FROM s_trip;
-- name: rebuild
WITH joined AS (
SELECT trip_id, departure_at, route_id, origin_agency, origin_city, dest_agency,
dest_city, distance_km, base_fare, plate, kind, capacity_kg
FROM s_trip
JOIN s_route USING (route_id)
JOIN s_origin USING (origin_agency)
JOIN s_dest USING (dest_agency)
JOIN s_vehicle USING (plate)
)
SELECT (SELECT count(*) FROM joined) AS reconstruida,
(SELECT count(*) FROM (TABLE joined EXCEPT TABLE trip_sheet) AS x) AS de_mas,
(SELECT count(*) FROM (TABLE trip_sheet EXCEPT TABLE joined) AS x) AS faltan;// Los ejemplos resueltos del capítulo, con fd.mjs. Se importan desde
// fd.test.mjs, y con `node examples.mjs` se imprimen las trazas que aparecen
// en los apuntes.
import {
attrs, fds, closureTrace, candidateKeys, isBCNF, bcnfViolation, bcnfDecompose,
canonicalCover, losslessBinary, lostDependencies, threeNFViolation, synthesize3NF,
formatSet, formatFd,
} from './fd.mjs';
// Silberschatz, sec. 7.4.2: R = (A, B, C, G, H, I).
export const BOOK = {
R: attrs('ABCGHI'),
F: fds('A -> B; A -> C; CG -> H; CG -> I; B -> H'),
};
// Silberschatz, sec. 7.4.3: cubierta canónica.
export const COVER = { F: fds('A -> BC; B -> C; A -> B; AB -> C') };
// Una cubierta canónica no es única.
export const CYCLE = { F: fds('A -> BC; B -> AC; C -> AB') };
// En un subesquema, BCNF se prueba con F⁺, no solo con F.
export const SUB = {
R: attrs('ABCDE'),
F: fds('A -> B; BC -> D'),
R1: attrs('AB'),
R2: attrs('ACDE'),
};
// Una dependencia que no cabe en ninguna tabla y aun así se preserva.
export const PRESERVE = {
R: attrs('ABC'),
F: fds('A -> B; B -> C; A -> C'),
schemas: [attrs('AB'), attrs('BC')],
};
// La hoja de viajes de Chasqui (trip_sheet.sql).
export const TRIP = {
R: attrs(`trip_id departure_at route_id origin_agency origin_city dest_agency
dest_city distance_km base_fare plate kind capacity_kg`),
F: fds(`trip_id -> departure_at route_id plate
route_id -> origin_agency dest_agency distance_km base_fare
origin_agency dest_agency -> route_id
origin_agency -> origin_city
dest_agency -> dest_city
plate -> kind capacity_kg`),
};
// La misma hoja, con F escrito "de más": dos dependencias que se deducen de
// las otras y un lado izquierdo con un atributo de sobra.
export const TRIP_LOOSE = {
F: [
...TRIP.F,
...fds(`trip_id -> origin_city
trip_id plate -> capacity_kg`),
],
};
// Cada cliente tiene un ejecutivo por agencia; cada ejecutivo, una agencia.
export const ATTENTION = {
R: attrs('customer_id agency_id executive_id'),
F: fds(`customer_id agency_id -> executive_id
executive_id -> agency_id`),
};
// Silberschatz, sec. 7.5.2: síntesis en 3NF de cust_banker_branch.
export const BANKER = {
R: attrs('customer_id employee_id branch_name type'),
F: fds(`customer_id employee_id -> branch_name type
employee_id -> branch_name
customer_id branch_name -> employee_id`),
};
// ------------------------------------------------------------ impresión
const set = (xs) => `{${formatSet(xs)}}`;
const schemas = (ss) => ss.map((s) => ` (${formatSet(s)})`).join('\n');
function showClosure(X, F) {
const { closure, steps } = closureTrace(X, F);
console.log(`${set(attrs(X))}⁺:`);
for (const s of steps) console.log(` por ${formatFd(s.fd)}: ${set(s.result)}`);
console.log(` = ${set(closure)}`);
}
function showCover(F) {
const { cover, steps } = canonicalCover(F);
for (const s of steps) {
const where = s.side === 'lhs' ? 'a la izquierda' : 'a la derecha';
console.log(` ${s.attr} es extraño ${where} en ${formatFd(s.before)}; queda {${s.cover.map(formatFd).join('; ')}}`);
}
console.log(` Fc = {${cover.map(formatFd).join('; ')}}`);
}
function showBCNF(R, F) {
const { schemas: out, steps } = bcnfDecompose(R, F);
steps.forEach((s, i) => {
console.log(`paso ${i + 1}: ${formatFd(s.violation)} viola BCNF en (${formatSet(s.schema)})`);
console.log(` -> (${formatSet(s.part)})`);
console.log(` -> (${formatSet(s.rest)})`);
});
console.log('resultado:');
console.log(schemas(out));
}
function show3NF(R, F) {
const r = synthesize3NF(R, F);
console.log(`Fc = {${r.cover.map(formatFd).join('; ')}}`);
if (r.addedKey) console.log(`se agrega la clave (${formatSet(r.addedKey)})`);
for (const s of r.removed) console.log(`se quita (${formatSet(s)}), contenido en otro`);
console.log(schemas(r.schemas));
}
if (import.meta.main) {
console.log('== Libro, sec. 7.4.2');
showClosure('AG', BOOK.F);
showClosure('A', BOOK.F);
console.log('claves candidatas:', candidateKeys(BOOK.R, BOOK.F).map(set).join(' '));
console.log('\n== Cubierta canónica, sec. 7.4.3');
showCover(COVER.F);
console.log('\n== Cubierta canónica no única');
showCover(CYCLE.F);
console.log('\n== BCNF en un subesquema');
console.log('R2 en BCNF:', isBCNF(SUB.R2, SUB.F), formatFd(bcnfViolation(SUB.R2, SUB.F)));
console.log('\n== Preservación con A -> C repartida');
console.log('perdidas:', lostDependencies(PRESERVE.schemas, PRESERVE.F).map(formatFd));
console.log('\n== trip_sheet');
showClosure('trip_id', TRIP.F);
showClosure('plate', TRIP.F);
showClosure('origin_agency dest_agency', TRIP.F);
console.log('claves candidatas:', candidateKeys(TRIP.R, TRIP.F).map(set).join(' '));
showBCNF(TRIP.R, TRIP.F);
console.log('\n== trip_sheet con F de más');
showCover(TRIP_LOOSE.F);
console.log('\n== trip_sheet en 3NF');
show3NF(TRIP.R, TRIP.F);
console.log('\n== attention');
console.log('claves candidatas:', candidateKeys(ATTENTION.R, ATTENTION.F).map(set).join(' '));
console.log('violación BCNF:', formatFd(bcnfViolation(ATTENTION.R, ATTENTION.F)));
console.log('violación 3NF:', threeNFViolation(ATTENTION.R, ATTENTION.F));
const split = [attrs('executive_id agency_id'), attrs('customer_id executive_id')];
console.log('sin pérdida:', losslessBinary(split[0], split[1], ATTENTION.F));
console.log('perdidas:', lostDependencies(split, ATTENTION.F).map(formatFd));
console.log('\n== cust_banker_branch');
console.log('claves candidatas:', candidateKeys(BANKER.R, BANKER.F).map(set).join(' '));
show3NF(BANKER.R, BANKER.F);
}-- Ejemplos del capítulo sobre la hoja de viajes (trip_sheet.sql) y la
-- relación attention. Cada consulta empieza con "-- name: ..."; las pruebas
-- (sql.test.mjs) las corren por su nombre. En psql: \i examples.sql.
-- Las sentencias que cambian datos van entre BEGIN y ROLLBACK.
-- name: sheet_rows
SELECT trip_id, route_id, origin_agency, dest_agency, distance_km, plate, kind, capacity_kg
FROM trip_sheet
WHERE plate = 'K2U-314'
ORDER BY trip_id
LIMIT 4;
-- name: sheet_repeats
SELECT count(*) AS filas,
count(DISTINCT route_id) AS rutas,
count(DISTINCT plate) AS vehiculos
FROM trip_sheet;
-- ------------------------------------------------------------ anomalías
-- name: update_anomaly
BEGIN;
UPDATE trip_sheet SET capacity_kg = 1500 WHERE trip_id = 47;
SELECT plate, array_agg(DISTINCT capacity_kg) AS capacidades
FROM trip_sheet
WHERE plate = 'K2U-314'
GROUP BY plate;
ROLLBACK;
-- name: insert_anomaly
INSERT INTO trip_sheet (plate, kind, capacity_kg)
VALUES ('Z9Z-999', 'van', 1200);
-- name: delete_anomaly
BEGIN;
DELETE FROM trip_sheet WHERE route_id = 113;
SELECT count(DISTINCT route_id) AS rutas,
count(*) FILTER (WHERE origin_agency = 'Lima - La Victoria'
AND dest_agency = 'Lima - Los Olivos') AS viajes_113
FROM trip_sheet;
ROLLBACK;
-- ------------------------------------------------------------ ¿se cumple X → Y?
-- Los contraejemplos de plate → kind, capacity_kg: ninguno.
-- name: fd_plate
SELECT plate
FROM trip_sheet
GROUP BY plate
HAVING count(DISTINCT (kind, capacity_kg)) > 1;
-- origin_city → origin_agency no se cumple: hay ciudades con varias agencias.
-- name: fd_city_agency
SELECT origin_city, count(DISTINCT origin_agency) AS agencias
FROM trip_sheet
GROUP BY origin_city
HAVING count(DISTINCT origin_agency) > 1
ORDER BY origin_city;
-- plate, departure_at → trip_id: los datos la contradicen.
-- name: fd_plate_departure
SELECT plate, departure_at, array_agg(trip_id ORDER BY trip_id) AS viajes
FROM trip_sheet
GROUP BY plate, departure_at
HAVING count(DISTINCT trip_id) > 1
ORDER BY departure_at, plate
LIMIT 3;
-- name: fd_plate_departure_count
SELECT count(*) AS grupos
FROM (SELECT plate, departure_at
FROM trip_sheet
GROUP BY plate, departure_at
HAVING count(DISTINCT trip_id) > 1) AS t;
-- distance_km → base_fare: los datos no la contradicen.
-- name: fd_distance_fare
SELECT distance_km
FROM trip_sheet
GROUP BY distance_km
HAVING count(DISTINCT base_fare) > 1;
-- count(DISTINCT ...) no cuenta los NULL: esta consulta no ve el problema.
-- name: null_trap
SELECT plate
FROM (VALUES ('A1A-111', 'bus'), ('A1A-111', NULL)) AS t (plate, kind)
GROUP BY plate
HAVING count(DISTINCT kind) > 1;
-- Contando el NULL como un valor más.
-- name: null_safe
SELECT plate
FROM (VALUES ('A1A-111', 'bus'), ('A1A-111', NULL)) AS t (plate, kind)
GROUP BY plate
HAVING count(DISTINCT kind)
+ CASE WHEN count(kind) < count(*) THEN 1 ELSE 0 END > 1;
-- ------------------------------------------------------------ descomposiciones
-- Partir (trip_id, origin_agency, origin_city) por origin_city: con pérdida.
-- El join natural de r1 y r2 se escribe con USING y los atributos comunes.
-- name: lossy
WITH original AS (
SELECT DISTINCT trip_id, origin_agency, origin_city FROM trip_sheet
), r1 AS (
SELECT DISTINCT trip_id, origin_city FROM trip_sheet
), r2 AS (
SELECT DISTINCT origin_city, origin_agency FROM trip_sheet
), joined AS (
SELECT trip_id, origin_agency, origin_city FROM r1 JOIN r2 USING (origin_city)
)
SELECT (SELECT count(*) FROM original) AS original,
(SELECT count(*) FROM joined) AS reconstruida,
(SELECT count(*) FROM (TABLE joined EXCEPT TABLE original) AS x) AS de_mas,
(SELECT count(*) FROM (TABLE original EXCEPT TABLE joined) AS x) AS faltan;
-- Un viaje que sale de Lima, con las agencias que le inventa el join.
-- name: lossy_trip
WITH r1 AS (
SELECT DISTINCT trip_id, origin_city FROM trip_sheet
), r2 AS (
SELECT DISTINCT origin_city, origin_agency FROM trip_sheet
)
SELECT trip_id, origin_city, origin_agency
FROM r1 JOIN r2 USING (origin_city)
WHERE trip_id = 47
ORDER BY origin_agency;
-- Partir por origin_agency: sin pérdida.
-- name: lossless
WITH original AS (
SELECT DISTINCT trip_id, origin_agency, origin_city FROM trip_sheet
), r1 AS (
SELECT DISTINCT trip_id, origin_agency FROM trip_sheet
), r2 AS (
SELECT DISTINCT origin_agency, origin_city FROM trip_sheet
), joined AS (
SELECT trip_id, origin_agency, origin_city FROM r1 JOIN r2 USING (origin_agency)
)
SELECT (SELECT count(*) FROM original) AS original,
(SELECT count(*) FROM joined) AS reconstruida,
(SELECT count(*) FROM (TABLE joined EXCEPT TABLE original) AS x) AS de_mas,
(SELECT count(*) FROM (TABLE original EXCEPT TABLE joined) AS x) AS faltan;
-- ------------------------------------------------------------ 4NF
-- Teléfonos y servicios de una agencia, independientes entre sí: en una sola
-- tabla, cada teléfono se repite con cada servicio.
-- name: mvd
WITH agency_phone (agency_id, phone) AS (
VALUES (6, '984111222'), (6, '984333444')
), agency_service (agency_id, service) AS (
VALUES (6, 'embalaje'), (6, 'recojo'), (6, 'seguro')
)
SELECT agency_id, phone, service
FROM agency_phone JOIN agency_service USING (agency_id)
ORDER BY phone, service;// Dependencias funcionales: clausura de atributos, claves, BCNF, 3NF,
// cubierta canónica, descomposición sin pérdida y preservación de
// dependencias. Sigue los algoritmos de Silberschatz, cap. 7.
//
// Representación:
// - Un atributo es un texto: 'trip_id', 'A'.
// - Un conjunto de atributos es un arreglo ordenado y sin repetidos.
// - Una dependencia es { lhs, rhs }, con lhs y rhs conjuntos de atributos.
//
// attrs('A B C'), attrs('trip_id, plate') y attrs('ABC') arman conjuntos; la
// última forma (letras mayúsculas pegadas) es la de los ejemplos del libro.
// fd('A B -> C') y fds('A -> B; CG -> H') arman dependencias.
// ------------------------------------------------------------ conjuntos
const sortUnique = (xs) => [...new Set(xs)].sort();
export function attrs(x) {
if (Array.isArray(x)) return sortUnique(x);
const text = x.trim();
if (text === '') return [];
if (/[\s,]/.test(text)) return sortUnique(text.split(/[\s,]+/).filter(Boolean));
if (/^[A-Z]+$/.test(text)) return sortUnique(text.split(''));
return [text];
}
export const union = (a, b) => sortUnique([...a, ...b]);
export const intersect = (a, b) => a.filter((x) => b.includes(x));
export const minus = (a, b) => a.filter((x) => !b.includes(x));
export const isSubset = (a, b) => a.every((x) => b.includes(x));
export const sameSet = (a, b) => isSubset(a, b) && isSubset(b, a);
// Subconjuntos no vacíos de xs, de menor a mayor tamaño y, a igual tamaño,
// en el orden de xs. El orden fijo hace que los resultados sean reproducibles.
export function subsets(xs) {
const out = [];
const pick = (start, size, acc) => {
if (acc.length === size) {
out.push([...acc]);
return;
}
for (let i = start; i < xs.length; i++) pick(i + 1, size, [...acc, xs[i]]);
};
for (let size = 1; size <= xs.length; size++) pick(0, size, []);
return out;
}
// ------------------------------------------------------------ dependencias
export function fd(text) {
const [lhs, rhs] = text.split(/->|→/);
if (rhs === undefined) throw new Error(`falta la flecha en "${text}"`);
return { lhs: attrs(lhs), rhs: attrs(rhs) };
}
export const fds = (text) =>
text.split(/[;\n]/).map((s) => s.trim()).filter(Boolean).map(fd);
const fmtSet = (xs) => (xs.every((x) => x.length === 1) ? xs.join('') : xs.join(', '));
export const formatSet = (xs) => (xs.length === 0 ? '∅' : fmtSet(xs));
export const formatFd = (f) => `${formatSet(f.lhs)} → ${formatSet(f.rhs)}`;
export const isTrivial = (f) => isSubset(f.rhs, f.lhs);
// Todos los atributos que aparecen en F.
export const attributesOf = (F) => sortUnique(F.flatMap((f) => [...f.lhs, ...f.rhs]));
// ------------------------------------------------------------ clausura
// Clausura de X bajo F, con la traza: qué dependencia agregó qué atributos.
// resultado := X
// repetir
// para cada W → Y en F
// si W ⊆ resultado, entonces resultado := resultado ∪ Y
// hasta que resultado no cambie
export function closureTrace(X, F) {
let result = attrs(X);
const steps = [];
let changed = true;
while (changed) {
changed = false;
for (const f of F) {
if (isSubset(f.lhs, result)) {
const added = minus(f.rhs, result);
if (added.length > 0) {
result = union(result, added);
steps.push({ fd: f, added, result });
changed = true;
}
}
}
}
return { closure: result, steps };
}
export const closure = (X, F) => closureTrace(X, F).closure;
// ¿F implica f? Es decir, ¿f está en F⁺?
export const implies = (F, f) => isSubset(f.rhs, closure(f.lhs, F));
// Dos conjuntos de dependencias son equivalentes si cada uno implica al otro.
export const equivalent = (F, G) => G.every((g) => implies(F, g)) && F.every((f) => implies(G, f));
export const isSuperkey = (X, R, F) => isSubset(attrs(R), closure(X, F));
// Claves candidatas: superclaves mínimas. Se prueban los subconjuntos de R de
// menor a mayor y se descartan los que contienen una clave ya encontrada.
export function candidateKeys(R, F) {
const keys = [];
for (const X of subsets(attrs(R))) {
if (keys.some((k) => isSubset(k, X))) continue;
if (isSuperkey(X, R, F)) keys.push(X);
}
return keys;
}
// ------------------------------------------------------------ BCNF
// Busca una dependencia de F⁺ que viole BCNF en Ri, un subesquema de R.
// Ojo: en un subesquema no basta con mirar las dependencias de F cuyos
// atributos caben en Ri; hay que mirar F⁺. Para cada α ⊆ Ri se calcula α⁺:
// si α⁺ ∩ Ri agrega algo a α pero no cubre Ri, entonces
// α → (α⁺ ∩ Ri) − α se cumple en Ri y α no es superclave de Ri.
// Primero se prueban los lados izquierdos de F (en su orden), así la
// descomposición sigue las dependencias que uno escribe; después, todos los
// subconjuntos de Ri.
export function bcnfViolation(Ri, F) {
const R = attrs(Ri);
const candidates = [
...F.map((f) => f.lhs).filter((lhs) => isSubset(lhs, R)),
...subsets(R),
];
for (const alpha of candidates) {
const c = intersect(closure(alpha, F), R);
const beta = minus(c, alpha);
if (beta.length > 0 && !isSubset(R, c)) return { lhs: alpha, rhs: beta };
}
return null;
}
export const isBCNF = (Ri, F) => bcnfViolation(Ri, F) === null;
// Algoritmo de descomposición en BCNF (Silberschatz, sec. 7.5.1):
// resultado := {R}
// mientras algún Ri de resultado no esté en BCNF
// sea α → β una dependencia no trivial que se cumple en Ri,
// con α ∩ β = ∅ y α no superclave de Ri
// resultado := (resultado − Ri) ∪ {Ri − β, α ∪ β}
// Aquí β = (α⁺ ∩ Ri) − α, lo más grande posible, para ahorrar pasos.
export function bcnfDecompose(R, F) {
let result = [attrs(R)];
const steps = [];
for (;;) {
const i = result.findIndex((Ri) => !isBCNF(Ri, F));
if (i === -1) break;
const Ri = result[i];
const v = bcnfViolation(Ri, F);
const part = union(v.lhs, v.rhs);
const rest = minus(Ri, v.rhs);
result = [...result.slice(0, i), part, rest, ...result.slice(i + 1)];
steps.push({ schema: Ri, violation: v, part, rest });
}
return { schemas: result, steps };
}
// ------------------------------------------------------------ cubierta canónica
// Regla de unión: junta las dependencias con el mismo lado izquierdo.
function unionRule(F) {
const out = [];
for (const f of F) {
const same = out.find((g) => sameSet(g.lhs, f.lhs));
if (same) same.rhs = union(same.rhs, f.rhs);
else out.push({ lhs: [...f.lhs], rhs: [...f.rhs] });
}
return out;
}
// Busca un atributo extraño en F, en este orden: para cada dependencia (en el
// orden de F), primero el lado izquierdo y después el derecho.
// A ∈ α es extraño en α → β si β ⊆ (α − A)⁺ calculada con F.
// A ∈ β es extraño en α → β si A ∈ α⁺ calculada con
// F' = (F − {α → β}) ∪ {α → (β − A)}.
export function findExtraneous(F) {
for (let i = 0; i < F.length; i++) {
const f = F[i];
if (f.lhs.length > 1) {
for (const A of f.lhs) {
const reduced = minus(f.lhs, [A]);
if (isSubset(f.rhs, closure(reduced, F))) {
return { index: i, side: 'lhs', attr: A, fd: f, test: reduced };
}
}
}
for (const A of f.rhs) {
const Fp = F.map((g, j) => (j === i ? { lhs: g.lhs, rhs: minus(g.rhs, [A]) } : g));
if (closure(f.lhs, Fp).includes(A)) {
return { index: i, side: 'rhs', attr: A, fd: f, test: f.lhs };
}
}
}
return null;
}
// Cubierta canónica (Silberschatz, sec. 7.4.3):
// Fc := F
// repetir
// aplicar la regla de unión a Fc
// buscar una dependencia con un atributo extraño y quitárselo
// hasta que Fc no cambie
// Una dependencia que se queda sin lado derecho se borra.
export function canonicalCover(F) {
let Fc = unionRule(F);
const steps = [];
for (;;) {
const e = findExtraneous(Fc);
if (e === null) break;
const before = Fc[e.index];
const after =
e.side === 'lhs'
? { lhs: minus(before.lhs, [e.attr]), rhs: before.rhs }
: { lhs: before.lhs, rhs: minus(before.rhs, [e.attr]) };
Fc = Fc.map((g, j) => (j === e.index ? after : g)).filter((g) => g.rhs.length > 0);
Fc = unionRule(Fc);
steps.push({ ...e, before, after, cover: Fc });
}
return { cover: Fc, steps };
}
// ------------------------------------------------------------ descomposiciones
// Prueba de descomposición sin pérdida para dos esquemas: R1 ∩ R2 → R1 o
// R1 ∩ R2 → R2 está en F⁺. Si las únicas restricciones son dependencias
// funcionales, la condición también es necesaria.
export function losslessBinary(R1, R2, F) {
const common = intersect(attrs(R1), attrs(R2));
const c = closure(common, F);
return isSubset(attrs(R1), c) || isSubset(attrs(R2), c);
}
// Preservación de dependencias (Silberschatz, sec. 7.4.4). Para cada α → β de
// F se calcula, sin construir F⁺, lo que α determina usando solo lo que se
// puede verificar dentro de cada Ri:
// resultado := α
// repetir
// para cada Ri: resultado := resultado ∪ ((resultado ∩ Ri)⁺ ∩ Ri)
// hasta que resultado no cambie
// α → β se preserva si β ⊆ resultado. Devuelve las que no se preservan.
export function lostDependencies(schemas, F) {
const lost = [];
for (const f of F) {
let result = [...f.lhs];
let changed = true;
while (changed) {
changed = false;
for (const Ri of schemas) {
const t = intersect(closure(intersect(result, Ri), F), Ri);
const next = union(result, t);
if (next.length > result.length) {
result = next;
changed = true;
}
}
}
if (!isSubset(f.rhs, result)) lost.push(f);
}
return lost;
}
export const preservesDependencies = (schemas, F) => lostDependencies(schemas, F).length === 0;
// ------------------------------------------------------------ 3NF
// 3NF para el esquema R completo, con F definido sobre R. Basta con revisar
// las dependencias de F (no hace falta F⁺): para cada α → β de F y cada
// atributo A de β − α, α es superclave o A está en alguna clave candidata.
// Devuelve la primera violación { lhs, attr } o null.
export function threeNFViolation(R, F) {
const keyAttrs = candidateKeys(R, F).flat();
for (const f of F) {
if (isSuperkey(f.lhs, R, F)) continue;
for (const A of minus(f.rhs, f.lhs)) {
if (!keyAttrs.includes(A)) return { lhs: f.lhs, attr: A };
}
}
return null;
}
export const is3NF = (R, F) => threeNFViolation(R, F) === null;
// Algoritmo de síntesis en 3NF (Silberschatz, sec. 7.5.2):
// Fc := cubierta canónica de F
// para cada α → β de Fc: agregar el esquema α ∪ β
// si ningún esquema contiene una clave candidata de R:
// agregar un esquema con una clave candidata cualquiera de R
// quitar los esquemas contenidos en otro
export function synthesize3NF(R, F) {
const { cover } = canonicalCover(F);
const schemas = [];
for (const f of cover) {
const s = union(f.lhs, f.rhs);
if (!schemas.some((t) => sameSet(t, s))) schemas.push(s);
}
const keys = candidateKeys(R, F);
let addedKey = null;
if (!schemas.some((s) => keys.some((k) => isSubset(k, s)))) {
addedKey = keys[0];
schemas.push(addedKey);
}
const removed = [];
const result = schemas.filter((s, i) => {
const inside = schemas.some((t, j) => j !== i && isSubset(s, t) && !(sameSet(s, t) && j > i));
if (inside) removed.push(s);
return !inside;
});
return { cover, schemas: result, addedKey, removed };
}// Lee un archivo .sql con consultas marcadas "-- name: nombre" y devuelve un
// objeto { nombre: texto }. Así las pruebas corren el mismo texto que el
// archivo que se muestra en clase.
import { readFileSync } from 'node:fs';
export function namedQueries(file) {
const text = readFileSync(new URL(file, import.meta.url), 'utf8');
const parts = text.split(/^-- name: (\w+)\n/m);
const queries = {};
for (let i = 1; i < parts.length; i += 2) {
queries[parts[i]] = parts[i + 1].trim();
}
return queries;
}-- La hoja de viajes de Chasqui como la llevaría una hoja de cálculo: una fila
-- por viaje, con los datos de la ruta, de las agencias y del vehículo
-- copiados en cada fila. Es el ejemplo de mal diseño del capítulo.
-- make la crea en chasqui_w07; también se carga con \i trip_sheet.sql.
SET client_min_messages = warning;
DROP TABLE IF EXISTS trip_sheet;
CREATE TABLE trip_sheet AS
SELECT t.trip_id,
t.departure_at,
r.route_id,
o.name AS origin_agency,
o.city AS origin_city,
d.name AS dest_agency,
d.city AS dest_city,
r.distance_km,
r.base_fare,
v.plate,
v.kind,
v.capacity_kg
FROM trip t
JOIN route r ON r.route_id = t.route_id
JOIN agency o ON o.agency_id = r.origin_agency_id
JOIN agency d ON d.agency_id = r.dest_agency_id
JOIN vehicle v ON v.vehicle_id = t.vehicle_id;
-- En la hoja, cada fila es un viaje: trip_id es la clave.
ALTER TABLE trip_sheet ADD PRIMARY KEY (trip_id);Pruebas
Comprueban lo que afirman los apuntes; se ejecutan con make test.
// Pruebas de fd.mjs: cada ejemplo resuelto de los apuntes, con el resultado
// que aparece en el texto. No usan la base de datos.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
attrs, fd, fds, closure, closureTrace, implies, equivalent, isSuperkey, candidateKeys,
isBCNF, bcnfViolation, bcnfDecompose, canonicalCover, losslessBinary, lostDependencies,
preservesDependencies, is3NF, threeNFViolation, synthesize3NF, formatFd, isTrivial,
} from './fd.mjs';
import { BOOK, COVER, CYCLE, SUB, PRESERVE, TRIP, TRIP_LOOSE, ATTENTION, BANKER } from './examples.mjs';
const fmtAll = (F) => F.map(formatFd);
test('attrs y fd arman conjuntos ordenados', () => {
assert.deepEqual(attrs('CAB'), ['A', 'B', 'C']);
assert.deepEqual(attrs('trip_id, plate'), ['plate', 'trip_id']);
assert.deepEqual(attrs('route_id'), ['route_id']);
assert.deepEqual(fd('CG -> H'), { lhs: ['C', 'G'], rhs: ['H'] });
assert.deepEqual(fd('A B → C'), { lhs: ['A', 'B'], rhs: ['C'] });
assert.equal(isTrivial(fd('AB -> A')), true);
assert.equal(isTrivial(fd('AB -> AC')), false);
});
// ------------------------------------------------------------ clausura
test('libro: {AG}⁺ = ABCGHI, paso a paso', () => {
const { closure: c, steps } = closureTrace('AG', BOOK.F);
assert.deepEqual(c, attrs('ABCGHI'));
assert.deepEqual(steps.map((s) => formatFd(s.fd)), ['A → B', 'A → C', 'CG → H', 'CG → I']);
});
test('libro: AG es la única clave candidata; A y G solos no son superclave', () => {
assert.deepEqual(closure('A', BOOK.F), attrs('ABCH'));
assert.deepEqual(closure('G', BOOK.F), attrs('G'));
assert.equal(isSuperkey(attrs('AG'), BOOK.R, BOOK.F), true);
assert.equal(isSuperkey(attrs('A'), BOOK.R, BOOK.F), false);
assert.deepEqual(candidateKeys(BOOK.R, BOOK.F), [attrs('AG')]);
});
test('libro: dependencias de F⁺ con la clausura (A → H, CG → HI, AG → I)', () => {
assert.equal(implies(BOOK.F, fd('A -> H')), true);
assert.equal(implies(BOOK.F, fd('CG -> HI')), true);
assert.equal(implies(BOOK.F, fd('AG -> I')), true);
assert.equal(implies(BOOK.F, fd('B -> C')), false);
});
test('reglas derivadas: unión, descomposición, pseudotransitividad', () => {
assert.equal(implies(fds('A -> B; A -> C'), fd('A -> BC')), true);
assert.equal(implies(fds('A -> BC'), fd('A -> B')), true);
assert.equal(implies(fds('A -> B; CB -> D'), fd('AC -> D')), true);
// La "regla" de dividir el lado izquierdo es falsa.
assert.equal(implies(fds('AB -> C'), fd('A -> C')), false);
});
test('trip_sheet: {trip_id}⁺ es todo; {plate}⁺ y {origin_agency, dest_agency}⁺ no', () => {
assert.deepEqual(closure('trip_id', TRIP.F), TRIP.R);
assert.deepEqual(closure('plate', TRIP.F), attrs('plate kind capacity_kg'));
assert.deepEqual(
closure('origin_agency dest_agency', TRIP.F),
attrs('origin_agency dest_agency route_id origin_city dest_city distance_km base_fare'),
);
assert.deepEqual(candidateKeys(TRIP.R, TRIP.F), [['trip_id']]);
});
// ------------------------------------------------------------ BCNF
test('trip_sheet no está en BCNF: route_id no es superclave', () => {
const v = bcnfViolation(TRIP.R, TRIP.F);
assert.equal(formatFd(v), 'route_id → base_fare, dest_agency, dest_city, distance_km, origin_agency, origin_city');
});
test('trip_sheet: descomposición en BCNF, cuatro pasos sin pérdida', () => {
const { schemas, steps } = bcnfDecompose(TRIP.R, TRIP.F);
assert.deepEqual(steps.map((s) => formatFd(s.violation)), [
'route_id → base_fare, dest_agency, dest_city, distance_km, origin_agency, origin_city',
'origin_agency → origin_city',
'dest_agency → dest_city',
'plate → capacity_kg, kind',
]);
assert.deepEqual(schemas, [
attrs('origin_agency origin_city'),
attrs('dest_agency dest_city'),
attrs('route_id origin_agency dest_agency distance_km base_fare'),
attrs('plate kind capacity_kg'),
attrs('trip_id departure_at route_id plate'),
]);
for (const s of steps) assert.equal(losslessBinary(s.part, s.rest, TRIP.F), true);
for (const s of schemas) assert.equal(isBCNF(s, TRIP.F), true);
assert.equal(preservesDependencies(schemas, TRIP.F), true);
});
test('la tabla de rutas tiene dos claves candidatas y está en BCNF', () => {
const route = attrs('route_id origin_agency dest_agency distance_km base_fare');
// candidateKeys sirve para un subesquema: X ⊆ route es superclave si X⁺ ⊇ route.
assert.deepEqual(candidateKeys(route, TRIP.F), [
['route_id'],
attrs('origin_agency dest_agency'),
]);
assert.equal(isBCNF(route, TRIP.F), true);
});
test('subesquema: (A, C, D, E) viola BCNF por AC → D, que no está en F', () => {
assert.equal(SUB.F.some((f) => f.lhs.every((a) => SUB.R2.includes(a)) && f.rhs.some((a) => SUB.R2.includes(a))), false);
assert.equal(isBCNF(SUB.R2, SUB.F), false);
assert.equal(formatFd(bcnfViolation(SUB.R2, SUB.F)), 'AC → D');
assert.equal(isBCNF(SUB.R1, SUB.F), true);
});
// ------------------------------------------------------------ cubierta canónica
test('libro: la cubierta canónica de {A → BC, B → C, A → B, AB → C} es {A → B, B → C}', () => {
const { cover, steps } = canonicalCover(COVER.F);
assert.deepEqual(fmtAll(cover), ['A → B', 'B → C']);
assert.deepEqual(steps.map((s) => [s.attr, s.side, formatFd(s.before)]), [
['C', 'rhs', 'A → BC'],
['A', 'lhs', 'AB → C'],
]);
assert.equal(equivalent(cover, COVER.F), true);
});
test('la cubierta canónica no es única', () => {
const { cover } = canonicalCover(CYCLE.F);
assert.deepEqual(fmtAll(cover), ['A → C', 'B → C', 'C → AB']);
// Las dos del libro también son cubiertas canónicas, equivalentes a F.
for (const other of [fds('A -> B; B -> C; C -> A'), fds('A -> C; C -> B; B -> A')]) {
assert.equal(equivalent(other, CYCLE.F), true);
assert.equal(canonicalCover(other).steps.length, 0);
}
});
test('trip_sheet: con F escrito de más, la cubierta canónica vuelve a las seis dependencias', () => {
const { cover, steps } = canonicalCover(TRIP_LOOSE.F);
assert.deepEqual(steps.map((s) => [s.attr, s.side]), [
['origin_city', 'rhs'],
['plate', 'lhs'],
['capacity_kg', 'rhs'],
]);
assert.deepEqual(fmtAll(cover), fmtAll(TRIP.F));
});
// ------------------------------------------------------------ descomposiciones
test('sin pérdida: separar por origin_city no sirve; por origin_agency, sí', () => {
assert.equal(losslessBinary(attrs('trip_id origin_city'), attrs('origin_city origin_agency'), TRIP.F), false);
assert.equal(losslessBinary(attrs('trip_id origin_agency'), attrs('origin_agency origin_city'), TRIP.F), true);
assert.deepEqual(closure('origin_city', TRIP.F), ['origin_city']);
});
test('preservación: A → C se preserva sin caber en ninguna tabla', () => {
assert.deepEqual(lostDependencies(PRESERVE.schemas, PRESERVE.F), []);
assert.deepEqual(fmtAll(lostDependencies([attrs('AB'), attrs('AC')], PRESERVE.F)), ['B → C']);
});
// ------------------------------------------------------------ attention, 3NF
test('attention: dos claves candidatas, no está en BCNF, sí en 3NF', () => {
assert.deepEqual(candidateKeys(ATTENTION.R, ATTENTION.F), [
attrs('customer_id agency_id'),
attrs('customer_id executive_id'),
]);
assert.equal(formatFd(bcnfViolation(ATTENTION.R, ATTENTION.F)), 'executive_id → agency_id');
assert.equal(threeNFViolation(ATTENTION.R, ATTENTION.F), null);
assert.equal(is3NF(ATTENTION.R, ATTENTION.F), true);
});
test('attention: la descomposición en BCNF es sin pérdida pero pierde una dependencia', () => {
const { schemas } = bcnfDecompose(ATTENTION.R, ATTENTION.F);
assert.deepEqual(schemas, [attrs('executive_id agency_id'), attrs('customer_id executive_id')]);
assert.equal(losslessBinary(schemas[0], schemas[1], ATTENTION.F), true);
assert.deepEqual(fmtAll(lostDependencies(schemas, ATTENTION.F)), ['agency_id, customer_id → executive_id']);
});
test('attention: la síntesis en 3NF deja la tabla como está', () => {
const r = synthesize3NF(ATTENTION.R, ATTENTION.F);
assert.deepEqual(r.schemas, [ATTENTION.R]);
assert.deepEqual(r.removed, [attrs('executive_id agency_id')]);
});
test('trip_sheet no está en 3NF; la síntesis da las mismas cinco tablas que BCNF', () => {
assert.deepEqual(threeNFViolation(TRIP.R, TRIP.F), { lhs: ['route_id'], attr: 'base_fare' });
const r = synthesize3NF(TRIP.R, TRIP.F);
assert.equal(r.addedKey, null);
assert.deepEqual(r.removed, [attrs('origin_agency dest_agency route_id')]);
const bcnf = bcnfDecompose(TRIP.R, TRIP.F).schemas;
assert.equal(r.schemas.length, 5);
for (const s of r.schemas) assert.ok(bcnf.some((t) => t.join() === s.join()));
});
test('libro: síntesis en 3NF de cust_banker_branch', () => {
assert.deepEqual(candidateKeys(BANKER.R, BANKER.F), [
attrs('customer_id branch_name'),
attrs('customer_id employee_id'),
]);
const r = synthesize3NF(BANKER.R, BANKER.F);
assert.deepEqual(fmtAll(r.cover), [
'customer_id, employee_id → type',
'employee_id → branch_name',
'branch_name, customer_id → employee_id',
]);
assert.deepEqual(r.schemas, [
attrs('customer_id employee_id type'),
attrs('customer_id branch_name employee_id'),
]);
assert.equal(preservesDependencies(r.schemas, BANKER.F), true);
assert.equal(losslessBinary(r.schemas[0], r.schemas[1], BANKER.F), true);
assert.equal(isBCNF(r.schemas[1], BANKER.F), false);
});
test('síntesis en 3NF: si ninguna tabla tiene una clave, se agrega una', () => {
// R(A, B, C) con F = {A → B}: la clave es AC y ninguna tabla la contiene.
const r = synthesize3NF(attrs('ABC'), fds('A -> B'));
assert.deepEqual(r.addedKey, attrs('AC'));
assert.deepEqual(r.schemas, [attrs('AB'), attrs('AC')]);
});// Pruebas de los ejemplos en SQL del capítulo: corren el texto de
// examples.sql, decompose.sql y attention.sql y comparan con los resultados
// que aparecen en los apuntes. Base de datos chasqui_w07 (make test la crea).
import { test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import pg from 'pg';
import { namedQueries } from './sqlfile.mjs';
const ex = namedQueries('./examples.sql');
const dec = namedQueries('./decompose.sql');
const att = namedQueries('./attention.sql');
let pool;
before(() => {
pool = new pg.Pool();
});
after(() => pool.end());
const all = async (text) => (await pool.query(text)).rows;
// Un texto con varias sentencias devuelve un resultado por sentencia; nos
// interesa el del SELECT.
const selectRows = (results) => results.find((r) => r.command === 'SELECT').rows;
test('la base de datos es chasqui_w07 y trip_sheet tiene un viaje por fila', async () => {
const [db] = await all('SELECT current_database() AS name');
assert.equal(db.name, 'chasqui_w07');
assert.deepEqual(await all(ex.sheet_repeats), [{ filas: '5914', rutas: '120', vehiculos: '24' }]);
const rows = await all(ex.sheet_rows);
assert.deepEqual(rows.map((r) => r.trip_id), [47, 126, 153, 206]);
assert.ok(rows.every((r) => r.kind === 'van' && r.capacity_kg === 1400));
});
// ------------------------------------------------------------ anomalías
test('anomalía de actualización: la placa queda con dos capacidades', async () => {
const client = await pool.connect();
try {
const rows = selectRows(await client.query(ex.update_anomaly));
assert.deepEqual(rows, [{ plate: 'K2U-314', capacidades: [1400, 1500] }]);
} finally {
client.release();
}
assert.deepEqual(await all(ex.fd_plate), []);
});
test('anomalía de inserción: un vehículo sin viaje no entra', async () => {
await assert.rejects(all(ex.insert_anomaly), {
code: '23502',
message: 'null value in column "trip_id" of relation "trip_sheet" violates not-null constraint',
});
});
test('anomalía de borrado: sin sus viajes, la ruta 113 desaparece', async () => {
const client = await pool.connect();
try {
const results = await client.query(ex.delete_anomaly);
assert.equal(results.find((r) => r.command === 'DELETE').rowCount, 75);
assert.deepEqual(selectRows(results), [{ rutas: '119', viajes_113: '0' }]);
} finally {
client.release();
}
assert.deepEqual(await all(ex.sheet_repeats), [{ filas: '5914', rutas: '120', vehiculos: '24' }]);
});
// ------------------------------------------------------------ ¿se cumple X → Y?
test('plate → kind, capacity_kg y distance_km → base_fare: ningún contraejemplo', async () => {
assert.deepEqual(await all(ex.fd_plate), []);
assert.deepEqual(await all(ex.fd_distance_fare), []);
});
test('origin_city → origin_agency: Lima y Arequipa la contradicen', async () => {
assert.deepEqual(await all(ex.fd_city_agency), [
{ origin_city: 'Arequipa', agencias: '2' },
{ origin_city: 'Lima', agencias: '3' },
]);
});
test('plate, departure_at → trip_id: 336 contraejemplos en los datos', async () => {
const rows = await all(ex.fd_plate_departure);
assert.deepEqual(rows.map((r) => [r.plate, r.viajes]), [
['P5Z-632', [1, 2]],
['R7D-433', [24, 26]],
['T8D-416', [37, 39]],
]);
assert.deepEqual(await all(ex.fd_plate_departure_count), [{ grupos: '336' }]);
});
test('count(DISTINCT ...) no ve el NULL; la versión con CASE sí', async () => {
assert.deepEqual(await all(ex.null_trap), []);
assert.deepEqual(await all(ex.null_safe), [{ plate: 'A1A-111' }]);
});
// ------------------------------------------------------------ descomposiciones
test('partir por origin_city agrega 5110 filas falsas y no pierde ninguna', async () => {
assert.deepEqual(await all(ex.lossy), [
{ original: '5914', reconstruida: '11024', de_mas: '5110', faltan: '0' },
]);
const rows = await all(ex.lossy_trip);
assert.deepEqual(rows.map((r) => r.origin_agency), ['Lima - Ate', 'Lima - La Victoria', 'Lima - Los Olivos']);
});
test('partir por origin_agency es sin pérdida', async () => {
assert.deepEqual(await all(ex.lossless), [
{ original: '5914', reconstruida: '5914', de_mas: '0', faltan: '0' },
]);
});
test('la descomposición en BCNF de trip_sheet: claves y join sin diferencias', async () => {
// Las tablas temporales son de una sesión: todo con el mismo client.
const client = await pool.connect();
try {
await client.query(dec.create);
await client.query(dec.keys);
const sizes = (await client.query(dec.sizes)).rows;
assert.deepEqual(sizes.map((r) => [r.tabla, r.filas]), [
['s_origin', '28'], ['s_dest', '28'], ['s_route', '120'], ['s_vehicle', '24'], ['s_trip', '5914'],
]);
assert.deepEqual((await client.query(dec.rebuild)).rows, [{ reconstruida: '5914', de_mas: '0', faltan: '0' }]);
} finally {
await client.query('DISCARD TEMP');
client.release();
}
});
test('Iquitos - Centro no aparece como origen: ningún viaje sale de ahí', async () => {
const rows = await all(`SELECT name FROM agency
WHERE name NOT IN (SELECT origin_agency FROM trip_sheet)`);
assert.deepEqual(rows, [{ name: 'Iquitos - Centro' }]);
});
// ------------------------------------------------------------ attention
test('attention: BCNF pierde la dependencia; 3NF la hace cumplir', async () => {
const client = await pool.connect();
const run = (name) => client.query(att[name]);
try {
await run('begin');
await run('bcnf_tables');
await run('bcnf_insert');
assert.deepEqual((await run('bcnf_lost')).rows, [{ customer_id: 1, agency_id: 6, ejecutivos: [101, 102] }]);
await run('three_nf_tables');
await run('savepoint');
await assert.rejects(run('three_nf_dup'), { code: '23505', constraint: 'attention_pk' });
await run('back');
await assert.rejects(run('three_nf_wrong_agency'), { code: '23503', constraint: 'attention_executive_fk' });
await run('back2');
} finally {
await run('rollback');
client.release();
}
const [t] = await all("SELECT to_regclass('attention') AS t");
assert.equal(t.t, null);
});
// ------------------------------------------------------------ 4NF
test('teléfonos por servicios: 2 × 3 = 6 filas', async () => {
const rows = await all(ex.mvd);
assert.equal(rows.length, 6);
assert.deepEqual(rows[0], { agency_id: 6, phone: '984111222', service: 'embalaje' });
});Compilación
Cómo se compilan y ejecutan los ejemplos.
# Semana 7: dependencias funcionales y normalización. Se usa dentro del
# contenedor del curso.
#
# make crea la base de datos chasqui_w07 con los datos de ejemplo
# y le agrega la hoja de viajes (trip_sheet.sql)
# make test la vuelve a crear y corre las pruebas (*.test.mjs)
# make clean borra chasqui_w07
DB = chasqui_w07
PSQL = psql -X -q -v ON_ERROR_STOP=1 -d $(DB)
.PHONY: all test clean
all:
chasqui-load $(DB)
$(PSQL) -f trip_sheet.sql
# Algunas pruebas crean tablas: los archivos se corren uno después del otro.
test: all
PGDATABASE=$(DB) node --test --test-concurrency=1 --test-reporter=spec
clean:
dropdb --if-exists $(DB)