Введение
По мере развития приложения схема базы данных меняется — добавляются таблицы, столбцы, индексы. Если изменения схемы применяются вручную и бесконтрольно, разные установки приложения у разных пользователей (или на разных стадиях разработки) рано или поздно расходятся в реальной структуре базы данных, что приводит к трудноуловимым ошибкам. Система миграций — упорядоченный, версионированный набор изменений схемы, применяемых последовательно и идемпотентно — решает эту проблему. В статье построим простую систему миграций для 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 на уровне СУБД) возможна гонка, при которой два процесса одновременно пытаются применить одну и ту же миграцию.