SQL и Qt

Repository pattern поверх QSqlDatabase: типобезопасный слой данных

Введение

В предыдущей статье мы увидели базовый пример UserRepository — в этой статье формализуем паттерн Repository: единый интерфейс доступа к данным конкретного типа сущности (User, Order, Product), скрывающий все детали SQL за методами с понятными именами (findById, save, delete, findAll). Разберём, как спроектировать такой репозиторий обобщённо, через интерфейс и конкретную реализацию, по аналогии с абстрактными интерфейсами из основного цикла статей.

Концепция

Хороший repository обычно реализует базовый набор CRUD-операций (Create, Read, Update, Delete) плюс несколько специфичных для конкретной сущности методов поиска. Проектирование через интерфейс (IUserRepository) и конкретную реализацию (SqlUserRepository) даёт ту же выгоду, что и абстрактные интерфейсы плагинов — возможность подменить реализацию для тестов (статья 238) без изменения кода, который использует репозиторий.

Пример кода: Repository pattern поверх QSqlDatabase

// iuserrepository.h — абстракция, не знающая о конкретной СУБД
#pragma once
#include "user.h"
#include <QVector>
#include <optional>

class IUserRepository
{
public:
    virtual ~IUserRepository() = default;

    virtual std::optional<User> findById(int id) = 0;
    virtual std::optional<User> findByEmail(const QString &email) = 0;
    virtual QVector<User> findAll() = 0;
    virtual bool save(User &user) = 0; // user передаётся по ссылке — id заполняется после INSERT
    virtual bool remove(int id) = 0;
};
// sqluserrepository.h — конкретная реализация на основе QSqlDatabase
#pragma once
#include "iuserrepository.h"
#include <QSqlQuery>
#include <QSqlError>
#include <QDebug>

class SqlUserRepository : public IUserRepository
{
public:
    explicit SqlUserRepository(QSqlDatabase db) : m_db(db) {}

    std::optional<User> findById(int id) override
    {
        QSqlQuery query(m_db);
        query.prepare("SELECT id, email, name, active FROM users WHERE id = :id");
        query.bindValue(":id", id);
        return executeAndFetchOne(query);
    }

    std::optional<User> findByEmail(const QString &email) override
    {
        QSqlQuery query(m_db);
        query.prepare("SELECT id, email, name, active FROM users WHERE email = :email");
        query.bindValue(":email", email);
        return executeAndFetchOne(query);
    }

    QVector<User> findAll() override
    {
        QVector<User> result;
        QSqlQuery query(m_db);
        query.exec("SELECT id, email, name, active FROM users ORDER BY name");
        while (query.next()) {
            result.append(mapRowToUser(query));
        }
        return result;
    }

    bool save(User &user) override
    {
        if (user.id == 0) {
            return insertNewUser(user);
        }
        return updateExistingUser(user);
    }

    bool remove(int id) override
    {
        QSqlQuery query(m_db);
        query.prepare("DELETE FROM users WHERE id = :id");
        query.bindValue(":id", id);
        if (!query.exec()) {
            qWarning() << "Ошибка удаления пользователя:" << query.lastError().text();
            return false;
        }
        return true;
    }

private:
    User mapRowToUser(const QSqlQuery &query)
    {
        return User{
            query.value("id").toInt(),
            query.value("email").toString(),
            query.value("name").toString(),
            query.value("active").toBool()
        };
    }

    std::optional<User> executeAndFetchOne(QSqlQuery &query)
    {
        if (!query.exec()) {
            qWarning() << "Ошибка запроса:" << query.lastError().text();
            return std::nullopt;
        }
        if (!query.next()) {
            return std::nullopt; // не найдено — не ошибка
        }
        return mapRowToUser(query);
    }

    bool insertNewUser(User &user)
    {
        QSqlQuery query(m_db);
        query.prepare("INSERT INTO users (email, name, active) VALUES (:email, :name, :active)");
        query.bindValue(":email", user.email);
        query.bindValue(":name", user.name);
        query.bindValue(":active", user.isActive);

        if (!query.exec()) {
            qWarning() << "Ошибка создания пользователя:" << query.lastError().text();
            return false;
        }
        user.id = query.lastInsertId().toInt(); // заполняем сгенерированный ID обратно в объект
        return true;
    }

    bool updateExistingUser(const User &user)
    {
        QSqlQuery query(m_db);
        query.prepare("UPDATE users SET email = :email, name = :name, active = :active WHERE id = :id");
        query.bindValue(":email", user.email);
        query.bindValue(":name", user.name);
        query.bindValue(":active", user.isActive);
        query.bindValue(":id", user.id);

        if (!query.exec()) {
            qWarning() << "Ошибка обновления пользователя:" << query.lastError().text();
            return false;
        }
        return true;
    }

    QSqlDatabase m_db;
};

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

save() реализует типичный паттерн «upsert по наличию ID»: если у объекта User поле id равно нулю (новый, ещё не сохранённый объект), вызывается insertNewUser(), иначе — updateExistingUser() для уже существующей записи. Параметр User &user принимается по ссылке именно потому, что после успешной вставки в insertNewUser() нужно записать обратно в объект сгенерированный базой данных автоинкрементный ID через query.lastInsertId() — без этого вызывающий код не узнал бы ID только что созданной записи.

executeAndFetchOne() — приватный вспомогательный метод, выделяющий общую логику выполнения запроса и получения одной строки результата, используемую и в findById(), и в findByEmail() — это устраняет дублирование кода обработки ошибок и разбора результата между похожими методами поиска. Различие между «не найдено» (query.next() вернул false без ошибки выполнения) и «ошибка выполнения запроса» (query.exec() вернул false) обрабатывается раздельно — это прямой ответ на подводный камень, отмеченный в конце предыдущей статьи.

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

  • Метод save() с неявной логикой «вставка или обновление по ID» может удивить, если ID равен 0 не потому, что объект новый, а из-за ошибки инициализации. Если структура User где-то создаётся без явной установки id (то есть он остаётся равным 0 по умолчанию для int), save() молча выполнит вставку новой записи вместо ожидаемого обновления существующей — для большей надёжности можно использовать std::optional<int> для ID вместо обычного int, чтобы отличить «ID не задан» от «ID равен нулю» более явно.
  • Каждый метод репозитория создаёт собственный QSqlQuery без переиспользования подготовленных запросов между вызовами. Для часто вызываемых операций (например, findById, выполняемого тысячи раз в цикле обработки) повторная подготовка одного и того же запроса при каждом вызове метода создаёт небольшие, но накопительные накладные расходы — для критичных по производительности сценариев можно кешировать подготовленные QSqlQuery как члены класса репозитория, готовя их один раз в конструкторе.
  • Отсутствие транзакционности между несколькими операциями, выполняемыми через разные вызовы методов репозитория. Если бизнес-логика вызывает save() для нескольких связанных объектов подряд, каждый вызов выполняется в своей отдельной неявной транзакции — для гарантии атомарности операции, затрагивающей несколько сущностей одновременно, нужен паттерн Unit of Work, разбираемый в статье 233.
  • Интерфейс IUserRepository с большим числом методов, растущим по мере добавления всё новых специфичных запросов («найти по статусу», «найти по дате регистрации», «найти топ-10 по активности») — постепенно превращает интерфейс в неудобный для реализации и тестирования «толстый» контракт; для гибкого, постепенно растущего набора запросов часто практичнее паттерн Specification или построитель запросов (Query Builder), позволяющий комбинировать условия без расширения самого интерфейса каждым новым специфичным методом.