ORM (object-relational mapping) переводит работу с объектами в коде в SQL-запросы к реляционной базе: в Prisma вы описываете модели в schema.prisma, получаете сгенерированный типизированный клиент, а prisma migrate dev превращает каждое изменение схемы в SQL-файл миграции, который хранится в Git. ORM ускоряет типовой CRUD и защищает от опечаток, но не отменяет знание SQL: сгенерированные миграции нужно читать, N+1 — отслеживать, а сложные отчёты удобнее писать сырым SQL.
После материала вы сможете
- описать модели и связи в
schema.prismaи прочитать SQL, который из них генерируетprisma migrate dev - создать, отредактировать и применить миграцию, в том числе переименование столбца без потери данных
- выполнить типизированные запросы со связями, транзакцию и найти N+1 по счётчику SQL-запросов
- объяснить, когда ORM мешает и запрос лучше написать на SQL через
$queryRaw
Что знать заранее
Проверено на
Prisma ORM 7.10.0PostgreSQL 17.11 (postgres:17-alpine)Node.js 24 LTS (24.18)TypeScript 5.9.3pg 8.23.1tsx 4.23.15
Сверено с документацией: 11 октября 2026 г.
Содержание
ORM: что он даёт и чего стоит
ORM — слой между кодом и базой данных. Вместо строки SELECT … FROM appointments JOIN clients … вы вызываете prisma.appointment.findMany({ include: { client: true } }) и получаете массив объектов с типами. Prisma — ORM для Node.js и TypeScript из трёх частей: схема schema.prisma (модели и связи), Prisma Client (клиент, который генерируется из схемы) и Prisma Migrate (миграции — SQL-файлы, которые шаг за шагом приводят базу к схеме).
Что даёт ORM
- Типы из схемы: опечатку в имени поля находит компилятор, а не пользователь
- Связи и вложенные записи без ручных JOIN и без ручной сборки объектов
- Миграции: история изменений схемы в Git, одинаковая для всех окружений
- Параметризованные запросы по умолчанию — значения не склеиваются в SQL
Чего стоит
- Ещё один слой: нужно понимать, какой SQL он отправляет
- Легко незаметно получить N+1 — десятки запросов вместо двух
- Отчёты, оконные функции и особые возможности PostgreSQL проще писать на SQL
- Зависимость от инструмента и его мажорных обновлений
Урок продолжает материал о проектировании связей: те же таблицы, внешние ключи и индексы, но описанные в схеме Prisma. Предметная область — сервис записи: клиенты, услуги и записи на время. В конце у вас будет схема, три миграции, CRUD-скрипт, транзакция, замер N+1 и отчёт на SQL.
Проект: Prisma 7.10 и PostgreSQL 17 в Docker
Окружение урока: Node.js 24 LTS, PostgreSQL 17 в контейнере и TypeScript 5.9. Минимальные требования Prisma 7 по руководству по обновлению — Node.js 20.19 и TypeScript 5.4, рекомендуемая версия TypeScript — 5.9. Порт 55817 выбран, чтобы не конфликтовать с локальным PostgreSQL на 5432; пароль test годится только для учебного контейнера.
docker run -d --rm --name booking-pg -e POSTGRES_PASSWORD=test -e POSTGRES_DB=booking -p 127.0.0.1:55817:5432 postgres:17-alpine
mkdir booking && cd booking
npm init -y
npm pkg set type=module
npm install @prisma/client@7.10.0 @prisma/adapter-pg@7.10.0 pg@8.23.1 dotenv@18.0.7
npm install -D prisma@7.10.0 typescript@5.9.3 tsx@4.23.15 @types/node@24 @types/pg@8.23.1
npx prisma init --datasource-provider postgresql --output ../src/generated/prisma --no-skillsPrisma 7 заметно отличается от шестой версии — многие статьи и ответы на форумах описывают старое поведение. Главные изменения из официального руководства по обновлению:
Генератор
- Prisma 6
prisma-client-js, клиент вnode_modules- Prisma 7
prisma-clientс обязательнымoutput; импорт из./generated/prisma/client
Строка подключения
- Prisma 6
urlв блокеdatasourceсхемы- Prisma 7
- в файле конфигурации Prisma (
prisma.config.tsилиprisma7.config.ts)
Подключение к БД
- Prisma 6
- встроенный движок запросов
- Prisma 7
- обязательный driver adapter; для PostgreSQL —
@prisma/adapter-pgповерхpg
Файл .env
- Prisma 6
- загружался автоматически
- Prisma 7
- не загружается: нужен
import "dotenv/config"
migrate dev
- Prisma 6
- сам запускал
generateи seed - Prisma 7
- не запускает:
prisma generateиprisma db seed— отдельными командами
Модули
- Prisma 6
- CommonJS допускался
- Prisma 7
- проект — ES-модули,
"type": "module"
Ещё одна деталь версии 7.10: prisma init создаёт не prisma.config.ts, о котором пишет документация, а prisma7.config.ts. В примечаниях к релизу 7.10.0 сказано, что CLI седьмой версии сначала ищет prisma7.config.*, затем .config/prisma7.* и только потом старые prisma.config.* — так проекты на Prisma 7 и 8 могут жить рядом без конфликта имён. Существующие проекты с prisma.config.ts продолжают работать.
// This file was generated by Prisma, and assumes you have installed the following:
// npm install --save-dev prisma dotenv
import "dotenv/config";
import { defineConfig } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
datasource: {
url: process.env["DATABASE_URL"],
},
});DATABASE_URL="postgresql://postgres:test@localhost:55817/booking"{
"compilerOptions": {
"target": "ES2023",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["node"]
},
"include": ["src", "prisma7.config.ts"]
}import "dotenv/config";
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "./generated/prisma/client";
const connectionString = process.env.DATABASE_URL;
if (!connectionString) throw new Error("DATABASE_URL is not set");
// Prisma 7: the client talks to PostgreSQL through a driver adapter (node-postgres)
const adapter = new PrismaPg({ connectionString });
export const prisma = new PrismaClient({
adapter,
log: [{ emit: "event", level: "query" }],
});
// Count SQL queries so that N+1 is visible in numbers, not by feel
export const sql = { count: 0, print: false };
prisma.$on("query", (e) => {
sql.count++;
if (sql.print) console.log(` SQL: ${e.query}`);
});Адаптер PrismaPg — это мост между сгенерированным клиентом и драйвером pg. Руководство по обновлению отдельно предупреждает: настройки пула соединений теперь берутся из драйвера, и у pg по умолчанию нет таймаута подключения (в Prisma 6 было 5 секунд). Подписка на событие query понадобится в разделе про N+1: она позволяет считать запросы, а не гадать.
Схема: модели, связи и имена в базе
Схема описывает генератор клиента, источник данных и модели. Три модели сервиса записи: Client (клиент), Service (услуга) и Appointment (запись). У клиента и у услуги может быть много записей — это две связи «один ко многим». Так выглядела схема перед первой миграцией:
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
}
datasource db {
provider = "postgresql"
}
model Client {
id Int @id @default(autoincrement())
name String
phone String @unique
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
appointments Appointment[]
@@map("clients")
}
model Service {
id Int @id @default(autoincrement())
title String @unique
durationMin Int @map("duration_min")
priceKzt Int @map("price_kzt")
appointments Appointment[]
@@map("services")
}
model Appointment {
id Int @id @default(autoincrement())
startsAt DateTime @map("starts_at") @db.Timestamptz(3)
clientId Int @map("client_id")
serviceId Int @map("service_id")
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
service Service @relation(fields: [serviceId], references: [id], onDelete: Restrict)
@@unique([serviceId, startsAt])
@@index([clientId])
@@map("appointments")
}@id @default(autoincrement())
- Что получится в PostgreSQL
- столбец
SERIALи первичный ключ
@unique
- Что получится в PostgreSQL
- уникальный индекс, например
clients_phone_key
@relation(fields, references, onDelete)
- Что получится в PostgreSQL
- внешний ключ с
ON DELETE CASCADEилиRESTRICT
@map / @@map
- Что получится в PostgreSQL
- имена в базе в snake_case, в коде — camelCase
@db.Timestamptz(3)
- Что получится в PostgreSQL
TIMESTAMPTZ(3)вместо типа по умолчаниюTIMESTAMP(3)
@@unique([serviceId, startsAt])
- Что получится в PostgreSQL
- одна запись на услугу в один момент времени
@@index([clientId])
- Что получится в PostgreSQL
- обычный индекс для поиска записей клиента
Поле со списком (appointments Appointment[]) в базе не существует: это обратная сторона связи, нужная клиенту Prisma. Реальный столбец — clientId в записи, а @relation объявляет его внешним ключом. Политики удаления выбраны осознанно: удалили клиента — его записи удаляются каскадом; услугу с записями удалить нельзя (Restrict), чтобы не потерять историю.
Про время: тип DateTime в Prisma по умолчанию становится TIMESTAMP(3) без часового пояса — это видно в первом черновике миграции. Для времени записи в Алматы и где угодно ещё надёжнее TIMESTAMPTZ: PostgreSQL хранит момент времени однозначно, а клиент получает его как Date в UTC.
Миграции: migrate dev, SQL и история
Команда prisma migrate dev сравнивает схему с историей миграций, генерирует новый SQL-файл, применяет его к базе разработки и записывает в служебную таблицу _prisma_migrations. Для проверки истории она использует теневую базу (shadow database): прогоняет в ней все миграции с нуля и сверяет результат.
$ npx prisma migrate dev --name init
Loaded Prisma config from prisma7.config.ts.
Prisma schema loaded from prisma\schema.prisma.
Datasource "db": PostgreSQL database "booking", schema "public" at "localhost:55817"
Applying migration `20261012005404_init`
The following migration(s) have been created and applied from new schema changes:
prisma\migrations/
└─ 20261012005404_init/
└─ migration.sql
Your database is now in sync with your schema.
┌─────────────────────────────────────────────────────────┐
│ Update available 7.10.0 -> 8.0.0-rc.22 │
│ │
│ This is a major update - please follow the guide at │
│ https://pris.ly/d/major-version-upgrade │
│ │
│ Run the following to update │
│ npm i --save-dev prisma@latest │
│ npm i @prisma/client@latest │
└─────────────────────────────────────────────────────────┘-- CreateTable
CREATE TABLE "clients" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"phone" TEXT NOT NULL,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "clients_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "services" (
"id" SERIAL NOT NULL,
"title" TEXT NOT NULL,
"duration_min" INTEGER NOT NULL,
"price_kzt" INTEGER NOT NULL,
CONSTRAINT "services_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "appointments" (
"id" SERIAL NOT NULL,
"starts_at" TIMESTAMPTZ(3) NOT NULL,
"client_id" INTEGER NOT NULL,
"service_id" INTEGER NOT NULL,
CONSTRAINT "appointments_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "clients_phone_key" ON "clients"("phone");
-- CreateIndex
CREATE UNIQUE INDEX "services_title_key" ON "services"("title");
-- CreateIndex
CREATE INDEX "appointments_client_id_idx" ON "appointments"("client_id");
-- CreateIndex
CREATE UNIQUE INDEX "appointments_service_id_starts_at_key" ON "appointments"("service_id", "starts_at");
-- AddForeignKey
ALTER TABLE "appointments" ADD CONSTRAINT "appointments_client_id_fkey" FOREIGN KEY ("client_id") REFERENCES "clients"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "appointments" ADD CONSTRAINT "appointments_service_id_fkey" FOREIGN KEY ("service_id") REFERENCES "services"("id") ON DELETE RESTRICT ON UPDATE CASCADE;Читайте сгенерированный SQL так же внимательно, как код коллеги: здесь видно, какие ограничения и индексы на самом деле появятся в базе. Рядом с папками миграций лежит migration_lock.toml с провайдером базы — по комментарию в самом файле его нужно хранить в системе контроля версий вместе с миграциями.
Вторая миграция добавляет статус записи и необязательную заметку. Перечисление (enum) Prisma превращает в тип PostgreSQL:
model Appointment {
id Int @id @default(autoincrement())
startsAt DateTime @map("starts_at") @db.Timestamptz(3)
clientId Int @map("client_id")
serviceId Int @map("service_id")
status AppointmentStatus @default(BOOKED)
note String?
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
service Service @relation(fields: [serviceId], references: [id], onDelete: Restrict)
@@unique([serviceId, startsAt])
@@index([clientId])
@@map("appointments")
}
enum AppointmentStatus {
BOOKED
CANCELLED
DONE
@@map("appointment_status")
}-- CreateEnum
CREATE TYPE "appointment_status" AS ENUM ('BOOKED', 'CANCELLED', 'DONE');
-- AlterTable
ALTER TABLE "appointments" ADD COLUMN "note" TEXT,
ADD COLUMN "status" "appointment_status" NOT NULL DEFAULT 'BOOKED';Третья миграция показывает главную опасность автогенерации. Переименуем note в comment. Prisma не знает о переименованиях: для неё это «одно поле исчезло, другое появилось». Поэтому создаём миграцию с флагом --create-only — файл появится, но не применится:
$ npx prisma migrate dev --name rename_note_to_comment --create-only
Loaded Prisma config from prisma7.config.ts.
Prisma schema loaded from prisma\schema.prisma.
Datasource "db": PostgreSQL database "booking", schema "public" at "localhost:55817"
$ cat prisma/migrations/20261012005439_rename_note_to_comment/migration.sql
/*
Warnings:
- You are about to drop the column `note` on the `appointments` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "appointments" DROP COLUMN "note",
ADD COLUMN "comment" TEXT;-- Rename instead of drop + add: keeps existing data
ALTER TABLE "appointments" RENAME COLUMN "note" TO "comment";$ npx prisma migrate dev
...
migrations/
└─ 20261012005439_rename_note_to_comment/
└─ migration.sql
Your database is now in sync with your schema.
$ docker exec booking-pg psql -U postgres -d booking -c "SELECT id, status, comment FROM appointments"
id | status | comment
----+--------+---------
1 | BOOKED | keep me
(1 row)
$ npx prisma generate
✔ Generated Prisma Client (7.10.0) to .\src\generated\prisma in 46msНа сервере применяется не migrate dev, а migrate deploy: документация называет migrate dev и migrate reset командами разработки, которые нельзя запускать в продакшене. migrate deploy только применяет ожидающие миграции — без теневой базы, без сброса и без генерации клиента. Проверка на пустой базе, которая изображает staging:
$ DATABASE_URL="postgresql://postgres:test@localhost:55817/booking_stage" npx prisma migrate deploy
Loaded Prisma config from prisma7.config.ts.
Prisma schema loaded from prisma\schema.prisma.
Datasource "db": PostgreSQL database "booking_stage", schema "public" at "localhost:55817"
3 migrations found in prisma/migrations
Applying migration `20261012005404_init`
Applying migration `20261012005422_add_status_and_note`
Applying migration `20261012005439_rename_note_to_comment`
The following migration(s) have been applied:
migrations/
└─ 20261012005404_init/
└─ migration.sql
└─ 20261012005422_add_status_and_note/
└─ migration.sql
└─ 20261012005439_rename_note_to_comment/
└─ migration.sql
All migrations have been successfully applied.Если кто-то изменил базу разработки вручную, migrate dev обнаружит расхождение с историей (drift) и предложит сбросить схему. В разработке это потеря тестовых данных, поэтому все изменения структуры — только через миграции:
$ docker exec booking-pg psql -U postgres -d booking -c "ALTER TABLE clients ADD COLUMN email text"
$ npx prisma migrate dev --name noop
...
Drift detected: Your database schema is not in sync with your migration history.
The following is a summary of the differences between the expected database schema given your migrations files, and the actual schema of the database.
It should be understood as the set of changes to get from the expected schema to the actual schema.
[*] Changed the `clients` table
[+] Added column `email`
We need to reset the "public" schema at "localhost:55817"
You may use prisma migrate reset to drop the development database.
All data will be lost.Запросы: CRUD, связи и транзакция
Скрипт ниже проходит полный цикл на пустой базе: создаёт услуги, создаёт клиента сразу с первой записью (вложенная запись — nested write), читает расписание со связями, меняет запись и проверяет обе политики удаления.
import { Prisma } from "./generated/prisma/client";
import { prisma } from "./db";
// Create: plain records
const haircut = await prisma.service.create({
data: { title: "Haircut", durationMin: 60, priceKzt: 8000 },
});
const manicure = await prisma.service.create({
data: { title: "Manicure", durationMin: 90, priceKzt: 10000 },
});
// Nested write: a client and their first booking in one transaction
const client = await prisma.client.create({
data: {
name: "Aigerim",
phone: "+77010000001",
appointments: {
create: { serviceId: haircut.id, startsAt: new Date("2026-10-20T10:00:00+05:00") },
},
},
include: { appointments: true },
});
console.log("created:", client.name, "bookings:", client.appointments.length);
// Read: relation via include, only needed fields via select
const schedule = await prisma.appointment.findMany({
where: { startsAt: { gte: new Date("2026-10-20T00:00:00+05:00") } },
orderBy: { startsAt: "asc" },
select: {
id: true,
startsAt: true,
status: true,
client: { select: { name: true } },
service: { select: { title: true, priceKzt: true } },
},
});
console.log("schedule:", JSON.stringify(schedule));
// Update: move the booking to another service and leave a comment
const updated = await prisma.appointment.update({
where: { id: client.appointments[0].id },
data: { serviceId: manicure.id, comment: "Client asked to switch" },
});
console.log("updated:", updated.serviceId, updated.comment, updated.status);
// Delete: Restrict on the relation does not let us delete a service with bookings
try {
await prisma.service.delete({ where: { id: manicure.id } });
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
console.log("delete service:", e.code); // P2003 — foreign key constraint
} else throw e;
}
// Cascade: deleting a client deletes their bookings (onDelete: Cascade)
await prisma.client.delete({ where: { id: client.id } });
console.log("bookings left:", await prisma.appointment.count());
await prisma.$disconnect();$ npx tsx src/crud.ts
created: Aigerim bookings: 1
schedule: [{"id":1,"startsAt":"2026-10-20T05:00:00.000Z","status":"BOOKED","client":{"name":"Aigerim"},"service":{"title":"Haircut","priceKzt":8000}}]
updated: 2 Client asked to switch BOOKED
delete service: P2003
bookings left: 0- Вложенная запись создаёт клиента и запись одной транзакцией — по документации, nested writes выполняются атомарно.
includeдобавляет связанные записи целиком,selectвыбирает только нужные поля, в том числе внутри связей. На одном уровне их совмещать нельзя.- Ошибки базы приходят как
PrismaClientKnownRequestErrorс кодом:P2002— нарушено уникальное ограничение,P2003— внешний ключ,P2025— нужная запись не найдена.
Транзакция нужна, когда несколько изменений имеют смысл только вместе. Перенос записи — классический случай: отменить старую и создать новую. Если новое время занято, отмена старой не должна сохраниться. Интерактивная транзакция $transaction(async (tx) => …) получает клиент tx, привязанный к транзакции; любое исключение внутри откатывает всё.
import { Prisma } from "./generated/prisma/client";
import { prisma } from "./db";
// Move a booking: cancel the old one and create a new one — both or neither
async function reschedule(appointmentId: number, newStartsAt: Date) {
return prisma.$transaction(async (tx) => {
const old = await tx.appointment.update({
where: { id: appointmentId, status: "BOOKED" },
data: { status: "CANCELLED" },
});
// If the slot is taken, create throws P2002 and the update above is rolled back
return tx.appointment.create({
data: { clientId: old.clientId, serviceId: old.serviceId, startsAt: newStartsAt },
});
});
}
const booking = await prisma.appointment.findFirstOrThrow({
where: { status: "BOOKED" },
orderBy: { id: "asc" },
});
const busy = await prisma.appointment.findFirstOrThrow({
where: { serviceId: booking.serviceId, id: { not: booking.id } },
});
try {
await reschedule(booking.id, busy.startsAt); // slot is taken by another client
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") {
console.log("slot is taken, transaction rolled back");
} else throw e;
}
const after = await prisma.appointment.findUniqueOrThrow({ where: { id: booking.id } });
console.log(`booking ${booking.id} status: ${after.status}`);
const moved = await reschedule(booking.id, new Date("2026-10-25T06:00:00Z"));
console.log(`new booking ${moved.id} at ${moved.startsAt.toISOString()}`);
const old = await prisma.appointment.findUniqueOrThrow({ where: { id: booking.id } });
console.log(`old booking ${booking.id} status: ${old.status}`);
await prisma.$disconnect();$ npx tsx src/seed.ts
clients: 20 bookings: 40
$ npx tsx src/reschedule.ts
slot is taken, transaction rolled back
booking 3 status: BOOKED
new booking 43 at 2026-10-25T06:00:00.000Z
old booking 3 status: CANCELLEDПараметры по умолчанию из документации: timeout — 5000 мс на всю транзакцию, maxWait — 2000 мс на её начало, уровень изоляции — тот, что задан в базе (для PostgreSQL — Read Committed). Документация советует держать транзакции короткими и не делать внутри сетевых запросов и медленных операций: долгие транзакции бьют по производительности и могут приводить к взаимным блокировкам. Условие status: "BOOKED" в update защищает от повторной отмены: если запись уже отменена, Prisma выбросит P2025.
N+1: как увидеть и убрать
N+1 — ситуация, когда код делает один запрос за списком, а затем ещё по запросу на каждый элемент. На 20 клиентах это 21 запрос, на 2000 — 2001; каждый идёт по сети к базе. В коде проблема выглядит безобидно: await внутри цикла. Заполним базу тестовыми данными и сравним два подхода, считая SQL через событие query из src/db.ts.
import { prisma } from "./db";
// Test data: 3 services, 20 clients, two bookings each (fake phone numbers)
await prisma.appointment.deleteMany();
await prisma.client.deleteMany();
await prisma.service.deleteMany();
const services = await prisma.service.createManyAndReturn({
data: [
{ title: "Haircut", durationMin: 60, priceKzt: 8000 },
{ title: "Manicure", durationMin: 90, priceKzt: 10000 },
{ title: "Massage", durationMin: 60, priceKzt: 15000 },
],
});
for (let i = 1; i <= 20; i++) {
await prisma.client.create({
data: {
name: `Client ${i}`,
phone: `+7701000${String(i).padStart(4, "0")}`,
appointments: {
create: [0, 1].map((k) => ({
serviceId: services[(i + k) % services.length].id,
startsAt: new Date(Date.UTC(2026, 9, 20 + k, 4 + (i % 8), 0)),
status: k === 0 ? "DONE" : "BOOKED",
})),
},
},
});
}
console.log("clients:", await prisma.client.count(), "bookings:", await prisma.appointment.count());
await prisma.$disconnect();import { prisma, sql } from "./db";
// Bad: 1 query for clients + 1 query per client = N+1
sql.count = 0;
const clients = await prisma.client.findMany({ orderBy: { id: "asc" } });
let total = 0;
for (const c of clients) {
const bookings = await prisma.appointment.findMany({ where: { clientId: c.id } });
total += bookings.length;
}
console.log(`loop: ${clients.length} clients, ${total} bookings, ${sql.count} SQL queries`);
// Good: include loads the relation for all clients at once
sql.count = 0;
sql.print = true;
const withBookings = await prisma.client.findMany({
orderBy: { id: "asc" },
include: { appointments: true },
});
sql.print = false;
const total2 = withBookings.reduce((n, c) => n + c.appointments.length, 0);
console.log(`include: ${withBookings.length} clients, ${total2} bookings, ${sql.count} SQL queries`);
await prisma.$disconnect();$ npx tsx src/n-plus-one.ts
loop: 20 clients, 40 bookings, 21 SQL queries
SQL: SELECT "public"."clients"."id", "public"."clients"."name", "public"."clients"."phone", "public"."clients"."created_at" FROM "public"."clients" WHERE 1=1 ORDER BY "public"."clients"."id" ASC OFFSET $1
SQL: SELECT "public"."appointments"."id", "public"."appointments"."starts_at", "public"."appointments"."client_id", "public"."appointments"."service_id", "public"."appointments"."status"::text, "public"."appointments"."comment" FROM "public"."appointments" WHERE "public"."appointments"."client_id" IN ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20) OFFSET $21
include: 20 clients, 40 bookings, 2 SQL queriesС include Prisma 7 по умолчанию делает отдельный запрос на каждую таблицу и собирает объекты в приложении — документация называет эту стратегию query. Стратегия join выполняет один запрос с LATERAL JOIN и агрегацией в JSON на стороне PostgreSQL, но в Prisma 7 она остаётся в превью и включается флагом relationJoins в генераторе. Для большинства экранов два запроса вместо N+1 — уже нормальный результат.
- В разработке считайте запросы: событие
queryили журнал сlog: ["query"]сразу показывают цикл - Ищите
await prisma…внутриfor,mapи резолверов GraphQL - Загружайте связи через
includeили вложенныйselect, а не отдельными вызовами - Для счётчиков используйте
_count, а не загрузку всех связанных строк - Выбирайте только нужные поля через
select— меньше данных по сети
Отдельный случай — findUnique: по документации Prisma автоматически объединяет такие вызовы в один запрос, если они пришли в одном такте событийного цикла; для findMany такой пакетной обработки нет. Как читать план выполнения медленного запроса — в уроке про EXPLAIN.
Когда писать сырой SQL и что есть кроме Prisma
Документация Prisma называет два повода для сырого SQL: сильно оптимизированный запрос и возможность базы, которой нет в API клиента. На практике это отчёты с агрегатами по нескольким таблицам, оконные функции, CTE, полнотекстовый поиск и специфичные типы PostgreSQL. Пример — выручка по услугам: groupBy в Prisma не умеет суммировать цену из связанной таблицы, а в SQL это один запрос.
import { prisma } from "./db";
type Row = { title: string; visits: bigint; revenue: bigint };
// Revenue by service: an aggregate over a JOIN is easier and clearer in SQL
const from = new Date("2026-10-01T00:00:00Z");
const rows = await prisma.$queryRaw<Row[]>`
SELECT s.title, count(*) AS visits, sum(s.price_kzt) AS revenue
FROM appointments a
JOIN services s ON s.id = a.service_id
WHERE a.status = 'DONE' AND a.starts_at >= ${from}
GROUP BY s.title
ORDER BY revenue DESC`;
// count() and sum() over integer in PostgreSQL return bigint → BigInt in JS
for (const r of rows) {
console.log(`${r.title}: ${r.visits} visits, ${Number(r.revenue).toLocaleString("en-US")} KZT`);
}
console.log(typeof rows[0].visits);
await prisma.$disconnect();$ npx tsx src/report.ts
Massage: 7 visits, 105,000 KZT
Manicure: 7 visits, 70,000 KZT
Haircut: 6 visits, 48,000 KZT
bigint$queryRaw — тегированный шаблон: каждая подстановка ${…} становится параметром подготовленного запроса, поэтому значение не может изменить сам SQL. Подставлять так можно только значения — не имена таблиц, столбцов и не ключевые слова. Результат приходит с типами по таблице соответствия: 64-битные целые — BigInt, numeric — Decimal, даты — Date. Для типобезопасного SQL Prisma рекомендует TypedSQL: запросы лежат в .sql-файлах в prisma/sql, а prisma generate --sql создаёт для них типизированные функции; в Prisma 7 это превью-функция (typedSql).
Где схема
- Prisma 7.10
- отдельный файл
schema.prisma - Drizzle ORM 0.45
- TypeScript-код (
pgTable) - TypeORM 1.1
- классы с декораторами
@Entity,@Column
Стиль запросов
- Prisma 7.10
- объектный API клиента
- Drizzle ORM 0.45
- SQL-подобный построитель и реляционный API
- TypeORM 1.1
- репозитории (Data Mapper) или Active Record
Миграции
- Prisma 7.10
- Prisma Migrate, SQL-файлы
- Drizzle ORM 0.45
- генерация и применение миграций
- TypeORM 1.1
- автогенерация миграций
Кому подходит
- Prisma 7.10
- типовой CRUD, строгие типы, удобные связи
- Drizzle ORM 0.45
- тем, кто хочет писать почти SQL с типами
- TypeORM 1.1
- проектам на классах-сущностях, в том числе на NestJS
Версии в таблице — текущие в npm на дату проверки. Выбор ORM — инженерный компромисс, а не вопрос «лучше или хуже»: по нашему опыту, важнее договориться в команде о правилах миграций и о том, где допустим сырой SQL, чем выбрать «правильную» библиотеку.
Миграции в команде и на проде, ошибки и упражнения
- Папка
prisma/migrationsиmigration_lock.toml— в Git; сгенерированный клиент — нет, его создаётprisma generateпри сборке - Применённую миграцию не редактируют, исправление — новой миграцией. На предупреждение
migrate deployне полагайтесь: документация его описывает, но в Prisma 7.10 правка применённой миграции прошла молча (проверено в уроке о границах и миграциях AI-проекта) — сверяйте контрольные суммы в CI - На сервере — только
migrate deployиз CI/CD;migrate devиmigrate reset— команды разработки - Каждый сгенерированный SQL читают на ревью: удаление столбца вместо переименования видно только там
- Переименования и изменения с данными — через
--create-onlyи ручную правку SQL - Обязательный столбец в таблице с данными — в несколько шагов: добавить nullable, заполнить, затем
SET NOT NULL - Перед миграцией продакшена — резервная копия и проверка на копии данных
npm install prisma поставил 8.0.0-rc.…
- Причина
- тег
latestу CLI указывает на RC восьмой версии - Что делать
- ставить
prisma@7и@prisma/client@7, фиксировать^7вpackage.json
Импорт ./generated/prisma/client не находится после клонирования
- Причина
- клиент генерируется в папку проекта и не хранится в Git;
migrate devв Prisma 7 его не создаёт - Что делать
npx prisma generateлокально и в сборке
CLI или приложение не видят DATABASE_URL
- Причина
- Prisma 7 не загружает
.envсам - Что делать
import "dotenv/config"в конфиге и в точке входа
«Drift detected» и предложение сбросить базу
- Причина
- схему меняли вручную в обход миграций
- Что делать
- в разработке —
migrate reset; изменение оформить миграцией
«…environment is non-interactive, which is not supported»
- Причина
- у изменения есть предупреждения, а
migrate devтребует подтверждения в терминале - Что делать
- запускать в интерактивном терминале; в CI использовать только
migrate deploy
«Added the required column … without a default value»
- Причина
- новый обязательный столбец в таблице со строками
- Что делать
--create-onlyи ручная миграция с заполнением
P2002 / P2003
- Причина
- нарушено уникальное ограничение / внешний ключ
- Что делать
- обработать как конфликт данных (409) или ошибку проверки
Последнюю ошибку из таблицы мы получили сами на базе с 40 записями — это реальное сообщение Prisma 7.10:
Error:
⚠️ We found changes that cannot be executed:
• Step 0 Added the required column `price_kzt` to the `appointments` table without a default value. There are 40 rows in this table, it is not possible to execute this step.
You can use prisma migrate dev --create-only to create the migration file, and manually modify it to address the underlying issue(s).
Then run prisma migrate dev to apply it and verify it works.Как проверено4 · пройденосверено 11 октября 2026 г.
- запущено, работает
установка, prisma init, prisma7.config.ts, .env, tsconfig.json
временный проект: prisma/@prisma/client/@prisma/adapter-pg 7.10.0, pg 8.23.1, dotenv 18.0.7, TypeScript 5.9.3, tsx 4.23.15, Node.js 24.18; prisma7.config.ts — без правок после init; контейнер в проверке назывался kb-pg-orm-i-migracii (в статье — booking-pg), postgres:17-alpine = PostgreSQL 17.11
- запущено, работает
schema.prisma (3 версии) и три миграции: init, add_status_and_note, rename_note_to_comment
prisma migrate dev; SQL в статье — реальные файлы миграций; rename создан через --create-only (реальный DROP+ADD с предупреждением), отредактирован на RENAME COLUMN и применён, данные сохранились; migrate status — up to date; migrate deploy на пустую базу booking_stage — 3 миграции; drift после ручного ALTER TABLE — реальный вывод
- запущено, работает
src/db.ts, src/crud.ts, src/seed.ts, src/n-plus-one.ts, src/reschedule.ts, src/report.ts
tsc --noEmit без ошибок; запуск через tsx на PostgreSQL 17.11; выводы в статье — реальные (P2003, 21 vs 2 SQL-запроса, откат транзакции по P2002, bigint из $queryRaw); P2025 при повторной отмене проверен отдельным запуском
- запущено, работает
упражнения: email (repeat), _count (modify), priceKzt с заполнением (apply)
отдельная база booking_ex: SQL упражнения 1 получен prisma migrate diff --from-config-datasource (migrate dev в неинтерактивной оболочке требует подтверждения предупреждения — это поведение описано в статье), применён migrate dev; упражнение 2 — 1 SQL-запрос, вывод 6/7/7; упражнение 3 — реальная ошибка 'Added the required column', ручная миграция применена к 40 строкам, NULL = 0
«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.