42. Aplicaciones en bases de datos

Las bases de datos relacionales están construidas sobre relaciones discretas. Cada tabla es una relación, cada clave foránea conecta dos relaciones, cada índice es una función de mapeo. Entender las relaciones subyacentes es clave para diseñar esquemas correctos y eficientes.

42.1 Introducción

Una base de datos relacional es un conjunto de relaciones (tablas) conectadas mediante funciones y relaciones discretas. Cada fila es una tupla, cada columna es un atributo, y las claves primarias y foráneas son las funciones que conectan las relaciones.

El término "base de datos relacional" viene precisamente de esto: está fundamentada en la teoría matemática de las relaciones.

En este tema veremos cómo las relaciones discretas se materializan en diseño de esquemas, normalización, consultas (SQL) y optimización.

42.2 Tablas como Relaciones

Una tabla es una relación matemática donde cada fila es una tupla y cada columna es un atributo. Por ejemplo, una tabla Empleados con columnas {id, nombre, departamento_id, salario} es una relación binaria sobre el conjunto de tuplas de 4 elementos.

Definición formal: Si la tabla Empleados tiene n filas, la relación es un conjunto de n tuplas, cada una de 4 elementos. La relación Empleados ⊆ ℤ × String × ℤ × ℝ.
id nombre departamento_id salario
1 Ana García 10 50000
2 Luis Pérez 20 60000
3 Marta López 10 55000
4 Carlos Ruiz 30 45000

Tabla Empleados (como relación): {(1, "Ana García", 10, 50000), (2, "Luis Pérez", 20, 60000), (3, "Marta López", 10, 55000), (4, "Carlos Ruiz", 30, 45000)}

Cada operación SQL se traduce en operaciones sobre relaciones:

// Tabla como array de objetos (cada objeto es una tupla)
const empleados = [
  { id: 1, nombre: "Ana García", departamento_id: 10, salario: 50000 },
  { id: 2, nombre: "Luis Pérez", departamento_id: 20, salario: 60000 },
  { id: 3, nombre: "Marta López", departamento_id: 10, salario: 55000 },
  { id: 4, nombre: "Carlos Ruiz", departamento_id: 30, salario: 45000 }
];

// SELECT nombre, salario FROM empleados WHERE departamento_id = 10
function selectEmpleados(tabla, predicado, columnas) {
  return tabla
    .filter(predicado)
    .map(fila => {
      const resultado = {};
      columnas.forEach(col => resultado[col] = fila[col]);
      return resultado;
    });
}

const resultado = selectEmpleados(
  empleados,
  e => e.departamento_id === 10,
  ["nombre", "salario"]
);

console.log(resultado);
// [
//   { nombre: "Ana García", salario: 50000 },
//   { nombre: "Marta López", salario: 55000 }
// ]

42.3 Claves Primarias y Funciones

Una clave primaria es un atributo (o conjunto de atributos) que identifica unívocamente cada tupla. Matemáticamente, define una función inyectiva desde las tuplas a los valores de la clave.

Concepto: Si id es clave primaria en la tabla Empleados, entonces la función f(tupla) = tupla.id es inyectiva: cada tupla mapea a un id único, y no hay dos tuplas con el mismo id.
// Validar que una columna es clave primaria (inyectividad)
function esClaveP rimaria(tabla, columna) {
  const valores = tabla.map(fila => fila[columna]);
  const valoresUnicos = new Set(valores);
  return valores.length === valoresUnicos.size;
}

console.log(esClaveP rimaria(empleados, "id"));     // true
console.log(esClaveP rimaria(empleados, "nombre")); // true (si nombres son únicos)
console.log(esClaveP rimaria(empleados, "departamento_id")); // false

// Tabla con clave primaria compuesta
const asignaciones = [
  { empleado_id: 1, proyecto_id: 100 },
  { empleado_id: 1, proyecto_id: 101 },
  { empleado_id: 2, proyecto_id: 100 },
  { empleado_id: 2, proyecto_id: 102 }
];

