Prisma ORM avec Claude Code : schema, migrations, transactions et tests
Guide Prisma ORM avec Claude Code : schema, migrations, transactions, seed/test et checklist production.
Prisma ORM est une couche de base de données typée pour manipuler une DB en sécurité depuis TypeScript. Avec Claude Code, il peut devenir un vrai flux de travail : concevoir le schema, générer et relire la migration SQL, écrire les requêtes Prisma Client, poser les limites de transaction, puis ajouter seed et tests.
Le piège consiste à le traiter comme un simple générateur CRUD. On obtient alors du code qui démarre, mais parfois avec des index absents, desinclude trop larges, des cascades dangereuses ou une migration qui échoue dès qu’il y a des données réelles. Ce guide utilise une API de blog pour montrer une approche publiable et monétisable, adaptée à un site de contenu ou à un outil SaaS.
Références officielles : Prisma ORM, Prisma Schema, Transactions et Prisma Migrate. Côté ClaudeCodeLab, consulte aussi le guide de démarrage Claude Code, Drizzle ORM, Supabase et Redis caching.
Vue d’ensemble
flowchart LR
A["Donner les règles métier à Claude Code"] --> B["Concevoir schema.prisma"]
B --> C["Générer et relire migration.sql"]
C --> D["Écrire les requêtes Prisma Client"]
D --> E["Ajouter seed et tests"]
E --> F["Valider la checklist production"]
Découpe le travail en trois livrables : schema, migration SQL et code TypeScript. Claude Code peut tout préparer, mais la validation des contraintes, index, suppressions et transactions reste une responsabilité humaine.
Prompt pour Claude Code
Conçois une couche Prisma ORM pour une API de blog.
Contexte:
- TypeScript + Prisma ORM + SQLite en local, migration possible vers PostgreSQL
- Modèles: User, Post, Category, Comment, Notification, AuditLog
- Post utilise un status string: DRAFT/PUBLISHED/ARCHIVED
- email et slug doivent être uniques
- La liste publique filtre par status, publishedAt, author, category
- Supprimer un Post supprime en cascade Comment et les lignes de jointure
- Supprimer un User est interdit pour l'instant; l'anonymisation viendra dans une migration séparée
Retour attendu:
1. prisma/schema.prisma
2. Points de review pour la migration SQL
3. Code Prisma Client create/list/publish
4. Commandes seed et test
5. Checklist avant production
Ce prompt force Claude Code à raisonner sur l’exploitation, pas seulement sur les tables. Les règles de suppression et les index sont liés à des écrans et à des risques métier.
Setup local
{
"type": "module",
"scripts": {
"db:generate": "prisma generate",
"db:migrate": "prisma migrate dev",
"db:deploy": "prisma migrate deploy",
"db:seed": "prisma db seed",
"dev": "tsx src/demo.ts",
"test": "vitest run"
},
"dependencies": {
"@prisma/client": "latest",
"dotenv": "latest"
},
"devDependencies": {
"prisma": "latest",
"tsx": "latest",
"typescript": "latest",
"vitest": "latest"
}
}
npm install
echo 'DATABASE_URL="file:./dev.db"' > .env
mkdir prisma src
// prisma.config.ts
import "dotenv/config";
import { defineConfig, env } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
seed: "tsx prisma/seed.ts",
},
datasource: {
url: env("DATABASE_URL"),
},
});
Schema Prisma
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
}
datasource db {
provider = "sqlite"
}
model User {
id String @id @default(cuid())
email String @unique
name String
role String @default("editor")
posts Post[]
comments Comment[]
notifications Notification[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([role])
}
model Post {
id String @id @default(cuid())
slug String @unique
title String
body String
status String @default("DRAFT")
authorId String
author User @relation(fields: [authorId], references: [id], onDelete: Restrict)
categories CategoriesOnPosts[]
comments Comment[]
publishedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([authorId])
@@index([status, publishedAt])
}
model Category {
id String @id @default(cuid())
slug String @unique
name String
posts CategoriesOnPosts[]
}
model CategoriesOnPosts {
postId String
categoryId String
assignedAt DateTime @default(now())
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade)
@@id([postId, categoryId])
}
model Comment {
id String @id @default(cuid())
body String
postId String
authorId String
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
author User @relation(fields: [authorId], references: [id], onDelete: Restrict)
createdAt DateTime @default(now())
@@index([postId, createdAt])
@@index([authorId])
}
model Notification {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
type String
message String
readAt DateTime?
createdAt DateTime @default(now())
@@index([userId, readAt])
}
model AuditLog {
id String @id @default(cuid())
action String
targetId String
metadata String?
createdAt DateTime @default(now())
@@index([action, createdAt])
}
Demande à Claude Code d’expliquer chaque index. status + publishedAt sert la liste publique; postId + createdAt sert les commentaires. Un index sans requête réelle derrière lui est un coût inutile.
Relire la migration
npx prisma format
npx prisma migrate dev --create-only --name init_blog
# Lire prisma/migrations/*/migration.sql dans la PR
npx prisma migrate dev
npx prisma generate
En production :
npx prisma migrate deploy
db push est utile en prototype, pas comme stratégie de production. Surveille lesDROP, les nouveauxNOT NULL, les unique constraints sur données existantes et les cascades.
La stratégie de rollback doit être écrite dans la PR avant le déploiement. Si la migration a déjà réussi, crée plutôt une nouvelle migration qui annule le changement. Si elle a échoué en cours de route, suis le workflow officiel des down migrations : vérifier le backup, lancer migrate status, appliquer le SQL de retour relu, puis marquer seulement cette migration comme rolled back avec migrate resolve.
npx prisma migrate status
npx prisma migrate resolve --rolled-back "20260603090000_failed_change"
Prisma Client et transactions
// src/db.ts
import { PrismaClient } from "./generated/prisma/client";
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: ["warn", "error"],
});
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}
// src/posts.ts
import { Prisma } from "./generated/prisma/client";
import { prisma } from "./db";
export async function listPublishedPosts(params: {
page?: number;
perPage?: number;
categorySlug?: string;
search?: string;
}) {
const page = Math.max(params.page ?? 1, 1);
const perPage = Math.min(Math.max(params.perPage ?? 20, 1), 50);
const where: Prisma.PostWhereInput = {
status: "PUBLISHED",
...(params.categorySlug
? { categories: { some: { category: { slug: params.categorySlug } } } }
: {}),
...(params.search
? { OR: [{ title: { contains: params.search } }, { body: { contains: params.search } }] }
: {}),
};
const [items, total] = await prisma.$transaction([
prisma.post.findMany({
where,
skip: (page - 1) * perPage,
take: perPage,
orderBy: [{ publishedAt: "desc" }, { createdAt: "desc" }],
select: {
id: true,
slug: true,
title: true,
publishedAt: true,
author: { select: { name: true } },
_count: { select: { comments: true } },
},
}),
prisma.post.count({ where }),
]);
return { items, pagination: { page, perPage, total } };
}
export async function publishPost(postId: string) {
return prisma.$transaction(async (tx) => {
const post = await tx.post.findUnique({
where: { id: postId },
select: { id: true, title: true, status: true, authorId: true },
});
if (!post) throw new Error("Post not found");
if (post.status === "PUBLISHED") return post;
const published = await tx.post.update({
where: { id: post.id },
data: { status: "PUBLISHED", publishedAt: new Date() },
select: { id: true, title: true, status: true, publishedAt: true },
});
await tx.notification.create({
data: {
userId: post.authorId,
type: "POST_PUBLISHED",
message: `${post.title} was published`,
},
});
await tx.auditLog.create({
data: {
action: "POST_PUBLISHED",
targetId: post.id,
metadata: JSON.stringify({ title: post.title }),
},
});
return published;
});
}
$transaction([]) convient aux lectures indépendantes comme liste et count. Une transaction interactive convient lorsqu’une action métier modifie plusieurs tables. Garde-la courte : pas d’e-mail, pas de webhook, pas de requête réseau lente dedans.
SQL sûr
Commence par les requêtes Prisma Client. Si un reporting a vraiment besoin de SQL, le pitfall est la concaténation de chaînes, qui crée un SQL-injection risk. La page Raw queries de Prisma recommande les tagged templates ou Prisma.sql.
import { Prisma } from "./generated/prisma/client";
import { prisma } from "./db";
export async function topAuthors(limit = 10) {
return prisma.$queryRaw<
{ authorId: string; postCount: bigint }[]
>(Prisma.sql`
SELECT "authorId", COUNT(*) AS "postCount"
FROM "Post"
WHERE "status" = ${"PUBLISHED"}
GROUP BY "authorId"
ORDER BY "postCount" DESC
LIMIT ${Math.min(limit, 50)}
`);
}
Demande à Claude Code de justifier chaque $queryRawUnsafe. Pour des noms de table ou colonne dynamiques, impose une allowlist relue par un humain.
Seed, test et checklist
// prisma/seed.ts
import { PrismaClient } from "../src/generated/prisma/client";
const prisma = new PrismaClient();
async function main() {
const user = await prisma.user.upsert({
where: { email: "editor@example.com" },
update: { name: "Editor" },
create: { email: "editor@example.com", name: "Editor", role: "admin" },
});
await prisma.post.upsert({
where: { slug: "claude-code-prisma-demo" },
update: { status: "PUBLISHED", publishedAt: new Date() },
create: {
slug: "claude-code-prisma-demo",
title: "Claude Code Prisma demo",
body: "Seeded article for local verification.",
status: "PUBLISHED",
publishedAt: new Date(),
authorId: user.id,
},
});
}
main().finally(async () => prisma.$disconnect());
npm run db:migrate -- --name init_blog
npm run db:generate
npm run db:seed
npm run test
Checklist : contraintes et index alignés avec les écrans, SQL non destructif, pagination bornée, transactions courtes, seed idempotent, tests de rollback ou de double exécution, production avecmigrate deploy.
Cas d’usage, pièges et CTA
Trois cas d’usage reviennent souvent : gestion d’articles, back-office SaaS et activation de droits après paiement. Le premier demande slug et statut fiables; le second impose le scope tenant; le troisième exige idempotence, audit log et transaction claire.
Dans un test de petite API de blog, Masa a demandé à Claude Code de lister les risques de migration avant d’écrire l’application. Deux problèmes sont sortis immédiatement : cascade trop large et trop de champs renvoyés dans la liste publique. C’est le bon usage de Prisma avec Claude Code : accélérer, puis vérifier par le type, le SQL et les tests.
Pour apprendre seul, commence par /products/. Pour un workflow d’équipe autour des revues DB avec Claude Code, regarde /training/. Une session avec votre schema réel ou une PR générée par IA permet d’aller directement aux risques concrets.
PDF gratuit: cheatsheet Claude Code
Saisissez votre email et téléchargez une page avec commandes, habitudes de review et workflow sûr.
Nous protégeons vos données et n'envoyons pas de spam.
À propos de l'auteur
Masa
Ingénieur spécialisé dans les workflows pratiques avec Claude Code.
Articles liés
Checklist d'audit de dépôt Claude Code avant la première modification
Auditer un dépôt en 20 minutes: périmètre, zones risquées, commandes de preuve et CTA revenus.
Claude Code Harness Lite : une petite structure pour changer sans dériver
Un workflow débutant pour séparer lecture, modification, preuve, URL publique et CTA de revenus.
Premier repo map avec Claude Code : lire un code existant sans brûler le contexte
Workflow sûr pour lire un dépôt avec Claude Code : carte, petites tâches, preuves, PDF gratuit, Gumroad et consultation.