Logo
Englika

Я никогда больше не буду использовать ORM

Я никогда больше не буду использовать ORM

Сначала мне казалось, что ORM ускоряет процесс разработки за счет того, что позволяет работать с базой данных как с сущностями. Простые запросы составляются быстрее, а сложные можно всегда написать вручную (напр, manager.query('SELECT * FROM users') в TypeORM). Однако со временем начинают возникать проблемы, о которых вначале не задумываешься.

Какое-то время назад я решил использовать ORM (в частности TypeORM), т.к. купился на следующие преимущества, которые в итоге оказались ложными.

Работа с базой, используя сущности

Может показаться, что проектировать базу данных, используя ORM, быстрее и проще. Создаешь сущности со связями между ними и работаешь с ними, даже не понимая, какая схема базы данных была создана и как генерируется каждый SQL-запрос.

Спустя время обнаруживается, что:

  • Множество возможностей определенной БД не поддерживается ORM (напр, JOIN LATERAL в TypeORM). ORM поддерживает множество баз данных, и ее возможности обычно ограничиваются теми, что существуют в каждой из них.
  • Если вы не сможете создать определенную колонку в таблице, используя ORM, то вам придется сделать это с помощью миграции. В это же время вы должны отключить синхронизацию таблицы, чтобы поле, созданное в результате миграции, не было удалено. Отныне любые изменения в эту таблицу должны будут вноситься с помощью миграций (даже в среде development).
  • SQL-запросы не всегда генерируются так, как вы ожидали, и они начинают выполняться медленно по мере роста данных в базе. Существует также риск того, что один и тот же запрос, написанный с использованием ORM, сегодня будет одним, а завтра другим (после обновления).
  • Некоторые свойства сущностей, по умолчанию, могут использовать некорректный тип в базе (напр, TypeORM хранит Date как timestamp, а не timestamptz). В результате вы перепроверяете, как фактически хранится каждый тип в базе данных или всегда указываете его явно.

Когда вы пишете SQL-запросы вручную, таких проблем не возникает, и запросы всегда выполняются точно так, как они написаны.

Создание индексов

Вы когда-нибудь пробовали создавать индексы, используя ORM? Это весело!

Обычно, ORM поддерживает создание только простых индексов. Напр, я не могу в TypeORM создать индекс для поиска по тексту, используя триграммы. Такой индекс приходится создавать, используя миграции.

CREATE INDEX companies_name_idx ON companies USING gin (name gin_trgm_ops)

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

Чтобы ORM при синхронизации не удалил индекс, созданный вручную с помощью миграций, вам также необходимо добавить команду, которая сообщит ORM о существовании этого индекса и о том, чтобы она его не трогала. Например, вот так это выглядит в TypeORM:

@Index('companies_name_idx', { synchronize: false })
class Company extends BaseEntity {
}

В результате, чтобы создать индекс, который быстро пишется на чистом SQL в 1 строку, вам нужно:

  1. Проверить, поддерживается ли данный тип индекса в ORM. Если да, то создать его используя ORM.
  2. Если индекс не поддерживается, то добавить новую миграцию с SQL-запросом, который создает индекс.
  3. Добавить комманду, которая скажет ORM, чтобы данный индекс игнорировался при синхронизации. Иначе индекс будет удален.

Вы можете сказать, что это возникает не часто, но вы будете не правы. Наверное, самый популярный индекс, который я использую после b-tree, это gin с триграммами для поиска по тексту, чтобы найти нужные строки в таблице по подстроке, используя ILIKE. Подробнее про сравнение индексов для поиска по тексту я писал тут.

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

Создавать индексы в миграциях неудобно. В этом случае все индексы разбросаны по разным миграциям. Если вы хотите посмотреть, какие индексы существуют в определенной таблице, вам необходимо либо зайти в базу данных и вывести список всех индексов в таблице (\d <table_name> в PostgreSQL), либо искать их по префиксу по всему проекту (только если все индексы, созданные в определенной таблице, имеют одинаковый префикс). Если индексы в одной и той же таблице могут иметь разные префиксы (напр, так именует индексы GitLab), то вы можете посмотреть список всех индексов только непосредственно через базу данных. Очень удобно, не правда ли?

Напротив, используя декларативное управление схемой, создание нового индекса – это просто добавление 1 строки с SQL-запросом в файл со схемой.

