Введение
QSqlRelationalTableModel (основной цикл, статья 144) хорошо подходит для простых связей, отображаемых прямо в QTableView, но для произвольной бизнес-логики, не привязанной к конкретному виджету, нужен более гибкий способ загрузки связанных данных через repository-слой. В статье разберём, как явно загружать связанные сущности — заказы конкретного клиента («один-ко-многим») и теги, связанные с товарами через промежуточную таблицу («многие-ко-многим»).
Концепция
Для связи «один-ко-многим» (один клиент — много заказов) обычно достаточно одного дополнительного метода в repository связанной сущности, принимающего внешний ключ родителя (findByCustomerId). Для связи «многие-ко-многим» (товары и теги через промежуточную таблицу product_tags) нужен JOIN через промежуточную таблицу, и обратная операция — обновление набора связей — реализуется как «удалить все существующие связи и вставить заново», что проще и надёжнее, чем вычислять точный diff между старым и новым набором связей.
Пример кода: Связи «один-ко-многим» и
// Связь "один-ко-многим": заказы конкретного клиента
class SqlOrderRepository
{
public:
explicit SqlOrderRepository(QSqlDatabase db) : m_db(db) {}
QVector<Order> findByCustomerId(int customerId)
{
QVector<Order> result;
QSqlQuery query(m_db);
query.prepare("SELECT id, customer_id, product_id, quantity, created_at "
"FROM orders WHERE customer_id = :customerId ORDER BY created_at DESC");
query.bindValue(":customerId", customerId);
query.exec();
while (query.next()) {
result.append(mapRecordToObject<Order>(query.record()));
}
return result;
}
private:
QSqlDatabase m_db;
};
// Связь "многие-ко-многим": товары и теги через промежуточную таблицу product_tags
class SqlProductTagRepository
{
public:
explicit SqlProductTagRepository(QSqlDatabase db) : m_db(db) {}
QStringList findTagsForProduct(int productId)
{
QStringList tags;
QSqlQuery query(m_db);
query.prepare(
"SELECT tags.name FROM tags "
"INNER JOIN product_tags ON tags.id = product_tags.tag_id "
"WHERE product_tags.product_id = :productId");
query.bindValue(":productId", productId);
query.exec();
while (query.next()) {
tags.append(query.value("name").toString());
}
return tags;
}
bool setTagsForProduct(int productId, const QStringList &tagNames)
{
if (!m_db.transaction()) return false;
// Простая и надёжная стратегия: удаляем все существующие связи
// и вставляем заново, а не вычисляем разницу между старым и новым набором
QSqlQuery deleteQuery(m_db);
deleteQuery.prepare("DELETE FROM product_tags WHERE product_id = :productId");
deleteQuery.bindValue(":productId", productId);
if (!deleteQuery.exec()) {
m_db.rollback();
return false;
}
for (const QString &tagName : tagNames) {
const int tagId = findOrCreateTagId(tagName);
if (tagId == -1) {
m_db.rollback();
return false;
}
QSqlQuery linkQuery(m_db);
linkQuery.prepare("INSERT INTO product_tags (product_id, tag_id) VALUES (:productId, :tagId)");
linkQuery.bindValue(":productId", productId);
linkQuery.bindValue(":tagId", tagId);
if (!linkQuery.exec()) {
m_db.rollback();
return false;
}
}
return m_db.commit();
}
private:
int findOrCreateTagId(const QString &tagName)
{
QSqlQuery findQuery(m_db);
findQuery.prepare("SELECT id FROM tags WHERE name = :name");
findQuery.bindValue(":name", tagName);
findQuery.exec();
if (findQuery.next()) {
return findQuery.value("id").toInt();
}
QSqlQuery insertQuery(m_db);
insertQuery.prepare("INSERT INTO tags (name) VALUES (:name)");
insertQuery.bindValue(":name", tagName);
if (!insertQuery.exec()) {
return -1;
}
return insertQuery.lastInsertId().toInt();
}
QSqlDatabase m_db;
};
Пояснения к коду
findByCustomerId() — типичный пример репозитория «один-ко-многим»: метод принимает внешний ключ (customerId) и возвращает все связанные записи через простой WHERE-фильтр, без необходимости в JOIN, поскольку внешний ключ напрямую хранится в таблице orders. findTagsForProduct() решает связь «многие-ко-многим» через INNER JOIN с промежуточной таблицей product_tags, которая хранит только пары (product_id, tag_id) без какой-либо дополнительной информации о самой связи.
setTagsForProduct() демонстрирует стратегию «удалить всё и вставить заново» для обновления набора связей — вместо сложной логики вычисления, какие конкретно связи добавились, а какие удалились (что было бы нужно при изменении только некоторых тегов), весь набор связей конкретного товара просто полностью заменяется новым, что значительно проще в реализации и менее подвержено ошибкам, хотя и не оптимально по производительности при очень частых изменениях большого количества связей. findOrCreateTagId() реализует паттерн «найти или создать» — типичную операцию для справочных таблиц, где значение (тег) может уже существовать или должно быть создано впервые.
Подводные камни
- Стратегия «удалить всё и вставить заново» неоптимальна при очень больших наборах связей или очень частых обновлениях. Если у товара тысячи тегов и обновляется лишь один из них, полное удаление и повторная вставка всех связей создаёт ненужную нагрузку — для таких случаев нужна более точная логика, вычисляющая фактическую разницу (diff) между старым и новым набором и выполняющая только необходимые добавления/удаления.
- Отсутствие проверки уникальности при
findOrCreateTagId()в условиях конкурентного доступа. Если два потока или процесса одновременно пытаются создать тег с одинаковым именем, которого ещё не существует, оба могут пройти проверкуSELECT(которая ничего не найдёт для обоих) и попытаться выполнитьINSERT, что приведёт к ошибке нарушения уникальности (если на колонкуnameтаблицыtagsналожено ограничение UNIQUE) для одного из них — такую гонку нужно либо явно обрабатывать (повторной попыткой найти после неудачной вставки), либо избегать через более высокий уровень изоляции транзакции. - N+1 проблема при загрузке тегов для списка товаров поштучно в цикле. Если нужно отобразить список из ста товаров с их тегами, вызов
findTagsForProduct()сто раз в цикле выполняет сто отдельных запросов вместо одного эффективного запроса с группировкой — для таких случаев стоит реализовать отдельный метод, загружающий теги для целого набора товаров одним запросом с последующей группировкой результата на стороне приложения. - Транзакция в
setTagsForProduct(), начинаемая прямо внутри репозитория, конфликтует с паттерном Unit of Work из предыдущей статьи, если этот метод вызывается как часть более крупной составной операции — повторный вызовm_db.transaction()внутри уже открытой внешней транзакции (начатой Unit of Work) может не сработать ожидаемым образом; для совместимости с Unit of Work методы репозитория, как правило, не должны сами управлять транзакциями, а только выполнять операции в рамках уже переданного, возможно уже открытого подключения.