Skip to content

📅 SAISON B Semaine 5 Épisode 4 - Maîtrise de Sequelize

📚 Notions du jour

Aujourd’hui, nous avons approfondi notre utilisation de Sequelize, l’ORM (Object-Relational Mapping) pour Node.js, en explorant des concepts clés pour construire une application robuste. Après avoir configuré Sequelize et testé la connexion à la base de données, nous passons maintenant à la définition des modèles, la gestion des associations, et l’implémentation complète du CRUD (Create, Read, Update, Delete).

Dans cet article, nous allons couvrir :

  • La définition des modèles avec sequelize.define() et les DataTypes.
  • La synchronisation du schéma avec sequelize.sync().
  • Les associations entre modèles (belongsTo, hasMany, belongsToMany).
  • Le chargement des données associées avec l’option include.
  • Le peuplement de la base de données (seeding).
  • Le remplacement de notre couche d’accès manuelle par les méthodes de Sequelize.

📚 Définition des Modèles avec sequelize.define() et DataTypes

Un modèle dans Sequelize représente une table dans la base de données. Chaque modèle est défini avec des colonnes (attributs) et des types de données (DataTypes).

Pour rappel nous avons déjà défini notre Model Coffee en cours hier ainsi que le Model Country lors du challenge (voir 📅 SAISON B Semaine 5 Épisode 3 - Introduction aux ORM).

Sur la même base nous avons défini un dernier Model Category.

🔗 Associations entre Modèles

Pour centraliser et organiser les relations entre nos modèles, nous créons un fichier index.js dans le dossier src/models/. Ce fichier importe tous les modèles et définit leurs associations.


Centralisation des Modèles et Définition des Associations

Exemple : Fichier src/models/index.js

src/models/index.js
// Import all models
import Country from "./Country.js";
import Category from "./Category.js";
import Coffee from "./Coffee.js";

// One-to-Many / Many-to-One Relationship
// A country has many coffees
Country.hasMany(Coffee, {
  foreignKey: 'country_id', // Foreign key in the Coffee table
  as: 'coffees'             // Alias for inclusion (join)
});

// A coffee belongs to one country
Coffee.belongsTo(Country, {
  foreignKey: 'country_id', // Foreign key in the Coffee table
  as: 'country'             // Alias for inclusion (join)
});

// Many-to-Many Relationship
// A coffee can have multiple categories
Coffee.belongsToMany(Category, {
  through: 'coffee_category', // Junction table name
  // Be careful not to reverse foreignKey and otherKey.
  // foreignKey must be different from the called model!
  foreignKey: 'coffee_id',  // Foreign key in the junction table (points to Coffee)
  otherKey: 'category_id',   // Other key in the junction table (points to Category)
  as: 'categories'           // Alias for inclusion (join)
});

// A category can have multiple coffees
Category.belongsToMany(Coffee, {
  through: 'coffee_category', // Junction table name
  // Be careful not to reverse foreignKey and otherKey.
  // foreignKey must be different from the called model!
  foreignKey: 'category_id', // Foreign key in the junction table (points to Category)
  otherKey: 'coffee_id',     // Other key in the junction table (points to Coffee)
  as: 'coffees'              // Alias for inclusion (join)
});

// Export all models
export { Country, Category, Coffee };

Explications :

  • hasMany et belongsTo : Définissent une relation 1:N (un pays a plusieurs cafés, un café appartient à un pays).
  • foreignKey : Spécifie la clé étrangère dans la table liée.
  • as : Définit un alias pour les requêtes avec include.
  • belongsToMany : Définit une relation N:N (un café peut avoir plusieurs catégories, une catégorie peut être associée à plusieurs cafés).
  • through : Spécifie le nom de la table de liaison (coffee_category).
  • foreignKey et otherKey : Définissent les clés étrangères dans la table de liaison.
    • foreignKey : Pointe vers le modèle appelant (ex: Coffee pour Coffee.belongsToMany).
    • otherKey : Pointe vers le modèle cible (ex: Category pour Coffee.belongsToMany).

💡 Points Clés sur les Associations

  1. Centralisation : Le fichier index.js regroupe toutes les définitions d’associations, ce qui rend le code plus organisé et maintenable.
  2. Clés Étrangères : Les relations 1:N utilisent une clé étrangère dans la table "N" (ex: country_id dans Coffee).
  3. Tables de Liaison : Les relations N:N nécessitent une table intermédiaire (through) avec deux clés étrangères.
  4. Alias (as) : Permettent de nommer clairement les associations dans les requêtes, ce qui améliore la lisibilité.

