Aller au contenu principal

NodeJS et ORM

Introduction

Dans le laboratoire précédent, nous avons vu comment nous connecter et utiliser une base de données. Dans ce nouveau laboratoire, nous allons utiliser un ORM. ORM signifie "object-relational mapping" et sert à transformer des relations en objet. Cela permet d’éviter d’utiliser directement du SQL et de pouvoir utiliser des méthodes sur des objets à la place. Nous verrons l’ORM Prisma, mais il en existe d’autres.

Présentation rapide de Prisma

Pré-requis

Ce laboratoire s'appuie sur le laboratoire précédent. Reprenez le code et modifiez-le directement (ou faites une copie si vous désirez garder une copie de chaque laboratoire.)

Initialisation

Reprenez le projet précédent et installez les paquets nécessaires à l’ORM :

npm i -D prisma

N’oubliez pas de lancer le container qui contient la base de données avec :

docker run --name postgres -e POSTGRES_PASSWORD=password -e POSTGRES_USER=john -e POSTGRES_DB=exercices -p 5432:5432 --rm -d postgres 

Et de créer la base de données avec:

npm run initDB 

Enfin, nous allons configurer Prisma pour qu'il puisse se connecter à notre base de données. Exécutez la commande suivante:

npx prisma init

Vous pouvez consulter votre fichier .env et vous verrez que du contenu a été ajouté:

# This was inserted by `prisma init`:
# Environment variables declared in this file are automatically made available to Prisma.
# See the documentation for more detail: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema

# Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB.
# See the documentation for all the connection string options: https://pris.ly/d/connection-strings

DATABASE_URL="..."

DATABASE_URL contient une chaine de caractères contenant toutes les informations nécessaires à Prisma pour la connexion.

DATABASE_URL="postgresql://john:password@localhost:5432/exercices?schema=public"

En regardant de plus près, vous verrez les mêmes informations que nous avions définies dans le fichier .env du laboratoire 2.

Enfin, nous allons générer notre client Prisma avec la commande suivante:

npx prisma db pull

Ok, mais à quoi sert tout cela ?

Un homme en costard bleu explique quelque chose avec un micro.

Reprenons depuis le départ, avec init nous avons pu permettre à Prisma de se connecter à la base de données. Mais ce n'est pas suffisant. Prisma a besoin de connaître la structure de vos tables pour pouvoir intéragir correctement avec. Nous pourrions créer les schémas à la main, mais... C'est là où la magie va pouvoir opérer ! prisma db pull demande à Prisma d'effectuer une inspection de la base de données et de créer automatiquement les schémas associés ! Voici un exemple que vous devriez avoir:

./prisma/schema.prisma
model product {
id Int @id @default(autoincrement())
name String?
price Decimal? @db.Decimal
purchase purchase[]
}

Il s'agit de la définition de notre table product mais traduite pour Prisma.

L'idée clé de Prisma est qu'il n'est qu'un générateur pour créer un client Prisma. En faisant npx prisma generate, nous allons créer un sur-mesure à notre base de données. L'avantage ? Notre client aura des propriétés correspondant à nos tables et parfaitement typées. Notre IDE va ensuite pouvoir indexer ce module et pouvoir proposer une autocomplétion très efficace ! Notre expérience de développement n'en sera qu'améliorée.

Évidemment, n'oublions pas de créer un client unique accessible pour notre code:

./database/databaseORM.js
import { PrismaClient } from '../generated/prisma/client.js';
const prisma = new PrismaClient();

export default prisma;

CRUD avec Prisma

Nous allons maintenant pouvoir faire notre première lecture avec notre ORM !

Selon la documentation officielle de Prisma, voici un exemple de lecture:

// By unique identifier
const user = await prisma.user.findUnique({
where: {
email: 'elsa@prisma.io',
},
})

// By ID
const user = await prisma.user.findUnique({
where: {
id: 99,
},
})

//Get all records
const users = await prisma.user.findMany()

Source 1 et Source 2

