Cours 04 - Récupération de données externes
Requêtes fetch et exécution asynchrone
Section intitulée « Requêtes fetch et exécution asynchrone »La fonction est fetch permet d’exécuter des requêtes http par code. Celle-ci est autant utilisable du côté serveur que du côté client malgré que certaines contraintes limites sont utilsation par le navigateur. Son appel retourne une promesse résolue par un type Response:
//Requête en GETfetch('https://url-vers-la-source-de-donnees').then(resp => resp.json()) //la réponse "incomplète" à convertir (en json ici).then(data => console.log(data)); //les données retournées par le serveur
//Requête en POSTfetch('url', { method: 'POST', headers: { 'Content-Type' : 'application/json'}, //dans la plupart des cas body: JSON.stringify({ //informations envoyées au serveur prop1: val1, prop2: val2 })}).then(resp => resp.json()) .then(data => console.log(data));Les promesses permettent donc de garder le code ordonné et lisible sans se perdre dans une série de callbacks entrecroisés.
async/await
Section intitulée « async/await »Le support général par Nodejs et les navigateurs d’async / await rend le tout plus lisible et améliore l’expérience de développement:
const fetchData = async () => { const resp = await fetch('https://url-vers-la-source-de-donnees'); const data = await resp.json(); console.log(data);};Await fait une pause (en quelque sorte) dans le code en attendant que la promesse soit résolue. Il est impossible d’utiliser await si la fonction qui englobe cette commande n’est pas marquée par un async.
Les deux méthodes then et async/await peuvent aussi être mélangées:
const fetchData = async () => { const data = await fetch('url') .then(resp => resp.json()); console.log(data);};Loading & Suspense
Section intitulée « Loading & Suspense »On utilisera un fichier loading.jsx lorsque l’on veut afficher une alternative UI temporaire à la page entière pendant l’exécution asynchrone du code s’y trouvant.
export default function Loading() { // Or a custom loading skeleton component return <p>Chargement...</p>}On utilisera Suspense pour afficher une alternative aux composantes async (comme un spinner ou un skeleton) jusqu’au moment où leurs promesses seront résolues:
const MaComposanteAsync = async () => { const resp = await fetch('https://url-vers-la-source-de-donnees'); const taches = await resp.json();
return ( <ul> { /* Affichage des tâches */} </ul> );};import { Suspense } from 'react';
// La page est affichée à l'utilisateur, mais un Skeleton s'affiche à la// place de MaComposanteAsync temporairement.// Une fois la promesse de MaComposanteAsync résolue,// le UI de la composante sera "streamé" vers le navigateur.
return (<main> <header><h1>Titre</h1></header> <Suspense fallback={<Skeleton/>}> <MaComposanteAsync/> </Suspense></main>);Couche d’accès aux données
Section intitulée « Couche d’accès aux données »On regroupera dans un même fichier les interactions correspondantes à une même ressource/table dans la base de données. On retrouvera par exemple dans un fichier _data/tasks.js les opérations CRUD liées à la table tasks ou les tâches récupérées depuis une source externe par fetch.
import “server-only”
Section intitulée « import “server-only” »Comme dans Nextjs tout le code client et serveur est rédigé en JavaScript, il sera nécessaire d’ajouter la mention import "server-only"; en haut de chacun de ces fichiers correspondant à la couche de données afin d’éviter de les importer par erreur durant le développement depuis une composante exécutée du côté client.
La centralisation du code dans un même fichier améliore l’organisation et l’entretien des interactions avec la base de données et peut permettre de remplacer facilement le contenu des fonction si la source de données venait qu’à changer sans avoir à reconsidérer la logique de l’application.
Dans une conception MVC des choses, on considérerait cette partie du code comme le modèle.
import 'server-only';
// Récupération des tâches depuis un API externeexport const getTasks = async () => { try { const resp = await fetch('https://jsonplaceholder.typicode.com/todos'); if (!resp.ok) { throw new Error("Données non accessibles"); }
const tasks = await resp.json(); return tasks;
} catch(err) { console.log('[GET TASKS ERROR]', err); return []; }};On les récupère ensuite depuis n’importe quelle composante serveur de l’application.
import {getTasks} from '@app/_data/tasks';
const PageTaches = async() => { const taches = await getTasks();
return ( <ul> { /* Affichage des tâches */ } </ul> );};Afin d’exécuter des requêtes aux bases de données, l’ORM Drizzle sera utilisé. Un ORM permet de faire le pont entre le code de l’application et la base de données. On y appelle donc des méthodes plutôt que de fabriquer des chaînes de caractères qu’on enverrait à la base de données.
Avantages
Section intitulée « Avantages »- Contrairement à son plus grand compétiteur, Prisma, la syntaxe de Drizzle ressemble beaucoup aux requêtes SQL classiques. Son fonctionnement est davantage transparent;
- Le poids de la la libtaire est très léger;
- La rédaction de ses schémas n’introduit pas de nouveau langage de programmation;
- Les informations retournées par les requêtes sont typées et compatibles avec Typescript;
- Drizzle est compatibles avec plusieurs types de bases de données;
Installation
Section intitulée « Installation »Dépendances:
Section intitulée « Dépendances: »Dépendances de développement
Section intitulée « Dépendances de développement »- Drizzle-Kit: permet l’utilisation des migrations et de Drizzle-Studio:
npm i drizzle-orm@rc @libsql/client dotenvnpm i -D drizzle-kit@rcConfiguration
Section intitulée « Configuration »La configuration se fait en plusieurs étapes. Il est cependant essentiel de bien comprendre le rôle de chacune de ces étapes.
package.json
Section intitulée « package.json »Ajouter une propriété "type": "module" au fichier package.json:
{"name": "titre-app","version": "0.1.0","private": true,"type: "module",}Variables d’environnement
Section intitulée « Variables d’environnement »Afin de ne pas laisser d’informations sensibles dans le code de l’application ainsi que de permettre de changer facilement certaines valeurs en fonction de l’environnement de diffusion, créer un fichier .env à la racine du projet:
# chemin vers la base de données à venirDATABASE_URL="file:./dev.db"Modification de .gitignore
Section intitulée « Modification de .gitignore »Afin d’éviter que la base de données soit éventuellement ajoutée à votre repositoire git, on l’ajoutera au fichier .gitignore:
# ajouter cette ligne à la fin du fichier*.dbConnexion à la base de données par le client
Section intitulée « Connexion à la base de données par le client »Créer des fichiers index.js et schema.js à l’endroit suivant:
Répertoiresrc/
Répertoiredb/ // nouveau dossier
- index.js
- schema.js
import 'dotenv/config'; // pour lire les variables d'environnementimport { drizzle } from 'drizzle-orm/libsql';
// correspond au nom de la variable dans .envexport const db = drizzle({ // logger: true, connection: {url: process.env.DATABASE_URL},});import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
export const postsTable = sqliteTable("posts", { id: integer().primaryKey({ autoIncrement: true }), title: text().notNull(),});Configuration pour Drizzle-kit
Section intitulée « Configuration pour Drizzle-kit »Créer un fichier drizzle.config.js à la racine du projet:
import "dotenv/config";import { defineConfig } from "drizzle-kit";export default defineConfig({ out: "./drizzle", schema: "./src/db/schema.js", dialect: "sqlite", dbCredentials: { // correspond au nom de la variable dans .env url: process.env.DATABASE_URL, },});Pousser les schémas vers la nouvelle body
Section intitulée « Pousser les schémas vers la nouvelle body »La base de données dev.db ainsi que la table posts seront créés en exécutant la commande suivante dans le terminal:
npx drizzle-kit pushDifférentes commandes de Drizzle-Kit
Section intitulée « Différentes commandes de Drizzle-Kit »Migrations
Section intitulée « Migrations »Afin de créer les tables dans la bd, on peut utiliser la commande:
npx drizzle-kit pushCelle-ci permet de tester rapidement les nouveaux schémas en développement sans se soucier de la gestion des fichiers de migration.
Sinon, on peut également utiliser:
npx drizzle-kit generateVérifie les différences entre les schémas actuels et les schémas de la version précédente et génère une migration. On pousse ensuite la migration avec:
npx drizzle-kit migrateDrizzle Studio permet de visualiser et modifier les données dans les tables à l’image de PhpMyAdmin. On le démarre avec la commande:
npx drizzle-kit studioSchémas Drizzle
Section intitulée « Schémas Drizzle »Création d’une table
Section intitulée « Création d’une table »Dans le fichier /src/db/schema.js, on déclare donc un schéma pour chaque table devant être ajoutée à la base de données.
On crée un schéma avec sqliteTable en lui passant le nom réellement donné à la table. Favoriser le snake_case. Chaque schéma doit être exporté afin d’être accessible par Drizzle-Kit et le code de l’application.
import { sqliteTable } from "drizzle-orm/sqlite-core";
export const nomTable = sqliteTable("nom_table", {//déclarer les champs ici});On déclare ensuite les champs dans le schéma selon la syntaxe suivante:
import { sqliteTable } from "drizzle-orm/sqlite-core";
export const nomTable = sqliteTable("nom_table", { nomPropriete: typeDeChamp("nom_propriété"), nomPropriete2: typeDeChamp2("nom_propriété2"),});L’alias (“nom_propriété”) est optionnel sur les champs, mais il permet de le renommer en snake_case (par exemple) dans la base de données.
Types de champ couramment utilisés
Section intitulée « Types de champ couramment utilisés »En SQLite, seuls les champs de type NULL, INTEGER, REAL, TEXT et BLOB sont supportés. Drizzle permet cependant d’étendre ces types en prenant en charge la conversion.
À partir du type INTEGER
Section intitulée « À partir du type INTEGER »// nombreinteger()// clé primaire avec auto-incrémentationinteger().primaryKey({ autoIncrement: true })// booléeninteger({ mode: 'boolean' })// timestampinteger({ mode: 'timestamp' })À partir du type texte
Section intitulée « À partir du type texte »// valeur textuelletext()// clé primaire textuelletext().primaryKey()// sauvegarde de données mixtes / structure changeantetext({ mode: 'json' })Attributs
Section intitulée « Attributs »Par défaut, les champs déclarés peuvent être null, il est donc important d’utiliser l’attribut notNull():
export const nomTable = sqliteTable("nom_table", { id: integer().primaryKey({ autoIncrement: true }), titre: text().notNull(),});Valeur par défaut
Section intitulée « Valeur par défaut »import { sql } from "drizzle-orm";import { text, sqliteTable } from "drizzle-orm/sqlite-core";
export const nomTable = sqliteTable("nom_table", { id: text().primaryKey(), title: text().default('test'), // valeur par défaut d'un timestamp = Maintenant createdAt: integer('created_at', { mode: 'timestamp' }).default(sql`(CURRENT_TIMESTAMP)`),});Requêtes
Section intitulée « Requêtes »Depuis une composante serveur ou préférablement depuis la couche d’accès aux données, on pourra exécuter la requête de base suivante:
import 'server-only';import { db } from "@/db";import { nomTable } from "@/db/schema";import {eq} from 'drizzle-orm';
export const getAllPosts = async () => { // posts sera un tableau contenant les éléments const posts = await db.select().from(nomTable).limit(5); return posts;};
export const getAllPostsTitle = async () => { // ne récupère que le champ title de la table et converti ensuite le tableau d'objets // en tableau de titres const posts = await db.select({title: nomTable.title}) .from(nomTable) .limit(5); return posts.map((p) => p.title);};
export const getPostById = async (unId) => { const post = await db.select().from(nomTable).where(eq(nomTable.id, unId)); // il est possible que post soit null si le id est introuvable return post;};Jointures
Section intitulée « Jointures »Depuis les schémas suivants:
export const users = sqliteTable('users', {id: integer().primaryKey({ autoIncrement: true }),name: text().notNull(),});export const pets = sqliteTable('pets', {id: integer().primaryKey({ autoIncrement: true }),name: text().notNull(),ownerId: integer('owner_id').notNull().references(() => users.id),})La requête:
const result = await db.select().from(users).leftJoin(pets, eq(users.id, pets.ownerId));console.log(result[0]);/* { user: { id: 1, name: 'Bob' }, pets: { id: 2, name: 'Henri', ownerId: 1 } }*/Ressources
Section intitulée « Ressources »await db.insert(users).values({ id: 1, name: 'Andrew' });Multiple
Section intitulée « Multiple »await db.insert(users).values([{ name: 'Andrew' }, { name: 'Dan' }]);await db.update(users).set({ name: "Mr. Dan" }).where(eq(users.name, "Dan"));await db.delete(users).where(eq(users.name, 'Dan'));