SQL и Qt

Миграции схемы для ORM-слоя: версионирование моделей данных

Введение

Основной цикл статей про SQL уже разбирал миграции схемы базы данных (статья 145) на уровне чистого SQL — в этой статье посмотрим на ту же задачу с точки зрения слоя ORM/repository: как синхронизировать изменения C++ структур сущностей (Q_GADGET/Q_PROPERTY из статьи 232) с соответствующими изменениями схемы базы данных, и как организовать процесс, чтобы оба аспекта (код сущности и схема таблицы) менялись согласованно, а не рассинхронизировались.

Концепция

Когда сущность (User, Product) описана через Q_PROPERTY, у разработчика есть соблазн добавить новое свойство в C++ класс и забыть про соответствующий столбец в базе данных (или наоборот) — поскольку рефлексия из статьи 232 просто «тихо» пропускает несовпадающие имена без ошибки компиляции. Дисциплинированный подход — держать миграции и определения сущностей рядом, в одном модуле/директории слоя данных, и в идеале — автоматизированную проверку (как часть тестов или CI), что список столбцов таблицы и список Q_PROPERTY сущности соответствуют друг другу хотя бы по количеству и именам.

Пример кода: Миграции схемы для ORM-слоя

// migrations/migration_003_add_user_phone.h —
// миграция и соответствующее изменение сущности оформляются как один логический коммит
#pragma once
#include <QSqlQuery>
#include <QSqlDatabase>

inline bool migration003_addUserPhone(QSqlDatabase &db)
{
    QSqlQuery query(db);
    return query.exec("ALTER TABLE users ADD COLUMN phone TEXT DEFAULT ''");
}
// user.h — синхронное изменение сущности В ТОМ ЖЕ коммите, что и миграция выше
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)
    Q_PROPERTY(QString phone MEMBER phone) // новое свойство добавлено синхронно с миграцией

public:
    int id = 0;
    QString email;
    QString name;
    bool active = true;
    QString phone;
};
// schema_consistency_test.cpp — автоматизированная проверка соответствия
// сущности и реальной структуры таблицы (часть тестового набора)
#include <QtTest/QtTest>
#include <QMetaObject>
#include "user.h"

class TestSchemaConsistency : public QObject
{
    Q_OBJECT
private slots:
    void testUserPropertiesMatchTableColumns()
    {
        QSqlDatabase db = QSqlDatabase::database(); // тестовое подключение с применёнными миграциями
        QSqlRecord record = db.record("users"); // структура реальной таблицы из СУБД

        const QMetaObject *metaObject = &User::staticMetaObject;
        QStringList missingInTable;

        for (int i = 0; i < metaObject->propertyCount(); ++i) {
            const QString propertyName = metaObject->property(i).name();
            if (record.indexOf(propertyName) == -1) {
                missingInTable.append(propertyName);
            }
        }

        QVERIFY2(missingInTable.isEmpty(),
                  qPrintable("Свойства User без соответствующего столбца в таблице: "
                            + missingInTable.join(", ")));
    }
};

QTEST_MAIN(TestSchemaConsistency)
#include "schema_consistency_test.moc"

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

Миграция migration003_addUserPhone и изменение класса User (добавление Q_PROPERTY phone) показаны как логически единое, синхронное изменение — на практике это означает, что оба файла должны попадать в один и тот же коммит системы контроля версий, что облегчает code review (рецензент сразу видит, что изменение схемы и изменение сущности связаны и согласованы друг с другом) и упрощает откат при необходимости (откат одного коммита откатывает оба изменения вместе).

TestSchemaConsistency показывает автоматизированную проверку этого соответствия — тест получает реальную структуру таблицы users из СУБД через db.record("users") (этот метод возвращает QSqlRecord, описывающий столбцы таблицы без необходимости выполнять реальный SELECT) и сравнивает имена столбцов с именами Q_PROPERTY сущности User, заваливая тест, если какое-то свойство не имеет соответствующего столбца в таблице — это превращает рассинхронизацию из статьи 232 (где несовпадение имён тихо пропускается рефлексией) в явную, обнаруживаемую в CI ошибку.

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

  • Тест проверяет соответствие только в одном направлении (свойства, для которых нет столбца), но не наоборот (столбцы, для которых нет свойства) — лишний столбец в таблице, не отражённый в сущности, просто не будет использоваться кодом, что менее опасно, чем отсутствующий столбец для существующего свойства, но всё равно может означать забытое поле, которое разработчик намеревался использовать, но не добавил в Q_PROPERTY; полная проверка должна сравнивать множества имён в обе стороны.
  • Выполнение теста схемы против реальной СУБД требует тестового окружения с применёнными миграциями — если тест запускается против «чистой» базы данных без последовательно применённых миграций (например, из-за ошибки в порядке запуска CI-шагов), он будет либо падать с ложноположительным результатом (таблицы вообще не существует), либо тестировать не ту версию схемы, которая ожидается — порядок шагов CI (применение миграций перед запуском тестов) критичен для корректности этой проверки.
  • DEFAULT '' в миграции ALTER TABLE для нового столбца — разумный выбор для текстового поля, но для других типов (числа, булевы значения) важно явно продумать, какое значение по умолчанию получат уже существующие строки таблицы при добавлении нового столбца, и убедиться, что C++ значение по умолчанию для нового Q_PROPERTY (QString phone без инициализатора, что даёт пустую строку) согласуется с тем, что фактически окажется в базе данных для старых записей.
  • Отсутствие синхронной проверки при удалении или переименовании свойства/столбца. Если свойство phone впоследствии переименовывается в phoneNumber в C++ коде без соответствующей миграции RENAME COLUMN в базе данных, маппинг через рефлексию (статья 232) просто не найдёт старый столбец phone под новым именем phoneNumber и тихо оставит поле пустым для всех существующих записей — переименование требует обязательной парной миграции, а не просто изменения кода сущности.