Utilisation de bases de données

On peut très bien uniquement stocker ses données sous la forme de variables, de liste ou encore de dictionnaires pendant l'exécution du serveur. Mais lorsque le serveur va s'arrêter on perdra toutes ses données... Pour conserver ses données il faut les stocker dans une base (il en existe de différents types selon l'usage) facilement accessible (en utilisant des requêtes facile à créer et à maintenir).

Nous allons montrer ici un cas d'utilisation simple que vous pourrez adapter à vos besoins futur :

Nous aurons besoin d'un projet pour tester ce que nous allons voir donc :

Créez un dossier bd-tests, puis initialisez-le :

npm init

Et on ajoute express :

 npm install --save express

SQLite

Tout ce que nous allons faire avec la base de donnée SQLite est transposable en utilisant un autre type de base de donnée utilisant un serveur dédié. SQlite sauve ses données soit en mémoire soit sur un fichier, ce qui permet d'utiliser des bases de données sans avoir à configurer un serveur dédié : c'est donc bien quand on apprend ou que l'on fait de petits sites, mais c'est souvent insuffisant pour des sites professionnels.

Installation de SQlite

Le module node sqlite3 nous permet d'utiliser SQlite dans nos serveurs node. Installons-le :

Dans le dossier bd-tests et avec un terminal :

npm install --save sqlite3

Il existe également Le module node better-sqlite3 est une version amélioré — selon ses auteurs — du module classique sqlite3, mais il ne fonctionne pas — à l'heure où je tape ces caractères — avec sequelize que nous utiliserons ensuite.

Il est parfois aussi utile d'avoir une cli pour vérifier ou installer des données. Vous pouvez l'installer directement sur votre système :

Une fois ce module installé vous pourrez utiliser l'utilitaire sqlite3 :

❯ sqlite3
SQLite version 3.43.2 2023-10-10 13:08:14
Enter ".help" for usage hints.
Connected to a transient in-memory database.
Use ".open FILENAME" to reopen on a persistent database.
sqlite>

Usage

On tape directement les commandes SQL dans une fonction js. Avec des promesses.

Nous n'utiliserons pas cette façon de faire qui est pleine de défauts (voir après) on va donc passer vite. Sachez juste que ça existe.

TBD : faire un tuto ?

fichier en cli

fichier sous node

mémoire sous node

ORM : sequelize

Nous n'allons pas utiliser sqlite en tapant juste des commandes sql pour plusieurs raisons :

On utilise un ORM (Object-Relational Mapping) qiu permet

Installation

npm install --save sequelize

Lien à la base de données

Lien avec une base de données SQlite en mémoire :

import { Sequelize } from 'sequelize';

const db = new Sequelize({
  dialect: 'sqlite',
  storage: ':memory:',
})

A la ligne 3, on crée la variable sequelize qui sera notre intermédiaire à la base de donnée.

On peut aussi utiliser un fichier (le fichier sera crée s'il n'existe pas encore). Ici c'est le fichier db.sqlite qui sera utilisé :

import { Sequelize } from 'sequelize';

import { fileURLToPath } from 'url';
import path from 'path';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);


const db = new Sequelize({
  dialect: 'sqlite',
  storage: path.join(__dirname, 'db.sqlite')
});

Modèles

L'intérêt des ORM est que l'on va décrire la base de donnée et interagir avec elle via des modèles. Chaque modèle est constitué de champs qui vont décrire nos données. Dans un formalisme objet :

Les types possible de champs sont disponible dans la documentation.

On va par exemple créer un modèle constitué d'une chaîne de caractère (STRING : chaîne de caractère d'au plus 255 caractères) et d'un entier (INTEGER) :

import { DataTypes } from "sequelize";

const MonModèle = db.define(
  "MonModèle",
  {
    nom: {
      type: DataTypes.STRING,
      allowNull: false,
    },
    valeur: {
      type: DataTypes.INTEGER,
      allowNull: false,
    },
  },
  {
    // Other model options go here
  }
);

