Skip to content

Commit 7b7c451

Browse files
committed
Implenent database upgrade mechanism
New class DatabaseUpgrader detects and/or reads version and if necessary, asks for user confirmatino before performing the upgrade. For #168
1 parent 4d38759 commit 7b7c451

File tree

10 files changed

+479
-41
lines changed

10 files changed

+479
-41
lines changed

PAL.pro

+5-3
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,10 @@ DEFINES += QT_DISABLE_DEPRECATED_BEFORE=0x060000 # disables all the APIs depreca
2727

2828

2929

30-
VERSION = 1.1.1
30+
VERSION = 1.2.0
3131
DEFINES += APP_VERSION_MAJOR=1
32-
DEFINES += APP_VERSION_MINOR=1
33-
DEFINES += APP_VERSION_PATCH=1
32+
DEFINES += APP_VERSION_MINOR=2
33+
DEFINES += APP_VERSION_PATCH=0
3434

3535
DEFINES += APP_COPYRIGHT='"\\\"2023 Simon Vetter\\\""'
3636
DEFINES += CODE_LINK='"\\\"https://github.com/svetter/pal\\\""'
@@ -57,6 +57,7 @@ SOURCES += \
5757
src/db/column.cpp \
5858
src/db/database.cpp \
5959
src/db/db_error.cpp \
60+
src/db/db_upgrade.cpp \
6061
src/db/normal_table.cpp \
6162
src/db/row_index.cpp \
6263
src/db/table.cpp \
@@ -122,6 +123,7 @@ HEADERS += \
122123
src/db/column.h \
123124
src/db/database.h \
124125
src/db/db_error.h \
126+
src/db/db_upgrade.h \
125127
src/db/normal_table.h \
126128
src/db/row_index.h \
127129
src/db/table.h \

src/db/database.cpp

+19-2
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424
#include "database.h"
2525

2626
#include "src/db/db_error.h"
27+
#include "src/db/db_upgrade.h"
28+
#include "src/settings/settings.h"
2729

2830
#include <QCoreApplication>
2931
#include <QSqlError>
@@ -127,15 +129,19 @@ void Database::createNew(QWidget* parent, const QString& filepath)
127129

128130
// All tables still empty of course, but this doubles as a table format check
129131
populateBuffers(parent);
132+
133+
// Set version
134+
projectSettings->databaseVersion.set(parent, getAppVersion());
130135
}
131136

132137
/**
133138
* Opens an existing database file at the given filepath and loads the data into the buffers.
134139
*
135140
* @param parent The parent window.
136141
* @param filepath The filepath of the existing database file.
142+
* @return True if the open was successful, false otherwise.
137143
*/
138-
void Database::openExisting(QWidget* parent, const QString& filepath)
144+
bool Database::openExisting(QWidget* parent, const QString& filepath)
139145
{
140146
assert(!databaseLoaded);
141147
qDebug() << "Opening database file" << filepath;
@@ -150,7 +156,18 @@ void Database::openExisting(QWidget* parent, const QString& filepath)
150156
}
151157
databaseLoaded = true;
152158

153-
populateBuffers(parent);
159+
// Upgrade database version
160+
DatabaseUpgrader upgrader = DatabaseUpgrader(this, parent);
161+
bool abort = !upgrader.checkDatabaseVersionAndUpgrade([this, parent] () {
162+
// After database structure was updated if needed:
163+
populateBuffers(parent);
164+
});
165+
166+
if (abort) {
167+
reset();
168+
return false;
169+
}
170+
return true;
154171
}
155172

156173
/**

src/db/database.h

+3-1
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ class Database {
9595

9696
void reset();
9797
void createNew(QWidget* parent, const QString& filepath);
98-
void openExisting(QWidget* parent, const QString& filepath);
98+
bool openExisting(QWidget* parent, const QString& filepath);
9999
bool saveAs(QWidget* parent, const QString& filepath);
100100
QString getCurrentFilepath() const;
101101

@@ -126,6 +126,8 @@ class Database {
126126

127127
public:
128128
static QString tr(const QString& string);
129+
130+
friend class DatabaseUpgrader;
129131
};
130132

131133

src/db/db_upgrade.cpp

+298
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
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

Comments
 (0)