Быстрее писать запросы

Это касается не только ORM, в который, обычно, встроен конструктор запросов, но и любого отдельного конструктора запросов (напр, Knex).

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

Так будет выглядеть самый простейший запрос:

// TypeORM
manager.getRepository(Task).findOneBy({ id: 1 });

// Knex
knex('tasks').where('id', 1).select().first();

// Slonik
pool.maybeOne(sql.unsafe`SELECT * FROM tasks WHERE id = ${id}`);

// Raw SQL
client.query('SELECT * FROM tasks WHERE id = $1', [1]);

В данном случае разницы никакой нет.

Давайте рассмотрим еще один пример с множеством условий.

// TypeORM
const query = ctx.manager
  .getRepository(Task)
  .createQueryBuilder()
  .where('completed');

if (id) query.andWhere(`id > :id`, { id });
if (searchTerm) query.andWhere(`name ILIKE :name`, { name: `%${searchTerm}%` });

const tasks = await query
  .orderBy('id', 'ASC')
  .limit(100)
  .getMany();
// Slonik
const tasks = await pool.any(sql.unsafe`
  SELECT * FROM tasks
  WHERE 
    completed AND
    ${id ? sql.fragment`id > ${id}` : true} AND
    ${searchTerm ? sql.fragment`name ILIKE ${`%${searchTerm}%`}` : true}
  ORDER BY id ASC
  LIMIT 100
`);

Второй вариант с чистым SQL-запросом определенно более удобочитаем.

Когда запрос становится немного сложнее, то во-первых, вам необходимо выяснить как его составить при помощи конструктора запросов, а во-вторых, убедиться, что он генерируется верно.

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

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

  1. Придумать, как написать SQL-запрос, чтобы он выполнялся быстро. Написать запросы для создания нужных индексов, чтобы ускорить его выполнение.
  2. Придумать, как сконструировать запрос с помощью ORM таким образом, чтобы она сгенерировала наиболее похожий SQL-запрос. Добавить индексы с помощью ORM.

Казалось бы, ORM и конструкторы запросов должны ускорять процесс разработки, но в итоге они добавляют лишнюю работу (добавляется второй шаг). Кроме того, ORM через некоторое время может начать генерировать запрос по-другому (после обновления).

Защита от SQL-инъекций

Еще один плюс ORM и конструкторов запросов – они вас защищают от SQL-инъекций и об этом даже не нужно задумываться. Или нужно?

Предположим, что мы столкнулись с ситуацией, когда запрос генерируется ORM неоптимально, и мы решили написать этот запрос на чистом SQL. Давайте воспользуемся встроенным в TypeORM методом manager.query.

// TypeORM
const input = '0; DROP TABLE tasks'
manager.query(`SELECT * FROM tasks WHERE id = ${input}`)

Поздравляю, кто-то удалил вашу таблицу tasks, несмотря на то, что вы использовали ORM (то же самое с query builders). Чтобы избежать этого, вам нужно использовать параметризованный запрос, т.е. указать параметры отдельно, а не вставлять их в сам запрос. Подробнее тут.

Запрос должен быть переписан следующим образом:

// TypeORM
manager.query('SELECT * FROM tasks WHERE id = $1', [input])

В данном случае ORM или query builder не дает вам никакого преимущества, т.к. параметризованные запросы вы можете делать и без них.

// pg
client.query('SELECT * FROM tasks WHERE id = $1', [input])

SQL-инъекции могут возникнуть не только в параметрах, но и в названии таблиц или колонок, если они вставляются в запрос динамически. В этом случае вам необходимо их экранизировать.

В итоге, во-первых, если кто-то использует ORM или конструктор запросов, это не значит, что он защищен от SQL-инъекций, а во-вторых, это не является преимуществом, т.к. можно использовать параметризованные запросы и без них.

Встроенные миграции

Пока работаешь в среде development все очень удобно: добавил новое свойство в сущность и автоматически добавилась новая колонка в таблице. Однако на production когда-нибудь вы можете потерять часть своих данных, потому что синхронизация была выполнена совершенно не так, как вы это планировали.

Например, если вы измените тип поля с timestamp (в TypeORM используется по умолчанию для типа Date) на timestamptz, то инструмент синхронизации выполнит следующие SQL-запросы:

