Почему я отказался от ORM в пользу чистого SQL

Во время выполнения очередного проекта мне пришлось работать с Битрикс ORM, при этом параллельно в системе был инстанс Laravel. Две разные ORM работали с единой базой данных. Не буду вдаваться в причины, по которым был выбран такой подход, и воздержусь от его оценки. Суть в том, что мне приходилось одновременно работать с двумя принципиально разными системами. Этот опыт привел меня к фундаментальному выводу: ORM — не для меня.

Проблема ORM

Проблема в том, что ORM во всех фреймворках одновременно и похожи, и различны. Основные различия — в синтаксисе: одни и те же ключевые слова в разных ORM используются по-своему. Запомнить все нюансы невозможно. До сих пор попытки сделать JOIN в ORM Битрикса вызывают у меня большие трудности. При этом у Битрикс ORM есть определённые ограничения. Я просто не вижу в ней плюсов.

Совсем другое дело — SQL-запросы. Они практически универсальны для всех реляционных СУБД. В большинстве проектов, над которыми я работаю, как раз используются реляционные базы данных. А главный аргумент в пользу ORM — о возможности легко сменить базу данных — на мой взгляд, несостоятелен. За всю мою практику не было ни одной причины менять базу в рабочем проекте, кроме одного раза при переходе с SQLite на Postgres. И даже при полном использовании ORM (в моем случае это был Django) эту задачу пришлось решать через множество конфликтов миграций, потому что эти базы данных сильно отличаются по типам данных.

Отдельного слова заслуживают агрегатные функции и вложенные запросы. Если JOIN еще можно воспринять после нескольких часов практики, и он даже может оказаться удобным, то сложные надстройки, требующие нетривиальных запросов, просто выносят мозг. Решение этой проблемы — разобраться с SQL и использовать чистые запросы. Это моментально даст ключ к пониманию любой ORM на любом языке программирования.

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

Посмотрим, как эта задача решается в разных инструментах.

Примеры использования ORM из практики

Чистый SQL выглядит прямо и понятно. Сразу видно, какие таблицы соединяются и по каким условиям.

SELECT
    u.id,
    u.name,
    COUNT(CASE WHEN o2.status = 'new' THEN o2.id END) as active_orders_count
FROM users u
INNER JOIN orders o1 ON u.id = o1.user_id AND o1.status = 'completed'
LEFT JOIN orders o2 ON u.id = o2.user_id AND o2.status = 'new'
GROUP BY u.id, u.name
HAVING COUNT(o1.id) > 0;

А теперь — магия ORM.

В Laravel с его мощным Eloquent это уже превращается в многострочную конструкцию с замыканиями и сырыми вставками.

$users = User::select('users.*')
    ->selectRaw('COUNT(CASE WHEN o2.status = ? THEN o2.id END) as active_orders_count', ['new'])
    ->join('orders as o1', function($join) {
        $join->on('users.id', '=', 'o1.user_id')
             ->where('o1.status', '=', 'completed');
    })
    ->leftJoin('orders as o2', function($join) {
        $join->on('users.id', '=', 'o2.user_id')
             ->where('o2.status', '=', 'new');
    })
    ->groupBy('users.id', 'users.name')
    ->havingRaw('COUNT(o1.id) > 0')
    ->get();

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

В Django ситуация становится ещё веселее. Я потратил время, пытаясь выразить это через ORM, и в итоге получил неработающий или дающий неверный результат запрос. Стандартный выход для Django-разработчика в такой ситуации — это сдаться и использовать raw(). Фактически, ты сам пишешь чистый SQL, а ORM просто его пропускает.

# Попытка сделать это "правильно" ведет в дебри аннотаций и агрегаций.
# В результате проще и надежнее:
users = User.objects.raw("""
    SELECT <здесь тот же самый SQL>
""")

Ну и апогей — Битрикс D7. Тот самый, с которого началась моя боль. Чтобы просто присоединить таблицу, тебе нужно объявлять сущности, runtime-поля, ReferenceField'ы... Код распухает до неузнаваемости, и ты уже на третьем экране забываешь, что вообще хотел сделать.

// ... (огромная портянка кода с объявлением Query и RuntimeField)
$query->registerRuntimeField(
    'COMPLETED_ORDERS',
    new Entity\ReferenceField(
        'COMPLETED_ORDERS',
        OrderTable::getEntity(),
        ['=this.ID' => 'ref.USER_ID', '=ref.STATUS' => new \Bitrix\Main\DB\SqlExpression('?s', 'completed')],
        ['join_type' => 'INNER']
    )
);
// ... и еще десяток строк такого кода
$dbRes = $query->exec(); // И молимся, что оно сработает.

После этого зрелища чистый SQL кажется глотком свежего воздуха. Один раз поняв суть запроса, ты можешь применить это знание в любом проекте, на любом языке. Мне, как человеку, который работает с PHP, Python, JS и Go, это невероятно важно. Я экономлю кучу времени и нервов, не втыкая каждый раз в документацию очередной ORM.

Выбор инструмента для работы базой данных

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

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

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

Ну, а если у Вас есть аргументы посильнее, приглашаю поспорить в комментариях.