SQL и Qt

Маппинг QSqlRecord на C++ структуры автоматически

Введение

В предыдущих статьях метод mapRowToUser() вручную перечислял каждое поле строки результата запроса — для структуры с десятком полей это многословно и подвержено ошибкам (легко перепутать порядок или забыть поле при добавлении нового). В статье разберём, как автоматизировать этот маппинг через рефлексию на основе Q_GADGET/QMetaObject, сократив объём ручного кода для каждой новой сущности.

Концепция

Q_GADGET — облегчённая версия Q_OBJECT для классов, не нуждающихся в сигналах/слотах, но желающих использовать метаобъектную систему Qt (в частности, рефлексию через QMetaObject) — то есть Q_PROPERTY работает и без наследования от QObject. Используя QMetaObject::propertyCount()/property(), можно написать общую функцию, которая для любого класса с Q_GADGET и набором Q_PROPERTY автоматически читает соответствующие столбцы из QSqlRecord по имени свойства, избавляя от необходимости писать индивидуальный маппинг-метод для каждой новой сущности.

Пример кода: Маппинг QSqlRecord на

// user.h — сущность с Q_GADGET вместо обычной структуры
#pragma once
#include <QObject>
#include <QString>

class User
{
    Q_GADGET
    Q_PROPERTY(int id MEMBER id)
    Q_PROPERTY(QString email MEMBER email)
    Q_PROPERTY(QString name MEMBER name)
    Q_PROPERTY(bool active MEMBER active)

public:
    int id = 0;
    QString email;
    QString name;
    bool active = true;
};
// sqlmapper.h — обобщённая функция маппинга на основе рефлексии Q_GADGET
#pragma once
#include <QSqlRecord>
#include <QMetaObject>
#include <QMetaProperty>

template <typename T>
T mapRecordToObject(const QSqlRecord &record)
{
    T object;
    const QMetaObject *metaObject = &T::staticMetaObject;

    for (int i = 0; i < metaObject->propertyCount(); ++i) {
        const QMetaProperty property = metaObject->property(i);
        const QString propertyName = property.name();

        const int fieldIndex = record.indexOf(propertyName);
        if (fieldIndex == -1) {
            continue; // в результате запроса нет столбца с таким именем — пропускаем
        }

        property.writeOnGadget(&object, record.value(fieldIndex));
    }

    return object;
}

// Обратная операция — заполнение списка значений для INSERT/UPDATE из объекта
template <typename T>
QVariantMap mapObjectToFieldValues(const T &object, const QStringList &excludeFields = {})
{
    QVariantMap values;
    const QMetaObject *metaObject = &T::staticMetaObject;

    for (int i = 0; i < metaObject->propertyCount(); ++i) {
        const QMetaProperty property = metaObject->property(i);
        const QString propertyName = property.name();

        if (excludeFields.contains(propertyName)) {
            continue;
        }

        values[propertyName] = property.readOnGadget(&object);
    }

    return values;
}
// Использование в репозитории — без единой строчки ручного перечисления полей
#include "sqlmapper.h"

QVector<User> SqlUserRepository::findAll()
{
    QVector<User> result;
    QSqlQuery query(m_db);
    query.exec("SELECT id, email, name, active FROM users ORDER BY name");

    while (query.next()) {
        result.append(mapRecordToObject<User>(query.record()));
    }
    return result;
}

bool SqlUserRepository::insertNewUser(User &user)
{
    const QVariantMap fields = mapObjectToFieldValues(user, {"id"}); // id генерируется базой, не вставляем его

    QStringList columnNames = fields.keys();
    QStringList placeholders;
    for (const QString &name : columnNames) {
        placeholders << (":" + name);
    }

    QSqlQuery query(m_db);
    query.prepare(QString("INSERT INTO users (%1) VALUES (%2)")
                      .arg(columnNames.join(", "), placeholders.join(", ")));

    for (const QString &name : columnNames) {
        query.bindValue(":" + name, fields[name]);
    }

    if (!query.exec()) return false;
    user.id = query.lastInsertId().toInt();
    return true;
}

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

User теперь объявлен с Q_GADGET и набором Q_PROPERTY ... MEMBER ... — синтаксис MEMBER связывает свойство непосредственно с полем класса без необходимости писать отдельные getter/setter методы, что особенно удобно для простых структур данных. mapRecordToObject<T>() — шаблонная функция, работающая с любым типом T, имеющим Q_GADGET: она перебирает все объявленные свойства через metaObject->property(i) и для каждого ищет столбец с совпадающим именем в QSqlRecord через record.indexOf(), записывая найденное значение через property.writeOnGadget().

mapObjectToFieldValues() выполняет обратную операцию — собирает имена и значения всех свойств объекта в QVariantMap, что используется в insertNewUser() для динамического построения SQL-запроса INSERT без ручного перечисления каждого столбца — параметр excludeFields позволяет исключить, например, поле id, которое генерируется самой базой данных и не должно передаваться при вставке.

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

  • Совпадение имени свойства Q_PROPERTY и имени столбца в SQL должно быть точным, иначе record.indexOf() вернёт -1, и поле будет тихо пропущено без явной ошибки — если в базе данных столбец называется user_email (snake_case, частая конвенция в SQL), а свойство в C++ называется email (camelCase, конвенция Qt/C++), автоматический маппинг не сработает без дополнительного слоя преобразования имён.
  • Производительность рефлексии через QMetaObject ниже прямого обращения к полям, поскольку каждое обращение проходит через дополнительный уровень индирекции метаобъектной системы — для большинства приложений эта разница незаметна, но при обработке очень больших объёмов записей (десятки тысяч строк за одну операцию) ручной, прямой маппинг может оказаться заметно быстрее.
  • Отсутствие валидации типов при автоматическом маппинге. Если значение в базе данных имеет тип, не совместимый напрямую с типом свойства (например, столбец содержит текстовое представление числа, а свойство объявлено как int), QVariant попытается выполнить неявное преобразование — это может либо сработать корректно благодаря гибкости QVariant, либо привести к «нулевому» или неожиданному значению без явной ошибки, что нужно явно проверять для критичных полей.
  • Q_GADGET не поддерживает сигналы, в отличие от Q_OBJECT — если впоследствии понадобится, например, уведомлять об изменении полей сущности через сигналы (как в Q_PROPERTY ... NOTIFY ...), потребуется переход на полноценный QObject-наследник, что меняет модель владения объектами (нужен родитель, нельзя свободно копировать как обычную структуру) — выбор между Q_GADGET и QObject для сущностей данных стоит делать осознанно, с учётом этого ограничения.