🔄 Synchronisation du Schéma avec sequelize.sync()

La synchronisation permet de créer ou mettre à jour les tables dans la base de données en fonction des modèles définis.

Exemple : Synchroniser Tous les Modèles

database/sync.js
import sequelize from './sequelize-client.js';

// IMPORTANT: import all models here
import { Coffee, Country, Category } from '../src/models/sequelize/index.js'; 

async function syncDb() {
  try {
    // The `{ alter: true }` option compares the state of the database with the Sequelize models.
    // Warning, this is useful in development but dangerous in production
    await sequelize.sync({ alter: true });
    console.log('Database synchronised OK');
  } catch (error) {
    console.error('Database synchronised NOT OK', error);
  } finally {
    sequelize.close();
  }
}

syncDb(); // Call the function to sync models

Explications :

  • sequelize.sync() : Crée les tables si elles n’existent pas.
  • { alter: true } : Met à jour la structure des tables si le modèle a changé (attention en production !).
  • { force: true } : Supprime et recrée les tables (à utiliser avec prudence).

🌱 Peuplement de la Base de Données (Seeding)

Le seeding (ou peuplement) consiste à remplir la base de données avec des données initiales, souvent utilisées pour :

  • Initialiser un environnement de développement avec des données réalistes.
  • Tester des fonctionnalités sans avoir à saisir manuellement des données.
  • Créer des jeux de données pour des démonstrations ou des tests automatisés.

Dans notre projet oCoffee, nous utilisons un fichier JSON (ocoffee-dataset.json) contenant des données pour les pays, catégories et cafés. Voici comment nous procédons pour peupler la base de données avec Sequelize.


1️⃣ Script de Seeding Complet

Exemple : Fichier seed.js

database/seed.js
// Import Sequelize instance and models
import sequelize from "./sequelize-client.js";
import { Country, Category, Coffee } from "../src/models/index.js";
// Import dataset from JSON file
import data from "../data/ocoffee-dataset.json" with { type: "json" };

// Main seeding function
async function seed() {
  // Drop all tables and recreate them (force: true)
  // WARNING: This will delete ALL existing data in the database!
  await sequelize.sync({ force: true, logging: false });

  // Create all countries from the dataset
  const countries = await Country.bulkCreate(data.countries, { returning: true });

  // Create all categories from the dataset
  const categories = await Category.bulkCreate(data.categories, { returning: true });

  // Loop through each coffee in the dataset
  for (const coffeeData of data.coffees) {
    // Create a coffee with its associated country
    // Find the country ID by matching the country name
    const coffee = await Coffee.create({
      ...coffeeData, // Spread all coffee properties
      country_id: countries.find(country => country.dataValues.name === coffeeData.country).id
    });

    // Find categories associated with the coffee in the dataset
    const categoriesToAdd = categories.filter(category =>
      coffeeData.categories.includes(category.dataValues.name)
    );

    // Associate categories with the coffee using Sequelize's magic method `addBar()`
    await coffee.addCategories(categoriesToAdd);

    // Load the country association (optional, for verification)
    await coffee.getCountry();
  }

  // Close the database connection
  await sequelize.close();
  console.log("Database seeding completed successfully!");
}

// Execute the seeding function
seed().catch(error => {
  console.error("Error during seeding:", error);
});

2️⃣ Explications Détailées

  1. sequelize.sync({ force: true, logging: false })
  2. force: true : Supprime et recrée toutes les tables de la base de données.
  3. logging: false : Désactive les logs SQL pour éviter la pollution de la console.

  4. Country.bulkCreate(data.countries, { returning: true })

  5. bulkCreate : Crée plusieurs enregistrements en une seule requête (optimisé pour les performances).
  6. returning: true : Retourne les instances créées (utile pour récupérer les IDs générés).

  7. Création des Cafés avec leurs Pays Associés

const coffee = await Coffee.create({
  ...coffeeData, // Copy all properties from the dataset
  country_id: countries.find(country => country.dataValues.name === coffeeData.country).id
});
  • ...coffeeData : Copie toutes les propriétés du café depuis le JSON.
  • country_id : Trouve l’ID du pays correspondant au nom dans le dataset.

  • Association des Catégories aux Cafés

