2. Prisma
Tijdens deze les bespreken we hoe je een Next applicatie kan verbinden met een database via Prisma, een TypeScript ORM voor relationele databases en MongoDB.
Hiervoor vertrekken we van een licht aangepaste versie van het voorbeeld van vorige les. De database simulatie is vervangen met een skelet dat we deze les verder aanvullen met Prisma code.
Startbestanden
Info
De startbestanden zijn heel gevoelig aan fouten. Open geen van de pagina's in de browser voordat de bijhorende database code geïmplementeerd is.
Als je een pagina toch te vroeg open kan je de .next folder verwijderen en de development server opnieuw opstarten.
Database server
Om persistentie toe te voegen hebben we nood aan een database. Doorheen deze lessenreeks gebruiken we Docker om een database server te starten.
Docker is een platform waarmee infrastructuur eenvoudig gereproduceerd kan worden op verschillende systemen aan de hand van containers. Deze containers zijn een geïsoleerde omgeving waarin enkel het absoluut noodzakelijke geïnstalleerd is.
De configuratie van de containers kan eenvoudig beschreven worden in een Docker Compose bestand. Zo is een volledige omgeving snel en eenvoudig reproduceerbaar op verschillende machines.
Voor de installatie van Docker verwijzen we door naar het development environment hoofdstuk.
Eens Docker correct geïnstalleerd is, kunnen we de database server starten door onderstaand commando uit te voeren in de root map van de startbestanden. In deze map is een bestand met de naam compose.yaml te vinden waarin de configuratie genoteerd is voor de database server.
docker compose up -dNadat dit commando succesvol uitgevoerd is, is Postgres geïnstalleerd. Dit is een gratis, open-source en gemakkelijk uitbreidbare database server. In tegenstelling tot een klassieke relationele database biedt Postgres ook ondersteuning voor (multidimensionale) array kolommen, JSON-kolommen, row level security en veel meer.
Installatie van Prisma
Prisma is een TypeScript ORM waarmee we queries kunnen schrijven en de database beheren via migrations. De structuur van de database wordt bepaald door één of meerdere schema files. Aan de hand van deze schema's (en de wijzigingen daarin) kunnen migrations opgebouwd worden om de wijzigingen in de database reproduceerbaar te maken. Daarnaast worden er op basis van de schema's ook TypeScript interfaces gegenereerd die de volledige database en alle mogelijke CRUD-operaties beschrijven.
Prisma maakt gebruik van database specifieke drivers die de communicatie met Postgres, SQL Server, ... implementeert. Aangezien we gebruik maken van Postgres installeren we pg (Node Postgres) en de bijhorende adapter voor Prisma en de nodige TypeScript bindings.
De Prisma CLI-tool die we gebruiken om migraties aan te maken en seed script uit te voeren, leest de inhoud van de .env file niet automatisch in. Daarom installeren we de dotenv library die dit probleem oplost.
pnpm add -D prisma @types/node @types/pg
pnpm add @prisma/adapter-pg pg @prisma/client dotenvnpm install -D prisma @types/node @types/pg
npm install @prisma/adapter-pg pg @prisma/client dotenvbun add -d prisma @types/node @types/pg
bun add @prisma/adapter-pg pg @prisma/client dotenvyarn add -D prisma @types/node @types/pg
yarn add @prisma/adapter-pg pg @prisma/client dotenvPrisma moet geïnitialiseerd worden voordat we een schema kunnen opstellen. Hiervoor gebruiken we onderstaand commando dat een nieuwe map aanmaakt waarin het schema en de migrations bewaard worden. Prisma ondersteund Postgres, MySQL, SQLite, SQL Server, MongoDB en CockroachDB, via de --datasource-provider parameter kan de gewenste database provider gekozen worden.
pnpm prisma init --datasource-provider postgresqlnpx prisma init --datasource-provider postgresqlbun prisma init --datasource-provider postgresqlyarn prisma init --datasource-provider postgresqlIn het nieuwe bestand /prisma.config.ts is te zien dat Prisma de migration en en het database schema bewaard in de /prisma map. Verder is ook te zien dat Prisma de database-URL uitleest uit de omgevingsvariabelen die gedefinieerd zijn in .env. Ook de .env file is automatisch gegenereerd, maar de inhoud moet nog aangepast worden zodat we verbinding kunnen maken met Postgres (in de Docker container).
// This file was generated by Prisma and assumes you have installed the following:
// npm install --save-dev prisma dotenv
import "dotenv/config";
import { defineConfig, env } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
datasource: {
url: env("DATABASE_URL"),
},
});DATABASE_URL=postgresql://postgres:postgresPassword@localhost:5432/backend-lectureSchema aanmaken
Nu dat de configuratie gedaan is, kunnen we een schema[1] schrijven voor de tabel waarin de contacten bewaard worden.
Als primary key gebruiken we een willekeurig gegenereerd UUID.
Merk op dat de contactinformatie bewaard wordt als JSON-data in de plaats van aparte kolommen. We doen dit omdat de contactinformatie eigendom is van één contact en nooit herbruikt zal worden, een extra tabel brengt dus deduplicatie met zich mee.[2]
Voor de avatar wordt ook een defaultoptie voorzien die verwijst naar een algemene avatar.
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
}
datasource db {
provider = "postgresql"
}
model Contact {
id String @id @default(uuid()) @db.Uuid
firstName String @db.VarChar(255)
lastName String? @db.VarChar(255)
description String? @db.VarChar(255)
avatar String @default("/avatars/placeholder.png") @db.VarChar(255)
contactInfo Json @default("[]") @db.JsonB
favorite Boolean @default(false) @db.Boolean
}Typed JSON-kolommen
Alhoewel Prisma JSON-kolommen ondersteund, bevatten de gegenereerde types geen informatie over het soort data dat in de JSON-kolom zit. Om dit probleem op te lossen kunnen we gebruik maken van de prisma-json-types-generator library.
pnpm add -D prisma-json-types-generatornpm install -D prisma-json-types-generatorbun add -d prisma-json-types-generatoryarn add -D prisma-json-types-generatorVervolgens voegen we een nieuw stukje code toe aan de prisma.schema en definiëren we de structuur van de contactInfo kolom in index.d.ts. Tenslotte moeten we een commentaarlijn met drie slashes toevoegen om het schema en de informatie in index.d.ts te mixen.
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
}
generator json {
provider = "prisma-json-types-generator"
}
datasource db {
provider = "postgresql"
}
model Contact {
id String @id @default(uuid()) @db.Uuid
firstName String @db.VarChar(255)
lastName String? @db.VarChar(255)
description String? @db.VarChar(255)
avatar String @default("/avatars/placeholder.png") @db.VarChar(255)
/// [ContactInfo]
contactInfo Json @default("[]") @db.JsonB
favorite Boolean @default(false) @db.Boolean
}declare global {
namespace PrismaJson {
type ContactInfo = {type: string; value: string}[]
}
}
// This file must be a module.
export {}Migrations
Database migrations zijn een manier om wijzigingen in de database reproduceerbaar te maken en te delen met anderen. Een migration beschrijft dikwijls een kleine, incrementele, wijziging in de database. Elke wijziging wordt bewaard in een nieuw plain text bestand en kan dus eenvoudig toegevoegd worden aan version control. De verzameling van alle migrations beschrijft hoe de database opgebouwd is en moet dus bewaard blijven, als je deze overschrijft of verwijderd verlies je data verliezen in een productieomgeving. Tijdens het deployen van een nieuwe versie van de applicatie wordt de migratiegeschiedenis op de productieserver vergeleken met de migrations in de prisma-map, vervolgens worden alle nieuwe migrations stap-per-stap uitgevoerd.
Om een migration aan te maken en uit te voeren gebruiken we één van onderstaande commando's.
Hieronder worden drie varianten van het commando gegeven, de eerste wordt gebruikt in development. De tweede versie kan gebruikt worden om de database leeg te maken en vervolgens alle bestaande migrations uit te voeren. Het is duidelijk dat dit commando enkel in een development omgeving gebruikt mag worden. De laatste variant wordt gebruikt in productie, de migrations worden uitgevoerd en data in de database wordt nooit verwijderd. Als er een fout gedetecteerd wordt stopt het commando en kunnen de migrations niet uitgevoerd worden.
# Nieuwe migration aanmaken en uitvoeren.
pnpm prisma migrate dev
# Database leeg maken en migrations uitvoeren.
pnpm prisma migrate reset# Nieuwe migration aanmaken en uitvoeren.
npx prisma migrate dev
# Database leeg maken en migrations uitvoeren.
npx prisma migrate reset# Nieuwe migration aanmaken en uitvoeren.
bun prisma migrate dev
# Database leeg maken en migrations uitvoeren.
bun prisma migrate reset# Nieuwe migration aanmaken en uitvoeren.
yarn prisma migrate dev
# Database leeg maken en migrations uitvoeren.
yarn prisma migrate resetpnpx prisma migrate deploynpx prisma migrate deploybunx prisma migrate deployyarn dlx prisma migrate deployNadat we het prisma migrate dev commando uitvoeren, wordt er gevraagd naar de naam van migration. Aangezien dit de eerste migration is gebruiken we de naam 'initial-migration'.
> pnpm prisma migrate dev
Environment variables loaded from .env
Prisma schema loaded from prisma/schema.prisma
Datasource "db": PostgreSQL database "postgres", schema "public" at "localhost:5432"
? Enter a name for the new migration: › initial migrationDe gegenereerde SQL-code is terug te vinden in de Prisma map.