Une fois le modèle donné, il faut synchroniser la base de donnée avec celui-ci (si par exemple la base était créer avec un vieux modèle, il faut même changer la base pour qu'elle corresponde à notre nouveau modèle). Ceci se fait avec la promesse sync :

await sequelize.sync();

console.log("synchronisation terminée.");

Il est important d'attendre la fin de la synchronisation avant de lire ou sauver des données

Normalement, la synchronisation des bases ne se fait pas en production. On a un script de création des modèles et de synchronisation que l'on n'exécute que lorsque le modèle change.

Comme ici on a une base de donnée en mémoire, elle est crée à chaque lancement du serveur, ce qui nous oblige à synchroniser à chaque démarrage (il faut ajouter les tables à la base fraîchement créée).

import { Sequelize, DataTypes } from "sequelize";

const db = new Sequelize({
  dialect: "sqlite",
  storage: ":memory:",
});

const MonModèle = db.define(
  "MonModèle",
  {
    nom: {
      type: DataTypes.STRING,
      allowNull: false,
    },
    valeur: {
      type: DataTypes.INTEGER,
      allowNull: false,
    },
  },
  {
    // Other model options go here
  }
);

await db.sync();

console.log("Base de donnée créée et synchronisée.");

Champs spéciaux

Clé primaire

Si l'on ne crée pas de clé primaire avec notre modèle, sequelize va créer un champ spécial nommé id qui s'incrémente tout seul et est la clé primaire de notre table.

Par défaut, utilisez toujours ce champ comme clé primaire.

Temps

Par défaut, sequelize ajoute deux champs spéciaux createdAt and updatedAt pour connaître la date de création et de la dernière mise à jour d'une donnée.

Le type de ces champ et DataTypes.DATE. A chaque fois que vous devez utiliser des dates ou des heures, il est indispensable d'utiliser des types dédiées. Il est criminel d'utiliser une chaîne de caractère pour stocker des dates ou des heures : il y a trop de cas particuliers selon les pays et ou d'exception (année bissextile, etc).

Lire et sauver des données

Exemple

import { Sequelize, DataTypes } from "sequelize";

const db = new Sequelize({
  dialect: "sqlite",
  storage: ":memory:",
});

const MonModèle = db.define(
  "MonModèle",
  {
    nom: {
      type: DataTypes.STRING,
      allowNull: false,
    },
    valeur: {
      type: DataTypes.INTEGER,
      allowNull: false,
    },
  },
  {
    // Other model options go here
  }
);

async function initDB() {
  await db.sync();

  var data = await MonModèle.create({
    nom: "un nombre premier",
    valeur: 7,
  });

  console.log("message crée : ");
  console.log(data.toJSON());

  data = await MonModèle.create({
    nom: "un autre nombre premier",
    valeur: 3,
  });
  console.log("message crée : ");
  console.log(data.toJSON());
}

await initDB();
console.log("Base de donnée créée et synchronisée.");

let data;
console.log("Lecture id = 1 :");
data = await MonModèle.findByPk(1);
console.log(data.toJSON());

console.log("---------");
console.log("clé primaire : ", data.id);
console.log("nom : ", data.nom);
console.log("valeur : ", data.valeur);
console.log("date de création création : ", data.createdAt);
console.log("dernière modification : ", data.updatedAt);
console.log("---------");

console.log("Lecture id qui n'existe pas :");
data = await MonModèle.findByPk(42);
console.log(data); // n'existe pas

console.log("Lecture tous les éléments :");
data = await MonModèle.findAll();
for (let element of data) {
  console.log(element.toJSON());
}

console.log("Lecture requête :");
data = await MonModèle.findAll({
  where: {
    valeur: 3,
  },
});
for (let element of data) {
  console.log(element.toJSON());
}

Créer un fichier ma_db_test.js qui contient le code précédent et exécutez le avec la commande :

node ma_db_test.js

Le code vu dans la console qui ressemble à du SQL est bien du SQL. Ce sont les commandes faites par sequelize.

On crée une fonction asynchrone initDB dont le but est de se synchroniser puis de créer des données dans la base. A l'intérieur d'une fonction asynchrone on exécute du code avec await, comme ça on est sur qu'on ne passera à la ligne suivant qu'une fois la ligne avec le await exécutée (on est sur que l'on crée des données une fois la base synchronisée)

Les données sont affichées à l'écran sous la forme d'un json. Mais vous avez accès aux différents champs (entre les deux console.log("---------")).

Modifier le code pour qu'il utilise initDB via une promesse.

corrigé

On utilise ensuite cette fonction de façon asynchrone, avec un then. On voit que c'est exécuté de façon asynchrone puisque lorsque l'on exécute le code, la chaîne "coucou" est écrite tout en haut de l'exécution, bien avant les requêtes en base de sequelize.

import { Sequelize, DataTypes } from "sequelize";

const db = new Sequelize({
  dialect: "sqlite",
  storage: ":memory:",
});

const MonModèle = db.define(
  "MonModèle",
  {
    nom: {
      type: DataTypes.STRING,
      allowNull: false,
    },
    valeur: {
      type: DataTypes.INTEGER,
      allowNull: false,
    },
  },
  {
    // Other model options go here
  }
);

async function initDB() {
  await db.sync();

  var data = await MonModèle.create({
    nom: "un nombre premier",
    valeur: 7,
  });

  console.log("message crée : ");
  console.log(data.toJSON());

  data = await MonModèle.create({
    nom: "un autre nombre premier",
    valeur: 3,
  });
  console.log("message crée : ");
  console.log(data.toJSON());
}

console.log("Début synchronisation.");
initDB().then(async () => {
  console.log("Fin synchronisation.");
  console.log("Début utilisation.");
  let data;
  console.log("Lecture id = 1 :");
  data = await MonModèle.findByPk(1);
  console.log(data.toJSON());

  console.log("---------");
  console.log("clé primaire : ", data.id);
  console.log("nom : ", data.nom);
  console.log("valeur : ", data.valeur);
  console.log("date de création création : ", data.createdAt);
  console.log("dernière modification : ", data.updatedAt);
  console.log("---------");

  console.log("Lecture id qui n'existe pas :");
  data = await MonModèle.findByPk(42);
  console.log(data); // n'existe pas

  console.log("Lecture tous les éléments :");
  data = await MonModèle.findAll();
  for (let element of data) {
    console.log(element.toJSON());
  }

  console.log("Lecture requête :");
  data = await MonModèle.findAll({
    where: {
      valeur: 3,
    },
  });
  for (let element of data) {
    console.log(element.toJSON());
  }

  console.log("Fin utilisation.");
});

console.log("coucou !");

CRUD

Accéder aux données se fait, on l'a vue, en utilisant le formalisme CRUD, c'est à dire que l'on veut avoir des url qui nous permettent de :

Nous allons accéder à la base uniquement en utilisant ces méthodes.

Nous utiliserons l'id qui est ajouté par défaut à chaque message pour spécifier directement un message.

Create

Créer une donnée en sequelize peu se faire comme ça :

await MonModèle.create({
    nom: "un nombre premier",
    valeur: 7,
});

Le message est poussé en base. La clé primaire est le champ id. Si c'est le premier élément que vous créez, son id sera de 1, et si vous en créez d'autres, l'id va augmenter. C'est la clé primaire de notre modèle.

Read

Lire une instance en connaissant sa clé primaire.

Asynchrone :

var data = await MonModèle.findByPk(1);

Ou avec une promesse, ce qui va souvent être le cas :

MonModèle.findByPk(1).then((data) => {
  console.log(data.toJSON());
});

Si l'on donne une clé primaire inexistante, on récupère l'objet null.

Update

Mettre à jour un objet en connaissant sa clé primaire et les attributs à changer :

MonModèle.findByPk(1).then(async (data) => {
  data.valeur = 9;
  await data.save();
});

Remarquez que l'on a créée une fonction de type async pour assurer que la donnée sera sauvée avant de terminer la fonction du then.

Delete

MonModèle.findByPk(1).then(async (data) => {
  await data.destroy();
});

Remarquez que l'on a créée une fonction de type async pour assurer que la donnée sera détruite avant de terminer la fonction du then.

Configurations

Dans un serveur utilisant une base de données on a coutume d'avoir plusieurs fichiers pour gérer la base de donnée

  1. un fichier de configuration qui sera utilisé pour définir la base à utiliser (en mémoire, un fichier, etc)
  2. un fichier d'initialisation que l'on exécute pour créer la base et lorsque les champs de la base (le modèle) est modifié
  3. les différentes parties où est utilisé la base.

Configuration de la base

Créez le fichier db.js et copiez_collez-y le code suivant.

import { Sequelize, DataTypes } from 'sequelize';

import { fileURLToPath } from 'url';
import path from 'path';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);


const sequelize = new Sequelize({
  dialect: 'sqlite',
  storage: path.join(__dirname, 'db.sqlite')
});

const MonModèle = sequelize.define(
  "MonModèle",
  {
    nom: {
      type: DataTypes.STRING,
      allowNull: false,
    },
    valeur: {
      type: DataTypes.INTEGER,
      allowNull: false,
    },
  },
  {
    // Other model options go here
  }
);


export default {
    sequelize: sequelize,
    model: {
        MonModèle: MonModèle,    
    }
}

Ce fichier contient tout ce qui est nécessaire à l'utilisation de la base de donnée. Il exporte l'orm sequelize et le model

En javascript lorsque l'on lit un module (avec require) ce module n'est lu qu'une seule fois. Toutes les autres fois il ne fera que rendre l'objet module.exports.

Ceci permet de ne faire l’initialisation de la base qu'une seule fois et d'être assuré de rendre toujours le même objet export default.

Dans la vraie vie, on a plusieurs fichiers d'initialisation selon l'environnement du serveur :

  • en production : avec la vraie base, souvent derrière un serveur de base de donnée
  • en développement : pour les petit tests lorsque l'on développe : avec une base en sqlite sous la forme d'un fichier
  • en test : pour les tests unitaires de routes. Souvent une base en mémoire.

Initialisation de la base

Créez le fichier init.db.js et copiez_collez-y le code suivant.

import db from "./db.js"

async function initDB() {
  await db.sequelize.sync({ force: true });

  var data = await db.model.MonModèle.create({
    nom: "un nombre premier",
    valeur: 7,
  });

  console.log("message crée : ");
  console.log(data.toJSON());

  data = await db.model.MonModèle.create({
    nom: "un autre nombre premier",
    valeur: 3,
  });
}

initDB().then(() => {
  console.log("base initialisée");
});

Ce code synchronise la base si nécessaire. Il utilise la base rendue par le require du fichier db.js. Il ne faut le faire que lorsque la base est nouvellement créée. On l'exécute par la commande :

node init.db.js

Utilisation de la base

Créez le fichier app.js et copiez_collez-y le code suivant.

import db from "./db.js"

async function utilisation() {
  var data

  console.log("Lecture id = 1 :");
  data = await db.model.MonModèle.findByPk(1);
  console.log(data.toJSON());

  console.log("---------");
  console.log("clé primaire : ", data.id);
  console.log("nom : ", data.nom);
  console.log("valeur : ", data.valeur);
  console.log("date de création création : ", data.createdAt);
  console.log("dernière modification : ", data.updatedAt);
  console.log("---------");

  console.log("Lecture id qui n'existe pas :");
  data = await db.model.MonModèle.findByPk(42);
  console.log(data); // n'existe pas

  console.log("Lecture tous les éléments :");
  data = await db.model.MonModèle.findAll();
  for (let element of data) {
    console.log(element.toJSON());
  }

  console.log("Lecture requête :");
  data = await db.model.MonModèle.findAll({
    where: {
      valeur: 3,
    },
  });
  for (let element of data) {
    console.log(element.toJSON());
  }
}

utilisation();

On est obligé de charger le modèle via le import, mais on est pas obligé de faire la synchronisation si le fichier de base existe déjà et est synchronisé avec le modèle.

On peut exécuter la modification et la visualisation du code avec :

node app.js

Notez qu'on a utilisé une fonction asynchrone car on veut pourvoir exécuter nos trois requêtes à la suite (d'où les await).