Pourquoi avons-nous besoin de relations ?
Imaginez que vous développez une boutique en ligne. Vous avez des utilisateurs qui passent des commandes. Vous pouvez, bien sûr, stocker toutes les données dans un tableau, en dupliquant les informations sur l'utilisateur dans chaque commande. Mais c'est une mauvaise idée pour plusieurs raisons :
Tout d'abord, vous dupliquerez les données. Si l'utilisateur change d'e-mail ou d'adresse, vous devrez mettre à jour les enregistrements dans toutes ses commandes. Deuxièmement, cela prend plus de place dans la base de données. Troisièmement, il est facile de faire une erreur et d'obtenir des données incohérentes.
Les relations résolvent ce problème en permettant de stocker des données dans des tableaux séparés et de les relier par des clés externes.
Communication un-à-plusieurs (One-to-Many)
C'est le type de connexion le plus courant dans les bases de données. L'idée est simple : un enregistrement dans la première table peut être associé à plusieurs enregistrements dans la deuxième table, mais un enregistrement dans la deuxième table n'est associé qu'à un seul enregistrement dans la première.
Exemples classiques
Utilisateurs et commandes. Un utilisateur peut passer plusieurs commandes, mais chaque commande n'appartient qu'à un seul utilisateur.
Catégories et produits. Une catégorie peut contenir de nombreux produits, mais chaque produit appartient à une seule catégorie.
Auteurs et articles. Un auteur peut écrire de nombreux articles, mais chaque article a un auteur.
Mise en œuvre en SQL
Créons des tableaux pour relier les utilisateurs et les commandes :
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
);Ici, user_id dans le tableau orders est une clé étrangère qui fait référence à id dans le tableau users. Le paramètre ON DELETE CASCADE signifie que lors de la suppression d'un utilisateur, toutes ses commandes seront également supprimées automatiquement.
Travail avec les données
Ajoutons un utilisateur et plusieurs de ses commandes :
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');Pour obtenir toutes les commandes de l'utilisateur avec ses données, on utilise 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;Travail dans l'application
Si vous utilisez ORM, par exemple TypeORM ou Sequelize, la relation un-à-plusieurs est configurée de manière déclarative :
// 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;
}Vous pouvez désormais obtenir facilement un utilisateur avec toutes ses commandes :
const user = await userRepository.findOne({
where: { id: 1 },
relations: ['orders']
});
console.log(user.orders); // Tableau de toutes les commandes de l'utilisateur
Relation plusieurs-à-plusieurs (Many-to-Many)
La relation plusieurs-à-plusieurs est un peu plus compliquée. Ici, un enregistrement dans le premier tableau peut être associé à plusieurs enregistrements dans le second tableau, et vice versa.
Scénarios typiques
Étudiants et cours. Un étudiant peut s'inscrire à plusieurs cours, et de nombreux étudiants peuvent étudier dans un même cours.
Produits et étiquettes. Un produit peut avoir plusieurs balises, et une balise peut être appliquée à plusieurs produits.
Utilisateurs et rôles. Un utilisateur peut avoir plusieurs rôles et un rôle peut être attribué à plusieurs utilisateurs.
Tableau intermédiaire
Dans les bases de données relationnelles, la relation plusieurs-à-plusieurs est réalisée par le biais d'une table intermédiaire qui contient les clés étrangères des deux tables liées.
Créons un système de balises pour les produits :
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
);Le tableau product_tags est le tableau intermédiaire. Il relie les produits et les balises. Faites attention à la clé primaire composite : la combinaison de product_id et tag_id doit être unique, ce qui empêche d'ajouter deux fois la même balise au produit.
Ajout de données
-- Добавляем товары
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 - ПремиумRequêtes de données
Obtenons tous les produits avec une certaine balise :
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';Obtenons toutes les balises pour un produit spécifique :
SELECT tags.*
FROM tags
INNER JOIN product_tags ON tags.id = product_tags.tag_id
WHERE product_tags.product_id = 1;Trouvons des produits qui ont deux balises spécifiques :
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 et plusieurs-à-plusieurs
Avec ORM, il devient plus facile de travailler avec de telles connexions :
@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[];
}Traitement des données :
// Créer un produit avec des balises
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);
// Nous recevons les marchandises avec toutes les étiquettes
const productWithTags = await productRepository.findOne({
where: { id: 1 },
relations: ['tags']
});Tableau intermédiaire avec des données supplémentaires
Parfois, dans un tableau intermédiaire, il est nécessaire de stocker non seulement des connexions, mais également des informations supplémentaires. Par exemple, dans le système de cours, nous pouvons avoir besoin de connaître la date d'inscription de l'étudiant et sa progression :
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)
);Ici, enrollments n'est plus seulement une table de liaison, mais une entité à part entière avec ses propres attributs.
Dans TypeORM, une telle relation doit être décrite explicitement :
@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[];
}Performance et optimisation
Indices
Pour les clés étrangères, créez toujours des index. Ceci est essentiel pour les performances des requêtes 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);Problème N+1
C'est un problème classique lorsque vous travaillez avec des connexions. Imaginez que vous recevez une liste de 100 utilisateurs, puis pour chacun d'eux, vous téléchargez ses commandes avec une requête séparée. Il s'avère que 101 requêtes à la base de données.
La solution est d'utiliser le chargement anticipé :
// Mauvais - N+1 problème
const users = await userRepository.find();
for (const user of users) {
user.orders = await orderRepository.find({ where: { userId: user.id } });
}
// Bien - une requête avec JOIN
const users = await userRepository.find({
relations: ['orders']
});Chargement paresseux vs chargement gourmand
Il n'est pas toujours nécessaire de télécharger toutes les données associées. Si un utilisateur a des milliers de commandes et que vous n'avez besoin que d'informations de base à son sujet, ne téléchargez pas toutes les commandes à la fois. Faites-le à la demande :
// Chargement de l'utilisateur uniquement
const user = await userRepository.findOne({ where: { id: 1 } });
// Plus tard, si nécessaire, nous téléchargeons les commandes
if (needOrders) {
const orders = await orderRepository.find({
where: { userId: user.id },
take: 10,
order: { createdAt: 'DESC' }
});
}Erreurs courantes
Absence de restrictions d'intégrité
Si vous n'utilisez pas FOREIGN KEY et ON DELETE CASCADE, vous pouvez obtenir des enregistrements « en attente » — des commandes qui font référence à des utilisateurs inexistants.
-- Плохо
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
);Mauvais choix du type de connexion
Parfois, les développeurs utilisent beaucoup-à-beaucoup là où un-à-beaucoup suffit, compliquant la structure inutilement. Ou, au contraire, ils essaient de faire avec un-à-plusieurs, bien que la logique commerciale exige plusieurs-à-plusieurs.
Analysez toujours le domaine : l'entité A peut-elle avoir plusieurs relations avec l'entité B, et vice versa ?
Duplication des données
Les développeurs débutants dupliquent parfois les données au lieu de créer des liens :
-- Плохо
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)
);Quand la dénormalisation est justifiée
Malgré tous les avantages de la normalisation, il est parfois logique de dénormaliser légèrement les données pour des raisons de performances. Par exemple, si vous avez constamment besoin de connaître le nombre de commandes d'un utilisateur, vous pouvez ajouter un compteur :
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();Mais rappelez-vous : la dénormalisation est un compromis entre la vitesse de lecture et la complexité du support. Utilisez-la uniquement lorsque cela est vraiment nécessaire.
Conclusion
Comprendre les relations entre les tableaux est la base du travail avec les bases de données relationnelles. La relation un-à-plusieurs couvre la plupart des scénarios et est facile à mettre en œuvre. Plusieurs-à-plusieurs nécessite une table intermédiaire, mais donne de la flexibilité dans la modélisation des relations complexes.
Points clés à retenir : utilisez toujours des clés externes pour maintenir l'intégrité des données, créez des index pour optimiser les requêtes, soyez attentif au problème N+1 et choisissez le bon type de connexion en fonction de la logique métier de votre application.
Annexe Code est une plateforme éducative pour les développeurs débutants, où vous trouverez des cours structurés sur Python, JavaScript, HTML, CSS et d'autres technologies de programmation.
Nous avons créé un communauté Telegram, où les développeurs s'entraident pour résoudre des problèmes, partager des expériences et discuter de nouvelles technologies. Rejoignez Codique, pour apprendre la programmation à un rythme confortable avec le soutien de mentors expérimentés et de personnes partageant les mêmes idées !
Entraînez-vous sur des tâches réelles, et au fil du temps, vous comprendrez intuitivement quelle structure de données est la mieux adaptée à une situation particulière. Bonne chance pour le développement !
