SQL и Qt

Миграции схемы базы данных в Qt-приложении: версионирование и накатывание изменений

Введение

По мере развития приложения схема базы данных меняется — добавляются таблицы, столбцы, индексы. Если изменения схемы применяются вручную и бесконтрольно, разные установки приложения у разных пользователей (или на разных стадиях разработки) рано или поздно расходятся в реальной структуре базы данных, что приводит к трудноуловимым ошибкам. Система миграций — упорядоченный, версионированный набор изменений схемы, применяемых последовательно и идемпотентно — решает эту проблему. В статье построим простую систему миграций для Qt-приложения с локальной SQLite базой.

Концепция

Каждая миграция — это пронумерованный SQL-скрипт (или функция на C++, исполняющая SQL), переводящий схему базы данных с одной версии на следующую. Текущая версия схемы хранится непосредственно в самой базе данных (отдельная таблица schema_version или встроенный механизм PRAGMA user_version в SQLite). При старте приложения система миграций сравнивает текущую версию базы с последней известной версией кода и последовательно применяет все недостающие миграции в правильном порядке.

Пример кода: Миграции схемы базы данных в Qt-приложении

#pragma once
#include <QSqlDatabase>
#include <QSqlQuery>
#include <QSqlError>
#include <QDebug>
#include <vector>
#include <functional>

struct Migration
{
    int version;
    QString description;
    std::function<bool(QSqlDatabase &)> apply;
};

class MigrationRunner
{
public:
    explicit MigrationRunner(QSqlDatabase &db) : m_db(db)
    {
        registerMigrations();
    }

    bool runPendingMigrations()
    {
        const int currentVersion = getCurrentVersion();
        qDebug() << "Текущая версия схемы:" << currentVersion;

        for (const auto &migration : m_migrations) {
            if (migration.version <= currentVersion) continue;

            qDebug() << "Применение миграции" << migration.version << ":" << migration.description;

            if (!m_db.transaction()) {
                qWarning() << "Не удалось начать транзакцию для миграции" << migration.version;
                return false;
            }

            if (!migration.apply(m_db)) {
                qWarning() << "Миграция" << migration.version << "провалена, выполняется откат";
                m_db.rollback();
                return false;
            }

            setCurrentVersion(migration.version);

            if (!m_db.commit()) {
                qWarning() << "Не удалось закоммитить миграцию" << migration.version;
                m_db.rollback();
                return false;
            }
        }
        return true;
    }

private:
    int getCurrentVersion()
    {
        QSqlQuery query(m_db);
        query.exec("PRAGMA user_version");
        if (query.next()) {
            return query.value(0).toInt();
        }
        return 0;
    }

    void setCurrentVersion(int version)
    {
        QSqlQuery query(m_db);
        query.exec(QString("PRAGMA user_version = %1").arg(version));
    }

    void registerMigrations()
    {
        m_migrations.push_back({1, "Создание таблицы users", [](QSqlDatabase &db) {
            QSqlQuery query(db);
            return query.exec(
                "CREATE TABLE users ("
                "id INTEGER PRIMARY KEY AUTOINCREMENT, "
                "username TEXT NOT NULL UNIQUE, "
                "created_at TEXT NOT NULL)");
        }});

        m_migrations.push_back({2, "Добавление поля email в users", [](QSqlDatabase &db) {
            QSqlQuery query(db);
            return query.exec("ALTER TABLE users ADD COLUMN email TEXT");
        }});

        m_migrations.push_back({3, "Создание индекса по email", [](QSqlDatabase &db) {
            QSqlQuery query(db);
            return query.exec("CREATE INDEX idx_users_email ON users(email)");
        }});
    }

    QSqlDatabase &m_db;
    std::vector<Migration> m_migrations;
};
// Использование при старте приложения
#include "migrationrunner.h"

bool initializeDatabase()
{
    QSqlDatabase db = QSqlDatabase::addDatabase("QSQLITE");
    db.setDatabaseName("app_data.db");
    if (!db.open()) {
        return false;
    }

    MigrationRunner runner(db);
    return runner.runPendingMigrations();
}

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

Каждая Migration хранит номер версии, описание (для логирования) и функцию, фактически выполняющую SQL-команды изменения схемы. runPendingMigrations() читает текущую версию схемы через PRAGMA user_version (встроенный в SQLite механизм хранения произвольного целого числа версии прямо в файле базы данных, без необходимости в отдельной таблице), пропускает уже применённые миграции и последовательно выполняет оставшиеся, оборачивая каждую в транзакцию — если миграция проваливается, происходит откат, и версия схемы не обновляется, что позволяет безопасно повторить попытку при следующем запуске после исправления проблемы.

Регистрация миграций в порядке возрастания номеров версий в registerMigrations() — простой, явный способ держать историю изменений схемы прямо в коде приложения, синхронизированную с системой контроля версий самого исходного кода.

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

  • Изменение уже применённой и выпущенной миграции вместо добавления новой. Если миграция с номером 2 уже была применена на машинах пользователей, и впоследствии её код в репозитории меняется, у пользователей, уже выполнивших оригинальную версию, будет иное состояние схемы, чем у тех, кто выполнит уже изменённую версию при первой установке — миграции должны быть неизменяемыми после публикации; любое исправление оформляется как новая, отдельная миграция.
  • ALTER TABLE с ограниченной поддержкой в SQLite. SQLite поддерживает ограниченный набор операций ALTER TABLE (например, до недавних версий не поддерживалось удаление столбца напрямую) — для сложных изменений схемы (удаление столбца, изменение типа) часто требуется паттерн «создать новую таблицу, скопировать данные, удалить старую, переименовать новую», который нужно явно реализовывать в коде миграции.
  • Отсутствие транзакции вокруг миграции, изменяющей структуру и данные одновременно. Без транзакции (как в примере, где m_db.transaction()/commit()/rollback() оборачивают каждую миграцию) сбой посередине многошаговой миграции может оставить базу данных в промежуточном, несогласованном состоянии — для SQLite не все DDL-операции полностью транзакционны во всех версиях, что стоит проверять отдельно для критичных миграций.
  • Параллельный запуск нескольких экземпляров приложения, одновременно пытающихся применить миграции к общей базе данных (например, при сетевой базе данных, используемой несколькими клиентами) — без дополнительного механизма блокировки (advisory lock на уровне СУБД) возможна гонка, при которой два процесса одновременно пытаются применить одну и ту же миграцию.