// Clave primaria compuesta: (empleado_id, proyecto_id)
function esClavePrimariaCompuesta(tabla, columnas) {
  const claves = tabla.map(fila => 
    columnas.map(col => fila[col]).join("|")
  );
  const clavesUnicas = new Set(claves);
  return claves.length === clavesUnicas.size;
}

console.log(esClavePrimariaCompuesta(asignaciones, ["empleado_id", "proyecto_id"]));
// true

42.4 Claves Foráneas y Relaciones entre Tablas

Una clave foránea es una función que relaciona tuplas de una tabla con tuplas de otra. Si la tabla Empleados tiene una columna departamento_id, esa columna es una clave foránea que mapea cada empleado a un departamento.

Concepto formal: La clave foránea FK: Empleados → Departamentos es una función donde FK(empleado) = empleado.departamento_id, que debe existir como clave primaria en la tabla Departamentos.

Tabla Departamentos:

id nombre
10 Ingeniería
20 Ventas
30 Administración

Tabla Empleados (con Claves Foráneas):

id nombre departamento_id FK salario
1 Ana García 10 50000
2 Luis Pérez 20 60000
const departamentos = [
  { id: 10, nombre: "Ingeniería" },
  { id: 20, nombre: "Ventas" },
  { id: 30, nombre: "Administración" }
];

const empleados = [
  { id: 1, nombre: "Ana García", departamento_id: 10, salario: 50000 },
  { id: 2, nombre: "Luis Pérez", departamento_id: 20, salario: 60000 },
  { id: 3, nombre: "Marta López", departamento_id: 10, salario: 55000 }
];

// Validar integridad referencial
function validarIntegridadReferencial(tablaPrincipal, columnaFK, 
                                       tableFK, columnaPK) {
  for (const fila of tablaPrincipal) {
    const valor_fk = fila[columnaFK];
    if (!tableFK.find(f => f[columnaPK] === valor_fk)) {
      return false; // FK invalida
    }
  }
  return true;
}

console.log(validarIntegridadReferencial(
  empleados, "departamento_id",
  departamentos, "id"
)); // true

// JOIN: combinar empleados con sus departamentos
function join(tablaIzquierda, tablaD erecha, 
              columnasIzquierda, columnasD erecha,
              condición) {
  const resultado = [];
  for (const filaIzq of tablaIzquierda) {
    for (const filaDer of tablaD erecha) {
      if (condición(filaIzq, filaDer)) {
        resultado.push({
          ...filaIzq,
          ...Object.fromEntries(
            columnasD erecha.map(col => 
              [col, filaDer[col]]
            )
          )
        });
      }
    }
  }
  return resultado;
}

const empleadosConDepartamentos = join(
  empleados,
  departamentos,
  ["id", "nombre", "salario"],
  ["nombre"],
  (e, d) => e.departamento_id === d.id
);

console.log(empleadosConDepartamentos);
// [
//   { id: 1, nombre: "Ana García", salario: 50000, nombre: "Ingeniería" },
//   { id: 2, nombre: "Luis Pérez", salario: 60000, nombre: "Ventas" },
//   { id: 3, nombre: "Marta López", salario: 55000, nombre: "Ingeniería" }
// ]

42.5 Normalización y Relaciones de Dependencia

La normalización es el proceso de diseñar un esquema de base de datos sin redundancias. Se basa en identificar dependencias funcionales: dado un atributo, ¿qué otros atributos se determinan unívocamente?

Dependencia funcional: En la tabla Empleados, id → nombre significa que el id determina unívocamente el nombre. No puede haber dos empleados con el mismo id y diferente nombre.

Las formas normales (1FN, 2FN, 3FN, BCNF) eliminan redundancias identificando estas dependencias:

❌ Mal diseño (sin normalizar):

empleado_id nombre departamento jefe
1 Ana Ingeniería Carlos
2 Luis Ingeniería Carlos
3 Marta Ventas María

Problema: "Ingeniería" y "Carlos" se repiten. La dependencia funcional departamento → jefe no es sobre la clave primaria, indicando falta de normalización.

