Cours Next.js · Module 4: Bases de données et accès aux données

Modèles de données et migrations

12 min de lecture
Dans cette leçon6

Dans les modules précédents, nous avons abordé diverses bases de données et différents ORM pour les applications Next.js. Nous allons maintenant nous concentrer sur un aspect clé du travail avec les bases : créer des modèles de données et gérer les migrations. Ces compétences sont essentielles pour construire des applications scalables dotées d'une couche de données bien conçue.

Concevoir des modèles de données

Une bonne conception des modèles de données est le socle d'une application stable. Des modèles mal conçus peuvent entraîner des problèmes de performance, des difficultés de maintenance et des limites de scalabilité.

Les principes de conception des modèles

  1. Normalisation - Évite la duplication des données
  2. Clés non ambiguës - Chaque enregistrement doit avoir un identifiant unique
  3. Intégrité référentielle - Les relations entre tables doivent être clairement définies
  4. Économie d'attributs - Ne crée pas de champs inutiles
  5. Types de données appropriés - Choisis les types optimaux pour chaque champ

Exemple de schéma pour une application e-commerce

Voici un exemple de schéma pour une application e-commerce simple :

1// Prisma schema
2model User {
3  id            String    @id @default(cuid())
4  email         String    @unique
5  name          String?
6  password      String
7  role          Role      @default(CUSTOMER)
8  orders        Order[]
9  shippingAddresses Address[] @relation("UserShippingAddresses")
10  createdAt     DateTime  @default(now())
11  updatedAt     DateTime  @updatedAt
12}
13
14model Product {
15  id            String    @id @default(cuid())
16  name          String
17  description   String?
18  price         Decimal   @db.Decimal(10, 2)
19  inventory     Int       @default(0)
20  images        String[]
21  categories    Category[]
22  orderItems    OrderItem[]
23  createdAt     DateTime  @default(now())
24  updatedAt     DateTime  @updatedAt
25}
26
27model Category {
28  id            String    @id @default(cuid())
29  name          String    @unique
30  description   String?
31  products      Product[]
32  parentCategory Category?  @relation("SubCategories", fields: [parentId], references: [id])
33  parentId      String?
34  subCategories Category[] @relation("SubCategories")
35  createdAt     DateTime  @default(now())
36  updatedAt     DateTime  @updatedAt
37}
38
39model Order {
40  id            String    @id @default(cuid())
41  user          User      @relation(fields: [userId], references: [id])
42  userId        String
43  status        OrderStatus @default(PENDING)
44  items         OrderItem[]
45  totalAmount   Decimal   @db.Decimal(10, 2)
46  shippingAddress Address   @relation(fields: [addressId], references: [id])
47  addressId     String
48  paymentIntent String?
49  createdAt     DateTime  @default(now())
50  updatedAt     DateTime  @updatedAt
51}
52
53model OrderItem {
54  id            String    @id @default(cuid())
55  order         Order     @relation(fields: [orderId], references: [id])
56  orderId       String
57  product       Product   @relation(fields: [productId], references: [id])
58  productId     String
59  quantity      Int
60  priceAtPurchase Decimal  @db.Decimal(10, 2)
61}
62
63model Address {
64  id            String    @id @default(cuid())
65  street        String
66  city          String
67  state         String
68  zipCode       String
69  country       String
70  user          User      @relation("UserShippingAddresses", fields: [userId], references: [id])
71  userId        String
72  orders        Order[]
73  isDefault     Boolean   @default(false)
74  createdAt     DateTime  @default(now())
75  updatedAt     DateTime  @updatedAt
76}
77
78enum Role {
79  ADMIN
80  STAFF
81  CUSTOMER
82}
83
84enum OrderStatus {
85  PENDING
86  PROCESSING
87  SHIPPED
88  DELIVERED
89  CANCELLED
90}

Les relations entre modèles

