{}const=>[]async()letfn</>var
DesarrolloSQL

Relaciones en las bases de datos: uno a muchos y muchos a muchos

Analizamos los tipos fundamentales de relaciones en las bases de datos relacionales. Aprende a diseñar correctamente relaciones uno-a-muchos y muchos-a-muchos, crear tablas intermedias, trabajar con claves externas y optimizar consultas. Ejemplos en SQL y TypeORM, análisis de errores típicos y mejores prácticas para desarrolladores.

К

Kodik

Autor

10 min de lectura

¿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.

🔥 100.000+ estudiantes ya están con nosotros

¿Cansado de leer teoría?
¡Hora de programar!

Kodik — una app donde aprendes a programar con práctica. Mentor IA, lecciones interactivas, proyectos reales.

🤖 IA 24/7
🎓 Certificados
💰 Gratis
🚀 Empezar
Se unieron hoy

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!

🎯Deja de postergar

¿Te gustó el artículo?
¡Hora de practicar!

En Kodik no solo lees — escribes código de inmediato. Teoría + práctica = habilidades reales.

Práctica instantánea
🧠IA explica código
🏆Certificado

Sin registro • Sin tarjeta