Введение
В предыдущей статье мы увидели базовый пример 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), позволяющий комбинировать условия без расширения самого интерфейса каждым новым специфичным методом.