Dans la conception d'une base de données, les relations entre modèles sont cruciales. On distingue trois grands types de relations :

  1. Un-à-un (1:1)

    • Chaque enregistrement de la table A est associé à exactement un enregistrement de la table B
    • Exemple : User et Profile
    1model User {
    2  id        String   @id @default(cuid())
    3  profile   Profile?
    4}
    5
    6model Profile {
    7  id        String   @id @default(cuid())
    8  user      User     @relation(fields: [userId], references: [id])
    9  userId    String   @unique // @unique garantit une relation 1:1
    10  bio       String?
    11}
  2. Un-à-plusieurs (1:N)

    • Un enregistrement de la table A peut être associé à plusieurs enregistrements de la table B
    • Exemple : User et Orders
    1model User {
    2  id        String   @id @default(cuid())
    3  orders    Order[]
    4}
    5
    6model Order {
    7  id        String   @id @default(cuid())
    8  user      User     @relation(fields: [userId], references: [id])
    9  userId    String
    10}
  3. Plusieurs-à-plusieurs (N:M)

    • Plusieurs enregistrements de la table A peuvent être associés à plusieurs enregistrements de la table B
    • Exemple : Products et Categories
    1model Product {
    2  id          String      @id @default(cuid())
    3  categories  Category[]
    4}
    5
    6model Category {
    7  id          String      @id @default(cuid())
    8  products    Product[]
    9}

Les migrations de base de données

Les migrations sont un mécanisme qui permet de modifier la structure de la base de manière contrôlée. Elles permettent de suivre l'historique des changements, de collaborer en équipe et de déployer les mises à jour de schéma en toute sécurité.

Que sont les migrations ?

Les migrations sont des fichiers contenant des changements du schéma de la base (création de tables, ajout de colonnes, modification de relations, etc.). Chaque migration représente un changement unique et atomique de la structure de la base.

Pourquoi utiliser des migrations ?

  1. Historique des changements - Documentation complète de l'évolution de la structure de la base
  2. Collaboration en équipe - Facilite le travail de plusieurs développeurs sur un même projet
  3. Versionnement - Intégration avec les systèmes de contrôle de version
  4. Déploiement sûr - Tester les changements avant la production
  5. Rollback - Possibilité d'annuler les changements en cas de problème

Les migrations dans Prisma

Prisma propose un système de migration avancé, bien intégré à la modélisation des données.

Créer la première migration

1# Initialiser Prisma (si ce n'est pas encore fait)
2npx prisma init
3
4# Après avoir défini les modèles dans schema.prisma
5npx prisma migrate dev --name init

Cette commande :

  1. Analyse le fichier schema.prisma
  2. Génère un fichier SQL avec les instructions de création de tables
  3. Applique la migration à la base de données
  4. Génère le client Prisma

Faire évoluer le schéma et les migrations suivantes

Après avoir modifié le schéma, on peut générer une nouvelle migration :

1# Après avoir modifié schema.prisma
2npx prisma migrate dev --name add_user_preferences

Déployer les migrations en production

En environnement de production, on utilise une commande différente qui applique uniquement les migrations sans générer le client :

1npx prisma migrate deploy

Réinitialiser la base de données (développement uniquement)

En environnement de développement, il faut parfois réinitialiser la base :

1npx prisma migrate reset