ALTER TABLE "tasks" DROP COLUMN "created_at";
ALTER TABLE "tasks" ADD "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now();

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

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

  1. Сгенерировать SQL-запросы, которые обновят схему базы данных до нужного состояния. Запросы не выполнять.
  2. Создать pull request, в рамках которого происходит ревью данных запросов.
  3. После одобрения запросы выполняются и схема базы данных обновляется.

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

Некоторые говорят, что одним из преимуществ ORM является то, что она позволяет вам соблюдать принцип DRY (Don't Repeat Yourself) в том смысле, что вам не нужно писать схему базы данных дважды (в SQL и в классах, на которые маппятся таблицы). Однако, это актуально только до тех пор, пока вы не сделали первый релиз. После этого все изменения схемы нужно также делать дважды: в миграциях и в классах.

Напротив, декларативное управление схемой позволяет:

  • Автоматически синхронизировать схему базы данных (все изменения проходят код ревью).
  • Хранить всю схему целиком в одном файле (или один файл - одна таблица для удобства).

Легче перейти на другую БД

ORM или конструктор запросов позволяет вам перейти на другую базу данных с меньшими усилиями, поскольку они поддерживают множество различных баз данных. Например, вы можете легко перейти с MySQL на PostgreSQL и обратно, но при одном условии: если вы не используете специфику конкретной базы данных, что сделать практически невозможно. Как минимум, вы будете использовать индексы, которые будут специфичны для конкретной базы данных, и, как максимум, вы будете использовать запросы, написанные на чистом SQL с использованием возможностей выбранной базы данных.

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

Обычно, когда действительно нужно использовать несколько баз данных, то требуется одну часть данных хранить в одной базе (напр, в PostgreSQL), а другую часть – в другой (напр, в ClickHouse). Если ORM или конструктор запросов поддерживает обе базы данных, то можно пользоваться одним инструментом. Это единственный плюс.

Про это преимущество хорошо сказал один человек вот тут:

"Database-agnostic" is a pervasive and pernicious lie.
It almost always means "lowest common denominator amongst 
all supported databases".

Some databases are so far behind, they really shouldn't be 
abstracted by the same library. They have VERY different use
cases and abilities. The access model for a SQLite-based app
is quite different from a Postgres-based app.

It's like having an F-150, a Cybertruck, and a Prius while
hiring a driver that won't take either off-road or for more
than 100 miles at a time because he also has to be able to
drive a Nissan Leaf the exact same way.

But folks still hire him because he claims to handle anything
with a steering wheel and pedals. Technically true, but misses
the point of the different options.

That's an ORM.

ORM проще SQL

Тут есть 2 варианта:

  1. Вы не знаете SQL. В этом случае, скорее всего, ORM – это просто черный ящик для вас. Описали вид данных, который должен храниться, и вызываете определенные методы для того, чтобы выполнять CRUD операции. Если что-то выполняется медленно или возникает ошибка при выполнении SQL-запроса, вы не понимаете, почему так происходит и как это исправить. Когда вы будете использовать другую ORM, вам будет казаться, что вы работаете с другой базой данных, т.к. набор методов у каждой ORM свой. Изучайте SQL.
  2. Вы знаете SQL, но не представляете как все настроить так, чтобы можно было удобно работать с базой. Подробнее ниже.

Учитывая все вышеизложенные проблемы, можно сказать, что ORM дает преимущества только в самом начале (если вы не тестируете запросы), а в перспективе дает множество минусов:

  • Приходится перепроверять как генерируется каждый запрос.
  • Добавляется куча лишней работы при создании индексов, которые не поддерживаются ORM (коих порой около половины).
  • Неудобная синхронизация схемы на продакшене.
  • Использование возможностей, которые специфичны для конкретной базы данных возможно только через чистые SQL запросы. В этом случае появляется риск словить SQL-инъекцию, если об этом не задумываться.

ORM просто не для всех проектов

Понимая минусы ORM, кто-то скажет, что она просто не для всех проектов. И тут мы переходим к вопросу: в каких же проектах лучше использовать ORM?

Я слышал следующие варианты:

  • ORM для простых проектов.
  • ORM для проектов с большим количеством CRUD операций.
  • Для MVP (Minimum Viable Product).
  • Для небольших команд (< 30 человек).

Представьте, что вы создали MVP вашего проекта и различные компании начали его использовать. Они вам говорят, что нужно срочно добавить определенные возможности, без которых они не могут жить. Неужели вы скажите им подождать и начнете переписывать почти все API, потому что теперь вам нужно заменить ORM на чистый SQL? Конечно же все будет не так.

Проект будет усложняться, данные расти, уйти из ORM будет все сложнее, проблемы будут накапливаться, пока в какой-то момент вы не устанете от того, что большое количество запросов выполняется медленно и в итоге вам приходится делать двойную работу: написать SQL запрос, а потом придумать как его сконструировать, используя конструктор запросов так, чтобы итоговый SQL-запрос был максимально похож на то, что вы хотите.

Был бы смысл в использовании ORM, если бы разработка ускорялась в несколько раз, но это не так. Более того, со временем все происходит с точностью до наоборот.

Переход на чистый SQL

Как только я принял решение отказаться от ORM, сразу возникло множество вопросов:

  • Как синхронизировать схему базы данных в разных средах?
  • Как выполнять миграции?
  • Как добавить поддержку типов в результатах SQL-запросов?

Чтобы для себя ответить на эти вопросы, я решил создать отдельный простенький проект и в рамках него все настроить. Этот проект – API с использованием TypeScript, Node.js, express, GraphQL с 5 операциями (task, tasks, createTask, updateTask, deleteTask) и unit-тестами, которые проверяют взаимодействие с базой данных. В нем есть 3 ветки: typeorm, knex (query builder) и slonik (raw SQL).

В качестве помощника для составления чистых SQL-запросов выбран slonik. Кто-то может сказать, что это тоже query builder, но с использованием template literals, однако, это скорее набор утилит, который позволяет безопасно выполнять чистые SQL-запросы.

Чтобы управлять схемой базы данных декларативным способом, я сделал свой инструмент dsm, который использует migra под капотом, потому что atlas не поддерживает extensions/views/triggers/functions.

Синхронизация схемы базы данных

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

В среде development синхронизация выполняется по команде yarn sync следующим образом:

  • Запускается инструмент декларативного управления схемой.
  • Вычисляется разница между желаемой схемой, расположенной в schema.sql и текущей схемой в базе данных. Генерируются SQL-запросы, которые трансформируют схему базы данных в нужное состояние.
  • Выводятся сгенерированные SQL-запросы для ручного подтверждения.
  • После одобрения запросы выполняются, преобразуя схему базы данных в желаемую.
Pending changes:

alter table "public"."tasks" add column "name" text not null;

Do you want to execute them? [y/n] y
Done.

В среде test синхронизация выполняется также, только подтверждения не требуется. Даже если будет инструкция DROP, то ничего не произойдет, т.к. данных в таблицах нет.

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

Миграции

Выполнять миграции только для DML (манипулирования данными). Для DDL использовать декларативное управление схемой (см. выше). Доступны следующие команды:

  • yarn m:new <migration_name> – создает новую миграцию с указанным названием (используйте snake_case).
  • yarn m:apply – применяет новые миграции.

Типизация

Если не добавить типизацию, то результаты SQL-запросов в TypeScript будут иметь тип any. Конкретизировать типы можно одним из следующих способов:

  1. Сделать генерацию типов. Скрипт должен обходить все ts файлы в проекте, находить SQL-запросы, получить у базы данных текущую схему, определять колонки, используемые в SELECT/RETURNING и генерировать нужные типы (по аналогии с relay-compiler). Готовые библиотеки: pgTyped, zapatos, @slonik/typegen.
  2. Создавать типы вручную. Можно использовать zod (или альтернативы), чтобы помимо интерфейсов была еще возможность проверять тип ответов SQL-запросов в runtime (TypeORM, кстати, этого не делает). Ответы SQL-запросов в runtime могут измениться в случае, если кто-либо изменит схему базы данных вручную (напр, сделает hot fix).

Я решил использовать второй вариант, чтобы можно было проверять форму ответов SQL-запросов в runtime.

Статьи и обсуждения на эту тему

Похожие статьи

Как получить N строк для каждой группы в SQL

Давайте предположим, что вы разрабатываете главную страницу в интернет-магазине. На этой странице должны отображаться категории товаров с 10 товарами в каждой. Как сделать запрос к базе данных? Какие индексы нужно создать, чтобы ускорить выполнение...

Как получить N строк для каждой группы в SQL