¿Por qué necesitamos conexiones?
Imagina que estás desarrollando una tienda en línea. Tienes usuarios que hacen pedidos. Por supuesto, es posible almacenar todos los datos en una tabla, duplicando la información del usuario en cada pedido. Pero esta es una mala idea por varias razones:
En primer lugar, duplicarás los datos. Si el usuario cambia su correo electrónico o dirección, tendrá que actualizar los registros en todos sus pedidos. En segundo lugar, ocupa más espacio en la base de datos. En tercer lugar, es fácil cometer un error y obtener datos inconsistentes.
Las relaciones resuelven este problema, permitiendo almacenar datos en tablas separadas y vincularlos a través de claves externas.
Comunicación de uno a muchos (One-to-Many)
Este es el tipo de relación más común en las bases de datos. La esencia es simple: una entrada en la primera tabla puede estar asociada con varias entradas en la segunda tabla, pero una entrada en la segunda tabla está asociada con solo una entrada en la primera.
Ejemplos clásicos
Usuarios y pedidos. Un usuario puede hacer muchos pedidos, pero cada pedido pertenece a un solo usuario.
Categorías y productos. Una categoría puede contener muchos productos, pero cada producto pertenece a una sola categoría.
Autores y artículos. Un autor puede escribir muchos artículos, pero cada artículo tiene un autor.
Implementación en SQL
Vamos a crear tablas para conectar usuarios y pedidos:
CREATE TABLE users (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(100) UNIQUE NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL,
total_amount DECIMAL(10, 2) NOT NULL,
status VARCHAR(50) DEFAULT 'pending',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);Aquí user_id en la tabla orders es una clave externa que hace referencia a id en la tabla users. El parámetro ON DELETE CASCADE significa que cuando se elimina un usuario, todos sus pedidos también se eliminan automáticamente.
Trabajo con datos
Añadiremos un usuario y algunos de sus pedidos:
INSERT INTO users (name, email)
VALUES ('Alexey Ivanov', 'alexey@example.com');
INSERT INTO orders (user_id, total_amount, status)
VALUES
(1, 1500.00, 'completed'),
(1, 2300.50, 'pending'),
(1, 890.00, 'completed');Para obtener todos los pedidos del usuario junto con sus datos, usamos JOIN:
SELECT
users.name,
users.email,
orders.id as order_id,
orders.total_amount,
orders.status
FROM users
INNER JOIN orders ON users.id = orders.user_id
WHERE users.id = 1;Trabajar en la aplicación
Si utilizas ORM, por ejemplo TypeORM o Sequelize, la relación uno-a-muchos se configura de forma declarativa:
// TypeORM
@Entity()
class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
@Column()
email: string;
@OneToMany(() => Order, order => order.user)
orders: Order[];
}
@Entity()
class Order {
@PrimaryGeneratedColumn()
id: number;
@Column('decimal')
totalAmount: number;
@Column()
status: string;
@ManyToOne(() => User, user => user.orders)
user: User;
@Column()
userId: number;
}Ahora puedes obtener fácilmente un usuario con todos sus pedidos:
const user = await userRepository.findOne({
where: { id: 1 },
relations: ['orders']
});
console.log(user.orders); // Matriz de todos los pedidos del usuario
Relación muchos-a-muchos (Many-to-Many)
La relación de muchos a muchos es un poco más complicada. Aquí, un registro en la primera tabla puede estar vinculado a varios registros en la segunda tabla, y viceversa.
Escenarios típicos
Estudiantes y cursos. Un estudiante puede inscribirse en varios cursos y muchos estudiantes pueden estudiar en un curso.
Productos y etiquetas. Un producto puede tener varias etiquetas y una etiqueta se puede aplicar a varios productos.
Usuarios y roles. Un usuario puede tener varios roles y un rol puede asignarse a varios usuarios.
Tabla intermedia
En las bases de datos relacionales, la relación muchos-a-muchos se implementa a través de una tabla intermedia que contiene las claves externas de ambas tablas vinculadas.
Vamos a crear un sistema de etiquetas para los productos:
CREATE TABLE products (
id SERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL,
price DECIMAL(10, 2) NOT NULL,
description TEXT
);
CREATE TABLE tags (
id SERIAL PRIMARY KEY,
name VARCHAR(50) UNIQUE NOT NULL
);
CREATE TABLE product_tags (
product_id INTEGER NOT NULL,
tag_id INTEGER NOT NULL,
PRIMARY KEY (product_id, tag_id),
FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE CASCADE,
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
);La tabla product_tags es la tabla intermedia. Vincula productos y etiquetas. Preste atención a la clave principal compuesta: la combinación de product_id y tag_id debe ser única, lo que evita que la misma etiqueta se agregue al producto dos veces.
Añadir datos
-- Добавляем товары
INSERT INTO products (name, price, description)
VALUES
('MacBook Pro', 150000, 'Laptop for developers'),
('iPhone 15', 80000, 'Latest generation smartphone'),
('iPad Air', 60000, 'Work tablet');
-- Добавляем теги
INSERT INTO tags (name)
VALUES
('Apple'),
('Electronics'),
('For work'),
('Premium');
-- Связываем товары с тегами
INSERT INTO product_tags (product_id, tag_id)
VALUES
(1, 1), -- MacBook Pro - Apple
(1, 2), -- MacBook Pro - Электроника
(1, 3), -- MacBook Pro - Для работы
(1, 4), -- MacBook Pro - Премиум
(2, 1), -- iPhone 15 - Apple
(2, 2), -- iPhone 15 - Электроника
(2, 4); -- iPhone 15 - ПремиумSolicitudes de datos
Obtenemos todos los productos con una etiqueta específica:
SELECT products.*
FROM products
INNER JOIN product_tags ON products.id = product_tags.product_id
INNER JOIN tags ON product_tags.tag_id = tags.id
WHERE tags.name = 'Apple';Obtenemos todas las etiquetas para un producto específico:
SELECT tags.*
FROM tags
INNER JOIN product_tags ON tags.id = product_tags.tag_id
WHERE product_tags.product_id = 1;Encontramos productos que tienen dos etiquetas específicas:
SELECT products.*
FROM products
WHERE id IN (
SELECT product_id
FROM product_tags
INNER JOIN tags ON product_tags.tag_id = tags.id
WHERE tags.name IN ('Apple', 'For work')
GROUP BY product_id
HAVING COUNT(DISTINCT tags.id) = 2
);ORM y muchos-a-muchos
Con ORM, trabajar con tales conexiones se vuelve más fácil:
@Entity()
class Product {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
@Column('decimal')
price: number;
@ManyToMany(() => Tag, tag => tag.products)
@JoinTable({
name: 'product_tags',
joinColumn: { name: 'product_id' },
inverseJoinColumn: { name: 'tag_id' }
})
tags: Tag[];
}
@Entity()
class Tag {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
@ManyToMany(() => Product, product => product.tags)
products: Product[];
}Trabajo con datos:
// Creamos un producto con etiquetas
const product = new Product();
product.name = 'MacBook Pro';
product.price = 150000;
const tag1 = await tagRepository.findOne({ where: { name: 'Apple' } });
const tag2 = await tagRepository.findOne({ where: { name: 'Premium' } });
product.tags = [tag1, tag2];
await productRepository.save(product);
// Recibimos los productos con todas las etiquetas
const productWithTags = await productRepository.findOne({
where: { id: 1 },
relations: ['tags']
});Tabla intermedia con datos adicionales
A veces, en una tabla intermedia, es necesario almacenar no solo conexiones, sino también información adicional. Por ejemplo, en el sistema de cursos, es posible que necesitemos conocer la fecha de inscripción y el progreso del estudiante:
CREATE TABLE students (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(100) UNIQUE NOT NULL
);
CREATE TABLE courses (
id SERIAL PRIMARY KEY,
title VARCHAR(200) NOT NULL,
duration_hours INTEGER NOT NULL
);
CREATE TABLE enrollments (
id SERIAL PRIMARY KEY,
student_id INTEGER NOT NULL,
course_id INTEGER NOT NULL,
enrolled_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
progress INTEGER DEFAULT 0,
completed BOOLEAN DEFAULT false,
FOREIGN KEY (student_id) REFERENCES students(id) ON DELETE CASCADE,
FOREIGN KEY (course_id) REFERENCES courses(id) ON DELETE CASCADE,
UNIQUE(student_id, course_id)
);Aquí enrollments ya no es solo una tabla de enlace, sino una entidad completa con sus propios atributos.
En TypeORM, esta relación debe describirse explícitamente:
@Entity()
class Enrollment {
@PrimaryGeneratedColumn()
id: number;
@ManyToOne(() => Student, student => student.enrollments)
student: Student;
@ManyToOne(() => Course, course => course.enrollments)
course: Course;
@Column()
enrolledAt: Date;
@Column()
progress: number;
@Column()
completed: boolean;
}
@Entity()
class Student {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
@OneToMany(() => Enrollment, enrollment => enrollment.student)
enrollments: Enrollment[];
}
@Entity()
class Course {
@PrimaryGeneratedColumn()
id: number;
@Column()
title: string;
@OneToMany(() => Enrollment, enrollment => enrollment.course)
enrollments: Enrollment[];
}Rendimiento y optimización
Índices
Para las claves externas, siempre cree índices. Esto es crítico para el rendimiento de las consultas JOIN:
CREATE INDEX idx_orders_user_id ON orders(user_id);
CREATE INDEX idx_product_tags_product_id ON product_tags(product_id);
CREATE INDEX idx_product_tags_tag_id ON product_tags(tag_id);Problema N+1
Este es un problema clásico cuando se trabaja con conexiones. Imagina que recibes una lista de 100 usuarios y luego cargas sus pedidos para cada uno con una solicitud separada. Resulta en 101 consultas a la base de datos.
La solución es usar la carga anticipada:
// Mal - N+1 problema
const users = await userRepository.find();
for (const user of users) {
user.orders = await orderRepository.find({ where: { userId: user.id } });
}
// Bueno: una solicitud con JOIN
const users = await userRepository.find({
relations: ['orders']
});Carga lenta vs carga codiciosa
No siempre es necesario cargar todos los datos relacionados. Si un usuario tiene miles de pedidos y solo necesita información básica sobre él, no cargue todos los pedidos a la vez. Hazlo bajo demanda:
// Solo se carga el usuario
const user = await userRepository.findOne({ where: { id: 1 } });
// Más tarde, si es necesario, cargamos los pedidos
if (needOrders) {
const orders = await orderRepository.find({
where: { userId: user.id },
take: 10,
order: { createdAt: 'DESC' }
});
}Errores comunes
Ausencia de restricciones de integridad
Si no usas FOREIGN KEY y ON DELETE CASCADE, puedes obtener registros "colgantes", es decir, pedidos que hacen referencia a usuarios inexistentes.
-- Плохо
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL,
total_amount DECIMAL(10, 2)
);
-- Хорошо
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL,
total_amount DECIMAL(10, 2),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);Selección incorrecta del tipo de conexión
A veces los desarrolladores utilizan muchos-a-muchos donde es suficiente uno-a-muchos, complicando la estructura innecesariamente. O, por el contrario, intentan prescindir de uno-a-muchos, aunque la lógica empresarial requiere muchos-a-muchos.
Analiza siempre el dominio: ¿puede la entidad A tener varias relaciones con la entidad B y viceversa?
Duplicación de datos
Los desarrolladores principiantes a veces duplican datos en lugar de crear enlaces:
-- Плохо
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
user_name VARCHAR(100),
user_email VARCHAR(100),
user_phone VARCHAR(20),
total_amount DECIMAL(10, 2)
);
-- Хорошо
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL,
total_amount DECIMAL(10, 2),
FOREIGN KEY (user_id) REFERENCES users(id)
);Cuando la desnormalización está justificada
A pesar de todas las ventajas de la normalización, a veces tiene sentido desnormalizar un poco los datos para mejorar el rendimiento. Por ejemplo, si necesita conocer constantemente el número de pedidos de un usuario, puede añadir un contador:
CREATE TABLE users (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(100) UNIQUE NOT NULL,
orders_count INTEGER DEFAULT 0
);
-- Триггер для автоматического обновления счётчика
CREATE OR REPLACE FUNCTION update_orders_count()
RETURNS TRIGGER AS $
BEGIN
IF TG_OP = 'INSERT' THEN
UPDATE users SET orders_count = orders_count + 1
WHERE id = NEW.user_id;
ELSIF TG_OP = 'DELETE' THEN
UPDATE users SET orders_count = orders_count - 1
WHERE id = OLD.user_id;
END IF;
RETURN NULL;
END;
$ LANGUAGE plpgsql;
CREATE TRIGGER orders_count_trigger
AFTER INSERT OR DELETE ON orders
FOR EACH ROW EXECUTE FUNCTION update_orders_count();Pero recuerde: la desnormalización es un compromiso entre la velocidad de lectura y la complejidad del soporte. Úsela solo cuando sea realmente necesario.
Conclusión
Comprender las relaciones entre tablas es la base para trabajar con bases de datos relacionales. La relación uno-a-muchos cubre la mayoría de los escenarios y es fácil de implementar. Muchos-a-muchos requiere una tabla intermedia, pero ofrece flexibilidad en el modelado de relaciones complejas.
Puntos clave a recordar: utiliza siempre claves externas para mantener la integridad de los datos, crea índices para optimizar las consultas, ten cuidado con el problema N+1 y elige el tipo de conexión correcto en función de la lógica empresarial de tu aplicación.
Anexo Kodik es una plataforma educativa para desarrolladores principiantes, donde encontrarás cursos estructurados en Python, JavaScript, HTML, CSS y otras tecnologías de programación.
Hemos creado un comunidad en Telegram, donde los desarrolladores se ayudan mutuamente a resolver problemas, comparten experiencias y discuten nuevas tecnologías. Únete a Kodikupara aprender programación a un ritmo cómodo con el apoyo de mentores experimentados y personas de ideas afines.
Practica con tareas reales y, con el tiempo, comprenderás intuitivamente qué estructura de datos es la más adecuada para una situación determinada. ¡Buena suerte con el desarrollo!