Cette commande :

  1. Supprime toute la base de données
  2. Crée une nouvelle base vide
  3. Applique toutes les migrations
  4. Exécute les seeders (s'ils sont définis)

Les migrations dans Drizzle

Drizzle propose également un système de migration, mais basé sur des fichiers SQL.

Générer les migrations

1# Configuration drizzle.config.ts
2#
3# Génération des migrations
4npx drizzle-kit generate:pg

Cette commande génère des fichiers de migration SQL à partir des définitions du schéma.

Exécuter les migrations

1// migrations.ts
2import { migrate } from 'drizzle-orm/node-postgres/migrator';
3import { db } from './db';
4
5async function main() {
6  await migrate(db, { migrationsFolder: './drizzle' });
7  console.log('Migrations terminées');
8}
9
10main().catch((e) => {
11  console.error('Échec des migrations');
12  console.error(e);
13  process.exit(1);
14});

Les migrations dans Sequelize

Sequelize propose un système de migration basé sur des fichiers JavaScript/TypeScript.

Initialiser les migrations

1# Installation de la CLI Sequelize
2npm install -g sequelize-cli
3
4# Initialisation de la structure du projet
5npx sequelize-cli init

Cela crée la structure suivante :

  • config/ - configuration de la base de données
  • migrations/ - fichiers de migration
  • models/ - modèles Sequelize
  • seeders/ - données initiales

Créer une migration

1npx sequelize-cli migration:generate --name create-users

Cela crée le squelette d'un fichier de migration :

1// migrations/YYYYMMDDHHMMSS-create-users.js
2'use strict';
3
4/** @type {import('sequelize-cli').Migration} */
5module.exports = {
6  async up(queryInterface, Sequelize) {
7    await queryInterface.createTable('Users', {
8      id: {
9        allowNull: false,
10        autoIncrement: true,
11        primaryKey: true,
12        type: Sequelize.INTEGER
13      },
14      name: {
15        type: Sequelize.STRING
16      },
17      email: {
18        type: Sequelize.STRING,
19        unique: true
20      },
21      createdAt: {
22        allowNull: false,
23        type: Sequelize.DATE
24      },
25      updatedAt: {
26        allowNull: false,
27        type: Sequelize.DATE
28      }
29    });
30  },
31
32  async down(queryInterface, Sequelize) {
33    await queryInterface.dropTable('Users');
34  }
35};

Exécuter les migrations

1# Exécute toutes les migrations en attente
2npx sequelize-cli db:migrate
3
4# Annule la dernière migration
5npx sequelize-cli db:migrate:undo
6
7# Annule toutes les migrations
8npx sequelize-cli db:migrate:undo:all

Techniques de migration avancées

Le seeding de données

Le seeding consiste à peupler la base avec des données initiales, ce qui est utile pour les tests et le développement.

Le seeding dans Prisma

1// prisma/seed.ts
2import { PrismaClient } from '@prisma/client';
3
4const prisma = new PrismaClient();
5
6async function main() {
7  // Création des catégories
8  const electronicsCategory = await prisma.category.upsert({
9    where: { name: 'Electronics' },
10    update: {},
11    create: {
12      name: 'Electronics',
13      description: 'Appareils et accessoires électroniques'
14    },
15  });
16
17  // Création des produits
18  const product1 = await prisma.product.upsert({
19    where: { id: 'prod_1' },
20    update: {},
21    create: {
22      id: 'prod_1',
23      name: 'Smartphone X',
24      description: 'Dernier modèle avec des fonctionnalités avancées',
25      price: 999.99,
26      inventory: 50,
27      images: ['/images/smartphone-x-1.jpg', '/images/smartphone-x-2.jpg'],
28      categories: {
29        connect: { id: electronicsCategory.id }
30      }
31    },
32  });
33
34  console.log({ electronicsCategory, product1 });
35}
36
37main()
38  .catch((e) => {
39    console.error(e);
40    process.exit(1);
41  })
42  .finally(async () => {
43    await prisma.$disconnect();
44  });

Exécution du seeder :

1npx prisma db seed

Configuration dans package.json :

1{
2  "prisma": {
3    "seed": "ts-node prisma/seed.ts"
4  }
5}

Le seeding dans Sequelize

1// seeders/YYYYMMDDHHMMSS-demo-user.js
2'use strict';
3
4/** @type {import('sequelize-cli').Migration} */
5module.exports = {
6  async up(queryInterface, Sequelize) {
7    await queryInterface.bulkInsert('Users', [
8      {
9        name: 'John Doe',
10        email: 'john@example.com',
11        createdAt: new Date(),
12        updatedAt: new Date()
13      },
14      {
15        name: 'Jane Smith',
16        email: 'jane@example.com',
17        createdAt: new Date(),
18        updatedAt: new Date()
19      }
20    ]);
21  },
22
23  async down(queryInterface, Sequelize) {
24    await queryInterface.bulkDelete('Users', null, {});
25  }
26};

Exécution du seeder :

1npx sequelize-cli db:seed:all
2
3# Annule le dernier seeder
4npx sequelize-cli db:seed:undo
5
6# Annule tous les seeders
7npx sequelize-cli db:seed:undo:all

Les transactions dans les migrations

Les transactions garantissent que les migrations sont atomiques - soit tous les changements sont appliqués, soit aucun.

Les transactions dans Prisma

Prisma utilise automatiquement des transactions pour les migrations, sans configuration supplémentaire.

Les transactions dans Sequelize

1// Exemple de migration avec une transaction
2module.exports = {
3  async up(queryInterface, Sequelize) {
4    const transaction = await queryInterface.sequelize.transaction();
5    try {
6      // Première opération
7      await queryInterface.createTable('Products', {
8        id: {
9          allowNull: false,
10          autoIncrement: true,
11          primaryKey: true,
12          type: Sequelize.INTEGER
13        },
14        //
15      }, { transaction });
16
17      // Deuxième opération
18      await queryInterface.addColumn(
19        'Users',
20        'roleId',
21        {
22          type: Sequelize.INTEGER,
23          references: {
24            model: 'Roles',
25            key: 'id'
26          },
27        },
28        { transaction }
29      );
30
31      // Valide la transaction
32      await transaction.commit();
33    } catch (error) {
34      // Annule tous les changements en cas d'erreur
35      await transaction.rollback();
36      throw error;
37    }
38  },
39
40  async down(queryInterface, Sequelize) {
41    //
42  }
43};

Migrations avec transformation des données

Parfois, il faut non seulement modifier la structure, mais aussi transformer les données existantes.

1module.exports = {
2  async up(queryInterface, Sequelize) {
3    // 1. Ajoute une nouvelle colonne
4    await queryInterface.addColumn('Users', 'fullName', {
5      type: Sequelize.STRING,
6      allowNull: true
7    });
8
9    // 2. Récupère tous les utilisateurs
10    const users = await queryInterface.sequelize.query(
11      'SELECT id, firstName, lastName FROM "Users"',
12      { type: Sequelize.QueryTypes.SELECT }
13    );
14
15    // 3. Met à jour les données de chaque utilisateur
16    for (const user of users) {
17      await queryInterface.sequelize.query(
18        `UPDATE "Users" SET "fullName" = ? WHERE id = ?`,
19        {
20          replacements: [`${user.firstName} ${user.lastName}`.trim(), user.id],
21          type: Sequelize.QueryTypes.UPDATE
22        }
23      );
24    }
25
26    // 4. Supprime les colonnes inutiles
27    await queryInterface.removeColumn('Users', 'firstName');
28    await queryInterface.removeColumn('Users', 'lastName');
29  },
30
31  async down(queryInterface, Sequelize) {
32    // Logique pour restaurer l'état précédent...
33  }
34};

Bonnes pratiques pour les modèles et les migrations

Conception des modèles

  1. Utilise des noms non ambigus - Les noms de modèles doivent être au singulier (par ex. User, pas Users)
  2. Respecte les conventions de nommage - Sois cohérent dans le style (camelCase, snake_case, etc.)
  3. Documente les modèles - Ajoute des commentaires expliquant le rôle des champs et des relations
  4. Validation au niveau du modèle - Définis des contraintes et des règles de validation
  5. Types d'ID - Préfère UUID/CUID aux ID auto-incrémentés pour une meilleure scalabilité

Gérer les migrations

  1. Migrations petites et ciblées - Une migration doit contenir un seul changement logique
  2. Teste les migrations - Teste toujours les migrations sur une copie de la base de production avant le déploiement
  3. Ne modifie jamais les migrations existantes - Crées-en de nouvelles plutôt que de modifier les anciennes
  4. Fournis toujours des down migrations - Implémente la logique de rollback
  5. Contrôle de version - Stocke les migrations dans le dépôt de code

Performance

  1. Index - Ajoute des index sur les colonnes fréquemment recherchées
  2. Découpe les grosses migrations - Pour les grandes tables, divise les opérations en plus petites parties
  3. Évite les verrous - Planifie les migrations pour minimiser le temps de verrouillage des tables
  4. Surveille les performances - Teste le temps d'exécution des migrations sur des jeux de données réalistes

Les migrations dans le contexte de Next.js

Stratégie de migration pour les applications Next.js

Dans les applications Next.js, il est préférable de lancer les migrations :

  1. Pendant le déploiement - Sur des plateformes comme Vercel, tu peux utiliser des hooks de build/déploiement
  2. En étape distincte - Dans le pipeline CI/CD, avant le déploiement réel
  3. Manuellement avant le déploiement - En cas de migrations complexes

Exemple de configuration pour Vercel

Pour les applications hébergées sur Vercel, on peut configurer les migrations dans le fichier package.json :

1{
2  "scripts": {
3    "vercel-build": "prisma migrate deploy && next build"
4  }
5}

Déployer les migrations en production

Voici un workflow typique de déploiement des migrations en production :

  1. Développement local :

    • Créer et tester les migrations en local
    • Committer les migrations dans le contrôle de version
  2. Staging :

    • Déployer vers l'environnement de test
    • Lancer les tests automatisés
    • Vérifier le bon fonctionnement des migrations
  3. Production :

    • Créer une sauvegarde de la base de production
    • Déployer les migrations pendant les périodes de faible charge
    • Surveillance après déploiement

Récapitulatif

Les modèles de données et les migrations forment le socle de la couche de données d'une application Next.js. Une conception réfléchie et une gestion systématique des changements garantissent la stabilité, la scalabilité et la sécurité de l'application.

  • Les modèles de données définissent la structure des informations stockées et les relations entre elles
  • Les migrations permettent de modifier la structure de la base en toute sécurité

Le choix du bon ORM et de la bonne stratégie de migration dépend des spécificités du projet, mais les principes de conception des modèles restent similaires quelle que soit la technologie.

Dans le prochain module, nous verrons comment récupérer efficacement des données dans les server components de Next.js.

Code de cette leçon : App.tsx
1import React, { useState } from 'react';
2
3// Modèles de données et migrations dans Prisma
4// Relations : 1:1, 1:N, N:M
5
6const models = [
7  {
8    name: 'Citizen',
9    fields: [
10      { name: 'id', type: 'String @id @default(cuid())' },
11      { name: 'name', type: 'String' },
12      { name: 'email', type: 'String @unique' },
13      { name: 'district', type: 'District @relation(...)' },
14      { name: 'districtId', type: 'String' },
15      { name: 'energyLogs', type: 'EnergyLog[]' },
16    ],
17    color: '#64ffda',
18  },
19  {
20    name: 'District',
21    fields: [
22      { name: 'id', type: 'String @id @default(cuid())' },
23      { name: 'name', type: 'String @unique' },
24      { name: 'sector', type: 'String' },
25      { name: 'citizens', type: 'Citizen[]' },
26      { name: 'powerGrid', type: 'PowerGrid?' },
27    ],
28    color: '#ff79c6',
29  },
30  {
31    name: 'EnergyLog',
32    fields: [
33      { name: 'id', type: 'String @id @default(cuid())' },
34      { name: 'usage', type: 'Float' },
35      { name: 'citizen', type: 'Citizen @relation(...)' },
36      { name: 'citizenId', type: 'String' },
37      { name: 'createdAt', type: 'DateTime @default(now())' },
38    ],
39    color: '#bd93f9',
40  },
41];
42
43// Exemples de migrations
44const migrations = [
45  { name: '001_init', desc: 'Création des tables Citizen, District' },
46  { name: '002_energy', desc: 'Ajout de la table EnergyLog' },
47  { name: '003_index', desc: 'Index sur email et districtId' },
48];
49
50export default function DataModelsDemo() {
51  const [activeModel, setActiveModel] = useState(0);
52
53  return (
54    <div style={{
55      background: '#0f0f23',
56      minHeight: '100vh',
57      padding: '24px',
58      color: '#e0e0e0',
59      fontFamily: 'monospace'
60    }}>
61      <h1 style={{ color: '#64ffda' }}>
62        Modèles de données et migrations
63      </h1>
64      <p style={{ color: '#8892b0', marginBottom: '20px' }}>
65        Relations : 1 Citizen &rarr; 1 District (N:1),
66        1 Citizen &rarr; N EnergyLog (1:N)
67      </p>
68
69      {/* Boutons des modèles */}
70      <div style={{ display: 'flex', gap: '8px', marginBottom: '16px' }}>
71        {models.map((m, i) => (
72          <button
73            key={m.name}
74            onClick={() => setActiveModel(i)}
75            style={{
76              background: activeModel === i ? m.color : '#1a1a2e',
77              color: activeModel === i ? '#0f0f23' : m.color,
78              border: `1px solid ${m.color}`,
79              padding: '8px 16px',
80              borderRadius: '4px',
81              cursor: 'pointer',
82              fontFamily: 'monospace',
83            }}
84          >
85            {m.name}
86          </button>
87        ))}
88      </div>
89
90      {/* Champs du modèle */}
91      <div style={{
92        background: '#1a1a2e',
93        borderRadius: '8px',
94        padding: '16px',
95        border: `1px solid ${models[activeModel].color}`,
96        marginBottom: '20px',
97      }}>
98        <h3 style={{ color: models[activeModel].color }}>
99          model {models[activeModel].name}
100        </h3>
101        {models[activeModel].fields.map((f) => (
102          <div key={f.name} style={{
103            padding: '6px 0',
104            borderBottom: '1px solid #16213e',
105            display: 'flex',
106            gap: '12px'
107          }}>
108            <span style={{ color: '#50fa7b', minWidth: '100px' }}>
109              {f.name}
110            </span>
111            <span style={{ color: '#8892b0' }}>{f.type}</span>
112          </div>
113        ))}
114      </div>
115
116      {/* Migrations */}
117      <h3 style={{ color: '#ff79c6' }}>Historique des migrations</h3>
118      {migrations.map((m, i) => (
119        <div key={m.name} style={{
120          display: 'flex',
121          alignItems: 'center',
122          gap: '12px',
123          padding: '8px',
124          background: '#1a1a2e',
125          borderRadius: '4px',
126          marginBottom: '4px',
127        }}>
128          <span style={{ color: '#50fa7b' }}>&#10003;</span>
129          <code style={{ color: '#bd93f9' }}>{m.name}</code>
130          <span style={{ color: '#8892b0' }}>{m.desc}</span>
131        </div>
132      ))}
133
134      <div style={{
135        marginTop: '16px',
136        padding: '12px',
137        background: '#1a3a2e',
138        borderRadius: '8px',
139        border: '1px solid #2ea043',
140        color: '#7ee787',
141      }}>
142        <strong>Commande :</strong> npx prisma migrate dev --name nom_migration
143      </div>
144    </div>
145  );
146}

Tu as repéré une erreur dans cette leçon ?

Teste tes connaissances

Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.

  1. 1. PostgreSQL est :

  2. 2. MongoDB stocke les données sous forme de :

Voici 2 des 3 questions de cette leçon. Réponds aux autres dans le jeu.

Tâches pratiques dans le jeu

  • Éditeur de code

    Implémente un tableau de bord avec récupération dynamique de données

  • Clique dans l'ordre

    Compose la syntaxe SQL de création de table

  • Éditeur de code

    Crée un panneau de contrôle avec récupération de données en temps réel

Articles utiles