✅ Diseño normalizado:

Tabla Departamentos:

id nombre jefe_id
10 Ingeniería 4
20 Ventas 5

Tabla Empleados:

id nombre departamento_id
1 Ana García 10
2 Luis Pérez 10
3 Marta López 20
// Detectar dependencias funcionales
function dependenciaFuncional(tabla, atributoX, atributoY) {
  const mapeo = {};
  for (const fila of tabla) {
    const valorX = fila[atributoX];
    const valorY = fila[atributoY];
    
    if (mapeo[valorX] !== undefined && mapeo[valorX] !== valorY) {
      return false; // No es dependencia funcional
    }
    mapeo[valorX] = valorY;
  }
  return true;
}

const datosNoNormalizados = [
  { id: 1, nombre: "Ana", depto: "Ing", jefe: "Carlos" },
  { id: 2, nombre: "Luis", depto: "Ing", jefe: "Carlos" },
  { id: 3, nombre: "Marta", depto: "Ventas", jefe: "María" }
];

console.log(dependenciaFuncional(datosNoNormalizados, "id", "nombre"));
// true (id → nombre)

console.log(dependenciaFuncional(datosNoNormalizados, "depto", "jefe"));
// true (depto → jefe) - dependencia no primaria, indica falta de normalización

// En datos normalizados:
const departamentos = [
  { id: 10, nombre: "Ingeniería", jefe_id: 4 },
  { id: 20, nombre: "Ventas", jefe_id: 5 }
];

console.log(dependenciaFuncional(departamentos, "id", "nombre"));
// true (id → nombre en tabla Departamentos)

console.log(dependenciaFuncional(departamentos, "nombre", "jefe_id"));
// true (nombre → jefe_id, pero name es ahora clave primaria)

42.6 Índices como Funciones Invertidas

Un índice es una estructura que invierte una función para acelerar búsquedas. En lugar de recorrer toda la tabla, un índice mapea valores de atributo a posiciones de fila.

Concepto: Si queremos buscar empleados por departamento_id frecuentemente, creamos un índice que mapea departamento_id a la lista de empleados de ese departamento. Sin índice, buscamos O(n). Con índice, buscamos O(1) en promedio.
// Tabla sin índice
const empleados = [
  { id: 1, nombre: "Ana García", departamento_id: 10 },
  { id: 2, nombre: "Luis Pérez", departamento_id: 20 },
  { id: 3, nombre: "Marta López", departamento_id: 10 },
  { id: 4, nombre: "Carlos Ruiz", departamento_id: 30 }
];

// Búsqueda sin índice: O(n)
function buscarSinIndice(tabla, departamento_id) {
  return tabla.filter(e => e.departamento_id === departamento_id);
}

// Crear índice en departamento_id
function crearIndice(tabla, atributo) {
  const indice = {};
  for (const fila of tabla) {
    const valor = fila[atributo];
    if (!indice[valor]) {
      indice[valor] = [];
    }
    indice[valor].push(fila);
  }
  return indice;
}

const indiceDeptos = crearIndice(empleados, "departamento_id");

console.log(indiceDeptos);
// {
//   "10": [
//     { id: 1, nombre: "Ana García", departamento_id: 10 },
//     { id: 3, nombre: "Marta López", departamento_id: 10 }
//   ],
//   "20": [ { id: 2, nombre: "Luis Pérez", departamento_id: 20 } ],
//   "30": [ { id: 4, nombre: "Carlos Ruiz", departamento_id: 30 } ]
// }

// Búsqueda con índice: O(1) promedio
function buscarConIndice(indice, departamento_id) {
  return indice[departamento_id] || [];
}

console.log(buscarConIndice(indiceDeptos, 10));
// Resultado instantáneo sin recorrer toda la tabla

// Índice compuesto (en dos atributos)
function crearIndiceCompuesto(tabla, atributos) {
  const indice = {};
  for (const fila of tabla) {
    const clave = atributos.map(a => fila[a]).join("|");
    if (!indice[clave]) {
      indice[clave] = [];
    }
    indice[clave].push(fila);
  }
  return indice;
}

