Как делать11 октября 2026 г.12 мин чтения
Базы данныхШаг 8 из 12

ORM и миграции на примере Prisma

Коротко

ORM (object-relational mapping) переводит работу с объектами в коде в SQL-запросы к реляционной базе: в Prisma вы описываете модели в schema.prisma, получаете сгенерированный типизированный клиент, а prisma migrate dev превращает каждое изменение схемы в SQL-файл миграции, который хранится в Git. ORM ускоряет типовой CRUD и защищает от опечаток, но не отменяет знание SQL: сгенерированные миграции нужно читать, N+1 — отслеживать, а сложные отчёты удобнее писать сырым SQL.

Пошаговый урокУровень: СреднийORM и миграцииПрактика: ~1,5–3 ч

После материала вы сможете

  • описать модели и связи в 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 г.

Содержание
  1. 01ORM: что он даёт и чего стоит
  2. 02Проект: Prisma 7.10 и PostgreSQL 17 в Docker
  3. 03Схема: модели, связи и имена в базе
  4. 04Миграции: migrate dev, SQL и история
  5. 05Запросы: CRUD, связи и транзакция
  6. 06N+1: как увидеть и убрать
  7. 07Когда писать сырой SQL и что есть кроме Prisma
  8. 08Миграции в команде и на проде, ошибки и упражнения

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 годится только для учебного контейнера.

bash
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-skills
Контейнер PostgreSQL 17, проект на ES-модулях и Prisma 7.10 с зафиксированными версиями

Prisma 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 продолжают работать.

