From ed6e2fba4ab189a2f6c99c08e9c52403abbcc497 Mon Sep 17 00:00:00 2001 From: andrewlalis Date: Wed, 3 Jan 2024 18:03:46 -0500 Subject: [PATCH] Added schema migration infrastructure. --- .../data/impl/JdbcDataSourceFactory.java | 69 ++++++++++++++++--- .../data/impl/migration/JdbcMigration.java | 15 ++++ .../perfin/data/impl/migration/Migration.java | 7 ++ .../data/impl/migration/Migrations.java | 28 ++++++++ .../impl/migration/PlainSQLMigration.java | 35 ++++++++++ .../migration/M1_AddBalanceRecordDeleted.sql | 5 ++ 6 files changed, 151 insertions(+), 8 deletions(-) create mode 100644 src/main/java/com/andrewlalis/perfin/data/impl/migration/JdbcMigration.java create mode 100644 src/main/java/com/andrewlalis/perfin/data/impl/migration/Migration.java create mode 100644 src/main/java/com/andrewlalis/perfin/data/impl/migration/Migrations.java create mode 100644 src/main/java/com/andrewlalis/perfin/data/impl/migration/PlainSQLMigration.java create mode 100644 src/main/resources/sql/migration/M1_AddBalanceRecordDeleted.sql diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSourceFactory.java b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSourceFactory.java index e5a8d9b..bf18f06 100644 --- a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSourceFactory.java +++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSourceFactory.java @@ -2,6 +2,8 @@ package com.andrewlalis.perfin.data.impl; import com.andrewlalis.perfin.data.DataSource; 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.model.Profile; import org.slf4j.Logger; @@ -48,7 +50,7 @@ public class JdbcDataSourceFactory { log.debug("Database loaded for profile {} has schema version {}.", profileName, loadedSchemaVersion); if (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) { 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."); @@ -66,13 +68,7 @@ public class JdbcDataSourceFactory { ) { if (in == null) throw new IOException("Could not load database schema SQL file."); String schemaStr = new String(in.readAllBytes(), StandardCharsets.UTF_8); - List statements = Arrays.stream(schemaStr.split(";")) - .map(String::strip).filter(s -> !s.isBlank()).toList(); - for (String statementText : statements) { - try (Statement stmt = conn.createStatement()) { - stmt.executeUpdate(statementText); - } - } + executeSqlScript(schemaStr, conn); try { writeCurrentSchemaVersion(profileName); } 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 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) { return Profile.getDir(profileName).resolve("database.mv.db"); } diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/migration/JdbcMigration.java b/src/main/java/com/andrewlalis/perfin/data/impl/migration/JdbcMigration.java new file mode 100644 index 0000000..1b71c22 --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/data/impl/migration/JdbcMigration.java @@ -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; +} diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/migration/Migration.java b/src/main/java/com/andrewlalis/perfin/data/impl/migration/Migration.java new file mode 100644 index 0000000..2fe343d --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/data/impl/migration/Migration.java @@ -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; +} diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/migration/Migrations.java b/src/main/java/com/andrewlalis/perfin/data/impl/migration/Migrations.java new file mode 100644 index 0000000..79a7d6c --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/data/impl/migration/Migrations.java @@ -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 getMigrations() { + final Map migrations = new HashMap<>(); + migrations.put(1, new PlainSQLMigration("/sql/migration/M1_AddBalanceRecordDeleted.sql")); + return migrations; + } + + public static List 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; + } +} diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/migration/PlainSQLMigration.java b/src/main/java/com/andrewlalis/perfin/data/impl/migration/PlainSQLMigration.java new file mode 100644 index 0000000..8630aff --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/data/impl/migration/PlainSQLMigration.java @@ -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 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); + } + } + } +} diff --git a/src/main/resources/sql/migration/M1_AddBalanceRecordDeleted.sql b/src/main/resources/sql/migration/M1_AddBalanceRecordDeleted.sql new file mode 100644 index 0000000..5740723 --- /dev/null +++ b/src/main/resources/sql/migration/M1_AddBalanceRecordDeleted.sql @@ -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;