9.3 — ORM & query builders
🎯 Objectif : utiliser un ORM en confiance, savoir descendre en SQL brut quand nécessaire, et toujours détecter le problème N+1.
À l'issue de cet axe, tu sauras :
- Distinguer ORM (Active Record vs Data Mapper) et query builder
- Choisir Prisma 7, Drizzle, SQLAlchemy, Eloquent, Doctrine selon le contexte
- Identifier et fixer un problème N+1
- Utiliser des transactions, des bulks inserts, du raw SQL quand pertinent
- Configurer migrations, seeds, factories
Confirmé
ORM, query builder, raw SQL — distinction
Section intitulée « ORM, query builder, raw SQL — distinction »| Niveau | Exemple | Forces |
|---|---|---|
| Raw SQL | db.exec('SELECT * FROM users WHERE id = $1', [42]) | Contrôle total, perfs maximales |
| Query builder | db.select().from(users).where(eq(users.id, 42)) | Composabilité, type-safety, plus lisible que SQL string |
| ORM Active Record | User.find(42), user.posts | Productivité, relations magiques |
| ORM Data Mapper | entityManager.find(User, 42) | Séparation domaine ↔ persistance, propre en DDD |
Tu peux mixer les niveaux — tous les ORMs modernes permettent du raw SQL quand nécessaire.
Le paysage 2026
Section intitulée « Le paysage 2026 »Node.js / TypeScript
Section intitulée « Node.js / TypeScript »| Outil | Style | Note |
|---|---|---|
| Prisma 7 | Schema-first, ORM | Productivité maximale, doc top, engine TS/WASM depuis v7 |
| Drizzle | Query builder TS-first | Edge-ready, proche du SQL, bundle minuscule |
| Kysely | Pure query builder | Type-safety extrême, pas d’ORM |
| TypeORM | Active Record / Data Mapper | Style Java, NestJS-friendly, en perte de vitesse |
| MikroORM | Data Mapper proche de Doctrine | Niche, utile si tu viens de PHP |
| Outil | Style | Note |
|---|---|---|
| SQLAlchemy 2 | Data Mapper, sync ou async | Le plus puissant, verbose |
| SQLModel | SQLAlchemy + Pydantic | Le mariage parfait pour FastAPI |
| Tortoise ORM | Active Record async, inspiré Django | Pour FastAPI sans SQLAlchemy |
| Django ORM | Active Record | Inclus dans Django, très productif |
| Outil | Style | Note |
|---|---|---|
| Eloquent (Laravel) | Active Record | Productivité maximale |
| Doctrine (Symfony) | Data Mapper | Style Java/Hibernate, très puissant |
Verdict 2026
Section intitulée « Verdict 2026 »| Si tu… | Choisis |
|---|---|
| Démarres en Node + Postgres avec équipe productive | Prisma 7 |
| Vises l’edge (Cloudflare Workers) ou veux du contrôle SQL | Drizzle |
| Es en FastAPI | SQLAlchemy 2 async ou SQLModel |
| Es en Django | Django ORM (pas de choix) |
| Es en Laravel | Eloquent (pas de choix) |
| Es en Symfony | Doctrine ORM |
Le problème N+1 — LA bête noire
Section intitulée « Le problème N+1 — LA bête noire »C’est THE bug ORM le plus répandu. Il fait passer une page de 50 ms à 5 secondes.
// ❌ N+1 PROBLEMconst orders = await prisma.order.findMany({ take: 100 });for (const order of orders) { console.log(order.customer.name); // 100 requêtes SQL supplémentaires !}// Total : 1 + 100 = 101 requêtes// ✅ Eager loading — 1 seule requête (avec JOIN)const orders = await prisma.order.findMany({ take: 100, include: { customer: true },});for (const order of orders) { console.log(order.customer.name); // pas de requête supplémentaire}// Total : 1 requêteDétecter N+1
Section intitulée « Détecter N+1 »Toujours activer les logs SQL en dev :
// Prismaconst prisma = new PrismaClient({ log: ['query'] });Tu vois exactement les requêtes générées. Si tu vois la même requête se répéter 50 fois → N+1.
Outils :
- Prisma Studio + log
- Laravel Debugbar (PHP)
- Django Debug Toolbar (Python)
- PgHero (PostgreSQL — analyse les slow queries)
Prisma 7 — l’option productive
Section intitulée « Prisma 7 — l’option productive »npm install prisma @prisma/clientnpx prisma initdatasource db { provider = "postgresql" url = env("DATABASE_URL")}
generator client { provider = "prisma-client-js"}
model Customer { id Int @id @default(autoincrement()) email String @unique name String orders Order[] createdAt DateTime @default(now())}
model Order { id Int @id @default(autoincrement()) customer Customer @relation(fields: [customerId], references: [id]) customerId Int status OrderStatus @default(pending) totalCents Int @default(0) createdAt DateTime @default(now())
@@index([customerId, createdAt])}
enum OrderStatus { pending paid shipped cancelled}npx prisma migrate dev --name initnpx prisma generate// Usageimport { PrismaClient } from '@prisma/client';const prisma = new PrismaClient();
// Lectureconst orders = await prisma.order.findMany({ where: { customerId: 42, status: 'paid' }, include: { customer: true }, orderBy: { createdAt: 'desc' }, take: 20,});
// Écritureconst order = await prisma.order.create({ data: { customerId: 42, totalCents: 2990, status: 'pending', },});
// Transactionawait prisma.$transaction(async (tx) => { await tx.order.update({ where: { id: 1 }, data: { status: 'paid' } }); await tx.product.update({ where: { id: 5 }, data: { stock: { decrement: 1 } } });});
// Raw SQL quand nécessaireconst result = await prisma.$queryRaw<{ total: number }[]>` SELECT SUM(total_cents) AS total FROM orders WHERE status = 'paid'`;Prisma 7 — engine TS/WASM
Section intitulée « Prisma 7 — engine TS/WASM »Depuis Prisma 7 (2026), l’engine Rust est remplacé par un engine TypeScript + WebAssembly. Bénéfices :
- Bundle ~85 % plus léger (1.6 Mo vs 11 Mo).
- Performances ~3× meilleures sur grandes requêtes.
- Compatible edge (Cloudflare Workers, Vercel Edge).
- Plus de processus séparé à gérer.
C’est le saut générationnel qui rend Prisma viable sur edge runtime.
Drizzle — le query builder TS
Section intitulée « Drizzle — le query builder TS »npm install drizzle-orm postgresnpm install -D drizzle-kitimport { pgTable, serial, text, integer, timestamp, pgEnum, uniqueIndex, index } from 'drizzle-orm/pg-core';import { sql, relations } from 'drizzle-orm';
export const orderStatus = pgEnum('order_status', ['pending', 'paid', 'shipped', 'cancelled']);
export const customers = pgTable('customers', { id: serial('id').primaryKey(), email: text('email').notNull(), name: text('name').notNull(), createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),}, (t) => ({ emailIdx: uniqueIndex('customers_email_idx').on(t.email),}));
export const orders = pgTable('orders', { id: serial('id').primaryKey(), customerId: integer('customer_id').notNull().references(() => customers.id), status: orderStatus('status').notNull().default('pending'), totalCents: integer('total_cents').notNull().default(0), createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),}, (t) => ({ customerCreatedIdx: index('orders_customer_created_idx').on(t.customerId, t.createdAt),}));
export const customersRelations = relations(customers, ({ many }) => ({ orders: many(orders),}));
export const ordersRelations = relations(orders, ({ one }) => ({ customer: one(customers, { fields: [orders.customerId], references: [customers.id], }),}));// Codeimport { drizzle } from 'drizzle-orm/postgres-js';import postgres from 'postgres';import { eq, and, desc } from 'drizzle-orm';
const client = postgres(process.env.DATABASE_URL!);const db = drizzle(client, { schema });
const recentOrders = await db.query.orders.findMany({ where: and( eq(orders.customerId, 42), eq(orders.status, 'paid') ), with: { customer: true }, // eager loading orderBy: desc(orders.createdAt), limit: 20,});Migrations Drizzle
Section intitulée « Migrations Drizzle »npx drizzle-kit generate # génère le SQL diffnpx drizzle-kit migrate # appliqueBulks et perf
Section intitulée « Bulks et perf »Insérer 10 000 lignes une par une = 10 000 requêtes = catastrophe. Toujours bulk :
// Prismaawait prisma.product.createMany({ data: arrayOf10kProducts });
// Drizzleawait db.insert(products).values(arrayOf10kProducts);
// SQL brutINSERT INTO products (sku, name, price) VALUES ('A', 'Foo', 100), ('B', 'Bar', 200), ('C', 'Baz', 300), ...;Postgres permet aussi COPY pour des imports massifs (millions de lignes en quelques secondes).
Quand descendre en SQL brut
Section intitulée « Quand descendre en SQL brut »Cas où le SQL natif est meilleur que l’ORM :
- Reporting complexe : window functions, CTE imbriquées, agrégats.
- Performances critiques : éviter les abstractions.
- Features spécifiques DB :
LISTEN/NOTIFY,pg_advisory_lock, etc. - Migration de données :
INSERT ... SELECT ... ON CONFLICT. - Recherche full-text avancée :
tsvector,tsquery.
// Prisma raw SQL (typé)const result = await prisma.$queryRaw<{ month: Date; total: number }[]>` SELECT DATE_TRUNC('month', created_at) AS month, SUM(total_cents) AS total FROM orders WHERE created_at >= NOW() - INTERVAL '12 months' GROUP BY 1 ORDER BY 1;`;$queryRaw (Prisma) ou db.execute(sql\…`)` (Drizzle) paramétrise automatiquement les valeurs interpolées — pas de SQL injection.
Migrations — ne JAMAIS éditer une migration appliquée
Section intitulée « Migrations — ne JAMAIS éditer une migration appliquée »Une migration appliquée en prod est immuable. Si tu as besoin de corriger, crée une nouvelle migration.
Sinon les environnements dérivent (dev a une version, staging une autre, prod encore une autre) et plus rien ne marche.
Auto-évaluation
Section intitulée « Auto-évaluation »Pour aller plus loin
Section intitulée « Pour aller plus loin »- Prisma Documentation — prisma.io/docs
- Drizzle ORM — orm.drizzle.team
- SQLAlchemy 2.0 Tutorial — docs.sqlalchemy.org
- Patterns of Enterprise Application Architecture — Martin Fowler (Active Record vs Data Mapper)
Suite : 9.4 — Données opérationnelles — sauvegardes, réplication, sharding.