Added schema migration infrastructure.

This commit is contained in:
Andrew Lalis 2024-01-03 18:03:46 -05:00
parent a7654e49ca
commit ed6e2fba4a
6 changed files with 151 additions and 8 deletions

View File

@ -2,6 +2,8 @@ package com.andrewlalis.perfin.data.impl;
import com.andrewlalis.perfin.data.DataSource; import com.andrewlalis.perfin.data.DataSource;
import com.andrewlalis.perfin.data.ProfileLoadException; import com.andrewlalis.perfin.data.ProfileLoadException;
import com.andrewlalis.perfin.data.impl.migration.Migration;
import com.andrewlalis.perfin.data.impl.migration.Migrations;
import com.andrewlalis.perfin.data.util.FileUtil; import com.andrewlalis.perfin.data.util.FileUtil;
import com.andrewlalis.perfin.model.Profile; import com.andrewlalis.perfin.model.Profile;
import org.slf4j.Logger; import org.slf4j.Logger;
@ -48,7 +50,7 @@ public class JdbcDataSourceFactory {
log.debug("Database loaded for profile {} has schema version {}.", profileName, loadedSchemaVersion); log.debug("Database loaded for profile {} has schema version {}.", profileName, loadedSchemaVersion);
if (loadedSchemaVersion < SCHEMA_VERSION) { if (loadedSchemaVersion < SCHEMA_VERSION) {
log.debug("Schema version {} is lower than the app's version {}. Performing migration.", loadedSchemaVersion, SCHEMA_VERSION); log.debug("Schema version {} is lower than the app's version {}. Performing migration.", loadedSchemaVersion, SCHEMA_VERSION);
// TODO: Do migration migrateToCurrentSchemaVersion(profileName, loadedSchemaVersion);
} else if (loadedSchemaVersion > SCHEMA_VERSION) { } else if (loadedSchemaVersion > SCHEMA_VERSION) {
log.debug("Schema version {} is higher than the app's version {}. Cannot continue.", loadedSchemaVersion, SCHEMA_VERSION); log.debug("Schema version {} is higher than the app's version {}. Cannot continue.", loadedSchemaVersion, SCHEMA_VERSION);
throw new ProfileLoadException("Profile " + profileName + " has a database with an unsupported schema version."); throw new ProfileLoadException("Profile " + profileName + " has a database with an unsupported schema version.");
@ -66,13 +68,7 @@ public class JdbcDataSourceFactory {
) { ) {
if (in == null) throw new IOException("Could not load database schema SQL file."); if (in == null) throw new IOException("Could not load database schema SQL file.");
String schemaStr = new String(in.readAllBytes(), StandardCharsets.UTF_8); String schemaStr = new String(in.readAllBytes(), StandardCharsets.UTF_8);
List<String> statements = Arrays.stream(schemaStr.split(";")) executeSqlScript(schemaStr, conn);
.map(String::strip).filter(s -> !s.isBlank()).toList();
for (String statementText : statements) {
try (Statement stmt = conn.createStatement()) {
stmt.executeUpdate(statementText);
}
}
try { try {
writeCurrentSchemaVersion(profileName); writeCurrentSchemaVersion(profileName);
} catch (IOException e) { } catch (IOException e) {
@ -102,6 +98,63 @@ public class JdbcDataSourceFactory {
} }
} }
private void migrateToCurrentSchemaVersion(String profileName, int currentVersion) throws ProfileLoadException {
// Before starting, copy the database file to a backup folder.
Path backupDatabaseFile = getDatabaseFile(profileName).resolveSibling("migration-backup-database.mv.db");
try {
Files.copy(getDatabaseFile(profileName), backupDatabaseFile);
} catch (IOException e) {
throw new ProfileLoadException("Failed to prepare database backup prior to schema migration.", e);
}
int version = currentVersion;
JdbcDataSource dataSource = new JdbcDataSource(getJdbcUrl(profileName), Profile.getContentDir(profileName));
while (version < SCHEMA_VERSION) {
log.info("Migrating profile {} from version {} to version {}.", profileName, version, version + 1);
try {
Migration m = Migrations.get(version);
m.migrate(dataSource);
version++;
} catch (Exception e) {
log.error("Migration from version " + version + " to " + (version+1) + " failed!", e);
log.debug("Restoring database from pre-migration backup.");
FileUtil.deleteIfPossible(getDatabaseFile(profileName));
try {
Files.copy(backupDatabaseFile, getDatabaseFile(profileName));
FileUtil.deleteIfPossible(backupDatabaseFile);
} catch (IOException e2) {
log.error("Failed to restore backup!", e2);
throw new ProfileLoadException("Failed to restore backup after a failed migration.", e2);
}
throw new ProfileLoadException("Migration failed and data restored to pre-migration state.", e);
}
}
try {
writeCurrentSchemaVersion(profileName);
} catch (IOException e) {
log.error("Failed to write current schema version after migration.");
FileUtil.deleteIfPossible(getDatabaseFile(profileName));
try {
Files.copy(backupDatabaseFile, getDatabaseFile(profileName));
FileUtil.deleteIfPossible(backupDatabaseFile);
} catch (IOException e2) {
throw new ProfileLoadException("Failed to restore backup after failing to set schema version.", e2);
}
throw new ProfileLoadException("Failed to update the schema version file after the migration.", e);
}
FileUtil.deleteIfPossible(backupDatabaseFile);
log.info("Profile successfully migrated to latest version.");
}
private static void executeSqlScript(String script, Connection conn) throws SQLException {
List<String> statements = Arrays.stream(script.split(";"))
.map(String::strip).filter(s -> !s.isBlank()).toList();
for (String statementText : statements) {
try (Statement stmt = conn.createStatement()) {
stmt.executeUpdate(statementText);
}
}
}
private static Path getDatabaseFile(String profileName) { private static Path getDatabaseFile(String profileName) {
return Profile.getDir(profileName).resolve("database.mv.db"); return Profile.getDir(profileName).resolve("database.mv.db");
} }

View File

@ -0,0 +1,15 @@
package com.andrewlalis.perfin.data.impl.migration;
import com.andrewlalis.perfin.data.DataSource;
import com.andrewlalis.perfin.data.impl.JdbcDataSource;
public interface JdbcMigration extends Migration {
default void migrate(DataSource dataSource) throws Exception {
if (dataSource instanceof JdbcDataSource ds) {
migrateJdbc(ds);
} else {
throw new IllegalArgumentException("This migration only accepts JDBC data sources.");
}
}
void migrateJdbc(JdbcDataSource dataSource) throws Exception;
}

View File

@ -0,0 +1,7 @@
package com.andrewlalis.perfin.data.impl.migration;
import com.andrewlalis.perfin.data.DataSource;
public interface Migration {
void migrate(DataSource dataSource) throws Exception;
}

View File

@ -0,0 +1,28 @@
package com.andrewlalis.perfin.data.impl.migration;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class Migrations {
public static Map<Integer, Migration> getMigrations() {
final Map<Integer, Migration> migrations = new HashMap<>();
migrations.put(1, new PlainSQLMigration("/sql/migration/M1_AddBalanceRecordDeleted.sql"));
return migrations;
}
public static List<Migration> getAll() {
return getMigrations().entrySet().stream()
.sorted(Map.Entry.comparingByKey())
.map(Map.Entry::getValue)
.toList();
}
public static Migration get(int currentVersion) {
Migration selectedMigration = getMigrations().get(currentVersion);
if (selectedMigration == null) {
throw new IllegalArgumentException("No migration available from version " + currentVersion);
}
return selectedMigration;
}
}

View File

@ -0,0 +1,35 @@
package com.andrewlalis.perfin.data.impl.migration;
import com.andrewlalis.perfin.data.impl.JdbcDataSource;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.List;
public class PlainSQLMigration implements JdbcMigration {
private final String resourceName;
public PlainSQLMigration(String resourceName) {
this.resourceName = resourceName;
}
@Override
public void migrateJdbc(JdbcDataSource dataSource) throws Exception {
try (
var in = PlainSQLMigration.class.getResourceAsStream(resourceName);
var conn = dataSource.getConnection();
var stmt = conn.createStatement()
) {
if (in == null) throw new IOException("Failed to load resource " + resourceName);
String sqlString = new String(in.readAllBytes(), StandardCharsets.UTF_8);
List<String> sqlStatements = Arrays.stream(sqlString.split(";"))
.map(String::strip).filter(s -> !s.isBlank()).toList();
System.out.println("Running SQL Migration with " + sqlStatements.size() + " statements:");
for (String sqlStatement : sqlStatements) {
System.out.println(" Executing SQL statement:\n" + sqlStatement + "\n-----\n");
stmt.executeUpdate(sqlStatement);
}
}
}
}

View File

@ -0,0 +1,5 @@
ALTER TABLE balance_record
ADD COLUMN deleted BOOLEAN NOT NULL DEFAULT FALSE AFTER currency;
ALTER TABLE account_entry
ADD COLUMN deleted BOOLEAN NOT NULL DEFAULT FALSE AFTER currency;