const categoriesToAdd = categories.filter(category =>
  coffeeData.categories.includes(category.dataValues.name)
);
await coffee.addCategories(categoriesToAdd);
  • categories.filter(...) : Filtre les catégories pour ne garder que celles associées au café.
  • coffee.addCategories(...) : Méthode magique de Sequelize pour créer des associations N:N.

  • Chargement des Associations (Optionnel)

await coffee.getCountry(); // Load the associated country
  • getCountry() : Charge le pays associé pour vérification (optionnel).

  • Fermeture de la Connexion

await sequelize.close();
  • sequelize.close() : Ferme proprement la connexion à la base de données après le seeding.

3️⃣ Structure du Fichier JSON (ocoffee-dataset.json)

Le fichier JSON contient les données initiales pour les pays, catégories et cafés. Voici un exemple de structure :

data/ocoffee-dataset.json
{
  "countries": [
    { "name": "Colombia" },
    { "name": "Brazil" },
    { "name": "Ethiopia" }
  ],
  "categories": [
    { "name": "Bio" },
    { "name": "Fair Trade" },
    { "name": "Organic" }
  ],
  "coffees": [
    {
      "name": "Colombian Supreme",
      "price": 8.99,
      "description": "A rich and balanced coffee from Colombia.",
      "country": "Colombia",
      "categories": ["Bio", "Fair Trade"]
    },
    {
      "name": "Brazilian Santos",
      "price": 7.99,
      "description": "A smooth and nutty coffee from Brazil.",
      "country": "Brazil",
      "categories": ["Organic"]
    }
  ]
}

4️⃣ Points Clés du Seeding

  1. Efficacité : bulkCreate est bien plus rapide que de créer les enregistrements un par un.
  2. Associations : Sequelize fournit des méthodes magiques comme addCategories pour gérer les relations N:N.
  3. Flexibilité : Le seeding peut être adapté pour importer des données depuis un fichier, une API, ou même générées aléatoirement.
  4. Prudence : force: true supprime toutes les données existantes – à utiliser uniquement en développement ou en test !

🔄 Remplacement de la Couche d’Accès Manuelle par Sequelize

Avant Sequelize, nous utilisions un datamapper manuel pour interagir avec la base de données. Maintenant, nous pouvons remplacer ces méthodes par des appels Sequelize. Il ne sera plus nécessaire de taper des requêtes SQL dans notre code.

Nous allons pratiquer dans le challenge du jour 😊.


💡 Ce Que J’ai Appris Aujourd’hui

  1. Les modèles Sequelize permettent de définir des tables et leurs colonnes avec des types de données et des validations.
  2. Les associations (belongsTo, hasMany, belongsToMany) simplifient la gestion des relations entre tables.
  3. L’option include permet de charger des données associées en une seule requête (jointure).
  4. Le seeding est utile pour peupler la base de données avec des données initiales.
  5. Sequelize remplace avantageusement les requêtes SQL manuelles, ce qui rend le code plus lisible et maintenable.

⌨️ Challenge du jour : "Projet oCoffee" – projet fil rouge (suite)


☕️ Présentation du Challenge

Aujourd’hui, l’objectif était de finaliser l’intégration de Sequelize dans notre projet oCoffee en remplaçant complètement notre ancien système de datamapper manuel. Après avoir défini nos modèles et leurs associations, il était temps de brancher Sequelize directement dans nos contrôleurs pour que l’application utilise pleinement les fonctionnalités de l’ORM.

Pourquoi ?

  • Supprimer la duplication de code : Plus besoin d’écrire des requêtes SQL manuelles.
  • Améliorer la maintenabilité : Le code devient plus lisible et plus facile à modifier.
  • Profiter des fonctionnalités de Sequelize : Jointures, validations, et gestion des associations sans effort.

📌 Étapes à Réaliser


1️⃣ Supprimer l’Ancien Datamapper et les Modèles Manuels

J'ai commencé par supprimer le datamapper et tous les Modèles de class créés dans la première version POO de l'application.


2️⃣ Importer les Modèles Sequelize dans ProductController.js

Objectif : Préparer le contrôleur pour utiliser les modèles Sequelize.

Mon code :

