|
| 1 | +/* |
| 2 | + * Copyright 2023 Simon Vetter |
| 3 | + * |
| 4 | + * This file is part of PeakAscentLogger. |
| 5 | + * |
| 6 | + * PeakAscentLogger is free software: you can redistribute it and/or modify it under the terms |
| 7 | + * of the GNU General Public License as published by the Free Software Foundation, |
| 8 | + * either version 3 of the License, or (at your option) any later version. |
| 9 | + * |
| 10 | + * PeakAscentLogger is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; |
| 11 | + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. |
| 12 | + * See the GNU General Public License for more details. |
| 13 | + * |
| 14 | + * You should have received a copy of the GNU General Public License along with PeakAscentLogger. |
| 15 | + * If not, see <https://www.gnu.org/licenses/>. |
| 16 | + */ |
| 17 | + |
| 18 | +/** |
| 19 | + * @file db_upgrade.cpp |
| 20 | + * |
| 21 | + * This file defines the DatabaseUpgrader class. |
| 22 | + */ |
| 23 | + |
| 24 | +#include "db_upgrade.h" |
| 25 | + |
| 26 | +#include "src/db/db_error.h" |
| 27 | +#include "src/settings/settings.h" |
| 28 | + |
| 29 | +#include <QSqlQuery> |
| 30 | +#include <QMessageBox> |
| 31 | + |
| 32 | + |
| 33 | + |
| 34 | +/** |
| 35 | + * Creates a DatabaseUpgrader. |
| 36 | + * |
| 37 | + * @param db The database object. |
| 38 | + * @param parent The parent window. |
| 39 | + */ |
| 40 | +DatabaseUpgrader::DatabaseUpgrader(Database* db, QWidget* parent) : |
| 41 | + db(db), |
| 42 | + parent(parent) |
| 43 | +{} |
| 44 | + |
| 45 | + |
| 46 | + |
| 47 | +/** |
| 48 | + * Determines the version of the open database and upgrades it if necessary. |
| 49 | + * |
| 50 | + * In the first step, the current version of the database file is determined. If it matches the |
| 51 | + * current version of the application, no upgrade is necessary and the function returns immediately. |
| 52 | + * |
| 53 | + * Otherwise, the remaining work is split into two phases: |
| 54 | + * The first phase runs before database initialization, the second phase after. This means that the |
| 55 | + * first phase has to adapt the database structure to the current version. Here, the mechanisms |
| 56 | + * provided by the Database class can't be used yet, since the buffers are not yet initialized. |
| 57 | + * Instead, the database structure has to be modified directly using SQL queries. |
| 58 | + * Before the second phase, the caller-provided function executeAfterStructuralUpgrade is called, |
| 59 | + * which is intended for buffer initialization, which includes checking the database structure. |
| 60 | + * Then, in the second phase, changes in the app which do not affect the database structure can be |
| 61 | + * addressed, while also being able to use the usual mechanisms provided by the Database class. |
| 62 | + * |
| 63 | + * @param executeAfterStructuralUpgrade A function to execute after the database structure has been upgraded. Intended for buffer initialization, which includes checking the database structure. |
| 64 | + * @return True if the upgrade was successful or not necessary and the database can be opened, false if opening the database has to be aborted. |
| 65 | + */ |
| 66 | +bool DatabaseUpgrader::checkDatabaseVersionAndUpgrade(std::function<void ()> executeAfterStructuralUpgrade) |
| 67 | +{ |
| 68 | + assert(db->databaseLoaded); |
| 69 | + |
| 70 | + const QString currentDbVersion = determineCurrentDbVersion(); |
| 71 | + const QString appVersion = getAppVersion(); |
| 72 | + |
| 73 | + if (!isBelowVersion(currentDbVersion, appVersion)) { |
| 74 | + // No upgrade necessary |
| 75 | + executeAfterStructuralUpgrade(); |
| 76 | + return true; |
| 77 | + } |
| 78 | + |
| 79 | + // Determine whether upgrading from the old version will definitely break compatibility |
| 80 | + bool upgradeBreakCompatibility = isBelowVersion(currentDbVersion, "1.2.0"); |
| 81 | + |
| 82 | + // Ask user to confirm upgrade |
| 83 | + bool abort = !promptUserAboutUpgrade(currentDbVersion, upgradeBreakCompatibility); |
| 84 | + if (abort) return false; |
| 85 | + // Create backup copy of project file |
| 86 | + abort = !createFileBackupCopy(); |
| 87 | + if (abort) return false; |
| 88 | + |
| 89 | + |
| 90 | + // Variables to carry information over from the first to the second phase |
| 91 | + ItemID v1_2_0_defaultHikerToCarryOver; |
| 92 | + |
| 93 | + |
| 94 | + |
| 95 | + // === BEFORE INITIALIZING BUFFER === |
| 96 | + // This is where changes to the database structure have to be made. |
| 97 | + // Table buffers can't be used since they are not yet initialized. |
| 98 | + |
| 99 | + // 1.2.0 |
| 100 | + // Versions older than this have a different format for the project settings table |
| 101 | + // Check project settings table structure |
| 102 | + if (isBelowVersion(currentDbVersion, "1.2.0")) { |
| 103 | + // Extract only default hiker, ignore everything else (ascent filters) |
| 104 | + v1_2_0_defaultHikerToCarryOver = extractDefaultHikerFromBeforeV1_2_0(); |
| 105 | + // Remove old project settings table |
| 106 | + removeSettingsTableFromBeforeV1_2_0(); |
| 107 | + // Create new project settings table |
| 108 | + db->settingsTable->createTableInSql(parent); |
| 109 | + } |
| 110 | + |
| 111 | + |
| 112 | + |
| 113 | + // === CALL PROVIDED CODE === |
| 114 | + // Perform structure checks and initialize buffers |
| 115 | + executeAfterStructuralUpgrade(); |
| 116 | + |
| 117 | + |
| 118 | + |
| 119 | + // === AFTER INITIALIZING BUFFERS === |
| 120 | + // Database format now has to match current model and table buffers can be used. |
| 121 | + |
| 122 | + // 1.2.0 |
| 123 | + if (isBelowVersion(currentDbVersion, "1.2.0")) { |
| 124 | + // Save extracted default hiker back to new table, if present |
| 125 | + if (v1_2_0_defaultHikerToCarryOver.isValid()) { |
| 126 | + db->projectSettings->defaultHiker.set(parent, v1_2_0_defaultHikerToCarryOver.asQVariant()); |
| 127 | + } |
| 128 | + } |
| 129 | + |
| 130 | + |
| 131 | + // Set new version |
| 132 | + if (isBelowVersion(currentDbVersion, appVersion)) { |
| 133 | + qDebug().noquote().nospace() << "Upgraded database from v" << currentDbVersion << " to v" << appVersion; |
| 134 | + db->projectSettings->databaseVersion.set(parent, appVersion); |
| 135 | + |
| 136 | + showUpgradeSuccessMessage(currentDbVersion, appVersion); |
| 137 | + } |
| 138 | + |
| 139 | + return true; |
| 140 | +} |
| 141 | + |
| 142 | + |
| 143 | + |
| 144 | +/** |
| 145 | + * Determines the version of the open database. |
| 146 | + * |
| 147 | + * To detect structural changes in the database, table information is queried directly using SQL. |
| 148 | + * Then, if the structure matches the current model, the version is read from the database settings. |
| 149 | + * |
| 150 | + * @return The version of the open database. Approximate if no version string can be read directly from the database. |
| 151 | + */ |
| 152 | +QString DatabaseUpgrader::determineCurrentDbVersion() |
| 153 | +{ |
| 154 | + // Versions older than 1.2.0 have a different format for the project settings table |
| 155 | + // Check project settings table structure |
| 156 | + QString queryString = "PRAGMA table_info(ProjectSettings)"; |
| 157 | + QSqlQuery query(queryString); |
| 158 | + if (!query.exec()) displayError(parent, query.lastError(), queryString); |
| 159 | + QStringList columnNames; |
| 160 | + while (query.next()) { |
| 161 | + QString columnName = query.value(1).toString(); |
| 162 | + columnNames.append(columnName); |
| 163 | + } |
| 164 | + |
| 165 | + QStringList columnNamesBeforeV1_2_0 = { "projectSettingsID", "defaultHiker", "dateFilter", "peakHeightFilter", "volcanoFilter", "rangeFilter", "hikeKindFilter", "difficultyFilter", "hikerFilter" }; |
| 166 | + QStringList columnNamesAfterV1_2_0 = { "projectSettingID", "settingKey", "settingValue" }; |
| 167 | + |
| 168 | + if (columnNames == columnNamesBeforeV1_2_0) { |
| 169 | + return "1.1.1"; |
| 170 | + } |
| 171 | + assert(columnNames == columnNamesAfterV1_2_0); |
| 172 | + |
| 173 | + // Settings table has current format, get version |
| 174 | + queryString = QString( |
| 175 | + "SELECT settingValue FROM ProjectSettings" |
| 176 | + "\nWHERE settingKey='%1'").arg(db->projectSettings->databaseVersion.key); |
| 177 | + query = QSqlQuery(); |
| 178 | + if (!query.prepare(queryString) || !query.exec()) { |
| 179 | + displayError(parent, query.lastError(), queryString); |
| 180 | + } |
| 181 | + assert(query.next()); |
| 182 | + QString versionString = query.value(0).toString(); |
| 183 | + assert(versionString.split(".").size() == 3); |
| 184 | + return versionString; |
| 185 | +} |
| 186 | + |
| 187 | + |
| 188 | + |
| 189 | +/** |
| 190 | + * Shows a message box informing the user about the upgrade and asking them to confirm it. |
| 191 | + * |
| 192 | + * @param oldDbVersion The version of the database file before the upgrade. |
| 193 | + * @param claimOlderVersionsIncompatible Whether to claim that older versions of the app will be incompatible with the upgraded database. |
| 194 | + * @return True if the user confirmed the upgrade, false otherwise. |
| 195 | + */ |
| 196 | +bool DatabaseUpgrader::promptUserAboutUpgrade(const QString& oldDbVersion, bool claimOlderVersionsIncompatible) |
| 197 | +{ |
| 198 | + QString filepath = db->getCurrentFilepath(); |
| 199 | + QString windowTitle = Database::tr("Database upgrade necessary"); |
| 200 | + QString compatibilityStatement = claimOlderVersionsIncompatible |
| 201 | + ? Database::tr("After the upgrade, previous versions of PAL will no longer be able to open the file.") |
| 202 | + : Database::tr("After the upgrade, previous versions of PAL might no longer be able to open the file."); |
| 203 | + QString message = filepath + "\n\n" |
| 204 | + + Database::tr("Opening this project requires upgrading its database from version %1 to version %2." |
| 205 | + "\n%3" |
| 206 | + "\n\nDo you want to perform the upgrade now?" |
| 207 | + "\n\nNote: A copy of the project file in its current state will be created as a backup.") |
| 208 | + .arg(oldDbVersion, getAppVersion(), compatibilityStatement); |
| 209 | + auto buttons = QMessageBox::Yes | QMessageBox::No | QMessageBox::Cancel; |
| 210 | + auto defaultButton = QMessageBox::Cancel; |
| 211 | + |
| 212 | + auto response = QMessageBox::question(parent, windowTitle, message, buttons, defaultButton); |
| 213 | + |
| 214 | + return response == QMessageBox::Yes; |
| 215 | +} |
| 216 | + |
| 217 | +/** |
| 218 | + * Creates a backup copy of the project file and asks the user whether to continue if the copy |
| 219 | + * fails. |
| 220 | + * |
| 221 | + * @return True if the backup was created successfully or the user wants to continue anyway, false otherwise. |
| 222 | + */ |
| 223 | +bool DatabaseUpgrader::createFileBackupCopy() |
| 224 | +{ |
| 225 | + // Determine backup filename |
| 226 | + QString filepath = db->getCurrentFilepath(); |
| 227 | + QString backupFilepath = filepath + ".bak"; |
| 228 | + int backupFileCounter = 1; |
| 229 | + while (QFile(backupFilepath).exists()) { |
| 230 | + backupFilepath = filepath + " (" + QString::number(backupFileCounter++) + ").bak"; |
| 231 | + } |
| 232 | + |
| 233 | + // Copy file |
| 234 | + if (!QFile(filepath).copy(backupFilepath)) { |
| 235 | + qDebug() << "File copy failed:" << filepath << "to" << backupFilepath; |
| 236 | + // Ask user whether to continue |
| 237 | + QString windowTitle = Database::tr("Error creating backup"); |
| 238 | + QString message = filepath + "\n\n" |
| 239 | + + Database::tr("An error occurred while trying to create a backup of the project file." |
| 240 | + "\nDo you want to perform the upgrade anyway?" |
| 241 | + "\n\nNote: You can still create a backup manually before proceeding."); |
| 242 | + auto buttons = QMessageBox::Yes | QMessageBox::No | QMessageBox::Cancel; |
| 243 | + auto defaultButton = QMessageBox::Cancel; |
| 244 | + |
| 245 | + auto response = QMessageBox::question(parent, windowTitle, message, buttons, defaultButton); |
| 246 | + |
| 247 | + return response == QMessageBox::Yes; |
| 248 | + } |
| 249 | + return true; |
| 250 | +} |
| 251 | + |
| 252 | +/** |
| 253 | + * Shows a message box informing the user about the successful upgrade. |
| 254 | + * |
| 255 | + * @param previousVersion The version of the database file before the upgrade. |
| 256 | + * @param newVersion The version of the database file after the upgrade. |
| 257 | + */ |
| 258 | +void DatabaseUpgrader::showUpgradeSuccessMessage(const QString& previousVersion, const QString& newVersion) |
| 259 | +{ |
| 260 | + QString windowTitle = Database::tr("Database upgrade successful"); |
| 261 | + QString message = Database::tr("The database was successfully upgraded from version %1 to version %2.") |
| 262 | + .arg(previousVersion, newVersion); |
| 263 | + QMessageBox::information(parent, windowTitle, message); |
| 264 | +} |
| 265 | + |
| 266 | + |
| 267 | + |
| 268 | +/** |
| 269 | + * Extracts the default hiker from the project settings table of a database file with a version |
| 270 | + * older than 1.2.0. |
| 271 | + * |
| 272 | + * @return The ID of the default hiker, or an invalid ID if no default hiker was set. |
| 273 | + */ |
| 274 | +ItemID DatabaseUpgrader::extractDefaultHikerFromBeforeV1_2_0() |
| 275 | +{ |
| 276 | + QString queryString = QString( |
| 277 | + "SELECT defaultHiker FROM ProjectSettings" |
| 278 | + "\nWHERE projectSettingsID=1"); |
| 279 | + QSqlQuery query = QSqlQuery(); |
| 280 | + if (!query.prepare(queryString) || !query.exec()) { |
| 281 | + displayError(parent, query.lastError(), queryString); |
| 282 | + } |
| 283 | + assert(query.next()); |
| 284 | + QVariant value = query.value(0); |
| 285 | + return ItemID(value); |
| 286 | +} |
| 287 | + |
| 288 | +/** |
| 289 | + * Removes the project settings table from a database file with a version older than 1.2.0. |
| 290 | + */ |
| 291 | +void DatabaseUpgrader::removeSettingsTableFromBeforeV1_2_0() |
| 292 | +{ |
| 293 | + QString queryString = QString("DROP TABLE ProjectSettings"); |
| 294 | + QSqlQuery query = QSqlQuery(); |
| 295 | + if (!query.prepare(queryString) || !query.exec()) { |
| 296 | + displayError(parent, query.lastError(), queryString); |
| 297 | + } |
| 298 | +} |
0 commit comments