SQL и Qt

Зачем ORM в Qt-проекте: проблемы прямого SQL и что решает абстракция

Введение

Прямая работа с QSqlQuery, разобранная в основном цикле статей про SQL, прекрасно подходит для небольших приложений с несколькими запросами — но по мере роста проекта SQL-строки начинают дублироваться по всему коду, изменение схемы базы данных требует правки запросов в десятках мест, а бизнес-логика перемешивается с деталями конкретных SQL-операторов. ORM (Object-Relational Mapping) — слой абстракции, превращающий строки таблиц в обычные C++ объекты и инкапсулирующий SQL за понятным программным интерфейсом. В статье разберём, какие конкретные проблемы решает такой слой, и почему не всегда нужен полноценный сторонний ORM-фреймворк.

Концепция

Главная идея ORM — отделить «что нужно сделать с данными» (на уровне бизнес-логики: «найти всех активных пользователей», «сохранить заказ») от «как именно это выразить в SQL» (конкретный текст запроса, привязка параметров, разбор результата). Без такого слоя один и тот же запрос «получить пользователя по email» может быть продублирован в десяти разных местах кода с небольшими отличиями, и при изменении структуры таблицы users нужно найти и поправить все эти места вручную, рискуя что-то упустить.

Qt не предоставляет полноценного ORM «из коробки» — QSqlTableModel (основной цикл, статья 144) близок к этой идее, но ориентирован конкретно на связку с QTableView, а не на универсальный слой бизнес-логики. Поэтому для серьёзных приложений часто строят собственный, облегчённый слой абстракции на основе паттернов Repository и Data Mapper, не подключая тяжёлые сторонние ORM-библиотеки (которых для C++ значительно меньше и они менее зрелые, чем, например, в экосистеме Java или Python).

Пример кода: Зачем ORM в Qt-проекте

// ПРОБЛЕМА: SQL-запрос продублирован и перемешан с бизнес-логикой в разных местах
void exampleWithoutAbstraction()
{
    QSqlQuery query1;
    query1.prepare("SELECT id, email, name FROM users WHERE email = :email AND active = 1");
    query1.bindValue(":email", "alice@example.com");
    query1.exec();
    // ... разбор результата здесь ...

    // В другом файле, для похожей, но не идентичной задачи:
    QSqlQuery query2;
    query2.prepare("SELECT id, email, name FROM users WHERE email = :email"); // забыли "active = 1"!
    query2.bindValue(":email", "bob@example.com");
    query2.exec();
    // ... повторяющийся, чуть иной код разбора результата ...
}
// РЕШЕНИЕ: один источник истины для запроса и его результата
// user.h
#pragma once
#include <QString>
#include <optional>

struct User
{
    int id;
    QString email;
    QString name;
    bool isActive;
};

// userrepository.h
#pragma once
#include "user.h"
#include <QSqlQuery>
#include <QSqlDatabase>

class UserRepository
{
public:
    explicit UserRepository(QSqlDatabase db) : m_db(db) {}

    std::optional<User> findActiveByEmail(const QString &email)
    {
        QSqlQuery query(m_db);
        query.prepare("SELECT id, email, name, active FROM users WHERE email = :email AND active = 1");
        query.bindValue(":email", email);

        if (!query.exec() || !query.next()) {
            return std::nullopt;
        }

        return User{
            query.value("id").toInt(),
            query.value("email").toString(),
            query.value("name").toString(),
            query.value("active").toBool()
        };
    }

private:
    QSqlDatabase m_db;
};
// Использование — бизнес-логика теперь не знает о SQL вообще
void exampleWithRepository(UserRepository &repository)
{
    if (auto user = repository.findActiveByEmail("alice@example.com")) {
        qDebug() << "Найден активный пользователь:" << user->name;
    } else {
        qDebug() << "Пользователь не найден или неактивен";
    }
}

Пояснения к коду

В «проблемном» варианте видно, как два похожих запроса в разных частях кода рассинхронизировались — второй забыл условие active = 1, и это легко не заметить при code review, особенно если запросы физически находятся в разных файлах. В решении с UserRepository запрос «найти активного пользователя по email» существует ровно в одном месте — методе findActiveByEmail() — и любой код, которому нужна эта операция, вызывает один и тот же метод, гарантированно получая одинаковую логику фильтрации.

Возвращаемый тип std::optional<User> явно отражает в самой сигнатуре метода, что пользователь может быть не найден — вызывающий код обязан обработать оба случая (через if (auto user = ...)), что компилятор фактически «напоминает» сделать, в отличие от ситуации с прямым QSqlQuery, где забытая проверка query.next() просто привела бы к работе с «пустыми» значениями без явного сигнала о проблеме.

Подводные камни

  • Восприятие ORM/repository-слоя как панацеи от всех проблем производительности. Абстракция упорядочивает код, но не делает запросы автоматически быстрее — плохо спроектированный repository может скрывать N+1 проблему (множество мелких запросов вместо одного эффективного) ровно так же легко, как и неаккуратный прямой SQL-код, просто менее заметно из-за абстракции.
  • Слишком ранняя инвестиция в сложную ORM-инфраструктуру для маленького проекта. Если приложение работает с двумя-тремя таблицами и десятком запросов всего, полноценный слой repository с unit of work и кешированием (статьи 233, 235) может быть избыточным усложнением — польза от абстракции растёт пропорционально размеру и сложности проекта, и для совсем небольших задач прямой QSqlQuery остаётся разумным выбором.
  • Использование стороннего, незрелого C++ ORM-фреймворка вместо собственного, простого слоя. Экосистема ORM для C++ значительно менее развита, чем для языков с более динамической типизацией — некоторые сторонние решения могут быть слабо поддерживаемыми проектами с малым сообществом, и собственный, написанный под конкретные нужды слой repository часто оказывается более предсказуемым и проще поддерживаемым выбором, чем привязка к малоизвестной внешней библиотеке.
  • ORM-слой, скрывающий реальные ошибки SQL за общими, неинформативными исключениями или возвращаемыми значениями. Если UserRepository молча возвращает std::nullopt и при отсутствии пользователя, и при реальной ошибке SQL (неверный синтаксис, недоступность базы данных), вызывающий код не может различить эти два принципиально разных случая — для надёжного слоя стоит явно различать «не найдено» и «ошибка выполнения запроса», например через дополнительный механизм обработки ошибок, разбираемый в следующих статьях цикла.