Shadow database
Als we de inhoud van de database bekijken zien we dat er twee tabellen aangemaakt zijn.

De tabel _prisma_migrations wordt ook wel de shadow database genoemd en bevat alle informatie die nodig is om migrations terug te draaien naar een vorig punt en om schema drift te detecteren[3].
Types genereren
Na elke migratie moeten nieuwe TypeScript interfaces gegenereerd worden die de modellen in de database en de CRUD-methodes van de Prisma client beschrijven.
Hiervoor gebruiken we onderstaand commando:
pnpm prisma generatenpx prisma generatebun prisma generateyarn prisma generateMerk op dat Prisma onderstaande lijnen uitgeprint heeft:
✔ Generated Prisma Client (v6.16.3) to ./src/generated/prisma in 39ms
✔ Generated Prisma Json Types Generator (3.6.1) to ./prisma in 32msDe gegenereerde code wordt dus in de /src/generated/prisma folder geplaatst. Deze map moet niet noodzakelijk mee op git gezet worden omdat deze eenvoudig opnieuw gegenereerd kan worden, toch kiezen we er in deze cursus toch voor om dit te doen, zo is het voor studenten eenvoudiger om de voorbeelden uit te voeren.
Seeding
Alhoewel het mogelijk is om te beginnen programmeren met een lege database, is het dikwijls handiger als er al wat data beschikbaar is. Dit kan belangrijke data zijn die altijd beschikbaar moet zijn, zoals een initiële administrator account, of dummy data die enkel belangrijk is tijdens development, zoals automatisch gegenereerde klantgegeven.
Prisma kan gebruikt worden om zulke data automatisch toe te voegen tijdens een reset of als gevolg van het seed commando. Een reset is dikwijls nodig als je wisselt tussen branches, elke branch kan tenslotte wijzigingen aan het schema vereisen die nog niet in de main branch zitten.
Om via Prisma data te inserten moet er een seed-script aangemaakt worden. De startbestanden bevatten reeds drie scripts, seed.ts, seedDev.ts en seedProd.ts, deze moeten in de map /prisma geplaatst worden. In seed.ts wordt de omgeving gecontroleerd en wordt het gepaste script uitgevoerd. Het productie script is leeg en enkel ter illustratie toegevoegd.
De seed scripts zijn volledig uitgewerkt in de startbestanden, we bespreken de code niet in detail omdat alle relevante methodes ook verder in deze les aan bod komen.
Om het seed script aan Prisma te koppelen voegen we een nieuwe lijn toe aan prisma.config.ts. Aangezien het script geschreven is in TypeScript moeten we de code op de één of andere manier converteren naar JavaScript zodat Node deze kan uitvoeren. Hiervoor gebruiken we de tsx runtime die TypeScript code kan uitvoeren zonder dat we deze eerst moeten transpileren naar JavaScript.
We voegen ook de --conditions=react-server vlag toe zodat we het seed script later kunnen uitbreiden met functies die enkel geïmporteerd kunnen worden op de server (server component, server function of route (API) handler).
// This file was generated by Prisma and assumes you have installed the following:
// npm install --save-dev prisma dotenv
import "dotenv/config";
import {defineConfig, env} from "prisma/config";
export default defineConfig({
schema: 'prisma/schema.prisma',
migrations: {
path: 'prisma/migrations',
seed: 'pxpx tsx --conditions=react-server prisma/seed.ts',
},
datasource: {
url: env('DATABASE_URL'),
},
})Om het script tenslotte uit te voeren gebruiken we onderstaand commando.
pnpm prisma db seednpx prisma db seedbun prisma db seedyarn prisma db seedPrisma studio
Je kan de inhoud van je database ofwel bekijken via de ingebouwde database explorer van je IDE ( WebStorm, VsCode ), via een externe tool (DataGrip, DBeaver), of via Prisma studio.
Als je voor een connectie via je IDE of een externe tool kiest, moet je de database configureren. Hiervoor gebruik je volgende gegevens (tenzij je compose.yaml aangepast hebt of een andere connectionstring gebruikt hebt dan in bovenstaand voorbeeld):
- Host: localhost
- Port: 5432
- Database:
- Theorie: backend-lecture
- Oefeningen: backend-exercise
- Project: backend-project
- User: postgres
- Password: postgresPassword
Als je Prisma studio wilt gebruiken, voer je onderstaand commando uit:
pnpm prisma studionpx prisma studiobun prisma studioyarn prisma studio
Prisma client
Net zoal voor de logger in les 1, gebruiken we voor de prisma-client ook het
globalThis object zodat er maximaal één instantie van de prisma-client aangemaakt wordt.[4]
Om de client aan te maken moeten we eerst de omgevingsvariabele uitlezen, deze doorgeven aan de PG Adapter en de adapter daarna doorgeven aan de PrismaClient klasse.
import {PrismaClient} from '@/generated/prisma/client'
import {PrismaPg} from '@prisma/adapter-pg'
function createPrismaClient(): PrismaClient {
const adapter = new PrismaPg({connectionString: process.env.DATABASE_URL})
return new PrismaClient({adapter})
}
const globalForPrisma = globalThis as unknown as {prisma: PrismaClient}
export const prismaClient = globalForPrisma.prisma || createPrismaClient()
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prismaClientServer only
Aangezien Prisma verbinding moet maken met de database, is het onmogelijk om deze code in de browser uit te voeren. Om foutief gebruik snel te detecteren gebruiken we het server-only package.
pnpm add -D server-onlynpm install -D server-onlybun add -d server-onlyyarn add -D server-onlyZodra we deze library importeren in een bepaald bestand garanderen we dat de development server foutmeldingen geeft als één van de dingen in die file geïmporteerd worden in een client component. Het wordt dus onmogelijk om server-only code in de browser te importeren en uit te voeren.
We voegen dit import statement toe aan zowel de prismaClient file als de file met DAL-functies voor de contacten.
import 'server-only'
import {PrismaClient} from '@/generated/prisma/client'
import {PrismaPg} from '@prisma/adapter-pg'
function createPrismaClient(): PrismaClient {
const adapter = new PrismaPg({connectionString: process.env.DATABASE_URL})
return new PrismaClient({adapter})
}
const globalForPrisma = globalThis as unknown as {prisma: PrismaClient}
export const prismaClient = globalForPrisma.prisma || createPrismaClient()
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prismaClientimport 'server-only'
export type CreateContactParams = {}
export async function getContacts(contact: CreateContactParams): Promise<Contact[]> {
throw new Error('Not implemented')
}
export async function getContact(id: string): Promise<Contact> {
throw new Error('Not implemented')
}
export async function createContact(contact: Prisma.ContactCreateInput): Promise<Contact> {
throw new Error('Not implemented')
}
export async function deleteContact(id: string): Promise<void> {
throw new Error('Not implemented')
}
export type UpdateContactParams = {}
export async function updateContact(contact: UpdateContactParams): Promise<Contact> {
throw new Error('Not implemented')
}Single Table
Hieronder bespreken we hoe elke CRUD-operatie geïmplementeerd kan worden met Prisma. We implementeren eerst de CRUD-methodes die gebruikt worden om een contact aan te maken, uit te lezen en te bewerken. Alle code voor de UI en server functions is geschreven (en bevat geen nieuwe theorie), we moeten enkel de DAL-laag implementeren.
Create
Begrip: Prisma Create
Via de create en createMany methodes worden respectievelijk één of meerdere rijen aangemaakt.
// create geeft de ingevoegde rij terug.
const newFooRow = prisma.foo.create({
data: {
// ... Data
},
})// createMany geeft de ingevoegde rijen NIET terug.
const {count: numberOfInsertedRows} = prisma.foo.createMany({
data: [
{
// ... Data
},
{
// ... Data
},
],
})// createManyAndReturn geeft de ingevoegde rijen terug.
const newFooRows = prisma.foo.createManyAndReturn({
data: [
{
// ... Data
},
{
// ... Data
},
],
})Prisma genereert, zoals eerder besproken, TypeScript definities op basis van het schema. Deze definities kunnen geïmporteerd worden uit @/generated/prisma/client en bevatten interfaces die een tabel en de parameters voor elke CRUD-operatie beschrijven. Daarnaast wordt de volledige Prisma API gegenereerd, deze bevat alle methodes die nodig zijn om de database te bewerken. We gebruiken deze gegenereerde methodes en interfaces om een nieuw contact toe te voegen en om het returntype van de DAL-methodes te specificeren.
Notitie
Je kan de Prisma interfaces op twee manieren importeren, uit de client en browser submap
import type {Contact} from '@/generated/prisma/client'en
import type {Contact} from '@/generated/prisma/browser'Merk op dat we de Prisma.ContactCreateInput interface gebruiken om de parameters voor de createContact functie te definiëren. Om de server functions zo leesbaar mogelijk te maken, en om de eventuele overstap op een ander ORM te vereenvoudigen, exporteren we een nieuw type met een generische naam die niets meer met Prisma te maken heeft. Als we in de toekomst zouden willen overstappen op Drizzle, TypeORM, ... dan moeten we enkel aanpassingen doen in de DAL en kunnen alle andere files onaangepast blijven.
import 'server-only'
import type {Contact, Prisma} from '@/generated/prisma/client'
import {prismaClient} from '@/dal/prismaClient'
export type CreateContactParams = Prisma.ContactCreateInput
export function createContact(contact: CreateContactParams): Promise<Contact> {
return prismaClient.contact.create({data: contact})
}Read
Begrip: Prisma Read
Prisma bevat verschillende methodes om data uit te lezen, er zijn methodes beschikbaar om één of meerdere rijen op te halen en om deze te filteren.
Filteren gebeurd via de where parameter en kan op volgende manieren:
- Zoek op exacte matches voor één of meerdere kolommen, hierbij moet voldaan zijn aan alle voorwaarden:
{ someColumn: 'someValue', anotherColumn: 'anotherValue' } - Filteren via een ingebouwde filtering operatoren zoals equals, contains, gt, lt, in, startsWith, endsWith, notIn, ... Hier moet eveneens voldaan zijn aan alle voorwaarden:
{ someColumn: { notIn: ['x', 'y', 'z'] }, anotherColumn: { gt: 10 } } - Combineren van meerdere filters met AND of OR:
{ OR: [{ someColumn: 'someValue' }, { anotherColumn: {gte: 10 }] }
Het is optioneel mogelijk om aan te geven welke kolommen teruggegeven worden via de select parameter. Zodra de select parameter meegegeven is, worden enkel de opgegeven kolommen opgehaald uit de database.
Via de optionele omit parameter wordt aangeven welke kolommen niet teruggegeven moeten worden. Omdat select en omit elkaar uitsluiten, kan slechts één van beide parameters tegelijkertijd gebruikt worden.
const oneFoo = prisma.foo.findUnique({
where: { someUniqueProperty: 'someValue'},
// Select is optioneel, als je dit meegeeft moet je alle
// properties meegeven die je nodig hebt.
// Als de parameter ontbreekt worden alle kolommen teruggeven.
select: {
// ...
},
// Omit is optioneel, alle kolommen die hierin vermeld worden,
// worden niet teruggegeven door Prisma.
// NIET te combineren met select.
omit: {
// ...
}
})
const oneFoo2 = prisma.foo.findUniqueOrThrow({
where: { someUniqueProperty: 'someValue'},
select: {}, // Optioneel
omit: {}, // Optioneel, niet te combineren met select.
})
const firstMatchingFoo = prisma.findFirst({
where: {}, // Optioneel
select: {}, // Optioneel
omit: {}, // Optioneel, niet te combineren met select.
})
const allMatchingFoos = prisma.findMany({
where: {}, // Optioneel
select: {}, // Optioneel
omit: {}, // Optioneel, niet te combineren met select.
orderBy: { // Optioneel
someColumn: 'asc', // 'desc' is ook geldig.
},
})We gebruiken de findMany methode om alle contacten op te halen waarvoor de voornaam of achternaam overeenkomt met de opgegeven zoekterm.
Merk op dat we de mode parameter gebruiken om te specificeren dat de zoekterm niet hoofdlettergevoelig is. In tegenstelling tot SQL Server is Postgres wel hoofdlettergevoelig. Als we de parameter niet toevoegen of op de defaultwaarde laten staan, zal de query dus de hoofdlettergevoelige LIKE operator gebruiken, door de parameter op 'insensitive' te zetten wordt de ILIKE operator gebruikt die niet hoofdlettergevoelig is.
Voor de getContacts functie maken we gebruik van de findUniqueOrThrow methode. Als alternatief zou ook findUnique gebruikt kunnen worden, maar dan moet het returntype aangepast worden naar Promise<Contact | null>.
export function getContacts(contactName: string = ''): Promise<Contact[]> {
return prismaClient.contact.findMany({
where: {
OR: [
{firstName: {contains: contactName, mode: 'insensitive'}},
{lastName: {contains: contactName, mode: 'insensitive'}},
],
},
})
}
export function getFavoriteContacts(): Promise<Contact[]> {
return prismaClient.contact.findMany({where: {favorite: true}})
}
export function getContact(id: string): Promise<Contact> {
return prismaClient.contact.findUniqueOrThrow({where: {id}})
}Update
Begrip: Prisma Update
Via de update en updateMany methodes kunnen we respectievelijk één of meerdere rijen aanpassen.
const newFooRow = prisma.foo.update({
where: { someUniqueProperty: 'someValue'},
data: {
// ... Data
},
select: {}, // Optioneel
})const {count: numberOfUpdatedRows} = prisma.foo.updateMany({
where: { someProperty: 'someValue'},
data: {
// ... Data
},
})Merk op dat we de Prisma.ContactUpdateInput interface niet rechtstreeks kunnen gebruiken omdat de id property optioneel is en we deze nodig hebben om te bepalen welk record bijgewerkt moet worden.
export type UpdateContactParams = Prisma.ContactUpdateInput & {id: string}
export function updateContact(contact: UpdateContactParams): Promise<Contact> {
return prismaClient.contact.update({where: {id: contact.id}, data: contact})
}Delete
Begrip: Prisma Delete
Via de delete en deleteMany methodes kunnen we respectievelijk één of meerdere rijen verwijderen.
const deletedFooRow = prisma.foo.delete({
where: { someUniqueProperty: 'someValue'},
})// Verwijder specifieke rijen
const {count: numberOfDeletedRows} = prisma.foo.deleteMany({
where: { someUniqueProperty: 'someValue'},
})
// Verwijder alle rijen.
const {count: numberOfDeletedRowsAll} = prisma.foo.deleteMany({})export async function deleteContact(id: string): Promise<void> {
await prismaClient.contact.delete({where: {id}})
}TypedSQL
Een contact moet als favoriet gemarkeerd kunnen worden (of hier terug uit verwijderd worden), hiervoor moet de favorite kolom van waarde veranderen. Prisma ondersteund standaard geen updates op basis van de huidige waarde van een kolom, om dit probleem op te lossen hebben we twee opties:
- Eerste de oude waarde ophalen en vervolgens een update uitvoeren. Het nadeel van deze aanpak is dat we twee aparte queries moeten uitvoeren en dat deze sequentieel uitgevoerd moeten worden.
- Zelf een SQL-query schrijven, die alles in één keer uitvoert. Het nadeel van deze aanpak is dat dit iets complexer is en meer setup vereist.
Begrip: TypedSQL
Via de experimentele typedSql optie ondersteund Prisma de mogelijkheid om zelf SQL queries te schrijven. Alhoewel dit meestal niet nodig is, kan het soms een nuttige escape hatch zijn als je dingen moet doen zoals
- gemiddelde scores bepalen op basis van verschillende reviews
- data moet aanpassen op basis van de waarde van een bestaande kolom
- complexe queries moet schrijven/optimaliseren die via een ORM te traag zouden zijn
Prisma genereert TypeScript definities voor de SQL-query, de parameters en het return-type (dat altijd een array is, ook als er slechts één rij teruggegeven wordt).
Om TypedSQL te activeren moet de experimentele typedSql feature geactiveerd worden.
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
previewFeatures = ["typedSql"]
}Vervolgens schrijven we een SQL-query in de prisma/sql map, hierin wordt een dollarteken gebruikt om parameters aan te duiden. De RETURNING * clause geeft de aangepaste rij terug.
UPDATE "Contact"
SET favorite = not favorite
WHERE id = $1
RETURNING *Nu de query aangemaakt is, moeten we de TypeScript types genereren via onderstaand commando.
pnpm prisma generate --sqlnpx prisma generate --sqlbun prisma generate --sqlyarn prisma generate --sqlTenslotte kunnen we de nieuwe query oproepen in de DAL-laag. Merk op dat we het resultaat moeten casten naar Contact, de JSON-generator werkt momenteel niet met TypedSQL.
import {toggleFavoriteQuery} from '@/generated/prisma/sql/toggleFavoriteQuery'
export async function toggleFavorite(id: string): Promise<Contact> {
const data = await prismaClient.$queryRawTyped(toggleFavoriteQuery(id))
return data[0] as Contact
}Eén-op-veel relaties
Om het gebruik van één-op-veel relaties te illustreren voegen we afspraken toe aan de applicatie. Een afspraak zal steeds voor één contact zijn, maar met een contact kunnen meerdere afspraken ingepland worden.
Hiervoor moeten we natuurlijk een nieuw model toevoegen aan de database.
model Meeting {
id String @id @default(uuid()) @db.Uuid
title String
description String?
date DateTime
}Bovenstaande code bevat nog geen relaties. Om deze relatie te definiëren moeten we drie properties toevoegen, een foreign-key en twee relation properties.
De relation properties krijgen als type de naam van het model waarnaar verwezen wordt, aan de één kant van de relatie wordt een array gebruikt. De relation property aan de veel-kant van de relatie krijgt via de @relation decorator informatie mee over de foreign-key constraints.
model Contact {
id String @id @default(uuid()) @db.Uuid
firstName String @db.VarChar(255)
lastName String? @db.VarChar(255)
description String? @db.VarChar(255)
avatar String @default("/avatars/placeholder.png") @db.VarChar(255)
/// [ContactInfo]
contactInfo Json @default("[]") @db.JsonB
favorite Boolean @default(false) @db.Boolean
meetings Meeting[]
}
model Meeting {
id String @id @default(uuid()) @db.Uuid
title String
description String?
date DateTime
// Komt overeen met CONTRAINT FK_Meeting_Contact FOREIGN KEY (contactId) REFERENCES Contact(id) ON DELETE CASCADE
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
contactId String @db.Uuid
}Nu het model aangemaakt is moeten we een nieuwe migration aanmaken. Aangezien er wijzigingen zijn in het datamodel, moet het generate commando opnieuw uitgevoerd worden, het is hierbij belangrijk dat de --sql vlag niet vergeten wordt. Als deze er niet staat, wordt de /src/generated/prisma/sql map leeg gemaakt en werkt de TypedSQL query niet meer.
pnpm prisma migrate dev
pnpm prisma generate --sqlnpx prisma migrate dev
npx prisma generate --sqlbun prisma migrate dev
bun prisma generate --sqlyarn prisma migrate dev
yarn prisma generate --sqlNa deze migratie ziet het ERD er als volgt uit.