prisma7.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"],
  },
});
Файл конфигурации в том виде, в каком его создал prisma init 7.10: dotenv подключён явно
.env
DATABASE_URL="postgresql://postgres:test@localhost:55817/booking"
Строка подключения к учебному контейнеру; .env уже добавлен в .gitignore командой init
tsconfig.json
{
  "compilerOptions": {
    "target": "ES2023",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "noEmit": true,
    "types": ["node"]
  },
  "include": ["src", "prisma7.config.ts"]
}
Настройки TypeScript для ES-модулей, как рекомендует руководство Prisma 7
src/db.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}`);
});
Один экземпляр клиента на приложение: адаптер node-postgres и событие query для подсчёта SQL

Адаптер PrismaPg — это мост между сгенерированным клиентом и драйвером pg. Руководство по обновлению отдельно предупреждает: настройки пула соединений теперь берутся из драйвера, и у pg по умолчанию нет таймаута подключения (в Prisma 6 было 5 секунд). Подписка на событие query понадобится в разделе про N+1: она позволяет считать запросы, а не гадать.

Схема: модели, связи и имена в базе

Схема описывает генератор клиента, источник данных и модели. Три модели сервиса записи: Client (клиент), Service (услуга) и Appointment (запись). У клиента и у услуги может быть много записей — это две связи «один ко многим». Так выглядела схема перед первой миграцией:

prisma/schema.prisma
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): прогоняет в ней все миграции с нуля и сверяет результат.

terminal
$ 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                          │
└─────────────────────────────────────────────────────────┘
Реальный вывод первой миграции; CLI предлагает «обновиться» до 8.0.0-rc.22 — в рамках урока этого делать не нужно
prisma/migrations/20261012005404_init/migration.sql
-- 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 первой миграции без правок

Читайте сгенерированный SQL так же внимательно, как код коллеги: здесь видно, какие ограничения и индексы на самом деле появятся в базе. Рядом с папками миграций лежит migration_lock.toml с провайдером базы — по комментарию в самом файле его нужно хранить в системе контроля версий вместе с миграциями.

Вторая миграция добавляет статус записи и необязательную заметку. Перечисление (enum) Prisma превращает в тип PostgreSQL:

prisma/schema.prisma
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")
}
Фрагмент схемы: поле status с enum и поле note
prisma/migrations/20261012005422_add_status_and_note/migration.sql
-- 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';
SQL второй миграции: CREATE TYPE и два новых столбца; у существующих строк status станет BOOKED

Третья миграция показывает главную опасность автогенерации. Переименуем note в comment. Prisma не знает о переименованиях: для неё это «одно поле исчезло, другое появилось». Поэтому создаём миграцию с флагом --create-only — файл появится, но не применится:

terminal
$ 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;
Сгенерированный SQL удаляет столбец вместе с данными — Prisma предупреждает об этом в комментарии
prisma/migrations/20261012005439_rename_note_to_comment/migration.sql
-- Rename instead of drop + add: keeps existing data
ALTER TABLE "appointments" RENAME COLUMN "note" TO "comment";
Отредактированная миграция: переименование вместо удаления и создания
terminal
$ 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
Миграция применена, тестовая заметка сохранилась; клиент Prisma 7 перегенерирован вручную
Изменить schema.prismamigrate devПроверить SQLКоммит в Gitmigrate deploy
РазработкаCI/CD
Путь изменения схемы: от правки модели до применения на сервере

На сервере применяется не migrate dev, а migrate deploy: документация называет migrate dev и migrate reset командами разработки, которые нельзя запускать в продакшене. migrate deploy только применяет ожидающие миграции — без теневой базы, без сброса и без генерации клиента. Проверка на пустой базе, которая изображает staging:

terminal
$ 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.
Все три миграции применены по порядку к новой базе booking_stage

Если кто-то изменил базу разработки вручную, migrate dev обнаружит расхождение с историей (drift) и предложит сбросить схему. В разработке это потеря тестовых данных, поэтому все изменения структуры — только через миграции:

terminal
$ 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.
Ручной ALTER TABLE в обход миграций: Prisma показывает разницу и требует сброса

Запросы: CRUD, связи и транзакция

Скрипт ниже проходит полный цикл на пустой базе: создаёт услуги, создаёт клиента сразу с первой записью (вложенная запись — nested write), читает расписание со связями, меняет запись и проверяет обе политики удаления.

src/crud.ts
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();
CRUD через Prisma Client: create с вложенной записью, findMany с select по связям, update и delete
terminal
$ 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
Реальный вывод: время 10:00 по +05:00 хранится как 05:00 UTC; удаление услуги с записями отклонено кодом P2003
  • Вложенная запись создаёт клиента и запись одной транзакцией — по документации, nested writes выполняются атомарно.
  • include добавляет связанные записи целиком, select выбирает только нужные поля, в том числе внутри связей. На одном уровне их совмещать нельзя.
  • Ошибки базы приходят как PrismaClientKnownRequestError с кодом: P2002 — нарушено уникальное ограничение, P2003 — внешний ключ, P2025 — нужная запись не найдена.

Транзакция нужна, когда несколько изменений имеют смысл только вместе. Перенос записи — классический случай: отменить старую и создать новую. Если новое время занято, отмена старой не должна сохраниться. Интерактивная транзакция $transaction(async (tx) => …) получает клиент tx, привязанный к транзакции; любое исключение внутри откатывает всё.

src/reschedule.ts
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();
Перенос записи в интерактивной транзакции: P2002 на занятом слоте откатывает отмену
terminal
$ 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
Первая попытка упала и откатилась — запись 3 осталась BOOKED; вторая прошла целиком

Параметры по умолчанию из документации: 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.

src/seed.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();
Тестовые данные: 3 услуги и 20 клиентов по две записи; телефоны вымышленные
src/n-plus-one.ts
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();
Цикл с запросом на каждого клиента против include — со счётчиком SQL-запросов
terminal
$ 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
Реальный вывод: 21 запрос в цикле и 2 запроса с include; второй запрос забирает записи всех клиентов через IN (…)

С 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 это один запрос.

src/report.ts
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();
Отчёт через $queryRaw: значение from уходит в запрос параметром, а не склеивается в строку
terminal
$ npx tsx src/report.ts
Massage: 7 visits, 105,000 KZT
Manicure: 7 visits, 70,000 KZT
Haircut: 6 visits, 48,000 KZT
bigint
Реальный вывод: count и sum по integer в PostgreSQL возвращают bigint, в JavaScript это 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:

terminal
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.
migrate dev отказывается добавлять обязательный столбец без значения по умолчанию в непустую таблицу
Как проверено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.

Расскажите о задаче — мы изучим запрос и свяжемся с вами

Есть задача?
Обсудим и оценим — бесплатно.

Расскажите о продукте или проблеме. Предложим архитектуру, срок и стоимость первой версии после изучения задачи.

  • Оценка объёма и рисков до старта
  • NDA — по вашему или нашему шаблону
  • Старт работ в течение недели

Не присылайте через форму пароли, ключи доступа и исходный код — безопасную передачу доступа обсудим отдельно.

Нажимая кнопку, вы соглашаетесь с обработкой персональных данных.