src/controllers/ProductController.js
import { Coffee, Category, Country } from '../models/index.js';

3️⃣ Réécrire la Méthode renderHomePage

Objectif : Récupérer les 3 derniers cafés avec Sequelize.

Mon code :

src/controllers/ProductController.js
import CoreController from "./CoreController.js";
import { Country, Category, Coffee } from "../models/Index.js"

class ProductController extends CoreController {
  renderHomePage = async (req, res) => {
    try {
        // Fetch the 3 most recent coffees, ordered by creation date (descending)
        const articles = await Coffee.findAll({
        attributes: ['reference', 'name', 'id'], // Only select specific columns
        order: [['created_at', 'DESC']], // Sort by creation date (newest first)
        limit: 3, // Limit to 3 results
        });
        res.render("pages/home", { articles }); // Render the home page with articles
    } catch (error) {
        console.error(error);
        res.status(500).render("pages/error"); // Render error page on failure
    }
  };
}

export default new ProductController;

Explications :

  • findAll() : Méthode Sequelize pour récupérer plusieurs enregistrements.
  • attributes: ['reference', 'name', 'id'] : Sélectionne uniquement les colonnes nécessaires pour optimiser la requête.
  • order: [['created_at', 'DESC']] : Trie les résultats par date de création (du plus récent au plus ancien).
  • limit: 3 : Limite le nombre de résultats à 3 (pour la page d’accueil).

4️⃣ Réécrire la Méthode renderCatalogPage

Objectif : Récupérer tous les cafés et toutes les catégories pour la page catalogue.

Mon code :

src/controllers/ProductController.js
// ... Previous code...
renderCatalogPage = async (req, res) => {
  try {
    // Fetch all available coffees, including their associated country
    const articles = await Coffee.findAll({
      include: ['country'], // Include the associated country
      where: {
        available: true, // Only fetch available coffees
      }
    });

    // Fetch all categories for the catalog filters
    const categories = await Category.findAll();

    // Render the catalog page with coffees and categories
    res.render("pages/catalog", { articles, categories });
  } catch (error) {
    console.error(error);
    res.status(500).render("pages/error"); // Render error page on failure
  }
};

Explications :

  • include: ['country'] : Charge le pays associé à chaque café (relation 1:N).
  • where: { available: true } : Filtre pour ne récupérer que les cafés disponibles.
  • Category.findAll() : Récupère toutes les catégories pour les filtres du catalogue.

5️⃣ Réécrire la Méthode renderCoffeeDetailsPage

Objectif : Récupérer un café spécifique avec ses données associées (pays et catégories).

Mon code :

src/controllers/ProductController.js
renderCoffeeDetailsPage = async (req, res, next) => {
  try {
    // Parse the coffee ID from the URL parameters
    const articleId = parseInt(req.params.id);

    // Check if the ID is a valid number
    if (isNaN(articleId)) {
      return next(); // Pass to the next middleware (e.g., 404 handler)
    }

    // Fetch the coffee by ID, including its country and categories
    const article = await Coffee.findByPk(articleId, {
      include: [
        {
          model: Country,
          as: 'country',
          attributes: ['name'] // Only fetch the country name
        },
        {
          model: Category,
          as: 'categories',
          attributes: ['name'] // Only fetch the category names
        }
      ]
    });

    // If the coffee doesn't exist, render a 404 page
    if (!article) {
      return this.render404(req, res); // Use inherited method from CoreController
    }

    // Render the coffee details page with the article data
    res.render("pages/article", { article });
  } catch (error) {
    console.error(error);
    res.status(500).render("pages/error"); // Render error page on failure
  }
};

Explications :

  • findByPk() : Récupère un café par sa clé primaire (id).
  • include : Charge les données associées :
    • Country : Le pays du café (alias country).
    • Category : Les catégories du café (alias categories).
  • attributes: ['name'] : Ne récupère que le nom du pays et des catégories (optimisation).

💡 Ce Que J’ai Appris avec ce Challenge

  1. Sequelize simplifie grandement l’accès aux données en remplaçant les requêtes SQL manuelles par des méthodes JavaScript.
  2. Les jointures avec include permettent de charger les données associées en une seule requête.
  3. L’optimisation des requêtes avec attributes réduit la quantité de données inutiles récupérées.
  4. La refactorisation progressive permet de migrer vers un ORM sans casser l’application existante.