const empleadosProyectos = [
  { empleado_id: 1, proyecto_id: 100, horas: 40 },
  { empleado_id: 1, proyecto_id: 101, horas: 20 },
  { empleado_id: 2, proyecto_id: 100, horas: 30 }
];

const indiceCompuesto = crearIndiceCompuesto(empleadosProyectos, 
  ["empleado_id", "proyecto_id"]);

console.log(indiceCompuesto["1|100"]);
// [{ empleado_id: 1, proyecto_id: 100, horas: 40 }]

42.7 Consultas SQL como Operaciones sobre Relaciones

SQL es un lenguaje declarativo para operar sobre relaciones. Las operaciones principales (SELECT, WHERE, JOIN, GROUP BY) tienen correspondencias directas en álgebra relacional:

Operación SQL Álgebra Relacional Descripción
SELECT columnas Proyección (π) Selecciona columnas específicas
WHERE condición Selección (σ) Filtra filas según un predicado
JOIN Producto cartesiano (×) + selección Combina tablas según una condición
GROUP BY Agrupación Agrupa filas por atributo
UNION, EXCEPT, INTERSECT Operaciones de conjuntos Unión, diferencia, intersección
// Motor relacional simple en JavaScript

class BDRelacional {
  constructor() {
    this.tablas = {};
  }

  crearTabla(nombre, datos) {
    this.tablas[nombre] = datos;
  }

  // SELECT col1, col2 FROM tabla
  select(tabla, columnas) {
    return this.tablas[tabla].map(fila => {
      const resultado = {};
      columnas.forEach(col => resultado[col] = fila[col]);
      return resultado;
    });
  }

  // SELECT * FROM tabla WHERE condición
  where(tabla, predicado) {
    return this.tablas[tabla].filter(predicado);
  }

  // SELECT * FROM t1 JOIN t2 ON condición
  join(tabla1, tabla2, condición) {
    const resultado = [];
    for (const f1 of this.tablas[tabla1]) {
      for (const f2 of this.tablas[tabla2]) {
        if (condición(f1, f2)) {
          resultado.push({ ...f1, ...f2 });
        }
      }
    }
    return resultado;
  }

  // SELECT atributo, COUNT(*) FROM tabla GROUP BY atributo
  groupBy(tabla, atributo) {
    const grupos = {};
    for (const fila of this.tablas[tabla]) {
      const valor = fila[atributo];
      if (!grupos[valor]) grupos[valor] = [];
      grupos[valor].push(fila);
    }
    return Object.entries(grupos).map(([valor, filas]) => ({
      [atributo]: valor,
      count: filas.length
    }));
  }
}

const bd = new BDRelacional();

bd.crearTabla("empleados", [
  { id: 1, nombre: "Ana", depto_id: 10 },
  { id: 2, nombre: "Luis", depto_id: 20 },
  { id: 3, nombre: "Marta", depto_id: 10 }
]);

bd.crearTabla("departamentos", [
  { id: 10, nombre: "Ing" },
  { id: 20, nombre: "Ventas" }
]);

// SELECT nombre FROM empleados WHERE depto_id = 10
console.log(bd.where("empleados", e => e.depto_id === 10).map(e => e.nombre));
// ["Ana", "Marta"]

// SELECT e.nombre, d.nombre FROM empleados e JOIN departamentos d 
// ON e.depto_id = d.id
console.log(bd.join("empleados", "departamentos", 
  (e, d) => e.depto_id === d.id));

// SELECT depto_id, COUNT(*) FROM empleados GROUP BY depto_id
console.log(bd.groupBy("empleados", "depto_id"));
// [{ depto_id: 10, count: 2 }, { depto_id: 20, count: 1 }]

42.8 Transacciones y Consistencia

Las transacciones garantizan que las operaciones sobre relaciones múltiples se completan atómicamente. Las propiedades ACID (Atomicity, Consistency, Isolation, Durability) aseguran que las relaciones entre tablas se mantienen en un estado válido.