Hieronder worden de verschillende CRUD-operaties besproken. De server functions en UI zijn opnieuw uitgewerkt in de startbestanden, we moeten enkel de DAL-laag implementeren.
Read
Begrip: Prisma Read - Include
De find-methodes kunnen gebruikt worden om informatie over een gerelateerde tabel op te halen, dit kan één of meerdere niveaus (relaties) diep gaan.
const oneFoo = prisma.foo.findUnique({
where: { someUniqueProperty: 'someValue'},
include: {
relation: {
relation: true
}
}
})const oneFoo2 = prisma.foo.findUniqueOrThrow({
where: { someUniqueProperty: 'someValue'},
include: {
relation: {
select: {
column1: true,
column2: true,
}
}
}
})const firstMatchingFoo = prisma.findFirst({
where: {},
select: {
column1: true,
column2: true,
relation: {
column3: true,
}
},
})We kunnen een meeting ophalen zoals hierboven beschreven, maar we hebben niet alle informatie over een contact nodig om een meeting weer te geven. Enkel de voornaam en naam moeten gekend zijn. Het is natuurlijk eenvoudig om enkel de firstName en lastName properties uit te lezen via select, maar we moeten ook een TypeScript interface hebben die deze informatie beschrijft.
Begrip: Afgeleide Prisma interfaces
Soms zijn de interfaces die Prisma genereert niet voldoende, deze interfaces beschrijven namelijk één volledige tabel. Als je een query schrijft die slechts een deel van een tabel (DTO) ophaalt, of die data uit meerdere tabellen combineert moet je hiervoor een interface voorzien.
Aangezien het aantal mogelijke combinaties ontzettend groot is, kan Prisma de interfaces niet voorzien. Enerzijds omdat er anders zodanig veel interfaces zouden zijn dat intellisense traag wordt, anderzijds omdat de naamgeving van deze interfaces niet deterministisch is en dus niet geautomatiseerd kan worden. In plaats daarvan voorzien Prisma helpers om deze interfaces te genereren.
Dit process bestaat uit twee stappen:
- Definieer een object dat aan de select, omit of include properties van de Prisma-methodes meegegeven kan
worden en valideer deze via dePrisma.TableInclude,Prisma.TableOmit,Prisma.TableSelectinterfaces, hier wordt table natuurlijk vervangen met de naam van de tabel. - Gebruik het object dat hierboven gedefinieerd is om een nieuw type te generen, hiervoor gebruik je
Prisma.TableGetPayload<{include: typeof objectUitPunt1}>,Prisma.TableGetPayload<{omit: typeof objectUitPunt1}>ofPrisma.TableGetPayload<{select: typeof objectUitPunt1}>.
import type {Prisma} from '@/generated/prisma/clinet'
export const fooWithBarInclude = {
bar: {
select: {
column1: true,
column2: true,
},
},
} satisfies Prisma.FooInclude
export type FooWithBar = Prisma.FooGetPayload<{include: typeof fooWithBarInclude}>De satisfies operator garandeert dat de meetingWithContactInclude variabele voldoet aan de Prisma.MeetingInclude interface, maar zorgt er ook voor dat TypeScript heel specifiek weet welke specifieke vorm van de Prisma.MeetingInclude interface we gebruiken[5].
import type {Prisma} from '@/generated/prisma/client'
export const meetingWithContactInclude = {
contact: {
select: {
firstName: true,
lastName: true,
},
},
} satisfies Prisma.MeetingInclude
// Hier kan ook een select, omit, ... meegegeven worden.
export type MeetingWithContact = Prisma.MeetingGetPayload<{include: typeof meetingWithContactInclude}>Nu de interface gedefinieerd is, kunnen we deze gebruiken in de getMeeting en getMeetingById functie. Merk op dat we de meetingWithContactInclude variabele gebruiken als waarde voor de include property, op deze manier zijn de DAL-methodes kleiner en wordt het returntype automatisch aangepast als de include variable aangepast wordt.
export function getMeetings(): Promise<MeetingWithContact[]> {
return prismaClient.meeting.findMany({
orderBy: {date: 'asc'},
where: {date: {gte: new Date()}},
include: meetingWithContactInclude,
})
}
export function getMeeting(id: string): Promise<MeetingWithContact> {
return prismaClient.meeting.findUniqueOrThrow({
where: {id},
include: meetingWithContactInclude,
})
}Create
Begrip: Prisma Create voor één-op-veel
Via de create methode kan niet alleen een rij aan een tabel toegevoegd worden, maar kunnen ook relaties tussen verschillende tabellen gelegd worden.
De methode heeft een parameter met dezelfde naam als de relation property in het Prisma schema. Deze parameter is een object dat ofwel een create of connect property bevat. Via de create property kan een nieuwe rij in de gerelateerde tabel aangemaakt worden. Aan de connect property moet een id van een bestaande rij in de gerelateerde tabel meegegeven worden.
const newFooRow = prisma.foo.create({
data: {
// ... Data
relationProperty: {
create: {
// ... Data voor de gerelateerde tabel
}
}
},
})const newFooRow = prisma.foo.create({
data: {
// ... Data
relationProperty: {
connect: {
id: 1
}
}
},
})Er zijn twee mogelijke interfaces waarmee we de invoer voor een één-op-veel relatie kunnen definiëren. Enerzijds is er de Prisma.TableUncheckedCreateInput die de foreign keys als een gewone kolom verwacht, anderzijds kan ook de Prisma.TableCreateInput interface gebruikt worden. Deze interface verwacht dat de foreign keys meegegeven worden als foreignTable: {connect: {id}} meegegeven wordt.
Aangezien we signatuur van de DAL-methodes op een ORM-onafhankelijke manier willen implementeren, kiezen we steeds voor de unchecked variant.
Alhoewel we de foreign key rechtstreeks kunnen doorgeven in het dataobject, kiezen we in de body van de functie toch voor de foreignTable: {connect: {id}} syntax. Op die manier kan Prisma duidelijkere foutmeldingen geven in het geval er iets fout gaat tijdens de insert dat specifiek te wijten is aan de relatie.
export type CreateMeetingParams = Prisma.MeetingUncheckedCreateInput
export async function createMeeting({contactId, ...meeting}: CreateMeetingParams): Promise<MeetingWithContact> {
return prismaClient.meeting.create({
data: {
...meeting,
contact: {
connect: {id: contactId},
},
},
include: meetingWithContactInclude,
})
}Delete & update
De delete en update methodes bevatten geen nieuwigheden, we raden de gemotiveerde lezer aan om deze methodes zelf te implementeren. De oplossing is te vinden in het uitgewerkte lesvoorbeeld.
Veel-op-veel relaties
Om het gebruik van een veel-op-veel relatie te illustreren voegen we de mogelijkheid toe om contacten te groeperen/organiseren via tags.
Prisma ondersteund twee manieren om deze relatie weer te geven, de expliciete en impliciete manier. Via de expliciete manier moet de associatie tabel uitdrukkelijk vermeld worden in het schema, dit is meer werk, maar betekent ook dat we deze tabel rechtstreeks kunnen aanspreken via Prisma. Als we de impliciete manier gebruiken, moeten we de associatie tabel niet uitdrukkelijk aanmaken en kunnen we deze ook niet rechtstreeks aanspreken.
In het verdere verloop van deze cursus maken we gebruik van de impliciete manier, voor meer informatie over de expliciete manier verwijzen we door naar de documentatie.
Om een impliciete veel-op-veel relatie aan te maken moeten we een array toevoegen in beide tabellen. Als je gebruik maakt van de Prisma plugin voor WebStorm of Visual Studio Code mag je pas bewaren als beide arrays gedefinieerd zijn, anders wordt er automatisch een één-op-veel relatie aangemaakt.
model Contact {
id String @id @default(uuid()) @db.Uuid
firstName String @db.VarChar(255)
lastName String? @db.VarChar(255)
description String? @db.VarChar(255)
avatar String @default("/avatars/placeholder.png") @db.VarChar(255)
/// [ContactInfo]
contactInfo Json @default("[]") @db.JsonB
favorite Boolean @default(false) @db.Boolean
meetings Meeting[]
tags Tag[]
}
model Tag {
id String @id @default(uuid()) @db.Uuid
name String
description String?
contacts Contact[]
}Nadat we de aanpassing toegevoegd hebben aan de database via het pnpm prisma migrate dev commando en we de types opnieuw gegenereerd hebben via pnpm prisma generate --sql, ziet het ERD er als volgt uit.

