0

Обзор Prisma ORM — инструмента для работы с Node.js и TypeScript

Обложка: Обзор Prisma ORM — инструмента для работы с Node.js и TypeScript

В этой статье я хотел бы пройти по процессу установки и использованию Prisma ORM. У меня есть большой опыт использования различных ORM для реляционных баз данных с такими языками программирования как Python, TypeScript, JavaScript и PHP. Исходя из этого я опишу то, что мне нравится в этой библиотеке и что нет. Я буду использовать язык программирования TypeScript и MySQL в качестве базы данных. Yarn будет использоваться для инициализации проекта и управления зависимостями. Я не ожидаю каких-то глубоких знаний этих технологий и мои примеры будут по большей части объяснять сами себя.

Установка Prisma ORM в новом проекте

По большей части я буду использовать официальную документацию.

Давайте создадим проект и инициализируем его.

mkdir prisma_orm_review
cd prisma_orm_review
yarn init  -y
yarn add -D typescript ts-node @types/node

Добавим конфигурацию для TypeScript компилятора в tsconfig.json файл.

touch tsconfig.json
{
  "compilerOptions": {
    "sourceMap": true,
    "outDir": "dist",
    "strict": true,
    "lib": ["esnext"],
    "esModuleInterop": true
  }
}

Установим Prisma ORM

yarn add prisma

Инициализация Prisma ORM для базы данных MySQL.

npx prisma init --datasource-provider mysql

Модель данных

Prisma ORM использует несколько нестандартный подход к модели данных. Для того чтобы определить модели, необходимо использовать специальный файл и специфичный для этой библиотеки синтаксис. Он несколько менее гибок, чем моделирование при помощи аннотаций в *.ts файлах, но при этом легче читается. Большая часть типов данных, поддерживаемая в различных базах данных имеет поддержку в Prisma ORM, но в то же время есть ряд исключений, которые могут повлиять на выбор библиотеки для работы с данными.

model Account {
  id              Int       @id @default(autoincrement())
  parentAccountId Int?
  parentAccount   Account?  @relation("Parent", fields: [parentAccountId], references: [id])
  childAccounts   Account[] @relation("Parent")
  email           String    @unique
  name            String?
  meta            Json
  isActive        Boolean   @default(false)
  createdAt       DateTime  @default(now())
  posts           Post[]
}

model Post {
  id        Int     @id @default(autoincrement())
  title     String
  content   String?
  published Boolean @default(false)
  account   Account @relation(fields: [accountId], references: [id])
  accountId Int
}

На основании схемы Prisma ORM автоматически сгенерирует код для модели данных и миграции, для изменения базы данных.

npx prisma migrate dev --name init

Простые запросы к базе данных

Следующий скрипт позволяет сразу создать новый аккаунт и пост. Prisma ORM клиент позволяет создавать одновременно несколько связанных сущностей в одной транзакции, что облегчает работу со связанными сущностями.

Я настоятельно рекомендую включать логирование запросов к базе данных при разработке приложения. Это поможет отслеживать реальные запросы и понять какие запросы выполняются быстрее или медленнее. Конкретный пример я приведу ниже

script.ts

import { PrismaClient } from "@prisma/client";

const prisma = new PrismaClient({
  log: ["query", "info", "warn", "error"],
});

async function main() {
  // create an account
  const accountWithPost = await prisma.account.create({
    data: {
      email: "hello@exmaple.com",
      name: "John Doe",
      meta: {
        firstName: "John",
        lastName: "Doe",
        age: 30,
      },
      isActive: true,
      posts: {
        create: {
          title: "Post #1",
          content: "Hello world",
          published: true,
        },
      },
    },
  });
  console.log(accountWithPost);

  // select active accounts
  const activeAccounts = await prisma.account.findMany({
    where: {
      isActive: true,
    },
  });
  console.group(activeAccounts);
}

main()
  .then(async () => {
    await prisma.$disconnect();
  })
  .catch(async (e) => {
    console.error(e);
    await prisma.$disconnect();
    process.exit(1);
  });
BEGIN;
INSERT
INTO
    cta.Account (id, email, name, meta, isActive, createdAt)
VALUES
    (?, ?, ?, ?, ?, ?);
INSERT
INTO
    cta.Post (id, title, content, published, accountId)
VALUES
    (?, ?, ?, ?, ?);
SELECT
    cta.Account.id,
    cta.Account.parentAccountId,
    cta.Account.email,
    cta.Account.name,
    cta.Account.meta,
    cta.Account.isActive,
    cta.Account.createdAt
FROM
    cta.Account
WHERE
    cta.Account.id = ?
LIMIT ? OFFSET ?;
COMMIT;

