Nuxt + API externe. C’est comme ça que j’ai toujours utilisé Nuxt dans mes projets et comme ça qu’il était utilisé chez les différents clients pour lesquels j’ai travaillé. Mais récemment, j’ai bossé sur un projet où la contrainte était de tout faire dans Nuxt, et on s’est retrouvé avec un Nuxt + Drizzle + des routes API internes.
Du coup, j’en profite pour rédiger ici une présentation de la mise en place d’une application fullstack avec Nuxt. On se basera sur un petit projet très simple : une gestion légère de matériel d’astronomie avec liste et CRUD. On reste simple, mais cela permettra de voir quelques points intéressants de Drizzle ORM, comme les relations, par exemple.
Un seul prérequis : disposer d’une base de données locale (PostgreSQL, MySQL, etc.).
Vous pourrez aussi récupérer les sources depuis le GitLab public.
Mise en place
La stack sera la suivante :
- Nuxt avec :
- NuxtUi
- NuxtHub
- Drizzle
On ne s’attardera pas sur la partie UI, cela restera basique.
Installation de Nuxt
On peut maintenant installer notre environnement.
npm create nuxt@latest tuto-nuxt
npm install @nuxt/ui tailwindcss
npx nuxi module add hub
npm i drizzle-orm@beta -D
# Si postgresl
npm install drizzle-orm drizzle-kit postgres @electric-sql/pglite
# Si Mysql
npm install drizzle-orm drizzle-kit mysql2
# Si SQLite
npm install drizzle-orm drizzle-kit @libsql/client
On vérifie dans nuxt.config.ts que les modules sont bien chargés et on adapte la configuration.
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
...
modules: ['@nuxt/ui', '@nuxthub/core'],
hub: {
db: 'postgresql' // ou 'mysql' / 'sqlite'
}
})
On va se faire une petite base UI (rien de folichon).
<template>
<div>
<header class="topbar">
<NuxtLink to="/" class="brand">Astronomy Demo</NuxtLink>
<nav class="nav">
<NuxtLink to="/manufacturers">Manufacturers</NuxtLink>
<NuxtLink to="/stars">Stars</NuxtLink>
<NuxtLink to="/mounts">Mounts</NuxtLink>
<NuxtLink to="/telescopes">Telescopes</NuxtLink>
<NuxtLink to="/sessions">Sessions</NuxtLink>
</nav>
</header>
<NuxtPage />
</div>
</template>
<style scoped>
.topbar { display:flex; align-items:center; justify-content:space-between; padding:.75rem 1rem; border-bottom:1px solid #eee; position:sticky; top:0; background:#fff; z-index:10; }
.brand { font-weight:700; text-decoration:none; color:#222; }
.nav { display:flex; gap:10px; }
.nav a { text-decoration:none; color:#0b5ed7; }
.nav a.router-link-exact-active { text-decoration:underline; }
</style>
Configuration de la BDD
Dans le .env, on ajoute la configuration pour la base.
#Postgresql
DATABASE_URL='postgres://<user>:<password>@<host>:<port>/<database>'
#MySql
DATABASE_URL='mysql://<user>:<password>@<host>:<port>/<database>'
On lance la première migration (vide pour le moment) et on lance le serveur pour vérifier.
npx nuxt db generate
npm run dev
``npx nuxt db generateva générer les SQL de migrations (équivalent àbin/console make:migrationavec Doctrine et Symfony). Ces migrations seront exécutées soit en lançant le serveur de devnpm run dev, soit en buildant npx nuxt build, soit via npx nuxt db migrate(équivalent àbin/console do mi mi`).
Le module vient avec un plugin DrizzleStudio, qui vous permet d’accéder à votre base depuis la dev tool de Nuxt.

Vous pouvez visualiser vos tables et les manipuler, plutôt pratique !
Les schémas
Création des schémas
Maintenant que l’environnement est prêt, on peut passer à l’étape de création des schémas.
Les schémas sont un peu l’équivalent des entités avec Symfony et Doctrine. Ils vont nous permettre de définir nos différentes entités.
Pour notre mini CRUD, nous allons définir les entités suivantes :
- Télescope : chaque télescope possède un identifiant, un fabriquant, une ouverture et une focale.
- Fabriquant : chaque fabriquant a un identifiant et un nom.
- Monture : chaque monture possède un identifiant, un type et une charge maximale.
- Session d’observation : chaque session a un identifiant, une date, le télescope et la monture utilisés, ainsi que les astres ciblés.
- Astre : chaque astre est identifié par un id et possède un type.
Les relations entre ces entités sont les suivantes :
Chaque télescope est lié à un fabriquant et peut être utilisé dans plusieurs sessions. Chaque fabriquant peut fabriquer plusieurs télescopes. Chaque session d’observation utilise un télescope et une monture, et peut cibler plusieurs astres. Enfin, chaque monture peut être utilisée dans plusieurs sessions.
Nous allons maintenant traduire cette structure en schéma Drizzle. Pour plus de clarté, tous nos schémas seront regroupés dans un seul fichier schema.ts, mais rien n’empêche de créer un fichier par table si vous préférez.
Soit
server/
|-db/
|--schema.ts
Soit
server/
|-db
|--schema/
|----sessions.ts
|----manufacturers.ts
...
Notre shcema va donc ressembler à ça:
import {pgEnum, pgTable, serial, text, integer, date, primaryKey} from "drizzle-orm/pg-core";
import {defineRelations} from "drizzle-orm";
export const manufacturers = pgTable('manufacturers', {
id: serial().primaryKey(),
name: text().notNull()
})
export const stars = pgTable('stars', {
id: serial().primaryKey(),
name: text().notNull()
})
export const mountTypeEnum = pgEnum('mount_type', ['Altazimutal', 'Equatorial'])
export const mounts = pgTable('mounts', {
id: serial().primaryKey(),
mount_type: mountTypeEnum().default('Altazimutal'),
maxPayload: integer('max_payload').notNull()
})
export const telescopes = pgTable('telescopes', {
id: serial().primaryKey(),
manufacturerId: integer('manufacturer_id'),
focale: integer().notNull(),
apperture: integer().notNull()
})
export const sessions = pgTable('sessions', {
id: serial().primaryKey(),
telescopeId: integer('telescope_id'),
mountId: integer('mount_id'),
date: date().notNull()
})
export const starsToSessions = pgTable('stars_to_sessions', {
starId: integer('star_id').notNull().references(() => stars.id),
sessionId: integer('session_id').notNull().references(() => sessions.id)
},
(t) => [primaryKey({columns: [t.starId, t.sessionId]})]
)
const relations = defineRelations(
{ telescopes, manufacturers, sessions, stars, mounts, starsToSessions},
(r) => ({
telescopes: {
manufacturer: r.one.manufacturers({
from: r.telescopes.manufacturerId,
to: r.manufacturers.id
}),
sessions: r.many.sessions()
},
manufacturers: {
telescopes: r.many.telescopes(
)
},
sessions: {
telescope: r.one.telescopes({
from: r.sessions.telescopeId,
to: r.telescopes.id
}),
stars: r.many.stars({
from: r.sessions.id.through(r.starsToSessions.sessionId),
to: r.stars.id.through(r.starsToSessions.starId)
}),
mount: r.one.mounts({
from: r.sessions.mountId,
to: r.mounts.id
})
},
stars: {
sessions: r.many.sessions()
}
}));
Pas de panique, on va expliquer tout ça.
Explications
Définir une table
Chaque table va être représentée par une constante TypeScript, ici manufacturers, stars, etc. On déclare la table avec pgTable (ou mysqlTable / sqliteTable en fonction de votre base), on la nomme et on configure les champs.
export const maTable = pgTable('ma_table', {/** définition des champs **/})
Les IDs sont définis grâce à serial().primaryKey(). Pour les autres champs, on définit leur type (text, integer, etc., basé sur les possibilités du dialecte de votre base) et s’ils sont nullable ou non, et éventuellement une valeur par défaut.
name: text().default('coucou')
Il est possible de rajouter plus de contraintes via check(). Pour cela, je vous renvoie à la documentation officielle.
export const mountTypeEnum = pgEnum('mount_type', ['Altazimutal', 'Equatorial'])
va créer un tobject_type dans posgresql qu'on peut utiliser ensuite sur d'autre champs
mount_type: mountTypeEnum().default('Altazimutal'),
Ca diffère un peu sur Mysql et SQLite
// Mysql
mount_type: t.mysqlEnum(["Altazimutal", "Equatorial"]).default("Altazimutal"),
//SQLite
mount_type: t.text().$type<"Altazimutal" | "Equatorial">().default("Altazimutal"),
Enfin, dernier point : si vous souhaitez avoir un nom différent entre le TypeScript et la base, vous pouvez renommer le champ directement dans le type :
manufacturerId: integer('manufacturer_id')
Vous pouvez aussi configurer globalement Drizzle pour mapper automatiquement vos définitions camelCase en snake_case :
const db = drizzle({ connection: process.env.DATABASE_URL, casing: 'snake_case' })
Les relations
Vous remarquerez dans mon exemple que pour certains schémas on référence d’autres IDs : manufacturerId dans le schéma telescope par exemple. C’est le cas le plus simple qu’on utilise pour les One-To-One, Many-To-One et One-To-Many. Dans notre schéma, un télescope est lié à un fabricant donc on stocke l’ID du fabriquant dans le télescope, idem dans les sessions : une session va stocker l’ID du télescope et de la monture utilisée.
Mais pour les relations Many-To-Many ? On a dit qu’une session peut concerner plusieurs astres, et un même astre peut être observé dans plusieurs sessions. Techniquement, derrière, on a une table de liaison qui va stocker les IDs de sessions et les IDs d’astres. Dans les ORM comme Doctrine, cela se définit directement dans l’entité.
<?php
class Session
{
public function __construct(
#[ORM\ManyToMany(targetEntity: Star::class, inversedBy: 'sessions')]
private Collection $stars
){}
}
<?php
class Star
{
public function __construct(
#[ORM\ManyToMany(targetEntity: Session::class, mappedBy: 'stars')]
private Collection $sessions
){}
}
Avec Drizzle, c’est plus verbeux : cette table de liaison, vous devrez la déclarer vous-même, et c’est ce qu’on fait dans :
export const starsToSessions = pgTable('stars_to_sessions', {
starId: integer('star_id').notNull().references(() => stars.id),
sessionId: integer('session_id').notNull().references(() => sessions.id)
},
(t) => [primaryKey({columns: [t.starId, t.sessionId]})]
)
On définit notre table de liaison comme n’importe quelle autre table, et on met une clé sur les IDs.
C’est bien beau, mais juste dire que dans telescope on a mis un champ integer appelé manufacturerId ne suffit pas à créer le lien. On est d’accord, et là aussi il faut déclarer manuellement les relations entre nos schémas. C’est verbeux, mais au final c’est très lisible et logique. Dans notre exemple, c’est toute la partie :
const relations = defineRelations(
{ telescopes, manufacturers, sessions, stars, mounts, starsToSessions},
(r) => ({
telescopes: {
manufacturer: r.one.manufacturers({
from: r.telescopes.manufacturerId,
to: r.manufacturers.id
}),
sessions: r.many.sessions()
},
manufacturers: {
telescopes: r.many.telescopes(
)
},
sessions: {
telescope: r.one.telescopes({
from: r.sessions.telescopeId,
to: r.telescopes.id
}),
stars: r.many.stars({
from: r.sessions.id.through(r.starsToSessions.sessionId),
to: r.stars.id.through(r.starsToSessions.starId)
}),
mount: r.one.mounts({
from: r.sessions.mountId,
to: r.mounts.id
})
},
stars: {
sessions: r.many.sessions()
}
}));
La partie { telescopes, manufacturers, sessions, stars, mounts, starsToSessions} permet de passer l’ensemble des tables pour lesquelles on aura des relations, puis dans (r) => {} on va définir ces dites relations de manière presque littérale.
// tables telescopes
telescopes: {
manufacturer: r.one.manufacturers({ // lié à 1 fabricant
from: r.telescopes.manufacturerId, // l'id fabriquant est stocké dans manufacturerId
to: r.manufacturers.id // et il fait référence à l'id d'un fabricant
}),
sessions: r.many.sessions() // lié à plusieurs sessions
},
// on a l'inverse côté manufacturer
manufacturers: {
telescopes: r.many.telescopes() // 1 fabricant fabrique plusieurs téléscopes
},
// et côté sessions
sessions: {
// 1 session est lié à 1 téléscope
telescope: r.one.telescopes({
from: r.sessions.telescopeId, // id stocké dans telescopeId
to: r.telescopes.id // référence à telescope.id
}),
},
C'est finalement assez logique.
La où ça devient plus "tricky", c’est les Many-To-Many : on va devoir se baser sur la table de jointure, et non plus directement sur les deux tables. Pour rappel, une session peut concerner plusieurs astres, et les astres sont liés à plusieurs sessions.
sessions: {
...
// Une sessions, plusieurs astres
stars: r.many.stars({
// id de session lié à sessionId de la table de liaison
from: r.sessions.id.through(r.starsToSessions.sessionId),
// idem pour l'id de l'astre
to: r.stars.id.through(r.starsToSessions.starId)
}),
...
},
stars: {
// Le plus simple, un astres lié à plusieurs sessions
sessions: r.many.sessions()
}
Exécuter la migration
Maintenant que notre schema est bon, on peut lancer la migration
npx nuxt db generate
npx nuxt db migrate
La première commande va générer les scripts de migrations dans server/db/migrations/postgresql/ et la seconde va exécuter la migration. Comme indiqué plus haut, npm run dev ou npx nuxt build vont aussi exécuter les migrations.
À l’instar de Doctrine, les migrations sont stockées dans une table _hub_migrations.

On a notre base, on peut maintenant passer à notre petit CRUD.
Mise en place de l’API
On va commencer par mettre en place nos routes API pour gérer nos quelques entités. On ne va pas tous les traiter ici (vous aurez l’ensemble dans le repo GitHub), mais on va passer en revue un de chaque type : manufacturers pour une entité simple sans liaison, telescopes pour une entité One-To-Many et on finira avec Sessions qui gère du Many-To-Many. On va d’abord faire un point général sur la mise en place d’API avec Nuxt.
Les API avec Nuxt
Toute la gestion de la partie server se fait dans le répertoire server/. Un petit tour dans la doc et on retrouve la structure :
-| server/
---| api/
-----| hello.ts # /api/hello
---| routes/
-----| bonjour.ts # /bonjour
---| middleware/
-----| log.ts # log all requests
Nous, on va donc s’intéresser à la partie api/. Le répertoire api/ va donc contenir nos différents endpoints pour nos entités. Notez que tout ce qui se trouvera dans api/ sera autoloadé par Nuxt comme route préfixée par /api.
Plusieurs possibilités pour l’organisation de vos fichiers :
- Soit vous mettez tout à la racine de
api/sous la forme<entite>.<verb>.ts(ex.manufacturers.get.ts, etc.). - Soit dans des sous-répertoires, un par entité. C’est cette option que je garde, donc pour
manufacturerson se retrouve avec l’arborescence suivante :
-| server/
---| api/
-----| manufacturers/
-------| [id].delete.ts # suppression d'un fabriquant
-------| [id].get.ts # récupération d'un fabriquant
-------| [id].put.ts # mise à jour d'un fabriquant
-------| index.get.ts # récupération de tous les fabriquants
-------| index.post.ts # création d'un fabriquant
Comme on utilise un sous-répertoire spécifique, les endpoints ne ciblant pas spécifiquement un élément unique, ce sera index.<verb>.ts et [id].<verb>.ts pour les autres. Même si on pourrait très bien avoir [name].<verb>.ts, par exemple, le but étant d’indiquer les route parameters.
Ainsi [id].delete.ts va automatiquement créer une route /api/manufacturers/id et sera triggered automatiquement via un DELETE.
Chaque fichier va exporter un defineEventHandler qui va renvoyer votre JSON, une Promise, etc.
export default defineEventHandler((event) => {
return {
hello: 'world',
}
})
Interroger la BDD
Maintenant qu’on a notre structure, on va pouvoir s’attaquer à requêter notre BDD. Les accès à la BDD se font grâce aux SELECT, UPDATE, INSERT et DELETE. La construction des requêtes est très proche du SQL basique : il va falloir ressortir les cours de fac, pas de méthode magique comme avec Doctrine.
import { db, schema } from '@nuxthub/db'
await db.select().from(schema.manufacturers) // select
await db.update(schema.manufacturers).set().where() // update
await db.insert(schema.manufacturers).values() // insert
await db.delete(schema.manufacturers).where() // delete
Il existe bien sûr d’autres fonctions pour compléter les requêtes, faire des jointures, des filtres, etc. Je vous invite à consulter la documentation officielle pour voir ce qu’il est possible de faire.
On retourne à notre gestion des fabricants.
Exemple simple : gestion des fabricants
C’est le plus simple, pas de relations compliquées.
A noter l’utilisation de :
getRouterParams(event)pour récupérer les paramètres de routereadBody(event)pour récupérer le payload
Exemple Many-To-One / One-To-Many : gestion des télescopes
Après une mise en bouche simple, on va corser légèrement avec un One-To-Many : la relation telescopes <> fabriquant. On garde la même structure de dossier que pour les fabricants. Les delete, post et update étant de même acabit que pour les fabricants, on ne va pas revenir dessus.
En revanche, pour les get (liste et édition) on va pouvoir jouer avec les jointures.
Le but c'est de récupérer l'ensemble des téléscopes et le nom du fabriquant associé. Via Doctrine par exemple on aurait juste pu faire $telescope->getManufacturer()->getName() et c'était plié, mais pas de facilité de ce genre ici. Rouvrez vos manuels à la page des jointures, va falloir en refaire.
On veut donc récupérer les téléscopes et le nom du fabriquant associé, en SQL ça donnerait
SELECT
t.id,
t.apperture,
t.focale,
m.name as manufacturerName
FROM telescope t
INNER JOIN manufacturer m
ON t.manufacturer_id = m.id;
Ca va ça reste très light comme jointure. Maintenant il faut traduire cette requête dans drizzle
import { db, schema } from '@nuxthub/db'
import {eq, getColumns} from "drizzle-orm";
export default eventHandler(async (event) => {
const telescopes = schema.telescopes
const manufacturers = schema.manufacturers
return await db
.select({
...getColumns(telescopes),
manufacturerName: manufacturers.name
})
.from(telescopes)
.leftJoin(manufacturers, eq(telescopes.manufacturerId, manufacturers.id))
.orderBy(telescopes.id)
})
Très ressemblant, non ? J’avais prévenu que faire des requêtes Drizzle revenait plus ou moins à rédiger du SQL directement.
...getColumns() évite simplement d'avoir à re-spécifier manuellement tous les champs qu'on souhaite récupérer quand on veut le tout.
Pour récupérer un télescope en particulier, on serait parti sur la requête SQL classique :
SELECT
t.id,
t.apperture,
t.focale,
m.name as manufacturerName
FROM telescope t
INNER JOIN manufacturer m
ON t.manufacturer_id = m.id
WHERE t.id = <id_telescope>;
Avec Drizzle, on traduit presque littéralement :
import { db, schema } from '@nuxthub/db'
import {eq, getColumns} from 'drizzle-orm'
export default eventHandler(async (event) => {
const { id } = getRouterParams(event)
const telescopes = schema.telescopes
const manufacturers = schema.manufacturers
const rows = await db
.select({
...getColumns(telescopes),
manufacturerName: manufacturers.name,
})
.from(telescopes)
.innerJoin(manufacturers, eq(telescopes.manufacturerId, manufacturers.id))
.where(eq(telescopes.id, Number(id)))
.limit(1)
const telescope = rows?.[0]
if (!telescope) {
throw createError({ status: 404, message: `Telescope with id ${id} not found` })
}
return telescope
})
Exemple Many-To-Many : les sessions
On garde le meilleur pour la fin ! La gestion des Many-To-Many est un peu plus complexe et demandera un peu plus de "gymnastique".
Suppression/ajout/modification
Oubliez la encore les facilité offertes par Doctrine par exemple ou un delete se charge automatiquement d'aller supprimer la liaison par exemple, ici tout se fait à la main.
import { db, schema } from '@nuxthub/db'
import { eq } from 'drizzle-orm'
export default eventHandler(async (event) => {
const { id } = getRouterParams(event)
const sessionId = Number(id)
// On supprimes le lien sessions <=> stars en premier
await db
.delete(schema.starsToSessions)
.where(eq(schema.starsToSessions.sessionId, sessionId))
// On supprime la session
await db
.delete(schema.sessions)
.where(eq(schema.sessions.id, sessionId))
})
Et oui, ici on doit gérer manuellement la table de liaison, donc supprimer "à la main" la liaison.
Ca sera la même chose pour POST et UPDATE : on ajoutera manuellement la liaison sessions <=> stars, ou on la mettra à jour.
import { db, schema } from '@nuxthub/db'
import { eq } from 'drizzle-orm'
export default eventHandler(async (event) => {
const { id } = getRouterParams(event)
const sessionId = Number(id)
const { telescopeId, mountId, date, starIds } = await readBody(event)
await db
.update(schema.sessions)
.set({
telescopeId,
mountId,
date
})
.where(eq(schema.sessions.id, sessionId))
// On met à jour la table de liaison
// 1. On supprimes les anciennes
await db
.delete(schema.starsToSessions)
.where(eq(schema.starsToSessions.sessionId, sessionId))
// 2. On ajoute les nouvelles
if (starIds && starIds.length > 0) {
await db.insert(schema.starsToSessions).values(
starIds.map(starId => ({
sessionId,
starId
}))
)
}
})
import { db, schema } from '@nuxthub/db'
export default eventHandler(async (event) => {
const { telescopeId, mountId, date, starIds } = await readBody(event)
// On insert dans sessions
const result = await db
.insert(schema.sessions)
.values({
telescopeId,
mountId,
date
})
.returning({ insertedId: schema.sessions.id })
const sessionId = result[0]?.insertedId
// On insert dans la table de liaison
if (starIds && starIds.length > 0) {
await db.insert(schema.starsToSessions).values(
starIds.map((starId: any) => ({
sessionId,
starId
}))
)
}
return { id: sessionId }
})
Récupération
le GET collection
La on va s'amuser un peu ! Pour notre listing de sessions, on veut quelque chose de plutôt lisible :
- Les infos de base de la session : id et date
- Des labels lisibles pour le télescope et la monture (ex. : Skywatcher 127/1500 et Equatorial (10kg))
- La liste des étoiles (ex. : "M42", "M45" )
SELECT
s.*,
mfr.name || ' ' || t.apperture || '/' || t.focale AS telescopeLabel,
mt.mount_type || ' (' || mt.maxPayload || 'kg)' AS mountLabel,
COALESCE(stars_per_session.stars, '[]'::json) AS starNames
FROM sessions s
LEFT JOIN telescopes t ON s.telescopeId = t.id
LEFT JOIN manufacturers mfr ON t.manufacturerId = mfr.id
LEFT JOIN mounts mt ON s.mountId = mt.id
LEFT JOIN (
SELECT sts.sessionId,
COALESCE(JSON_AGG(st.name) FILTER (WHERE st.name IS NOT NULL), '[]'::json) AS stars
FROM starsToSessions sts
LEFT JOIN stars st ON sts.starId = st.id
GROUP BY sts.sessionId
) stars_per_session ON s.id = stars_per_session.sessionId
ORDER BY s.date;
Alors oui, j’ai volontairement fait un peu bourrin pour que la requête renvoie tout déjà formaté, pour vous montrer un exemple plus complexe.
Si on reprend cette requête et qu'on la ranspose dans Drizzle
import {db, schema} from '@nuxthub/db'
import {eq, getColumns, sql} from 'drizzle-orm'
export default eventHandler(async () => {
const { sessions, telescopes, mounts, starsToSessions, stars, manufacturers } = schema
// Subquery pour récupérer les étoiles par session
const starsSubquery = db
.select({
sessionId: starsToSessions.sessionId,
stars: sql<string[]>`
COALESCE(
JSON_AGG(${stars.name}) FILTER (WHERE ${stars.name} IS NOT NULL),
'[]'::json
)
`.as('stars') // <- important, alias obligatoire pour l'utiliser dans la request principale
})
.from(starsToSessions)
.leftJoin(stars, eq(starsToSessions.starId, stars.id))
.groupBy(starsToSessions.sessionId)
.as('stars_per_session')
// Requête principale
return await db
.select({
...getColumns(sessions),
telescopeLabel: sql<string>`
(${manufacturers.name} || ' ' || ${telescopes.focale} || '/' || ${telescopes.apperture})
`,
mountLabel: sql<string>`
(${mounts.mount_type} || ' (' || ${mounts.maxPayload} || 'kg)')
`,
starNames: starsSubquery.stars
})
.from(sessions)
.leftJoin(telescopes, eq(sessions.telescopeId, telescopes.id))
.leftJoin(manufacturers, eq(telescopes.manufacturerId, manufacturers.id))
.leftJoin(mounts, eq(sessions.mountId, mounts.id))
.leftJoin(starsSubquery, eq(sessions.id, starsSubquery.sessionId))
.orderBy(sessions.date)
})
De toute beauté, non ?
- La subquery
starsSubqueryva :- agréger toutes les étoiles d'une session en tableau JSON grâce à
JSON_AGG - fournir un tableau vide par défaut si aucune étoile grâce à
COALESCE(..., '[]'::json) - créer un alias via
.as()pour pouvoir l'utiliser dans notre query principale
- agréger toutes les étoiles d'une session en tableau JSON grâce à
- La query principale va :
- récupérer toutes les colonnes de
sessionsviagetColumns()(qu'on a déjà vu plus haut) - créer des labels lisibles pour le télescope et la monture avec
sql<T>(on y revient juste après) - associer toutes les étoiles de la session via
leftJoin(starsSubquery)
- récupérer toutes les colonnes de
Les autres jointures sont classiques, on ne va pas s'attarder dessus, mais revenons un instant sur l'utilisation de sql<T>.
L'opérateur sql<T> permet d'écrire du SQL natif et de typer le retour : sql<number[]> indique que la requête native va renvoyer un tableau de nombres, par exemple. Cela permet d'exécuter des fonctions PostgreSQL qui n'ont pas d'équivalent côté Drizzle. Typiquement ici, on s'en sert pour créer nos labels et notre tableau d'étoiles. On peut injecter des variables via ${} également. Cet opérateur est couplable avec .as() (utilisé dans notre exemple pour pouvoir le réutiliser), .mapWith(), et d'autres que vous pourrez retrouver dans la documentation Drizzle.
Le GET /id
On repasse sur du un peu plus léger. Pour récupérer une session en particulier, on a moins de mise en forme, mais on veut quand même garder notre tableau d'étoiles.
SELECT
s.*,
COALESCE(JSON_AGG(sts.starId) FILTER (WHERE sts.starId IS NOT NULL), '[]'::json) AS starIds
FROM sessions s
LEFT JOIN starsToSessions sts ON s.id = sts.sessionId
WHERE s.id = <id_session>
GROUP BY s.id
LIMIT 1;
Bien plus simple pour finir, je suis sympa.
import { db, schema } from '@nuxthub/db'
import {eq, getColumns, sql} from 'drizzle-orm'
export default eventHandler(async (event) => {
const { id } = getRouterParams(event)
const sessionId = Number(id)
// Requête unique avec agrégation JSON pour les étoiles
const rows = await db
.select({
...getColumns(schema.sessions),
starIds: sql<number[]>`
COALESCE(
JSON_AGG(${schema.starsToSessions.starId}) FILTER (WHERE ${schema.starsToSessions.starId} IS NOT NULL),
'[]'::json
)
`
})
.from(schema.sessions)
.leftJoin(schema.starsToSessions, eq(schema.sessions.id, schema.starsToSessions.sessionId))
.where(eq(schema.sessions.id, sessionId))
.groupBy(schema.sessions.id)
.limit(1)
const session = rows?.[0]
if (!session) {
throw createError({ status: 404, message: `Session with id ${id} not found` })
}
return session
})
On retrouve quelques éléments en commun avec GET collection :
- l'agrégation des étoiles avec
JSON_AGG - le tableau vide par défaut grâce à
COALESCE(..., '[]'::json) - l'éternel
getColumns() - le
.as()
Le front
On va passer très vite ici : le front reste du Nuxt/VueJS et de l'affichage de données classique. Vous pourrez jeter un œil dans le repo pour plus de détails, mais on va juste expliquer brièvement comment appeler nos routes.
Nuxt met à disposition 3 méthodes pour récupérer de la data :
$fetch: la plus basiqueuseFetch: qui est un wrapper autour de$fetchuseAsyncData: similaire àuseFetch, mais offrant plus de contrôle
On va regarder les 2 premiers, qui sont ceux utilisés dans mon exemple (voir repo). Quand utiliser l'un plutôt que l'autre ?
$fetch sera plutôt utilisé pour les requêtes POST, DELETE, PUT, etc. Bref, tout ce qui n'est pas du GET. Pourquoi ? Parce que $fetch est un simple wrapper HTTP qui n'intègre pas la partie SSR/Hydration de Nuxt, et s'il est utilisé pour récupérer des data au onMounted par exemple, la requête peut s'exécuter 2 fois :
- Une première fois côté serveur (SSR)
- Une deuxième fois côté client
Donc on l'utilise plutôt pour du hors-GET, parce que c'est généralement exécuté après une action, comme la soumission d'un formulaire, donc exécuté une seule fois côté client.
useFetch et useAsyncData, eux, sont des wrappers de $fetch qui vont permettre d'éviter ces doubles appels : l'appel API est fait côté serveur, et les data sont transmises au client via le payload, qui n'aura pas à refaire la requête.
Pour en revenir à nos APIs, on aura donc juste besoin d'appeler useFetch pour récupérer nos valeurs et $fetch pour les accès en écriture.
const id = computed(() => Number(route.params.id)) // On récupère l'id depuis la route
const { data: manufacturer } = await useFetch(`/api/manufacturers/${id.value}`)
Et pour envoyer
async function submit() {
await $fetch('/api/manufacturers', {
method: 'POST',
body: {name: name.value},
})
await router.push('/manufacturers')
}
Conclusion
J'espère avoir pu vous donner un aperçu des possibilités offertes par Nuxt pour faire du fullstack. Je ne vous cache pas que je m'attendais à quelque chose de plus semblable à Doctrine quand on m'a parlé de Drizzle (venant du monde PHP et Symfony à la base), mais cela a eu l'avantage de me faire refaire du SQL de manière un peu plus poussée (avec Doctrine, cela faisait un moment que je n'avais plus écrit de SQL).
Justement, venant de Doctrine, ça permet de voir les différentes philosophies de chacun : là où Doctrine est plus haut niveau (même s'il est possible de faire du SQL natif et de gérer du complexe en cas de besoin) et plus orienté objet, là où Drizzle met clairement plus l'accent sur le SQL, en typant, mais rien n'est caché derrière des méthodes magiques qui font tout le travail. Alors oui, c'est de mon point de vue moins sympa à utiliser, mais d'un autre côté, cela laisse aussi plus de contrôle.
Le code fourni en exemple et dans le repo est, je le sais, perfectible (plus de typage, ne pas autoriser à supprimer de monture ou téléscope si utilisé dans une session, etc.), mais il a surtout vocation à servir d'exemple simple. ;)