Escenario: Transferir dinero entre cuentas requiere decrementar una cuenta e incrementar otra. Si algo falla a mitad del camino, los datos quedan inconsistentes. Las transacciones previenen esto.
// Transacción: transferencia de dinero
class CuentaBancaria {
  constructor() {
    this.cuentas = {
      "001": { saldo: 1000 },
      "002": { saldo: 500 }
    };
    this.enTransacción = false;
  }

  transferir(cuentaOrigen, cuentaDestino, monto) {
    if (this.enTransacción) {
      throw new Error("Ya hay una transacción en curso");
    }

    this.enTransacción = true;
    try {
      // Validaciones
      if (!this.cuentas[cuentaOrigen]) {
        throw new Error("Cuenta origen no existe");
      }
      if (!this.cuentas[cuentaDestino]) {
        throw new Error("Cuenta destino no existe");
      }
      if (this.cuentas[cuentaOrigen].saldo < monto) {
        throw new Error("Fondos insuficientes");
      }

      // Operación atómica
      this.cuentas[cuentaOrigen].saldo -= monto;
      this.cuentas[cuentaDestino].saldo += monto;

      return {
        éxito: true,
        saldoOrigen: this.cuentas[cuentaOrigen].saldo,
        saldoDestino: this.cuentas[cuentaDestino].saldo
      };
    } catch (error) {
      // Rollback en caso de error
      console.error("Transacción fallida:", error.message);
      return { éxito: false, error: error.message };
    } finally {
      this.enTransacción = false;
    }
  }

  obtenerSaldo(cuenta) {
    return this.cuentas[cuenta]?.saldo ?? null;
  }
}

const banco = new CuentaBancaria();
console.log(banco.transferir("001", "002", 200));
// { éxito: true, saldoOrigen: 800, saldoDestino: 700 }

console.log(banco.obtenerSaldo("001")); // 800
console.log(banco.obtenerSaldo("002")); // 700

42.9 Errores Comunes

  • Violar integridad referencial: Insertar un empleado con departamento_id que no existe en la tabla Departamentos. Las bases de datos previenen esto con restricciones FK.
  • Diseñar sin normalizar: Redundancia de datos causa actualizaciones inconsistentes. Si cambias el nombre del departamento en una fila pero no en otra, los datos quedan corruptos.
  • Olvidar índices necesarios: Una tabla con millones de filas requiere índices en columnas consultadas frecuentemente. Sin ellos, cada búsqueda es lenta.
  • N+1 queries problem: En lugar de un único JOIN, hacer una consulta por cada fila de la tabla principal. Causa miles de consultas innecesarias.
  • No considerar la cardinalidad: Confundir relaciones 1:1, 1:N y M:N causa diseños ineficientes. Una relación M:N requiere una tabla de unión.

42.10 Qué debes recordar de este tema

  • Las tablas son relaciones matemáticas; cada fila es una tupla.
  • Las claves primarias implementan funciones inyectivas que identifican unívocamente tuplas.
  • Las claves foráneas son funciones que relacionan tuplas de diferentes tablas.
  • La normalización elimina redundancias identificando dependencias funcionales.
  • Los índices son funciones invertidas que aceleran búsquedas.
  • SQL es un lenguaje para operar sobre relaciones mediante selección, proyección y joins.
  • Las transacciones garantizan consistencia en operaciones multi-tabla.
  • El buen diseño de esquemas depende de entender las relaciones subyacentes.

42.11 Conclusión

Las bases de datos relacionales son el ejemplo más exitoso de cómo las matemáticas discretas (en particular, la teoría de relaciones) se aplican en informática práctica. Cada decisión de diseño—normalización, índices, claves, restricciones—emerge de entender las relaciones discretas subyacentes.

En el próximo tema exploraremos cómo las relaciones se aplican al análisis de algoritmos y complejidad computacional, donde la eficiencia depende de entender las operaciones que realizamos sobre relaciones.