Следующий запрос достанет из базы данных все активные аккаунты. В нашей безе есть всего лишь один аккаунт, поэтому такой запрос безопасен. В случае большой базы данных необходимо разбивать результат на страницы для избежания переполнения памяти.

SELECT
    cta.Account.id,
    cta.Account.parentAccountId,
    cta.Account.email,
    cta.Account.name,
    cta.Account.meta,
    cta.Account.isActive,
    cta.Account.createdAt
FROM
    cta.Account
WHERE
    cta.Account.isActive = ?

Использование «сырых» запросов

Теперь давайте достанем из базы первые 10 уникальных имен аккаунтов.

async function main() {
  const result = await prisma.account.findMany({
    select: {
      name: true,
    },
    where: {},
    distinct: ["name"],
    take: 10,
    skip: 0,
  });

  console.log(result);
}

Код выглядит довольно просто. Но нас ожидает сюрприз в логах. SQL запрос к базе данных выглядит следующим образом:

SELECT
    cta.Account.id,
    cta.Account.name
FROM
    cta.Account
WHERE
    1 = 1
ORDER BY
    cta.Account.id ASC

Как вы видите, в этом запросе не используются лимиты, прописанные в запросе. Это потому, что Prisma не использует `DISTINCT` в запросах и фильтрует данные в памяти. Это крайне сомнительное решение. Оно будет работать когда в базе есть несколько тысяч записей, но перестанет если там будет несколько миллионов. Да и вообще крайне странно доставать из базы миллион записей для того, чтобы отфильтровать первые 10 уникальных имен.

В этом случае необходимо уже спускаться на уровень «сырых» запросов к базе данных.

async function main() {
  const limit = 10;
  const result = await prisma.$queryRaw<Array<{ name: string }>>(
    Prisma.sql`SELECT DISTINCT
                   (name)
               FROM
                   Account
               LIMIT ${limit}`
  );

  console.log(result);
}

Полученный SQL запрос:

select distinct(name) from Account limit ?

На первый взгляд код из этого примера выглядит как классический пример из книги «Как сделать SQL инъекцию». Но на самом деле это не так. Под капотом Prisma делает немного магии, которая позволяет избежать уязвимостей.

Результат следующего кода это не просто строка с подставленными в нее значениями переменных. Prisma генерирует объект с запросом и параметрами этого запроса.

console.log(Prisma.sql`select distinct(name) from Account limit ${limit`);
{
  text: 'select distinct(name) from Account limit $1',
  sql: 'select distinct(name) from Account limit ?',
  values: [ 10 ]
}

Все переменные заменены на плейсхолдеры и поэтому такой запрос безопасен. В то же время такой подход может вызвать проблемы при составлении более сложных запросов. Пример я приведу в следующем разделе.

«Сырые» небезопасные запросы

Представьте себе ситуацию, в которой вам нужно фильтровать данные по полям из JSON объекта `meta`. При этом, то, по каким полям производить фильтрацию тоже определяется на уровне кода.

async function main() {
  const limit = 10;
  const jsonFieldName = "firstName";
  const jsonFieldValue = "John";

  const result = await prisma.$queryRaw<Array<{ name: string }>>(
    Prisma.sql`SELECT DISTINCT
        (name)
    FROM
        Account
    WHERE
        meta ->> "$.${jsonFieldName}" = ${jsonFieldValue}
    LIMIT ${limit};
`
  );
}

Полученный запрос работать не будет. В этом случае эта магия, которая работает под капотом `Prisma.sql` не позволит нам просто взять и собрать запрос из нескольких строк.

SELECT DISTINCT
    (name)
FROM
    Account
WHERE
    meta ->> "$.?" = ?
LIMIT ?;

Поэтому пришло время для использования `$queryRawUnsafe`. Мы все еще будем использовать параметры в запросе для значений JSON полей и лимитов

async function main() {
  const limit = 10;
  const jsonFieldName = "firstName";
  const jsonFieldValue = "John";

  const result = await prisma.$queryRawUnsafe<Array<{ name: string }>>(
    `SELECT DISTINCT
        (name)
    FROM
        Account
    WHERE
        meta ->> "$.${jsonFieldName}" = ?
    LIMIT ?;
`,
    jsonFieldValue,
    limit
  );

  console.log(result);
}

Полученный SQL:

SELECT DISTINCT
    (name)
FROM
    Account
WHERE
    meta ->> "$.firstName" = ?
LIMIT ?;

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

Выводы

В целом Prisma ORM — это неплохой выбор для проекта. Можно подчеркнуть, что не хватает возможности на уровне построителя запросов сделать `distinct`, но при этом достаточно просто двинуться в сторону «сырых» запросов к базе данных.