SQL и Qt

Связи «один-ко-многим» и «многие-ко-многим» без QSqlRelationalTableModel

Введение

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