prisma-sql
prisma-sql

В версии 1.58.0 библиотеки prisma-sql появился метод $batch, который позволяет выполнять несколько Prisma-запросов за один раунд-трип к базе данных.

Проблема

Типичный код для загрузки данных дашборда выглядит так:

const totalUsers = await prisma.user.count()
const activeUsers = await prisma.user.count({ where: { status: 'ACTIVE' } })
const recentProjects = await prisma.project.findMany({ take: 5 })
const taskStats = await prisma.task.aggregate({ _count: true })

4 отдельных обращения к БД = 4 раунд-трипа.

Даже если каждый запрос выполняется за 1мс, накладные расходы на сетевые вызовы могут добавить 3-5мс. При 10+ запросах это становится заметно.

Метод $batch объединяет запросы в один SQL с использованием CTE (Common Table Expressions) или UNION (в зависимости от методов внутри батча):

const dashboard = await prisma.$batch(batch => ({
  totalUsers: batch.user.count(),
  activeUsers: batch.user.count({ where: { status: 'ACTIVE' } }),
  recentProjects: batch.project.findMany({ take: 5 }),
  taskStats: batch.task.aggregate({ _count: true })
}))

// Результат идентичен отдельным вызовам функций
console.log(dashboard.totalUsers)      // number
console.log(dashboard.recentProjects)  // Project[]
console.log(dashboard.taskStats)       // { _count: number }

1 обращение к БД вместо 4.

Как это работает

Библиотека генерирует единый SQL с использованием CTE:

WITH 
  batch_0 AS (SELECT count(*)::int AS "_count._all" FROM "users"),
  batch_1 AS (SELECT count(*)::int AS "_count._all" FROM "users" WHERE status = $1),
  batch_2 AS (SELECT * FROM "projects" ORDER BY created_at DESC LIMIT 5),
  batch_3 AS (SELECT count(*)::int FROM "tasks")
SELECT 
  (SELECT COALESCE(json_agg(row_to_json(t)), '[]'::json) FROM batch_0 t) AS k0,
  (SELECT COALESCE(json_agg(row_to_json(t)), '[]'::json) FROM batch_1 t) AS k1,
  (SELECT COALESCE(json_agg(row_to_json(t)), '[]'::json) FROM batch_2 t) AS k2,
  (SELECT COALESCE(json_agg(row_to_json(t)), '[]'::json) FROM batch_3 t) AS k3

Каждый подзапрос выполняется параллельно внутри БД, результаты собираются в один JSON-ответ.

Специальная оптимизация для count

Когда батчите несколько count() по одной таблице, запросы автоматически оптимизируются с использованием FILTER:

const counts = await prisma.$batch(batch => ({
  total: batch.user.count(),
  active: batch.user.count({ where: { status: 'ACTIVE' } }),
  pending: batch.user.count({ where: { status: 'PENDING' } }),
  inactive: batch.user.count({ where: { status: 'INACTIVE' } })
}))

Генерируется оптимальный SQL:

SELECT 
  count(*) AS total,
  count(*) FILTER (WHERE status = $1) AS active,
  count(*) FILTER (WHERE status = $2) AS pending,
  count(*) FILTER (WHERE status = $3) AS inactive
FROM users

Один проход по таблице вместо четырех.

Цифры производительности

Тесты на MacBook Pro M1, PostgreSQL 15:

Простые запросы (4 запроса):

  • Последовательно: 1.43мс (0.36мс на запрос)

  • Batch: 0.67мс (0.17мс на запрос)

  • Ускорение: 2.12x

Сложный дашборд (8 запросов с relations):

  • Последовательно: 9.90мс

  • Batch: 6.07мс

  • Ускорение: 1.63x

Стресс-тест (45 запросов):

  • Построение SQL: 1.48мс (0.03мс на запрос)

  • Выполнение: 3.13мс

  • Парсинг: 0.09мс

  • Итого: 4.71мс на 45 запросов

Установка и использование

npm install prisma-sql postgres

В schema.prisma:

generator client {
  provider = "prisma-client"
}

generator sql {
  provider = "prisma-sql-generator"
}

Генерация:

npx prisma generate

Подключение:

import { PrismaClient } from '@prisma/client'
import { speedExtension, type SpeedClient } from './generated/sql'
import postgres from 'postgres'

