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