Create & delete
Om een nieuwe link toe te voegen of te verwijderen aan de associatie tabel gebruiken we de connect en disconnect properties. Aangezien het hier om een veel-op-veel relatie gaat moeten we een array meegeven in de plaats van een object.
export async function linkTagsToContact(tagId: string, contactIds: string[]): Promise<void> {
await prismaClient.tag.update({
where: {id: tagId},
data: {
contacts: {
connect: contactIds.map(id => ({id})),
},
},
})
}
export async function disconnectTagsFromContact(tagId: string, contactIds: string[]): Promise<void> {
await prismaClient.tag.update({
where: {id: tagId},
data: {
contacts: {
disconnect: contactIds.map(id => ({id})),
},
},
})
}Logging
De startbestanden bevatten reeds logging voor de server functions en actions, maar dit is niet voldoende. Prisma kan foutmeldingen genereren, voorlopig worden deze uitgeprint via een standaard console.error en niet via Pino.
Het is mogelijk om de in de configuratie van de Prisma client te abonneren op de verschillende events, maar dan worden errors nog steeds opgegooid en uitgeprint door Next. Dit is enkel zinvol als je heel gedetailleerde logs nodig hebt (elke individuele query), we verwijzen de geïnteresseerde lezer door naar de documentatie.
Als oplossing vervangen we de globale error methode van de console met het Pino equivalent. Hiervoor gebruiken we instrumentation.ts, een file die één keer uitgevoerd wordt als de Next server start. Aangezien de Pino levels overeenkomen met de standaard methodes van de console klasse, overschrijven we ook de warn, debug en log methodes.
import {getLogger} from '@/lib/logger'
import {indentMultiline} from '@/lib/utils'
export function register() {
console.error = async (...args: string[]) => {
const logger = await getLogger()
logger.error(indentMultiline(args.join('\n')))
}
console.warn = async (...args: string[]) => {
const logger = await getLogger()
logger.warn(indentMultiline(args.join('\n')))
}
console.debug = async (...args: string[]) => {
const logger = await getLogger()
logger.debug(indentMultiline(args.join('\n')))
}
console.log = async (...args: string[]) => {
const logger = await getLogger()
logger.info(indentMultiline(args.join('\n')))
}
}Na deze aanpassing worden de foutmeldingen als volgt uitgeprint.
Je kan dit testen door naar de detailpagina van een contact te gaan en een niet bestaand contactId in te geven, of door een string in te geven die niet voldoet aan het uuid4 formaat. Eventueel moet je de .next map eerste verwijderen.
[08:29:13.892] ERROR (36542): ⨯
DriverAdapterError: invalid input syntax for type uuid: "ongeldig-formaat"
method: "GET"
pathname: "/contacts/ongeldig-formaat"
requestId: "139454c7-120c-4aaa-b842-12abd176ab06"
[08:27:56.891] ERROR (36542): ⨯
PrismaClientKnownRequestError:
Invalid `{imported module ./src/dal/prismaClient.ts}["prismaClient"].contact.findUniqueOrThrow()` invocation in
/Users/sebastiaan/Projects/backend_lecture2_example/.next/dev/server/chunks/ssr/[root-of-the-server]__214b3fa0._.js:479:159
476 function getContact(id) {
477 // De findUnique methode geeft een null terug als er geen match gevonden is.
478 // De findUniqueOrThrow methode gooit een error als er geen match gevonden is.
→ 479 return {imported module ./src/dal/prismaClient.ts}["prismaClient"].contact.findUniqueOrThrow(
An operation failed because it depends on one or more records that were required but not found. No record was found for a query.
method: "GET"
pathname: "/contacts/aefb24d8-beaa-446c-926f-bbdc60e853a2"
requestId: "63716aeb-ab3d-4073-8ab7-6c69ae1cd84c"Voorbeeldcode
Uitgewerkt lesvoorbeeld met commentaar
We beperken ons in deze cursus tot één schema file, als je het schema wilt opsplitsen in meerdere files, verwijzen we naar de documentatie. ↩︎
De meeste relationele databases ondersteunen JSON-kolommen. SQL Server ondersteund dit sinds 2016, MySQL sinds 2015. ↩︎
Schema drift is het fenomeen waarbij de database niet meer overeenkomt met de schemafiles en migrations in je project. Dit kan bijvoorbeeld voorvallen als gevolg van merge conflicts of als je wisselt tussen branches. ↩︎
Als je toch meer verbindingen nodig hebt, kan je een connection pool gebruiken. Zo wordt er maximaal één client aangemaakt, maar kan die client meerdere connections gebruiken en zo een groter aantal requests verwerken. Zie de Prisma documentatie voor meer info. ↩︎
Voor meer informatie over de satisfies operator en waarom deze heel nuttig kan zijn, verwijzen we door naar een blogpost van Kent C. Dodds. ↩︎