const sql = postgres(process.env.DATABASE_URL)
const basePrisma = new PrismaClient()

export const prisma = basePrisma.$extends(
  speedExtension({ postgres: sql })
) as SpeedClient<typeof basePrisma>

// Готово! Теперь можно использовать $batch
const results = await prisma.$batch(batch => ({
  users: batch.user.findMany(),
  posts: batch.post.count()
}))

Поддерживаемые методы

  • findMany - выборка записей

  • findFirst - первая подходящая запись

  • findUnique - по уникальному полю

  • count - подсчет (с оптимизацией)

  • aggregate - агрегации

  • groupBy - группировка

Важно: не используйте await внутри batch

// ❌ Неправильно - выбросит ошибку
await prisma.$batch(async batch => ({
  users: await batch.user.findMany(), // Не await!
  posts: await batch.post.findMany()
}))

// ✅ Правильно - возвращаем запросы без await
await prisma.$batch(batch => ({
  users: batch.user.findMany(),
  posts: batch.post.findMany()
}))

Типизация

TypeScript полностью понимает типы результатов:

const results = await prisma.$batch(batch => ({
  users: batch.user.findMany({ 
    select: { id: true, email: true } 
  }),
  count: batch.post.count()
}))

results.users[0].email  // string
results.users[0].name   // ❌ ошибка - поля нет в select
results.count           // number

Сценарии использования

  1. Дашборды - загрузка всех данных одним запросом

  2. Аналитика - множественные агрегации

  3. Сравнения - данные за разные периоды

  4. и тд.

Пример: реальный дашборд

const dashboard = await prisma.$batch(batch => ({
  // Статистика организаций
  totalOrgs: batch.organization.count(),
  activeOrgs: batch.organization.count({ 
    where: { status: 'ACTIVE' } 
  }),
  
  // Статистика пользователей
  totalUsers: batch.user.count(),
  activeUsers: batch.user.count({ 
    where: { status: 'ACTIVE' } 
  }),
  
  // Последняя активность
  recentProjects: batch.project.findMany({
    take: 5,
    orderBy: { createdAt: 'desc' },
    include: { organization: true }
  }),
  
  // Агрегации
  taskStats: batch.task.aggregate({
    _count: true,
    _avg: { estimatedHours: true },
    where: { status: 'IN_PROGRESS' }
  }),
  
  // Пользователи по ролям
  adminCount: batch.user.count({ where: { role: 'ADMIN' } }),
  userCount: batch.user.count({ where: { role: 'USER' } })
}))

console.log(`
  Организации: ${dashboard.activeOrgs}/${dashboard.totalOrgs}
  Пользователи: ${dashboard.activeUsers}/${dashboard.totalUsers}
  Админы: ${dashboard.adminCount}
  Средние часы задачи: ${dashboard.taskStats._avg.estimatedHours}
`)

Ограничения

  • Только PostgreSQL (поддержка SQLite в планах)

  • Запросы выполняются параллельно, не в транзакции

  • Каждый запрос независим (нельзя использовать результат одного в другом)

  • Для транзакционных гарантий используйте $transaction

Отладка

Включите режим отладки для просмотра генерируемого SQL:

const prisma = basePrisma.$extends(
  speedExtension({ 
    postgres: sql,
    debug: true  // Выводит SQL в консоль
  })
) as SpeedClient<typeof basePrisma>

Мониторинг производительности

Отслеживайте производительность через callback:

const prisma = basePrisma.$extends(
  speedExtension({
    postgres: sql,
    onQuery: (info) => {
      console.log(`${info.model}.${info.method}: ${info.duration}мс`)
      
      if (info.duration > 100) {
        logger.warn('Медленный запрос', {
          model: info.model,
          method: info.method,
          sql: info.sql
        })
      }
    }
  })
) as SpeedClient<typeof basePrisma>

Заключение

Метод $batch позволяет:

  • Сократить количество раунд-трипов к БД

  • Ускорить загрузку дашбордов

  • Сохранить привычный Prisma API

  • Автоматически оптимизировать count-запросы

Библиотека prisma-sql также ускоряет обычные запросы в 2-7 раз благодаря выполнению через postgres.js вместо query engine Prisma.

Ссылки:


Теги: #postgresql #prisma #nodejs #typescript #optimization #database