Nous pouvons voir que nous utilisons notre client de la façon suivante: prisma.[SCHEMA].[OPERATION], où [SCHEMA] est le schéma visé (pour rappel: il s'agit simplement de la traduction pour Prisma de notre table SQL) et [OPERATION] par l'opération (lecture, écriture, suppression, mise à jour, etc.) désirée.

Essayons maintenant d'appliquer ces méthodes pour notre laboratoire. Créez le fichier suivant:

./controler/productORM.js
import prisma from "../database/databaseORM.js";

export const getProduct = async (req, res) => {
try {
const product = await prisma.product.findUnique({
where: {
id: parseInt(req.params.id)
}
});
if(product){
res.send(product);
} else {
res.sendStatus(404);
}
} catch (err) {
console.error(err);
res.sendStatus(500);
}
};
info

Comme vous pouvez le voir, ce controleur n'utilise pas de modèle. En réalité, le client prisma agit comme un modèle pour accéder à nos données.

On n'oublie pas de modifier notre routeur pour utiliser ce nouveau controleur !

./route/product.js
import Router from 'express';
import {
addProduct,
updateProduct,
deleteProduct
} from "../controler/product.js";
import {getProduct} from "../controler/productORM.js";

const router = Router();

router.post("/", addProduct);
router.patch("/", updateProduct);
router.get("/:id", getProduct);
router.delete("/:id", deleteProduct);


export default router;

Testez pour voir si tout fonctionne. Si oui, nous allons pouvoir passer à l'exercice

Exercice 1: finir le CRUD

Vous allez devoir implémenter le reste du CRUD en vous inspirant de l'exemple précédent.

./controler/productORM.js
import prisma from "../database/databaseORM.js";

export const getProduct = async (req, res)=> {
try {
const product = await prisma.product.findUnique({
where: {
id: parseInt(req.params.id)
}
});
if(product){
res.send(product);
} else {
res.sendStatus(404);
}
} catch (err) {
console.error(err);
res.sendStatus(500);
}
};

export const addProduct = async (req, res) => {
try {
//TODO: n'oubliez pas de renvoyer l'id de l'élément créé.
} catch (err) {
console.error(err);
res.sendStatus(500);
}
};

export const updateProduct = async (req, res) => {
try {
//TODO
} catch (e) {
console.error(e);
res.sendStatus(500);
}
};

export const deleteProduct = async (req, res) => {
try {
//TODO
} catch (e) {
console.error(e);
res.sendStatus(500);
}
};

Pour vous aider, voici différents liens expliquant comment faire:

N'hésitez pas à consulter la solution si vous coincez ou si vous voulez vérifier vos réponses.

Solution
./controler/productORM.js
import prisma from "../database/databaseORM.js";

export const getProduct = async (req, res)=> {
try {
const product = await prisma.product.findUnique({
where: {
id: parseInt(req.params.id)
}
});
if(product){
res.send(product);
} else {
res.sendStatus(404);
}
} catch (err) {
console.error(err);
res.sendStatus(500);
}
};

export const addProduct = async (req, res) => {
try {
const {name, price} = req.body;
const {id} = await prisma.product.create({
data: {
name,
price
},
select: {
id: true
}
});
res.status(201).send({id});
} catch (err) {
console.error(err);
res.sendStatus(500);
}
};

export const updateProduct = async (req, res) => {
try {
const {name , price, id} = req.body;
await prisma.product.update({
data: {
name,
price
},
where: {
id
}
});
res.sendStatus(204);
} catch (e) {
console.error(e);
res.sendStatus(500);
}
};

export const deleteProduct = async (req, res) => {
try {
await prisma.product.delete({
where: {
id: parseInt(req.params.id)
}
});
res.sendStatus(204);
} catch (e) {
console.error(e);
res.sendStatus(500);
}
};

Transaction avec Prisma

Prisma permet d'avoir une écriture des transactions via les Nested writes. L'idée est d'indiquer à Prisma qu'une écriture aura besoin d'une ou plusieurs autres écritures pour être réalisée. Prenons l'exemple de la documentation:

const result = await prisma.user.create({
data: {
email: 'elsa@prisma.io',
name: 'Elsa Prisma',
posts: {
create: [
{ title: 'How to make an omelette' },
{ title: 'How to eat an omelette' },
],
},
},
include: {
posts: true, // Include all posts in the returned object
},
})

Comme vous pouvez le voir, l'astuce consiste à avoir une propriété en plus (posts) dans le schéma. Il représente qu'un user peut avoir plusieurs posts. En indiquant l'ensemble des écritures, Prisma est capable de déterminer dans quel ordre effectuer les écritures et effectue le tout dans une transaction.

Source

Exercice 2: réalisation de la transaction

Nous allons faire de même avec nos achats. Nous allons écrire l'instruction permettant de créer un achat avec un nouvel utilisateur.

./controler/purchaseORM.js
import prisma from "../database/databaseORM.js";

export const postPurchase = async (req, res) => {
try {
const {articleID, quantity, clientID} = req.body;
await prisma.purchase.create({
data: {
quantity,
client: {
connect: {
id: clientID
}
},
product: {
connect: {
id: articleID
}
}

}
})
res.sendStatus(201);
} catch (e) {
console.error(e);
res.sendStatus(500);
}
};

export const purchaseWithRegistration = async (req, res) => {
try {
await prisma.purchase.create({
//TODO
});
res.sendStatus(201);
} catch (e) {
console.error(e);
res.sendStatus(500);
}
};

N'oubliez pas d'adapter les routes pour faire appel à notre nouveau controleur (voir ce qui a été fait plus haut).

Solution
./controler/purchaseORM.js
export const postPurchase = async (req, res) => {
try {
const {articleID, quantity, clientID} = req.body;
await prisma.purchase.create({
data: {
quantity,
client: {
connect: {
id: clientID
}
},
product: {
connect: {
id: articleID
}
}

}
})
res.sendStatus(201);
} catch (e) {
console.error(e);
res.sendStatus(500);
}
};

export const purchaseWithRegistration = async (req, res) => {
try {
const {client, purchase} = req.body;
const {name, firstname, address, email, password} = client;
await prisma.purchase.create({
data: {
quantity: purchase.quantity,
client: {
create: {
name,
firstname,
address,
email,
password
}
},
product : {
connect: {
id: purchase.articleID
}
}
}
});
res.sendStatus(201);
} catch (e) {
console.error(e);
res.sendStatus(500);
}
};

Réflexion sur les ORM

Comme vous avez pu le voir dans ce laboratoire, l’utilisation d’un ORM n’est pas aussi simple qu’il n’y paraît. Contrairement au langage SQL, qui est un standard, chaque ORM a sa propre façon de fonctionner ce qui peut vite devenir problématique. Par exemple: Sequelize (qui est un autre ORM) a tendance à faire beaucoup de choses discrètement et on arrive souvent dans des situations où on doit "lutter" contre l’ORM. Parfois on entend l’argument : il permet d’éviter d’apprendre le SQL et de dialoguer facilement avec une base de données. Cependant, vous tomberez rapidement sur des erreurs propres à SQL et non à votre ORM.

De plus les ORM ont tendance à faire des requêtes qui sont plus complexes à exécuter. En effet, comme la construction des requêtes est générique, il n’est pas possible d’effectuer des optimisations dans la requête. Voici un article en anglais qui explique le problème des performances avec un ORM si vous désirez en savoir plus sur le sujet : https://blog.logrocket.com/why-you-should-avoid-orms-with-examples-in-node-js-e0baab73fa5/

danger

Attention ! L’utilisation d’un ORM ne vous dispensera pas de certaines questions à l’examen. Si l’ORM fait des choses pour vous, vous devez être capables d’expliquer le fonctionnement (par exemple, les rollbacks automatiques). En effet, si vous ne comprenez pas les mécanismes derrière l’ORM, vous ne pouvez pas garantir que l’interaction avec la base de données s’est déroulée comme souhaitée.

Query builder : une alternative

Un query builder est un outil qui va vous aider à construire une requête. En utilisant des fonctions, comme where() vous pouvez construire des requêtes de manière dynamique. C’est le constructeur qui se chargera de créer une requête correcte par rapport à ce que vous lui avez demandé. Voici un exemple avec knex :

knex('users')
.join('contacts', 'users.id', '=', 'contacts.user_id')
.select('users.id', 'contacts.phone')

Outputs:
select "users"."id", "contacts"."phone" from "users" inner join "contacts" on "users"."id" = "contacts"."user_id"

Il y a beaucoup moins de "magie" qu’avec un ORM, mais vous gardez un meilleur contrôle de la situation.