From d62bf4a057fda178dbbe980d64bea0cf55532b56 Mon Sep 17 00:00:00 2001 From: James Mortemore Date: Fri, 3 Apr 2026 23:45:01 +0100 Subject: [PATCH 1/5] feat: add versioned database migration system Replace inline ALTER TABLE/CREATE INDEX blocks scattered across 29 Storage constructors with a centralised MigrationRunner that applies versioned SQL files from classpath manifests. Key capabilities: - Manifest-based migration discovery (db//migrations.list) - Configurable table name placeholders (${tableName}) - MySQL/MariaDB advisory locking for concurrent server startups - Connection pinning to ensure lock/SQL/unlock on same connection - Fresh-install detection (skip SQL, mark latest version) - Existing-install baseline (mark V1 without re-running) - Idempotent DDL (individual statement failures logged, not fatal) - Separate local/global scopes with shared bm_schema_version table - ClassLoader parameter for addon JAR resource loading Also removes StorageUtils.convertIpColumn() (IPv6 migration). --- .../banmanager/common/BanManagerPlugin.java | 12 + .../common/storage/IpBanRecordStorage.java | 23 -- .../common/storage/IpBanStorage.java | 17 - .../common/storage/IpMuteRecordStorage.java | 17 - .../common/storage/IpMuteStorage.java | 17 - .../storage/IpRangeBanRecordStorage.java | 25 -- .../common/storage/IpRangeBanStorage.java | 19 - .../common/storage/NameBanRecordStorage.java | 10 - .../common/storage/NameBanStorage.java | 15 - .../storage/PlayerBanRecordStorage.java | 20 - .../common/storage/PlayerBanStorage.java | 15 - .../common/storage/PlayerHistoryStorage.java | 30 -- .../common/storage/PlayerKickStorage.java | 5 - .../storage/PlayerMuteRecordStorage.java | 31 -- .../common/storage/PlayerMuteStorage.java | 37 -- .../common/storage/PlayerNoteStorage.java | 5 - .../storage/PlayerReportCommandStorage.java | 8 - .../storage/PlayerReportCommentStorage.java | 8 - .../common/storage/PlayerReportStorage.java | 23 -- .../common/storage/PlayerStorage.java | 8 - .../common/storage/PlayerWarnStorage.java | 29 -- .../common/storage/RollbackStorage.java | 8 - .../global/GlobalIpBanRecordStorage.java | 5 - .../storage/global/GlobalIpBanStorage.java | 8 - .../global/GlobalPlayerBanRecordStorage.java | 5 - .../global/GlobalPlayerBanStorage.java | 8 - .../global/GlobalPlayerMuteRecordStorage.java | 5 - .../global/GlobalPlayerMuteStorage.java | 17 - .../global/GlobalPlayerNoteStorage.java | 5 - .../storage/migration/MigrationRunner.java | 354 ++++++++++++++++++ .../storage/migration/SchemaVersion.java | 38 ++ .../banmanager/common/util/StorageUtils.java | 59 --- .../main/resources/db/global/V1__baseline.sql | 11 + .../main/resources/db/global/migrations.list | 1 + .../main/resources/db/local/V1__baseline.sql | 67 ++++ .../main/resources/db/local/migrations.list | 1 + .../migration/MigrationIntegrationTest.java | 81 ++++ .../migration/MigrationRunnerTest.java | 120 ++++++ 38 files changed, 685 insertions(+), 482 deletions(-) create mode 100644 common/src/main/java/me/confuser/banmanager/common/storage/migration/MigrationRunner.java create mode 100644 common/src/main/java/me/confuser/banmanager/common/storage/migration/SchemaVersion.java create mode 100644 common/src/main/resources/db/global/V1__baseline.sql create mode 100644 common/src/main/resources/db/global/migrations.list create mode 100644 common/src/main/resources/db/local/V1__baseline.sql create mode 100644 common/src/main/resources/db/local/migrations.list create mode 100644 common/src/test/java/me/confuser/banmanager/common/storage/migration/MigrationIntegrationTest.java create mode 100644 common/src/test/java/me/confuser/banmanager/common/storage/migration/MigrationRunnerTest.java diff --git a/common/src/main/java/me/confuser/banmanager/common/BanManagerPlugin.java b/common/src/main/java/me/confuser/banmanager/common/BanManagerPlugin.java index 7752190a3..3e1a6eb54 100644 --- a/common/src/main/java/me/confuser/banmanager/common/BanManagerPlugin.java +++ b/common/src/main/java/me/confuser/banmanager/common/BanManagerPlugin.java @@ -17,6 +17,7 @@ import me.confuser.banmanager.common.runnables.Runner; import me.confuser.banmanager.common.storage.*; import me.confuser.banmanager.common.storage.global.*; +import me.confuser.banmanager.common.storage.migration.MigrationRunner; import me.confuser.banmanager.common.storage.mariadb.MariaDBDatabase; import me.confuser.banmanager.common.storage.mysql.MySQLDatabase; import me.confuser.banmanager.common.util.DriverManagerUtil; @@ -174,6 +175,17 @@ public final void enable() throws Exception { throw new Exception("Unable to connect to database, ensure local is enabled in config and your connection details are correct"); } + ClassLoader cl = MigrationRunner.class.getClassLoader(); + + MigrationRunner localMigrations = new MigrationRunner( + this, localConn, config.getLocalDb(), "local", "players", cl); + localMigrations.migrate(); + + if (globalConn != null) { + MigrationRunner globalMigrations = new MigrationRunner( + this, globalConn, config.getGlobalDb(), "global", "playerBans", cl); + globalMigrations.migrate(); + } setupStorage(); } catch (SQLException e) { diff --git a/common/src/main/java/me/confuser/banmanager/common/storage/IpBanRecordStorage.java b/common/src/main/java/me/confuser/banmanager/common/storage/IpBanRecordStorage.java index 66c97f300..86be9d7b8 100644 --- a/common/src/main/java/me/confuser/banmanager/common/storage/IpBanRecordStorage.java +++ b/common/src/main/java/me/confuser/banmanager/common/storage/IpBanRecordStorage.java @@ -13,7 +13,6 @@ import me.confuser.banmanager.common.ormlite.support.ConnectionSource; import me.confuser.banmanager.common.ormlite.table.DatabaseTableConfig; import me.confuser.banmanager.common.ormlite.table.TableUtils; -import me.confuser.banmanager.common.util.StorageUtils; import java.sql.SQLException; @@ -30,28 +29,6 @@ public IpBanRecordStorage(BanManagerPlugin plugin) throws SQLException { if (!this.isTableExists()) { TableUtils.createTable(connectionSource, tableConfig); - } else { - // Attempt to add new columns - try { - String update = "ALTER TABLE " + tableConfig.getTableName() + " ADD COLUMN `createdReason` VARCHAR(255)"; - executeRawNoArgs(update); - } catch (SQLException e) { - } - try { - executeRawNoArgs("ALTER TABLE " + tableConfig.getTableName() + " ADD COLUMN `silent` TINYINT(1)"); - } catch (SQLException e) { - } - - try { - executeRawNoArgs("ALTER TABLE " + tableConfig.getTableName() - + " CHANGE `created` `created` BIGINT UNSIGNED," - + " CHANGE `pastCreated` `pastCreated` BIGINT UNSIGNED," - + " CHANGE `expired` `expired` BIGINT UNSIGNED" - ); - } catch (SQLException e) { - } - - StorageUtils.convertIpColumn(plugin, tableConfig.getTableName(), "ip"); } } diff --git a/common/src/main/java/me/confuser/banmanager/common/storage/IpBanStorage.java b/common/src/main/java/me/confuser/banmanager/common/storage/IpBanStorage.java index ebc98c0de..3bf1888b4 100644 --- a/common/src/main/java/me/confuser/banmanager/common/storage/IpBanStorage.java +++ b/common/src/main/java/me/confuser/banmanager/common/storage/IpBanStorage.java @@ -18,7 +18,6 @@ import me.confuser.banmanager.common.ormlite.table.DatabaseTableConfig; import me.confuser.banmanager.common.ormlite.table.TableUtils; import me.confuser.banmanager.common.util.IPUtils; -import me.confuser.banmanager.common.util.StorageUtils; import me.confuser.banmanager.common.util.TransactionHelper; import me.confuser.banmanager.common.util.UUIDUtils; @@ -39,22 +38,6 @@ public IpBanStorage(BanManagerPlugin plugin) throws SQLException { if (!this.isTableExists()) { TableUtils.createTable(connectionSource, tableConfig); - } else { - StorageUtils.convertIpColumn(plugin, tableConfig.getTableName(), "ip"); - - try { - executeRawNoArgs("ALTER TABLE " + tableConfig.getTableName() + " ADD COLUMN `silent` TINYINT(1)"); - } catch (SQLException e) { - } - - try { - executeRawNoArgs("ALTER TABLE " + tableConfig.getTableName() - + " CHANGE `created` `created` BIGINT UNSIGNED," - + " CHANGE `updated` `updated` BIGINT UNSIGNED," - + " CHANGE `expires` `expires` BIGINT UNSIGNED" - ); - } catch (SQLException e) { - } } loadAll(); diff --git a/common/src/main/java/me/confuser/banmanager/common/storage/IpMuteRecordStorage.java b/common/src/main/java/me/confuser/banmanager/common/storage/IpMuteRecordStorage.java index 32276a553..50b9a631f 100644 --- a/common/src/main/java/me/confuser/banmanager/common/storage/IpMuteRecordStorage.java +++ b/common/src/main/java/me/confuser/banmanager/common/storage/IpMuteRecordStorage.java @@ -12,7 +12,6 @@ import me.confuser.banmanager.common.ormlite.support.ConnectionSource; import me.confuser.banmanager.common.ormlite.table.DatabaseTableConfig; import me.confuser.banmanager.common.ormlite.table.TableUtils; -import me.confuser.banmanager.common.util.StorageUtils; import java.sql.SQLException; @@ -29,22 +28,6 @@ public IpMuteRecordStorage(BanManagerPlugin plugin) throws SQLException { if (!this.isTableExists()) { TableUtils.createTable(connectionSource, tableConfig); - } else { - StorageUtils.convertIpColumn(plugin, tableConfig.getTableName(), "ip"); - - try { - executeRawNoArgs("ALTER TABLE " + tableConfig.getTableName() + " ADD COLUMN `silent` TINYINT(1)"); - } catch (SQLException e) { - } - - try { - executeRawNoArgs("ALTER TABLE " + tableConfig.getTableName() - + " CHANGE `created` `created` BIGINT UNSIGNED," - + " CHANGE `pastCreated` `pastCreated` BIGINT UNSIGNED," - + " CHANGE `expired` `expired` BIGINT UNSIGNED" - ); - } catch (SQLException e) { - } } } diff --git a/common/src/main/java/me/confuser/banmanager/common/storage/IpMuteStorage.java b/common/src/main/java/me/confuser/banmanager/common/storage/IpMuteStorage.java index fb00264ff..00da52a7f 100644 --- a/common/src/main/java/me/confuser/banmanager/common/storage/IpMuteStorage.java +++ b/common/src/main/java/me/confuser/banmanager/common/storage/IpMuteStorage.java @@ -17,7 +17,6 @@ import me.confuser.banmanager.common.ormlite.table.DatabaseTableConfig; import me.confuser.banmanager.common.ormlite.table.TableUtils; import me.confuser.banmanager.common.util.IPUtils; -import me.confuser.banmanager.common.util.StorageUtils; import me.confuser.banmanager.common.util.TransactionHelper; import me.confuser.banmanager.common.util.UUIDUtils; @@ -36,22 +35,6 @@ public IpMuteStorage(BanManagerPlugin plugin) throws SQLException { if (!this.isTableExists()) { TableUtils.createTable(connectionSource, tableConfig); - } else { - StorageUtils.convertIpColumn(plugin, tableConfig.getTableName(), "ip"); - - try { - executeRawNoArgs("ALTER TABLE " + tableConfig.getTableName() + " ADD COLUMN `silent` TINYINT(1)"); - } catch (SQLException e) { - } - - try { - executeRawNoArgs("ALTER TABLE " + tableConfig.getTableName() - + " CHANGE `created` `created` BIGINT UNSIGNED," - + " CHANGE `updated` `updated` BIGINT UNSIGNED," - + " CHANGE `expires` `expires` BIGINT UNSIGNED" - ); - } catch (SQLException e) { - } } loadAll(); diff --git a/common/src/main/java/me/confuser/banmanager/common/storage/IpRangeBanRecordStorage.java b/common/src/main/java/me/confuser/banmanager/common/storage/IpRangeBanRecordStorage.java index e7a302f13..4ef5c9703 100644 --- a/common/src/main/java/me/confuser/banmanager/common/storage/IpRangeBanRecordStorage.java +++ b/common/src/main/java/me/confuser/banmanager/common/storage/IpRangeBanRecordStorage.java @@ -13,7 +13,6 @@ import me.confuser.banmanager.common.ormlite.support.ConnectionSource; import me.confuser.banmanager.common.ormlite.table.DatabaseTableConfig; import me.confuser.banmanager.common.ormlite.table.TableUtils; -import me.confuser.banmanager.common.util.StorageUtils; import java.sql.SQLException; @@ -30,30 +29,6 @@ public IpRangeBanRecordStorage(BanManagerPlugin plugin) throws SQLException { if (!this.isTableExists()) { TableUtils.createTable(connectionSource, tableConfig); - } else { - // Attempt to add new columns - try { - String update = "ALTER TABLE " + tableConfig.getTableName() + " ADD COLUMN `createdReason` VARCHAR(255)"; - executeRawNoArgs(update); - } catch (SQLException e) { - } - - StorageUtils.convertIpColumn(plugin, tableConfig.getTableName(), "fromIp"); - StorageUtils.convertIpColumn(plugin, tableConfig.getTableName(), "toIp"); - - try { - executeRawNoArgs("ALTER TABLE " + tableConfig.getTableName() + " ADD COLUMN `silent` TINYINT(1)"); - } catch (SQLException e) { - } - - try { - executeRawNoArgs("ALTER TABLE " + tableConfig.getTableName() - + " CHANGE `created` `created` BIGINT UNSIGNED," - + " CHANGE `pastCreated` `pastCreated` BIGINT UNSIGNED," - + " CHANGE `expired` `expired` BIGINT UNSIGNED" - ); - } catch (SQLException e) { - } } } diff --git a/common/src/main/java/me/confuser/banmanager/common/storage/IpRangeBanStorage.java b/common/src/main/java/me/confuser/banmanager/common/storage/IpRangeBanStorage.java index a9732c2b9..4acd2b3b7 100644 --- a/common/src/main/java/me/confuser/banmanager/common/storage/IpRangeBanStorage.java +++ b/common/src/main/java/me/confuser/banmanager/common/storage/IpRangeBanStorage.java @@ -19,7 +19,6 @@ import me.confuser.banmanager.common.ormlite.table.DatabaseTableConfig; import me.confuser.banmanager.common.ormlite.table.TableUtils; import me.confuser.banmanager.common.util.IPUtils; -import me.confuser.banmanager.common.util.StorageUtils; import me.confuser.banmanager.common.util.TransactionHelper; import me.confuser.banmanager.common.util.UUIDUtils; @@ -40,24 +39,6 @@ public IpRangeBanStorage(BanManagerPlugin plugin) throws SQLException { if (!this.isTableExists()) { TableUtils.createTable(connectionSource, tableConfig); - return; - } else { - StorageUtils.convertIpColumn(plugin, tableConfig.getTableName(), "fromIp"); - StorageUtils.convertIpColumn(plugin, tableConfig.getTableName(), "toIp"); - - try { - executeRawNoArgs("ALTER TABLE " + tableConfig.getTableName() + " ADD COLUMN `silent` TINYINT(1)"); - } catch (SQLException e) { - } - - try { - executeRawNoArgs("ALTER TABLE " + tableConfig.getTableName() - + " CHANGE `created` `created` BIGINT UNSIGNED," - + " CHANGE `updated` `updated` BIGINT UNSIGNED," - + " CHANGE `expires` `expires` BIGINT UNSIGNED" - ); - } catch (SQLException e) { - } } loadAll(); diff --git a/common/src/main/java/me/confuser/banmanager/common/storage/NameBanRecordStorage.java b/common/src/main/java/me/confuser/banmanager/common/storage/NameBanRecordStorage.java index 788f7d70d..d82a7108d 100644 --- a/common/src/main/java/me/confuser/banmanager/common/storage/NameBanRecordStorage.java +++ b/common/src/main/java/me/confuser/banmanager/common/storage/NameBanRecordStorage.java @@ -26,16 +26,6 @@ public NameBanRecordStorage(BanManagerPlugin plugin) throws SQLException { if (!this.isTableExists()) { TableUtils.createTable(connectionSource, tableConfig); - return; - } else { - try { - executeRawNoArgs("ALTER TABLE " + tableConfig.getTableName() - + " CHANGE `created` `created` BIGINT UNSIGNED," - + " CHANGE `pastCreated` `pastCreated` BIGINT UNSIGNED," - + " CHANGE `expired` `expired` BIGINT UNSIGNED" - ); - } catch (SQLException e) { - } } } diff --git a/common/src/main/java/me/confuser/banmanager/common/storage/NameBanStorage.java b/common/src/main/java/me/confuser/banmanager/common/storage/NameBanStorage.java index 62b8b7eb4..c107b7b39 100644 --- a/common/src/main/java/me/confuser/banmanager/common/storage/NameBanStorage.java +++ b/common/src/main/java/me/confuser/banmanager/common/storage/NameBanStorage.java @@ -32,21 +32,6 @@ public NameBanStorage(BanManagerPlugin plugin) throws SQLException { if (!this.isTableExists()) { TableUtils.createTable(connectionSource, tableConfig); - return; - } else { - try { - executeRawNoArgs("ALTER TABLE " + tableConfig.getTableName() + " ADD COLUMN `silent` TINYINT(1)"); - } catch (SQLException e) { - } - - try { - executeRawNoArgs("ALTER TABLE " + tableConfig.getTableName() - + " CHANGE `created` `created` BIGINT UNSIGNED," - + " CHANGE `updated` `updated` BIGINT UNSIGNED," - + " CHANGE `expires` `expires` BIGINT UNSIGNED" - ); - } catch (SQLException e) { - } } loadAll(); diff --git a/common/src/main/java/me/confuser/banmanager/common/storage/PlayerBanRecordStorage.java b/common/src/main/java/me/confuser/banmanager/common/storage/PlayerBanRecordStorage.java index 6cbaf3370..adf67d76a 100644 --- a/common/src/main/java/me/confuser/banmanager/common/storage/PlayerBanRecordStorage.java +++ b/common/src/main/java/me/confuser/banmanager/common/storage/PlayerBanRecordStorage.java @@ -29,26 +29,6 @@ public PlayerBanRecordStorage(BanManagerPlugin plugin) throws SQLException { if (!this.isTableExists()) { TableUtils.createTable(connectionSource, tableConfig); - return; - } else { - // Attempt to add new columns - try { - executeRawNoArgs("ALTER TABLE " + tableConfig.getTableName() + " ADD COLUMN `createdReason` VARCHAR(255)"); - } catch (SQLException e) { - } - try { - executeRawNoArgs("ALTER TABLE " + tableConfig.getTableName() + " ADD COLUMN `silent` TINYINT(1)"); - } catch (SQLException e) { - } - - try { - executeRawNoArgs("ALTER TABLE " + tableConfig.getTableName() - + " CHANGE `created` `created` BIGINT UNSIGNED," - + " CHANGE `pastCreated` `pastCreated` BIGINT UNSIGNED," - + " CHANGE `expired` `expired` BIGINT UNSIGNED" - ); - } catch (SQLException e) { - } } } diff --git a/common/src/main/java/me/confuser/banmanager/common/storage/PlayerBanStorage.java b/common/src/main/java/me/confuser/banmanager/common/storage/PlayerBanStorage.java index 138e0bb59..13f790aee 100644 --- a/common/src/main/java/me/confuser/banmanager/common/storage/PlayerBanStorage.java +++ b/common/src/main/java/me/confuser/banmanager/common/storage/PlayerBanStorage.java @@ -37,21 +37,6 @@ public PlayerBanStorage(BanManagerPlugin plugin) throws SQLException { if (!this.isTableExists()) { TableUtils.createTable(connectionSource, tableConfig); - return; - } else { - try { - executeRawNoArgs("ALTER TABLE " + tableConfig.getTableName() + " ADD COLUMN `silent` TINYINT(1)"); - } catch (SQLException e) { - } - - try { - executeRawNoArgs("ALTER TABLE " + tableConfig.getTableName() - + " CHANGE `created` `created` BIGINT UNSIGNED," - + " CHANGE `updated` `updated` BIGINT UNSIGNED," - + " CHANGE `expires` `expires` BIGINT UNSIGNED" - ); - } catch (SQLException e) { - } } loadAll(); diff --git a/common/src/main/java/me/confuser/banmanager/common/storage/PlayerHistoryStorage.java b/common/src/main/java/me/confuser/banmanager/common/storage/PlayerHistoryStorage.java index e66e8b431..b463b8ece 100644 --- a/common/src/main/java/me/confuser/banmanager/common/storage/PlayerHistoryStorage.java +++ b/common/src/main/java/me/confuser/banmanager/common/storage/PlayerHistoryStorage.java @@ -11,7 +11,6 @@ import me.confuser.banmanager.common.ormlite.support.ConnectionSource; import me.confuser.banmanager.common.ormlite.table.DatabaseTableConfig; import me.confuser.banmanager.common.ormlite.table.TableUtils; -import me.confuser.banmanager.common.util.StorageUtils; import java.sql.SQLException; import java.util.ArrayList; @@ -37,35 +36,6 @@ public PlayerHistoryStorage(BanManagerPlugin plugin) throws SQLException { if (!this.isTableExists()) { TableUtils.createTable(connectionSource, tableConfig); - } else { - try { - executeRawNoArgs("ALTER TABLE " + tableConfig.getTableName() - + " CHANGE `join` `join` BIGINT UNSIGNED," - + " CHANGE `leave` `leave` BIGINT UNSIGNED" - ); - } catch (SQLException e) { - } - - try { - executeRawNoArgs("ALTER TABLE " + tableConfig.getTableName() - + " MODIFY `ip` VARBINARY(16) NULL" - ); - } catch (SQLException e) { - } - - try { - executeRawNoArgs("ALTER TABLE " + tableConfig.getTableName() - + " ADD COLUMN `name` VARCHAR(16) NOT NULL DEFAULT '' AFTER `player_id`" - ); - } catch (SQLException e) { - } - - try { - executeRawNoArgs("CREATE INDEX idx_playerhistory_name ON " + tableConfig.getTableName() + " (name)"); - } catch (SQLException e) { - } - - StorageUtils.convertIpColumn(plugin, tableConfig.getTableName(), "ip"); } } diff --git a/common/src/main/java/me/confuser/banmanager/common/storage/PlayerKickStorage.java b/common/src/main/java/me/confuser/banmanager/common/storage/PlayerKickStorage.java index 08de526b3..6a071f3c8 100644 --- a/common/src/main/java/me/confuser/banmanager/common/storage/PlayerKickStorage.java +++ b/common/src/main/java/me/confuser/banmanager/common/storage/PlayerKickStorage.java @@ -24,11 +24,6 @@ public PlayerKickStorage(BanManagerPlugin plugin) throws SQLException { if (!this.isTableExists()) { TableUtils.createTable(connectionSource, tableConfig); - } else { - try { - executeRawNoArgs("ALTER TABLE " + tableConfig.getTableName() + " CHANGE `created` `created` BIGINT UNSIGNED"); - } catch (SQLException e) { - } } } diff --git a/common/src/main/java/me/confuser/banmanager/common/storage/PlayerMuteRecordStorage.java b/common/src/main/java/me/confuser/banmanager/common/storage/PlayerMuteRecordStorage.java index e97fd74c1..97b396b6c 100644 --- a/common/src/main/java/me/confuser/banmanager/common/storage/PlayerMuteRecordStorage.java +++ b/common/src/main/java/me/confuser/banmanager/common/storage/PlayerMuteRecordStorage.java @@ -29,37 +29,6 @@ public PlayerMuteRecordStorage(BanManagerPlugin plugin) throws SQLException { if (!this.isTableExists()) { TableUtils.createTable(connectionSource, tableConfig); - } else { - // Attempt to add new columns - try { - String update = "ALTER TABLE " + tableConfig.getTableName() + " ADD COLUMN `createdReason` VARCHAR(255), " - + " ADD COLUMN `soft` TINYINT(1)," + - " ADD KEY `" + tableConfig.getTableName() + "_soft_idx` (`soft`)"; - executeRawNoArgs(update); - } catch (SQLException e) { - } - try { - executeRawNoArgs("ALTER TABLE " + tableConfig.getTableName() + " ADD COLUMN `silent` TINYINT(1)"); - } catch (SQLException e) { - } - - try { - executeRawNoArgs("ALTER TABLE " + tableConfig.getTableName() - + " CHANGE `created` `created` BIGINT UNSIGNED," - + " CHANGE `pastCreated` `pastCreated` BIGINT UNSIGNED," - + " CHANGE `expired` `expired` BIGINT UNSIGNED" - ); - } catch (SQLException e) { - } - - try { - executeRawNoArgs("ALTER TABLE " + tableConfig.getTableName() + " ADD COLUMN `onlineOnly` TINYINT(1) NOT NULL DEFAULT 0"); - } catch (SQLException e) { - } - try { - executeRawNoArgs("ALTER TABLE " + tableConfig.getTableName() + " ADD COLUMN `remainingOnlineTime` BIGINT UNSIGNED NOT NULL DEFAULT 0"); - } catch (SQLException e) { - } } } diff --git a/common/src/main/java/me/confuser/banmanager/common/storage/PlayerMuteStorage.java b/common/src/main/java/me/confuser/banmanager/common/storage/PlayerMuteStorage.java index 0d670a863..e01d2455a 100644 --- a/common/src/main/java/me/confuser/banmanager/common/storage/PlayerMuteStorage.java +++ b/common/src/main/java/me/confuser/banmanager/common/storage/PlayerMuteStorage.java @@ -34,43 +34,6 @@ public PlayerMuteStorage(BanManagerPlugin plugin) throws SQLException { if (!this.isTableExists()) { TableUtils.createTable(connectionSource, tableConfig); - } else { - // Attempt to add new columns - try { - String update = "ALTER TABLE " + tableConfig - .getTableName() + " ADD COLUMN `soft` TINYINT(1)," + - " ADD KEY `" + tableConfig.getTableName() + "_soft_idx` (`soft`)"; - executeRawNoArgs(update); - } catch (SQLException e) { - } - try { - String update = "ALTER TABLE " + tableConfig - .getTableName() + " ADD UNIQUE KEY `" + tableConfig.getTableName() + "_player_idx` (`player_id`)"; - executeRawNoArgs(update); - } catch (SQLException e) { - } - try { - executeRawNoArgs("ALTER TABLE " + tableConfig.getTableName() + " ADD COLUMN `silent` TINYINT(1)"); - } catch (SQLException e) { - } - - try { - executeRawNoArgs("ALTER TABLE " + tableConfig.getTableName() - + " CHANGE `created` `created` BIGINT UNSIGNED," - + " CHANGE `updated` `updated` BIGINT UNSIGNED," - + " CHANGE `expires` `expires` BIGINT UNSIGNED" - ); - } catch (SQLException e) { - } - - try { - executeRawNoArgs("ALTER TABLE " + tableConfig.getTableName() + " ADD COLUMN `onlineOnly` TINYINT(1) NOT NULL DEFAULT 0"); - } catch (SQLException e) { - } - try { - executeRawNoArgs("ALTER TABLE " + tableConfig.getTableName() + " ADD COLUMN `pausedRemaining` BIGINT UNSIGNED NOT NULL DEFAULT 0"); - } catch (SQLException e) { - } } loadAll(); diff --git a/common/src/main/java/me/confuser/banmanager/common/storage/PlayerNoteStorage.java b/common/src/main/java/me/confuser/banmanager/common/storage/PlayerNoteStorage.java index a9ebd79a3..92c446fa1 100644 --- a/common/src/main/java/me/confuser/banmanager/common/storage/PlayerNoteStorage.java +++ b/common/src/main/java/me/confuser/banmanager/common/storage/PlayerNoteStorage.java @@ -27,11 +27,6 @@ public PlayerNoteStorage(BanManagerPlugin plugin) throws SQLException { if (!this.isTableExists()) { TableUtils.createTable(connectionSource, tableConfig); - } else { - try { - executeRawNoArgs("ALTER TABLE " + tableConfig.getTableName() + " CHANGE `created` `created` BIGINT UNSIGNED"); - } catch (SQLException e) { - } } } diff --git a/common/src/main/java/me/confuser/banmanager/common/storage/PlayerReportCommandStorage.java b/common/src/main/java/me/confuser/banmanager/common/storage/PlayerReportCommandStorage.java index 9a133df7b..bb7037831 100644 --- a/common/src/main/java/me/confuser/banmanager/common/storage/PlayerReportCommandStorage.java +++ b/common/src/main/java/me/confuser/banmanager/common/storage/PlayerReportCommandStorage.java @@ -18,14 +18,6 @@ public PlayerReportCommandStorage(BanManagerPlugin plugin) throws SQLException { if (!this.isTableExists()) { TableUtils.createTable(connectionSource, tableConfig); - } else { - try { - executeRawNoArgs("ALTER TABLE " + tableConfig.getTableName() - + " CHANGE `created` `created` BIGINT UNSIGNED," - + " CHANGE `updated` `updated` BIGINT UNSIGNED" - ); - } catch (SQLException e) { - } } } diff --git a/common/src/main/java/me/confuser/banmanager/common/storage/PlayerReportCommentStorage.java b/common/src/main/java/me/confuser/banmanager/common/storage/PlayerReportCommentStorage.java index 165650f0f..6987a1343 100644 --- a/common/src/main/java/me/confuser/banmanager/common/storage/PlayerReportCommentStorage.java +++ b/common/src/main/java/me/confuser/banmanager/common/storage/PlayerReportCommentStorage.java @@ -18,14 +18,6 @@ public PlayerReportCommentStorage(BanManagerPlugin plugin) throws SQLException { if (!this.isTableExists()) { TableUtils.createTable(connectionSource, tableConfig); - } else { - try { - executeRawNoArgs("ALTER TABLE " + tableConfig.getTableName() - + " CHANGE `created` `created` BIGINT UNSIGNED," - + " CHANGE `updated` `updated` BIGINT UNSIGNED" - ); - } catch (SQLException e) { - } } } diff --git a/common/src/main/java/me/confuser/banmanager/common/storage/PlayerReportStorage.java b/common/src/main/java/me/confuser/banmanager/common/storage/PlayerReportStorage.java index 103a40ecc..97b737dcd 100644 --- a/common/src/main/java/me/confuser/banmanager/common/storage/PlayerReportStorage.java +++ b/common/src/main/java/me/confuser/banmanager/common/storage/PlayerReportStorage.java @@ -25,29 +25,6 @@ public PlayerReportStorage(BanManagerPlugin plugin) throws SQLException { if (!this.isTableExists()) { TableUtils.createTable(connectionSource, tableConfig); - } else { - try { - String update = "ALTER TABLE " + tableConfig - .getTableName() + " ADD COLUMN `state_id` INT(11) NOT NULL DEFAULT 1," + - " ADD COLUMN `assignee_id` BINARY(16)," + - " ADD KEY `" + tableConfig.getTableName() + "_state_id_idx` (`state_id`)," + - " ADD KEY `" + tableConfig.getTableName() + "_assignee_id_idx` (`assignee_id`)"; - executeRawNoArgs(update); - } catch (SQLException e) { - } - try { - String update = "ALTER TABLE " + tableConfig.getTableName() + " MODIFY assignee_id BINARY(16) NULL"; - executeRawNoArgs(update); - } catch (SQLException e) { - } - - try { - executeRawNoArgs("ALTER TABLE " + tableConfig.getTableName() - + " CHANGE `created` `created` BIGINT UNSIGNED," - + " CHANGE `updated` `updated` BIGINT UNSIGNED" - ); - } catch (SQLException e) { - } } } diff --git a/common/src/main/java/me/confuser/banmanager/common/storage/PlayerStorage.java b/common/src/main/java/me/confuser/banmanager/common/storage/PlayerStorage.java index f66ae2335..17d8e56eb 100644 --- a/common/src/main/java/me/confuser/banmanager/common/storage/PlayerStorage.java +++ b/common/src/main/java/me/confuser/banmanager/common/storage/PlayerStorage.java @@ -16,7 +16,6 @@ import me.confuser.banmanager.common.ormlite.support.ConnectionSource; import me.confuser.banmanager.common.ormlite.table.DatabaseTableConfig; import me.confuser.banmanager.common.ormlite.table.TableUtils; -import me.confuser.banmanager.common.util.StorageUtils; import me.confuser.banmanager.common.util.UUIDProfile; import me.confuser.banmanager.common.util.UUIDUtils; @@ -42,13 +41,6 @@ public PlayerStorage(BanManagerPlugin plugin) throws SQLException { if (!isTableExists()) { TableUtils.createTable(connectionSource, tableConfig); - } else { - try { - executeRawNoArgs("ALTER TABLE " + tableConfig.getTableName() + " CHANGE `lastSeen` `lastSeen` BIGINT UNSIGNED"); - } catch (SQLException e) { - } - - StorageUtils.convertIpColumn(plugin, tableConfig.getTableName(), "ip", "bytes"); } setupConsole(); diff --git a/common/src/main/java/me/confuser/banmanager/common/storage/PlayerWarnStorage.java b/common/src/main/java/me/confuser/banmanager/common/storage/PlayerWarnStorage.java index dd48345dc..5f6927d2d 100644 --- a/common/src/main/java/me/confuser/banmanager/common/storage/PlayerWarnStorage.java +++ b/common/src/main/java/me/confuser/banmanager/common/storage/PlayerWarnStorage.java @@ -45,35 +45,6 @@ public PlayerWarnStorage(BanManagerPlugin plugin) throws SQLException { if (!this.isTableExists()) { TableUtils.createTable(connectionSource, tableConfig); - } else { - // Attempt to add new columns - try { - String update = "ALTER TABLE " + tableConfig - .getTableName() + " ADD COLUMN `expires` INT(10) NOT NULL DEFAULT 0," + - " ADD KEY `" + tableConfig.getTableName() + "_expires_idx` (`expires`)"; - executeRawNoArgs(update); - } catch (SQLException e) { - } - try { - String update = "ALTER TABLE " + tableConfig - .getTableName() + " ADD COLUMN `points` INT(10) NOT NULL DEFAULT 1," + - " ADD KEY `" + tableConfig.getTableName() + "_points_idx` (`points`)"; - executeRawNoArgs(update); - } catch (SQLException e) { - } - try { - String update = "ALTER TABLE " + tableConfig - .getTableName() + " MODIFY COLUMN `points` DECIMAL(60,2) NOT NULL DEFAULT 1"; - executeRawNoArgs(update); - } catch (SQLException e) { - } - try { - executeRawNoArgs("ALTER TABLE " + tableConfig.getTableName() - + " CHANGE `created` `created` BIGINT UNSIGNED," - + " CHANGE `expires` `expires` BIGINT UNSIGNED" - ); - } catch (SQLException e) { - } } } diff --git a/common/src/main/java/me/confuser/banmanager/common/storage/RollbackStorage.java b/common/src/main/java/me/confuser/banmanager/common/storage/RollbackStorage.java index 913245c75..1f7136567 100644 --- a/common/src/main/java/me/confuser/banmanager/common/storage/RollbackStorage.java +++ b/common/src/main/java/me/confuser/banmanager/common/storage/RollbackStorage.java @@ -23,14 +23,6 @@ public RollbackStorage(BanManagerPlugin plugin) throws SQLException { if (!this.isTableExists()) { TableUtils.createTable(connectionSource, tableConfig); - } else { - try { - executeRawNoArgs("ALTER TABLE " + tableConfig.getTableName() - + " CHANGE `created` `created` BIGINT UNSIGNED," - + " CHANGE `expires` `expires` BIGINT UNSIGNED" - ); - } catch (SQLException e) { - } } } diff --git a/common/src/main/java/me/confuser/banmanager/common/storage/global/GlobalIpBanRecordStorage.java b/common/src/main/java/me/confuser/banmanager/common/storage/global/GlobalIpBanRecordStorage.java index af0d222f0..f46db8457 100644 --- a/common/src/main/java/me/confuser/banmanager/common/storage/global/GlobalIpBanRecordStorage.java +++ b/common/src/main/java/me/confuser/banmanager/common/storage/global/GlobalIpBanRecordStorage.java @@ -24,11 +24,6 @@ public GlobalIpBanRecordStorage(BanManagerPlugin plugin) throws SQLException { if (!this.isTableExists()) { TableUtils.createTable(connectionSource, tableConfig); - } else { - try { - executeRawNoArgs("ALTER TABLE " + tableConfig.getTableName() + " CHANGE `created` `created` BIGINT UNSIGNED"); - } catch (SQLException e) { - } } } diff --git a/common/src/main/java/me/confuser/banmanager/common/storage/global/GlobalIpBanStorage.java b/common/src/main/java/me/confuser/banmanager/common/storage/global/GlobalIpBanStorage.java index 925570680..d9aeadd77 100644 --- a/common/src/main/java/me/confuser/banmanager/common/storage/global/GlobalIpBanStorage.java +++ b/common/src/main/java/me/confuser/banmanager/common/storage/global/GlobalIpBanStorage.java @@ -23,14 +23,6 @@ public GlobalIpBanStorage(BanManagerPlugin plugin) throws SQLException { if (!this.isTableExists()) { TableUtils.createTable(connectionSource, tableConfig); - } else { - try { - executeRawNoArgs("ALTER TABLE " + tableConfig.getTableName() - + " CHANGE `created` `created` BIGINT UNSIGNED," - + " CHANGE `expires` `expires` BIGINT UNSIGNED" - ); - } catch (SQLException e) { - } } } diff --git a/common/src/main/java/me/confuser/banmanager/common/storage/global/GlobalPlayerBanRecordStorage.java b/common/src/main/java/me/confuser/banmanager/common/storage/global/GlobalPlayerBanRecordStorage.java index e527f4c34..aaca16829 100644 --- a/common/src/main/java/me/confuser/banmanager/common/storage/global/GlobalPlayerBanRecordStorage.java +++ b/common/src/main/java/me/confuser/banmanager/common/storage/global/GlobalPlayerBanRecordStorage.java @@ -24,11 +24,6 @@ public GlobalPlayerBanRecordStorage(BanManagerPlugin plugin) throws SQLException if (!this.isTableExists()) { TableUtils.createTable(connectionSource, tableConfig); - } else { - try { - executeRawNoArgs("ALTER TABLE " + tableConfig.getTableName() + " CHANGE `created` `created` BIGINT UNSIGNED"); - } catch (SQLException e) { - } } } diff --git a/common/src/main/java/me/confuser/banmanager/common/storage/global/GlobalPlayerBanStorage.java b/common/src/main/java/me/confuser/banmanager/common/storage/global/GlobalPlayerBanStorage.java index 37b23eec4..0daf4243e 100644 --- a/common/src/main/java/me/confuser/banmanager/common/storage/global/GlobalPlayerBanStorage.java +++ b/common/src/main/java/me/confuser/banmanager/common/storage/global/GlobalPlayerBanStorage.java @@ -24,14 +24,6 @@ public GlobalPlayerBanStorage(BanManagerPlugin plugin) throws SQLException { if (!this.isTableExists()) { TableUtils.createTable(connectionSource, tableConfig); - } else { - try { - executeRawNoArgs("ALTER TABLE " + tableConfig.getTableName() - + " CHANGE `created` `created` BIGINT UNSIGNED," - + " CHANGE `expires` `expires` BIGINT UNSIGNED" - ); - } catch (SQLException e) { - } } } diff --git a/common/src/main/java/me/confuser/banmanager/common/storage/global/GlobalPlayerMuteRecordStorage.java b/common/src/main/java/me/confuser/banmanager/common/storage/global/GlobalPlayerMuteRecordStorage.java index 7c1685289..1348a69af 100644 --- a/common/src/main/java/me/confuser/banmanager/common/storage/global/GlobalPlayerMuteRecordStorage.java +++ b/common/src/main/java/me/confuser/banmanager/common/storage/global/GlobalPlayerMuteRecordStorage.java @@ -24,11 +24,6 @@ public GlobalPlayerMuteRecordStorage(BanManagerPlugin plugin) throws SQLExceptio if (!this.isTableExists()) { TableUtils.createTable(connectionSource, tableConfig); - } else { - try { - executeRawNoArgs("ALTER TABLE " + tableConfig.getTableName() + " CHANGE `created` `created` BIGINT UNSIGNED"); - } catch (SQLException e) { - } } } diff --git a/common/src/main/java/me/confuser/banmanager/common/storage/global/GlobalPlayerMuteStorage.java b/common/src/main/java/me/confuser/banmanager/common/storage/global/GlobalPlayerMuteStorage.java index e2182b04e..4c97646c3 100644 --- a/common/src/main/java/me/confuser/banmanager/common/storage/global/GlobalPlayerMuteStorage.java +++ b/common/src/main/java/me/confuser/banmanager/common/storage/global/GlobalPlayerMuteStorage.java @@ -24,23 +24,6 @@ public GlobalPlayerMuteStorage(BanManagerPlugin plugin) throws SQLException { if (!this.isTableExists()) { TableUtils.createTable(connectionSource, tableConfig); - } else { - // Attempt to add new columns - try { - String update = "ALTER TABLE " + tableConfig - .getTableName() + " ADD COLUMN `soft` TINYINT(1)," + - " ADD KEY `" + tableConfig.getTableName() + "_soft_idx` (`soft`)"; - executeRawNoArgs(update); - } catch (SQLException e) { - } - - try { - executeRawNoArgs("ALTER TABLE " + tableConfig.getTableName() - + " CHANGE `created` `created` BIGINT UNSIGNED," - + " CHANGE `expires` `expires` BIGINT UNSIGNED" - ); - } catch (SQLException e) { - } } } diff --git a/common/src/main/java/me/confuser/banmanager/common/storage/global/GlobalPlayerNoteStorage.java b/common/src/main/java/me/confuser/banmanager/common/storage/global/GlobalPlayerNoteStorage.java index 907985f53..a7c8160b8 100644 --- a/common/src/main/java/me/confuser/banmanager/common/storage/global/GlobalPlayerNoteStorage.java +++ b/common/src/main/java/me/confuser/banmanager/common/storage/global/GlobalPlayerNoteStorage.java @@ -24,11 +24,6 @@ public GlobalPlayerNoteStorage(BanManagerPlugin plugin) throws SQLException { if (!this.isTableExists()) { TableUtils.createTable(connectionSource, tableConfig); - } else { - try { - executeRawNoArgs("ALTER TABLE " + tableConfig.getTableName() + " CHANGE `created` `created` BIGINT UNSIGNED"); - } catch (SQLException e) { - } } } diff --git a/common/src/main/java/me/confuser/banmanager/common/storage/migration/MigrationRunner.java b/common/src/main/java/me/confuser/banmanager/common/storage/migration/MigrationRunner.java new file mode 100644 index 000000000..84d677c41 --- /dev/null +++ b/common/src/main/java/me/confuser/banmanager/common/storage/migration/MigrationRunner.java @@ -0,0 +1,354 @@ +package me.confuser.banmanager.common.storage.migration; + +import me.confuser.banmanager.common.BanManagerPlugin; +import me.confuser.banmanager.common.configs.DatabaseConfig; +import me.confuser.banmanager.common.ormlite.field.SqlType; +import me.confuser.banmanager.common.ormlite.stmt.StatementBuilder; +import me.confuser.banmanager.common.ormlite.support.CompiledStatement; +import me.confuser.banmanager.common.ormlite.support.ConnectionSource; +import me.confuser.banmanager.common.ormlite.support.DatabaseConnection; +import me.confuser.banmanager.common.ormlite.support.DatabaseResults; +import me.confuser.banmanager.common.ormlite.table.TableUtils; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class MigrationRunner { + + private static final Pattern VERSION_PATTERN = Pattern.compile("^V(\\d+)__(.+)\\.sql$"); + static final String SCHEMA_TABLE = "bm_schema_version"; + + private final BanManagerPlugin plugin; + private final ConnectionSource connectionSource; + private final DatabaseConfig dbConfig; + private final String scope; + private final String detectionTableKey; + private final ClassLoader resourceLoader; + + public MigrationRunner(BanManagerPlugin plugin, ConnectionSource connectionSource, + DatabaseConfig dbConfig, String scope, String detectionTableKey, + ClassLoader resourceLoader) { + this.plugin = plugin; + this.connectionSource = connectionSource; + this.dbConfig = dbConfig; + this.scope = scope; + this.detectionTableKey = detectionTableKey; + this.resourceLoader = resourceLoader; + } + + public void migrate() throws SQLException { + List migrations = loadManifest(); + if (migrations.isEmpty()) { + plugin.getLogger().info("[Migration:" + scope + "] No migrations found in manifest"); + return; + } + + int latestVersion = migrations.get(migrations.size() - 1).version; + boolean isH2 = dbConfig.getStorageType().equals("h2"); + + DatabaseConnection conn = connectionSource.getReadWriteConnection(""); + try { + if (!isH2) { + acquireAdvisoryLock(conn); + } + + try { + TableUtils.createTableIfNotExists(connectionSource, SchemaVersion.class); + + String detectionTableName = dbConfig.getTable(detectionTableKey).getTableName(); + if (!tableExists(conn, detectionTableName)) { + plugin.getLogger().info("[Migration:" + scope + "] Fresh install detected, marking schema at V" + latestVersion); + insertVersion(conn, latestVersion, "baseline (fresh install)"); + return; + } + + int currentVersion = getCurrentVersion(conn); + + if (currentVersion == 0) { + plugin.getLogger().info("[Migration:" + scope + "] Existing install detected, marking V1 as baseline"); + insertVersion(conn, 1, "baseline (existing install)"); + currentVersion = 1; + } + + int applied = 0; + for (MigrationFile migration : migrations) { + if (migration.version <= currentVersion) { + continue; + } + + plugin.getLogger().info("[Migration:" + scope + "] Applying V" + migration.version + " " + migration.description); + String sql = loadSqlFile(migration.filename); + if (sql.isEmpty()) { + throw new SQLException("[Migration:" + scope + "] Migration file not found or empty: " + migration.filename); + } + sql = substitutePlaceholders(sql); + executeMigrationStatements(conn, sql); + insertVersion(conn, migration.version, migration.description); + applied++; + } + + if (applied > 0) { + plugin.getLogger().info("[Migration:" + scope + "] Applied " + applied + " migration(s)"); + } + } finally { + if (!isH2) { + releaseAdvisoryLock(conn); + } + } + } finally { + connectionSource.releaseConnection(conn); + } + } + + private void acquireAdvisoryLock(DatabaseConnection conn) throws SQLException { + CompiledStatement stmt = conn.compileStatement( + "SELECT GET_LOCK('bm_migration_" + scope + "', 30)", + StatementBuilder.StatementType.SELECT, null, + DatabaseConnection.DEFAULT_RESULT_FLAGS, false); + DatabaseResults results = stmt.runQuery(null); + if (!results.next() || results.getInt(0) != 1) { + throw new SQLException("[Migration:" + scope + "] Could not acquire advisory lock (another server may be migrating)"); + } + } + + private void releaseAdvisoryLock(DatabaseConnection conn) { + try { + conn.executeStatement("SELECT RELEASE_LOCK('bm_migration_" + scope + "')", + DatabaseConnection.DEFAULT_RESULT_FLAGS); + } catch (SQLException e) { + plugin.getLogger().warning("[Migration:" + scope + "] Failed to release advisory lock", e); + } + } + + private List loadManifest() { + List migrations = new ArrayList<>(); + String manifestPath = "db/" + scope + "/migrations.list"; + + try (InputStream is = resourceLoader.getResourceAsStream(manifestPath)) { + if (is == null) { + plugin.getLogger().warning("[Migration:" + scope + "] No manifest found at " + manifestPath); + return migrations; + } + + try (BufferedReader reader = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + line = line.trim(); + if (line.isEmpty() || line.startsWith("#")) { + continue; + } + + Matcher matcher = VERSION_PATTERN.matcher(line); + if (matcher.matches()) { + int version = Integer.parseInt(matcher.group(1)); + String description = matcher.group(2).replace('_', ' '); + migrations.add(new MigrationFile(line, version, description)); + } else { + plugin.getLogger().warning("[Migration:" + scope + "] Skipping invalid manifest entry: " + line); + } + } + } + } catch (IOException e) { + plugin.getLogger().warning("[Migration:" + scope + "] Failed to read manifest: " + e.getMessage()); + } + + migrations.sort(Comparator.comparingInt(m -> m.version)); + return migrations; + } + + private boolean tableExists(DatabaseConnection conn, String tableName) { + try { + conn.executeStatement("SELECT 1 FROM `" + tableName + "` LIMIT 1", + DatabaseConnection.DEFAULT_RESULT_FLAGS); + return true; + } catch (SQLException e) { + return false; + } + } + + private int getCurrentVersion(DatabaseConnection conn) throws SQLException { + try { + CompiledStatement stmt = conn.compileStatement( + "SELECT COALESCE(MAX(version), 0) FROM " + SCHEMA_TABLE + " WHERE scope = ?", + StatementBuilder.StatementType.SELECT, null, + DatabaseConnection.DEFAULT_RESULT_FLAGS, false); + stmt.setObject(0, scope, SqlType.STRING); + DatabaseResults results = stmt.runQuery(null); + if (results.next()) { + return results.getInt(0); + } + } catch (SQLException e) { + // Table may not exist yet + } + return 0; + } + + private String loadSqlFile(String filename) { + String path = "db/" + scope + "/" + filename; + + try (InputStream is = resourceLoader.getResourceAsStream(path)) { + if (is == null) { + plugin.getLogger().warning("[Migration:" + scope + "] SQL file not found: " + path); + return ""; + } + + byte[] bytes = readAllBytes(is); + return new String(bytes, StandardCharsets.UTF_8); + } catch (IOException e) { + plugin.getLogger().warning("[Migration:" + scope + "] Failed to read SQL file: " + path); + return ""; + } + } + + private String substitutePlaceholders(String sql) { + for (Map.Entry> entry + : dbConfig.getTables().entrySet()) { + sql = sql.replace("${" + entry.getKey() + "}", entry.getValue().getTableName()); + } + return sql; + } + + private void executeMigrationStatements(DatabaseConnection conn, String sql) { + List statements = splitStatements(sql); + + for (String statement : statements) { + try { + conn.executeStatement(statement, DatabaseConnection.DEFAULT_RESULT_FLAGS); + } catch (SQLException e) { + plugin.getLogger().warning("[Migration:" + scope + "] Statement failed (continuing): " + e.getMessage()); + } + } + } + + static List splitStatements(String sql) { + List statements = new ArrayList<>(); + StringBuilder current = new StringBuilder(); + boolean inSingleQuote = false; + boolean inDoubleQuote = false; + boolean inLineComment = false; + boolean inBlockComment = false; + + for (int i = 0; i < sql.length(); i++) { + char c = sql.charAt(i); + char next = (i + 1 < sql.length()) ? sql.charAt(i + 1) : '\0'; + + if (inLineComment) { + if (c == '\n') { + inLineComment = false; + current.append(c); + } + continue; + } + + if (inBlockComment) { + if (c == '*' && next == '/') { + inBlockComment = false; + i++; + } + continue; + } + + if (c == '-' && next == '-' && !inSingleQuote && !inDoubleQuote) { + inLineComment = true; + i++; + continue; + } + + if (c == '/' && next == '*' && !inSingleQuote && !inDoubleQuote) { + inBlockComment = true; + i++; + continue; + } + + if (c == '\\' && (inSingleQuote || inDoubleQuote)) { + current.append(c); + if (next != '\0') { + current.append(next); + i++; + } + continue; + } + + if (c == '\'' && !inDoubleQuote) { + inSingleQuote = !inSingleQuote; + } else if (c == '"' && !inSingleQuote) { + inDoubleQuote = !inDoubleQuote; + } + + if (c == ';' && !inSingleQuote && !inDoubleQuote) { + String stmt = current.toString().trim(); + if (!stmt.isEmpty()) { + statements.add(stmt); + } + current.setLength(0); + } else { + current.append(c); + } + } + + String remaining = current.toString().trim(); + if (!remaining.isEmpty()) { + statements.add(remaining); + } + + return statements; + } + + private void insertVersion(DatabaseConnection conn, int version, String description) throws SQLException { + long appliedAt = System.currentTimeMillis() / 1000L; + CompiledStatement stmt = conn.compileStatement( + "INSERT INTO " + SCHEMA_TABLE + " (version, description, appliedAt, scope) VALUES (?, ?, ?, ?)", + StatementBuilder.StatementType.UPDATE, null, + DatabaseConnection.DEFAULT_RESULT_FLAGS, false); + stmt.setObject(0, version, SqlType.INTEGER); + stmt.setObject(1, description, SqlType.STRING); + stmt.setObject(2, appliedAt, SqlType.LONG); + stmt.setObject(3, scope, SqlType.STRING); + stmt.runUpdate(); + } + + private static byte[] readAllBytes(InputStream is) throws IOException { + byte[] buffer = new byte[4096]; + int bytesRead; + List chunks = new ArrayList<>(); + int totalLen = 0; + + while ((bytesRead = is.read(buffer)) != -1) { + byte[] chunk = new byte[bytesRead]; + System.arraycopy(buffer, 0, chunk, 0, bytesRead); + chunks.add(chunk); + totalLen += bytesRead; + } + + byte[] result = new byte[totalLen]; + int offset = 0; + for (byte[] chunk : chunks) { + System.arraycopy(chunk, 0, result, offset, chunk.length); + offset += chunk.length; + } + + return result; + } + + static class MigrationFile { + final String filename; + final int version; + final String description; + + MigrationFile(String filename, int version, String description) { + this.filename = filename; + this.version = version; + this.description = description; + } + } +} diff --git a/common/src/main/java/me/confuser/banmanager/common/storage/migration/SchemaVersion.java b/common/src/main/java/me/confuser/banmanager/common/storage/migration/SchemaVersion.java new file mode 100644 index 000000000..5ddedeb58 --- /dev/null +++ b/common/src/main/java/me/confuser/banmanager/common/storage/migration/SchemaVersion.java @@ -0,0 +1,38 @@ +package me.confuser.banmanager.common.storage.migration; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import me.confuser.banmanager.common.ormlite.field.DatabaseField; +import me.confuser.banmanager.common.ormlite.table.DatabaseTable; + +@DatabaseTable(tableName = "bm_schema_version") +@NoArgsConstructor +public class SchemaVersion { + + @DatabaseField(generatedId = true) + @Getter + private int id; + + @DatabaseField(canBeNull = false) + @Getter + private int version; + + @DatabaseField(canBeNull = false, columnDefinition = "VARCHAR(255) NOT NULL") + @Getter + private String description; + + @DatabaseField(canBeNull = false, columnDefinition = "BIGINT NOT NULL") + @Getter + private long appliedAt; + + @DatabaseField(canBeNull = false, columnDefinition = "VARCHAR(50) NOT NULL") + @Getter + private String scope; + + public SchemaVersion(int version, String description, String scope) { + this.version = version; + this.description = description; + this.scope = scope; + this.appliedAt = System.currentTimeMillis() / 1000L; + } +} diff --git a/common/src/main/java/me/confuser/banmanager/common/util/StorageUtils.java b/common/src/main/java/me/confuser/banmanager/common/util/StorageUtils.java index 9559d436b..7b42786cb 100644 --- a/common/src/main/java/me/confuser/banmanager/common/util/StorageUtils.java +++ b/common/src/main/java/me/confuser/banmanager/common/util/StorageUtils.java @@ -1,6 +1,5 @@ package me.confuser.banmanager.common.util; -import me.confuser.banmanager.common.BanManagerPlugin; import me.confuser.banmanager.common.configs.DatabaseConfig; import me.confuser.banmanager.common.ormlite.dao.BaseDaoImpl; import me.confuser.banmanager.common.ormlite.field.SqlType; @@ -8,7 +7,6 @@ import me.confuser.banmanager.common.ormlite.support.CompiledStatement; import me.confuser.banmanager.common.ormlite.support.ConnectionSource; import me.confuser.banmanager.common.ormlite.support.DatabaseConnection; -import me.confuser.banmanager.common.ormlite.support.DatabaseResults; import java.io.IOException; import java.sql.SQLException; @@ -61,61 +59,4 @@ public static void updateTimestampsToDbTime(ConnectionSource connectionSource, D } } - public static void convertIpColumn(BanManagerPlugin plugin, String table, String column) { - convertIpColumn(plugin, table, column, "int"); - } - - public static void convertIpColumn(BanManagerPlugin plugin, String table, String column, String idType) { - try (DatabaseConnection connection = plugin.getLocalConn().getReadWriteConnection(table)) { - if (connection.update("ALTER TABLE `" + table + "` CHANGE COLUMN `" + column + "` `" + column + "` VARBINARY(16) NOT NULL", null, null) != 0) { - plugin.getLogger().info("Converting " + table + " " + column + " data to support IPv6"); - - plugin.getLogger().info("Attempting fast IPv6 conversion..."); - - try { - if (connection - .compileStatement("UPDATE `" + table + "` SET " + column + " = INET6_ATON(INET_NTOA(" + column + "))", StatementBuilder - .StatementType.UPDATE, null, DatabaseConnection.DEFAULT_RESULT_FLAGS, false) - .runUpdate() == 0) { - throw new SQLException("Failed to fast convert, attempting slow conversion..."); - } else { - plugin.getLogger().info("Successfully converted " + table + " " + column + " data to support IPv6"); - } - } catch (Exception e) { - plugin.getLogger().severe("Failed to fast convert due to " + e.getMessage() + ", attempting slow conversion..."); - - DatabaseResults results = connection - .compileStatement("SELECT `id`, INET_NTOA(HEX(UNHEX(CAST(" + column + " AS UNSIGNED)))) FROM `" + table + "`", StatementBuilder - .StatementType.SELECT, null, DatabaseConnection.DEFAULT_RESULT_FLAGS, false) - .runQuery(null); - - while (results.next()) { - CompiledStatement statement = connection - .compileStatement("UPDATE " + table + " SET `" + column + "` = ? WHERE `id` = ?", StatementBuilder - .StatementType.UPDATE, null, DatabaseConnection.DEFAULT_RESULT_FLAGS, false); - - Object id; - - if (idType.equals("int")) { - id = results.getInt(0); - } else { - id = results.getBytes(0); - } - - String ipStr = results.getString(1); - byte[] ip = IPUtils.toBytes(ipStr); - - statement.setObject(0, ip, SqlType.BYTE_ARRAY); - statement.setObject(1, id, idType.equals("int") ? SqlType.INTEGER : SqlType.BYTE_ARRAY); - - if (statement.runUpdate() == 0) { - plugin.getLogger().severe("Unable to convert " + ipStr + " in " + table + " for id " + id); - } - } - } - } - } catch (SQLException | IOException e) { - plugin.getLogger().warning("Failed to process storage operation", e); - } - } } diff --git a/common/src/main/resources/db/global/V1__baseline.sql b/common/src/main/resources/db/global/V1__baseline.sql new file mode 100644 index 000000000..bf6a06d14 --- /dev/null +++ b/common/src/main/resources/db/global/V1__baseline.sql @@ -0,0 +1,11 @@ +-- Timestamp columns to BIGINT UNSIGNED +ALTER TABLE ${playerBans} CHANGE `created` `created` BIGINT UNSIGNED, CHANGE `expires` `expires` BIGINT UNSIGNED; +ALTER TABLE ${playerUnbans} CHANGE `created` `created` BIGINT UNSIGNED; +ALTER TABLE ${playerMutes} CHANGE `created` `created` BIGINT UNSIGNED, CHANGE `expires` `expires` BIGINT UNSIGNED; +ALTER TABLE ${playerUnmutes} CHANGE `created` `created` BIGINT UNSIGNED; +ALTER TABLE ${playerNotes} CHANGE `created` `created` BIGINT UNSIGNED; +ALTER TABLE ${ipBans} CHANGE `created` `created` BIGINT UNSIGNED, CHANGE `expires` `expires` BIGINT UNSIGNED; +ALTER TABLE ${ipUnbans} CHANGE `created` `created` BIGINT UNSIGNED; + +-- Soft mute (global mutes only) +ALTER TABLE ${playerMutes} ADD COLUMN `soft` TINYINT(1), ADD KEY `${playerMutes}_soft_idx` (`soft`); diff --git a/common/src/main/resources/db/global/migrations.list b/common/src/main/resources/db/global/migrations.list new file mode 100644 index 000000000..d6be464ff --- /dev/null +++ b/common/src/main/resources/db/global/migrations.list @@ -0,0 +1 @@ +V1__baseline.sql diff --git a/common/src/main/resources/db/local/V1__baseline.sql b/common/src/main/resources/db/local/V1__baseline.sql new file mode 100644 index 000000000..84a484198 --- /dev/null +++ b/common/src/main/resources/db/local/V1__baseline.sql @@ -0,0 +1,67 @@ +-- Timestamp columns to BIGINT UNSIGNED +ALTER TABLE ${playerBans} CHANGE `created` `created` BIGINT UNSIGNED, CHANGE `updated` `updated` BIGINT UNSIGNED, CHANGE `expires` `expires` BIGINT UNSIGNED; +ALTER TABLE ${playerBanRecords} CHANGE `created` `created` BIGINT UNSIGNED, CHANGE `pastCreated` `pastCreated` BIGINT UNSIGNED, CHANGE `expired` `expired` BIGINT UNSIGNED; +ALTER TABLE ${playerMutes} CHANGE `created` `created` BIGINT UNSIGNED, CHANGE `updated` `updated` BIGINT UNSIGNED, CHANGE `expires` `expires` BIGINT UNSIGNED; +ALTER TABLE ${playerMuteRecords} CHANGE `created` `created` BIGINT UNSIGNED, CHANGE `pastCreated` `pastCreated` BIGINT UNSIGNED, CHANGE `expired` `expired` BIGINT UNSIGNED; +ALTER TABLE ${playerWarnings} CHANGE `created` `created` BIGINT UNSIGNED, CHANGE `expires` `expires` BIGINT UNSIGNED; +ALTER TABLE ${playerReports} CHANGE `created` `created` BIGINT UNSIGNED, CHANGE `updated` `updated` BIGINT UNSIGNED; +ALTER TABLE ${playerReportComments} CHANGE `created` `created` BIGINT UNSIGNED, CHANGE `updated` `updated` BIGINT UNSIGNED; +ALTER TABLE ${playerReportCommands} CHANGE `created` `created` BIGINT UNSIGNED, CHANGE `updated` `updated` BIGINT UNSIGNED; +ALTER TABLE ${playerKicks} CHANGE `created` `created` BIGINT UNSIGNED; +ALTER TABLE ${playerNotes} CHANGE `created` `created` BIGINT UNSIGNED; +ALTER TABLE ${players} CHANGE `lastSeen` `lastSeen` BIGINT UNSIGNED; +ALTER TABLE ${playerHistory} CHANGE `join` `join` BIGINT UNSIGNED, CHANGE `leave` `leave` BIGINT UNSIGNED; +ALTER TABLE ${ipBans} CHANGE `created` `created` BIGINT UNSIGNED, CHANGE `updated` `updated` BIGINT UNSIGNED, CHANGE `expires` `expires` BIGINT UNSIGNED; +ALTER TABLE ${ipBanRecords} CHANGE `created` `created` BIGINT UNSIGNED, CHANGE `pastCreated` `pastCreated` BIGINT UNSIGNED, CHANGE `expired` `expired` BIGINT UNSIGNED; +ALTER TABLE ${ipMutes} CHANGE `created` `created` BIGINT UNSIGNED, CHANGE `updated` `updated` BIGINT UNSIGNED, CHANGE `expires` `expires` BIGINT UNSIGNED; +ALTER TABLE ${ipMuteRecords} CHANGE `created` `created` BIGINT UNSIGNED, CHANGE `pastCreated` `pastCreated` BIGINT UNSIGNED, CHANGE `expired` `expired` BIGINT UNSIGNED; +ALTER TABLE ${ipRangeBans} CHANGE `created` `created` BIGINT UNSIGNED, CHANGE `updated` `updated` BIGINT UNSIGNED, CHANGE `expires` `expires` BIGINT UNSIGNED; +ALTER TABLE ${ipRangeBanRecords} CHANGE `created` `created` BIGINT UNSIGNED, CHANGE `pastCreated` `pastCreated` BIGINT UNSIGNED, CHANGE `expired` `expired` BIGINT UNSIGNED; +ALTER TABLE ${nameBans} CHANGE `created` `created` BIGINT UNSIGNED, CHANGE `updated` `updated` BIGINT UNSIGNED, CHANGE `expires` `expires` BIGINT UNSIGNED; +ALTER TABLE ${nameBanRecords} CHANGE `created` `created` BIGINT UNSIGNED, CHANGE `pastCreated` `pastCreated` BIGINT UNSIGNED, CHANGE `expired` `expired` BIGINT UNSIGNED; +ALTER TABLE ${rollbacks} CHANGE `created` `created` BIGINT UNSIGNED, CHANGE `expires` `expires` BIGINT UNSIGNED; + +-- Silent column +ALTER TABLE ${playerBans} ADD COLUMN `silent` TINYINT(1); +ALTER TABLE ${playerBanRecords} ADD COLUMN `silent` TINYINT(1); +ALTER TABLE ${playerMutes} ADD COLUMN `silent` TINYINT(1); +ALTER TABLE ${playerMuteRecords} ADD COLUMN `silent` TINYINT(1); +ALTER TABLE ${ipBans} ADD COLUMN `silent` TINYINT(1); +ALTER TABLE ${ipBanRecords} ADD COLUMN `silent` TINYINT(1); +ALTER TABLE ${ipMutes} ADD COLUMN `silent` TINYINT(1); +ALTER TABLE ${ipMuteRecords} ADD COLUMN `silent` TINYINT(1); +ALTER TABLE ${ipRangeBans} ADD COLUMN `silent` TINYINT(1); +ALTER TABLE ${ipRangeBanRecords} ADD COLUMN `silent` TINYINT(1); +ALTER TABLE ${nameBans} ADD COLUMN `silent` TINYINT(1); + +-- Soft mute +ALTER TABLE ${playerMutes} ADD COLUMN `soft` TINYINT(1), ADD KEY `${playerMutes}_soft_idx` (`soft`); +ALTER TABLE ${playerMuteRecords} ADD COLUMN `createdReason` VARCHAR(255), ADD COLUMN `soft` TINYINT(1), ADD KEY `${playerMuteRecords}_soft_idx` (`soft`); + +-- Created reason +ALTER TABLE ${playerBanRecords} ADD COLUMN `createdReason` VARCHAR(255); +ALTER TABLE ${ipBanRecords} ADD COLUMN `createdReason` VARCHAR(255); +ALTER TABLE ${ipRangeBanRecords} ADD COLUMN `createdReason` VARCHAR(255); + +-- Mute unique key +ALTER TABLE ${playerMutes} ADD UNIQUE KEY `${playerMutes}_player_idx` (`player_id`); + +-- Warn points/expires +ALTER TABLE ${playerWarnings} ADD COLUMN `expires` INT(10) NOT NULL DEFAULT 0, ADD KEY `${playerWarnings}_expires_idx` (`expires`); +ALTER TABLE ${playerWarnings} ADD COLUMN `points` INT(10) NOT NULL DEFAULT 1, ADD KEY `${playerWarnings}_points_idx` (`points`); +ALTER TABLE ${playerWarnings} MODIFY COLUMN `points` DECIMAL(60,2) NOT NULL DEFAULT 1; + +-- Report workflow columns +ALTER TABLE ${playerReports} ADD COLUMN `state_id` INT(11) NOT NULL DEFAULT 1, ADD COLUMN `assignee_id` BINARY(16), ADD KEY `${playerReports}_state_id_idx` (`state_id`), ADD KEY `${playerReports}_assignee_id_idx` (`assignee_id`); +ALTER TABLE ${playerReports} MODIFY assignee_id BINARY(16) NULL; + +-- Online mute +ALTER TABLE ${playerMutes} ADD COLUMN `onlineOnly` TINYINT(1) NOT NULL DEFAULT 0; +ALTER TABLE ${playerMutes} ADD COLUMN `pausedRemaining` BIGINT UNSIGNED NOT NULL DEFAULT 0; +ALTER TABLE ${playerMuteRecords} ADD COLUMN `onlineOnly` TINYINT(1) NOT NULL DEFAULT 0; +ALTER TABLE ${playerMuteRecords} ADD COLUMN `remainingOnlineTime` BIGINT UNSIGNED NOT NULL DEFAULT 0; + +-- History +ALTER TABLE ${playerHistory} MODIFY `ip` VARBINARY(16) NULL; +ALTER TABLE ${playerHistory} ADD COLUMN `name` VARCHAR(16) NOT NULL DEFAULT '' AFTER `player_id`; +CREATE INDEX idx_playerhistory_name ON ${playerHistory} (name); diff --git a/common/src/main/resources/db/local/migrations.list b/common/src/main/resources/db/local/migrations.list new file mode 100644 index 000000000..d6be464ff --- /dev/null +++ b/common/src/main/resources/db/local/migrations.list @@ -0,0 +1 @@ +V1__baseline.sql diff --git a/common/src/test/java/me/confuser/banmanager/common/storage/migration/MigrationIntegrationTest.java b/common/src/test/java/me/confuser/banmanager/common/storage/migration/MigrationIntegrationTest.java new file mode 100644 index 000000000..62aa4729e --- /dev/null +++ b/common/src/test/java/me/confuser/banmanager/common/storage/migration/MigrationIntegrationTest.java @@ -0,0 +1,81 @@ +package me.confuser.banmanager.common.storage.migration; + +import me.confuser.banmanager.common.*; +import me.confuser.banmanager.common.configs.PluginInfo; +import me.confuser.banmanager.common.ormlite.dao.Dao; +import me.confuser.banmanager.common.ormlite.dao.DaoManager; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.util.List; + +import static org.junit.Assert.*; +import static org.mockito.Mockito.spy; + +public class MigrationIntegrationTest { + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + private BanManagerPlugin plugin; + private TestServer server = spy(new TestServer()); + + @Before + public void setup() throws Exception { + CommonLogger logger = new TestLogger(); + PluginInfo pluginInfo = BasePluginTest.setupConfigs(temporaryFolder); + plugin = new BanManagerPlugin(pluginInfo, logger, temporaryFolder.getRoot(), new TestScheduler(), server, new TestMetrics()); + server.enable(plugin); + } + + @After + public void cleanup() { + if (plugin != null) { + plugin.disable(); + } + } + + @Test + public void freshInstall_marksLatestVersion() throws Exception { + plugin.enable(); + + Dao dao = DaoManager.createDao(plugin.getLocalConn(), SchemaVersion.class); + List versions = dao.queryForAll(); + + assertFalse("Schema versions should have been recorded", versions.isEmpty()); + + boolean hasLocal = false; + for (SchemaVersion sv : versions) { + if ("local".equals(sv.getScope())) { + hasLocal = true; + assertEquals("Fresh install should mark at latest version", 1, sv.getVersion()); + assertTrue("Description should indicate fresh install", sv.getDescription().contains("fresh install")); + } + } + + assertTrue("Should have a local scope version", hasLocal); + } + + @Test + public void secondEnable_isIdempotent() throws Exception { + plugin.enable(); + plugin.disable(); + + CommonLogger logger = new TestLogger(); + PluginInfo pluginInfo = BasePluginTest.setupConfigs(temporaryFolder); + plugin = new BanManagerPlugin(pluginInfo, logger, temporaryFolder.getRoot(), new TestScheduler(), server, new TestMetrics()); + server.enable(plugin); + plugin.enable(); + + Dao dao = DaoManager.createDao(plugin.getLocalConn(), SchemaVersion.class); + + String[] rawResults = dao.queryRaw( + "SELECT COUNT(*) FROM " + MigrationRunner.SCHEMA_TABLE + " WHERE scope = ?", "local" + ).getFirstResult(); + + int count = Integer.parseInt(rawResults[0]); + assertEquals("Should have exactly one local version row (no duplicates from second enable)", 1, count); + } +} diff --git a/common/src/test/java/me/confuser/banmanager/common/storage/migration/MigrationRunnerTest.java b/common/src/test/java/me/confuser/banmanager/common/storage/migration/MigrationRunnerTest.java new file mode 100644 index 000000000..98e703726 --- /dev/null +++ b/common/src/test/java/me/confuser/banmanager/common/storage/migration/MigrationRunnerTest.java @@ -0,0 +1,120 @@ +package me.confuser.banmanager.common.storage.migration; + +import org.junit.Test; + +import java.util.List; + +import static org.junit.Assert.*; + +public class MigrationRunnerTest { + + @Test + public void splitStatements_singleStatement() { + List result = MigrationRunner.splitStatements("ALTER TABLE foo ADD COLUMN bar INT;"); + assertEquals(1, result.size()); + assertEquals("ALTER TABLE foo ADD COLUMN bar INT", result.get(0)); + } + + @Test + public void splitStatements_multipleStatements() { + String sql = "ALTER TABLE foo ADD COLUMN bar INT;\nALTER TABLE baz DROP COLUMN qux;"; + List result = MigrationRunner.splitStatements(sql); + assertEquals(2, result.size()); + assertEquals("ALTER TABLE foo ADD COLUMN bar INT", result.get(0)); + assertEquals("ALTER TABLE baz DROP COLUMN qux", result.get(1)); + } + + @Test + public void splitStatements_ignoreSemicolonInSingleQuotes() { + String sql = "UPDATE foo SET bar = 'hello;world';"; + List result = MigrationRunner.splitStatements(sql); + assertEquals(1, result.size()); + assertEquals("UPDATE foo SET bar = 'hello;world'", result.get(0)); + } + + @Test + public void splitStatements_ignoreSemicolonInDoubleQuotes() { + String sql = "UPDATE foo SET bar = \"hello;world\";"; + List result = MigrationRunner.splitStatements(sql); + assertEquals(1, result.size()); + assertEquals("UPDATE foo SET bar = \"hello;world\"", result.get(0)); + } + + @Test + public void splitStatements_skipLineComments() { + String sql = "-- This is a comment\nALTER TABLE foo ADD COLUMN bar INT;"; + List result = MigrationRunner.splitStatements(sql); + assertEquals(1, result.size()); + assertEquals("ALTER TABLE foo ADD COLUMN bar INT", result.get(0)); + } + + @Test + public void splitStatements_skipBlockComments() { + String sql = "/* block comment */ALTER TABLE foo ADD COLUMN bar INT;"; + List result = MigrationRunner.splitStatements(sql); + assertEquals(1, result.size()); + assertEquals("ALTER TABLE foo ADD COLUMN bar INT", result.get(0)); + } + + @Test + public void splitStatements_emptyInput() { + List result = MigrationRunner.splitStatements(""); + assertTrue(result.isEmpty()); + } + + @Test + public void splitStatements_onlyComments() { + String sql = "-- just a comment\n/* another comment */"; + List result = MigrationRunner.splitStatements(sql); + assertTrue(result.isEmpty()); + } + + @Test + public void splitStatements_noTrailingSemicolon() { + String sql = "ALTER TABLE foo ADD COLUMN bar INT"; + List result = MigrationRunner.splitStatements(sql); + assertEquals(1, result.size()); + assertEquals("ALTER TABLE foo ADD COLUMN bar INT", result.get(0)); + } + + @Test + public void splitStatements_blankLinesBetween() { + String sql = "ALTER TABLE a ADD COLUMN b INT;\n\n\nALTER TABLE c ADD COLUMN d INT;"; + List result = MigrationRunner.splitStatements(sql); + assertEquals(2, result.size()); + } + + @Test + public void splitStatements_multiLineStatement() { + String sql = "ALTER TABLE foo\n CHANGE `created` `created` BIGINT UNSIGNED,\n CHANGE `updated` `updated` BIGINT UNSIGNED;"; + List result = MigrationRunner.splitStatements(sql); + assertEquals(1, result.size()); + assertTrue(result.get(0).contains("CHANGE `created`")); + assertTrue(result.get(0).contains("CHANGE `updated`")); + } + + @Test + public void splitStatements_escapedSingleQuote() { + String sql = "UPDATE foo SET bar = 'O\\'Brien';"; + List result = MigrationRunner.splitStatements(sql); + assertEquals(1, result.size()); + assertEquals("UPDATE foo SET bar = 'O\\'Brien'", result.get(0)); + } + + @Test + public void splitStatements_escapedDoubleQuote() { + String sql = "UPDATE foo SET bar = \"say \\\"hello\\\"\";"; + List result = MigrationRunner.splitStatements(sql); + assertEquals(1, result.size()); + assertEquals("UPDATE foo SET bar = \"say \\\"hello\\\"\"", result.get(0)); + } + + @Test + public void splitStatements_escapedSemicolonInQuotes() { + String sql = "UPDATE foo SET bar = 'test\\;value'; ALTER TABLE baz ADD x INT;"; + List result = MigrationRunner.splitStatements(sql); + assertEquals(2, result.size()); + assertEquals("UPDATE foo SET bar = 'test\\;value'", result.get(0)); + assertEquals("ALTER TABLE baz ADD x INT", result.get(1)); + } +} From debe681a2fdb10db5267a5ef9ccdeefbb416eab2 Mon Sep 17 00:00:00 2001 From: James Mortemore Date: Sat, 4 Apr 2026 08:44:23 +0100 Subject: [PATCH 2/5] fix: qualify migration scope with detection table name for multi-instance isolation Instances sharing the same database but using different table prefixes now track migrations independently (e.g. "local:bm_players" vs "local:bm_s2_players") instead of colliding on a bare "local" scope. --- .../storage/migration/MigrationRunner.java | 35 ++++++++++++------- .../migration/MigrationIntegrationTest.java | 5 +-- 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/common/src/main/java/me/confuser/banmanager/common/storage/migration/MigrationRunner.java b/common/src/main/java/me/confuser/banmanager/common/storage/migration/MigrationRunner.java index 84d677c41..a34a3070f 100644 --- a/common/src/main/java/me/confuser/banmanager/common/storage/migration/MigrationRunner.java +++ b/common/src/main/java/me/confuser/banmanager/common/storage/migration/MigrationRunner.java @@ -35,6 +35,13 @@ public class MigrationRunner { private final String detectionTableKey; private final ClassLoader resourceLoader; + /** + * The instance-qualified scope used for database operations (version tracking, advisory locks). + * Combines the resource scope with the detection table name to isolate instances that share + * the same database but use different table prefixes (e.g. "local:bm_players" vs "local:bm_s2_players"). + */ + private String instanceScope; + public MigrationRunner(BanManagerPlugin plugin, ConnectionSource connectionSource, DatabaseConfig dbConfig, String scope, String detectionTableKey, ClassLoader resourceLoader) { @@ -53,6 +60,9 @@ public void migrate() throws SQLException { return; } + String detectionTableName = dbConfig.getTable(detectionTableKey).getTableName(); + instanceScope = scope + ":" + detectionTableName; + int latestVersion = migrations.get(migrations.size() - 1).version; boolean isH2 = dbConfig.getStorageType().equals("h2"); @@ -65,9 +75,8 @@ public void migrate() throws SQLException { try { TableUtils.createTableIfNotExists(connectionSource, SchemaVersion.class); - String detectionTableName = dbConfig.getTable(detectionTableKey).getTableName(); if (!tableExists(conn, detectionTableName)) { - plugin.getLogger().info("[Migration:" + scope + "] Fresh install detected, marking schema at V" + latestVersion); + plugin.getLogger().info("[Migration:" + instanceScope + "] Fresh install detected, marking schema at V" + latestVersion); insertVersion(conn, latestVersion, "baseline (fresh install)"); return; } @@ -75,7 +84,7 @@ public void migrate() throws SQLException { int currentVersion = getCurrentVersion(conn); if (currentVersion == 0) { - plugin.getLogger().info("[Migration:" + scope + "] Existing install detected, marking V1 as baseline"); + plugin.getLogger().info("[Migration:" + instanceScope + "] Existing install detected, marking V1 as baseline"); insertVersion(conn, 1, "baseline (existing install)"); currentVersion = 1; } @@ -86,10 +95,10 @@ public void migrate() throws SQLException { continue; } - plugin.getLogger().info("[Migration:" + scope + "] Applying V" + migration.version + " " + migration.description); + plugin.getLogger().info("[Migration:" + instanceScope + "] Applying V" + migration.version + " " + migration.description); String sql = loadSqlFile(migration.filename); if (sql.isEmpty()) { - throw new SQLException("[Migration:" + scope + "] Migration file not found or empty: " + migration.filename); + throw new SQLException("[Migration:" + instanceScope + "] Migration file not found or empty: " + migration.filename); } sql = substitutePlaceholders(sql); executeMigrationStatements(conn, sql); @@ -98,7 +107,7 @@ public void migrate() throws SQLException { } if (applied > 0) { - plugin.getLogger().info("[Migration:" + scope + "] Applied " + applied + " migration(s)"); + plugin.getLogger().info("[Migration:" + instanceScope + "] Applied " + applied + " migration(s)"); } } finally { if (!isH2) { @@ -112,21 +121,21 @@ public void migrate() throws SQLException { private void acquireAdvisoryLock(DatabaseConnection conn) throws SQLException { CompiledStatement stmt = conn.compileStatement( - "SELECT GET_LOCK('bm_migration_" + scope + "', 30)", + "SELECT GET_LOCK('bm_migration_" + instanceScope + "', 30)", StatementBuilder.StatementType.SELECT, null, DatabaseConnection.DEFAULT_RESULT_FLAGS, false); DatabaseResults results = stmt.runQuery(null); if (!results.next() || results.getInt(0) != 1) { - throw new SQLException("[Migration:" + scope + "] Could not acquire advisory lock (another server may be migrating)"); + throw new SQLException("[Migration:" + instanceScope + "] Could not acquire advisory lock (another server may be migrating)"); } } private void releaseAdvisoryLock(DatabaseConnection conn) { try { - conn.executeStatement("SELECT RELEASE_LOCK('bm_migration_" + scope + "')", + conn.executeStatement("SELECT RELEASE_LOCK('bm_migration_" + instanceScope + "')", DatabaseConnection.DEFAULT_RESULT_FLAGS); } catch (SQLException e) { - plugin.getLogger().warning("[Migration:" + scope + "] Failed to release advisory lock", e); + plugin.getLogger().warning("[Migration:" + instanceScope + "] Failed to release advisory lock", e); } } @@ -182,7 +191,7 @@ private int getCurrentVersion(DatabaseConnection conn) throws SQLException { "SELECT COALESCE(MAX(version), 0) FROM " + SCHEMA_TABLE + " WHERE scope = ?", StatementBuilder.StatementType.SELECT, null, DatabaseConnection.DEFAULT_RESULT_FLAGS, false); - stmt.setObject(0, scope, SqlType.STRING); + stmt.setObject(0, instanceScope, SqlType.STRING); DatabaseResults results = stmt.runQuery(null); if (results.next()) { return results.getInt(0); @@ -225,7 +234,7 @@ private void executeMigrationStatements(DatabaseConnection conn, String sql) { try { conn.executeStatement(statement, DatabaseConnection.DEFAULT_RESULT_FLAGS); } catch (SQLException e) { - plugin.getLogger().warning("[Migration:" + scope + "] Statement failed (continuing): " + e.getMessage()); + plugin.getLogger().warning("[Migration:" + instanceScope + "] Statement failed (continuing): " + e.getMessage()); } } } @@ -313,7 +322,7 @@ private void insertVersion(DatabaseConnection conn, int version, String descript stmt.setObject(0, version, SqlType.INTEGER); stmt.setObject(1, description, SqlType.STRING); stmt.setObject(2, appliedAt, SqlType.LONG); - stmt.setObject(3, scope, SqlType.STRING); + stmt.setObject(3, instanceScope, SqlType.STRING); stmt.runUpdate(); } diff --git a/common/src/test/java/me/confuser/banmanager/common/storage/migration/MigrationIntegrationTest.java b/common/src/test/java/me/confuser/banmanager/common/storage/migration/MigrationIntegrationTest.java index 62aa4729e..c7dc59f01 100644 --- a/common/src/test/java/me/confuser/banmanager/common/storage/migration/MigrationIntegrationTest.java +++ b/common/src/test/java/me/confuser/banmanager/common/storage/migration/MigrationIntegrationTest.java @@ -48,10 +48,11 @@ public void freshInstall_marksLatestVersion() throws Exception { boolean hasLocal = false; for (SchemaVersion sv : versions) { - if ("local".equals(sv.getScope())) { + if (sv.getScope().startsWith("local:")) { hasLocal = true; assertEquals("Fresh install should mark at latest version", 1, sv.getVersion()); assertTrue("Description should indicate fresh install", sv.getDescription().contains("fresh install")); + assertEquals("Scope should include detection table name", "local:bm_players", sv.getScope()); } } @@ -72,7 +73,7 @@ public void secondEnable_isIdempotent() throws Exception { Dao dao = DaoManager.createDao(plugin.getLocalConn(), SchemaVersion.class); String[] rawResults = dao.queryRaw( - "SELECT COUNT(*) FROM " + MigrationRunner.SCHEMA_TABLE + " WHERE scope = ?", "local" + "SELECT COUNT(*) FROM " + MigrationRunner.SCHEMA_TABLE + " WHERE scope = ?", "local:bm_players" ).getFirstResult(); int count = Integer.parseInt(rawResults[0]); From 1d21fc8401cbd237935ff38aa41d6868e229210d Mon Sep 17 00:00:00 2001 From: James Mortemore Date: Sat, 4 Apr 2026 08:54:17 +0100 Subject: [PATCH 3/5] fix: use explicit instanceId config for multi-instance scope isolation Replaces the detection-table-name-derived scope with an opt-in instanceId config field. This avoids scope breakage when admins rename tables, while still supporting shared-database setups. --- .../banmanager/common/configs/DatabaseConfig.java | 3 +++ .../common/storage/conversion/AdvancedBan.java | 2 +- .../banmanager/common/storage/conversion/H2.java | 2 +- .../common/storage/conversion/LiteBans.java | 2 +- .../common/storage/migration/MigrationRunner.java | 13 +++++-------- common/src/main/resources/config.yml | 4 ++++ .../storage/migration/MigrationIntegrationTest.java | 5 ++--- 7 files changed, 17 insertions(+), 14 deletions(-) diff --git a/common/src/main/java/me/confuser/banmanager/common/configs/DatabaseConfig.java b/common/src/main/java/me/confuser/banmanager/common/configs/DatabaseConfig.java index 8a8d0f0a0..64069d97a 100644 --- a/common/src/main/java/me/confuser/banmanager/common/configs/DatabaseConfig.java +++ b/common/src/main/java/me/confuser/banmanager/common/configs/DatabaseConfig.java @@ -41,6 +41,8 @@ public abstract class DatabaseConfig { @Getter private int connectionTimeout; @Getter + private String instanceId; + @Getter private HashMap> tables = new HashMap<>(); private File dataFolder; @@ -62,6 +64,7 @@ private DatabaseConfig(File dataFolder, ConfigurationSection conf) { verifyServerCertificate = conf.getBoolean("verifyServerCertificate", false); maxLifetime = conf.getInt("maxLifetime", 1800000); connectionTimeout = conf.getInt("connectionTimeout", 30000); + instanceId = conf.getString("instanceId", ""); if (maxConnections > 30) maxConnections = 30; } diff --git a/common/src/main/java/me/confuser/banmanager/common/storage/conversion/AdvancedBan.java b/common/src/main/java/me/confuser/banmanager/common/storage/conversion/AdvancedBan.java index 26d6614dd..853142256 100644 --- a/common/src/main/java/me/confuser/banmanager/common/storage/conversion/AdvancedBan.java +++ b/common/src/main/java/me/confuser/banmanager/common/storage/conversion/AdvancedBan.java @@ -195,7 +195,7 @@ public AdvancedBanConfig(String storageType, String host, int port, String name, boolean isEnabled, int maxConnections, int leakDetection, int maxLifetime, int connectionTimeout, HashMap> tables, File dataFolder) { super(storageType, host, port, name, user, password, useSSL, verifyServerCertificate, allowPublicKeyRetrieval, - isEnabled, maxConnections, leakDetection, maxLifetime, connectionTimeout, tables, dataFolder); + isEnabled, maxConnections, leakDetection, maxLifetime, connectionTimeout, "", tables, dataFolder); } } } diff --git a/common/src/main/java/me/confuser/banmanager/common/storage/conversion/H2.java b/common/src/main/java/me/confuser/banmanager/common/storage/conversion/H2.java index 27b9c5963..b847a6543 100644 --- a/common/src/main/java/me/confuser/banmanager/common/storage/conversion/H2.java +++ b/common/src/main/java/me/confuser/banmanager/common/storage/conversion/H2.java @@ -600,7 +600,7 @@ public void importNameBans() { class H2Config extends DatabaseConfig { public H2Config(String storageType, String host, int port, String name, String user, String password, boolean useSSL, boolean verifyServerCertificate, boolean allowPublicKeyRetrieval, boolean isEnabled, int maxConnections, int leakDetection, int maxLifetime, int connectionTimeout, HashMap> tables, File dataFolder) { - super(storageType, host, port, name, user, password, useSSL, verifyServerCertificate, allowPublicKeyRetrieval, isEnabled, maxConnections, leakDetection, maxLifetime, connectionTimeout, tables, dataFolder); + super(storageType, host, port, name, user, password, useSSL, verifyServerCertificate, allowPublicKeyRetrieval, isEnabled, maxConnections, leakDetection, maxLifetime, connectionTimeout, "", tables, dataFolder); } } } diff --git a/common/src/main/java/me/confuser/banmanager/common/storage/conversion/LiteBans.java b/common/src/main/java/me/confuser/banmanager/common/storage/conversion/LiteBans.java index 9a096011b..8315d572f 100644 --- a/common/src/main/java/me/confuser/banmanager/common/storage/conversion/LiteBans.java +++ b/common/src/main/java/me/confuser/banmanager/common/storage/conversion/LiteBans.java @@ -998,7 +998,7 @@ public LiteBansConfig(String storageType, String host, int port, String name, St boolean isEnabled, int maxConnections, int leakDetection, int maxLifetime, int connectionTimeout, HashMap> tables, File dataFolder) { super(storageType, host, port, name, user, password, useSSL, verifyServerCertificate, allowPublicKeyRetrieval, - isEnabled, maxConnections, leakDetection, maxLifetime, connectionTimeout, tables, dataFolder); + isEnabled, maxConnections, leakDetection, maxLifetime, connectionTimeout, "", tables, dataFolder); } } } diff --git a/common/src/main/java/me/confuser/banmanager/common/storage/migration/MigrationRunner.java b/common/src/main/java/me/confuser/banmanager/common/storage/migration/MigrationRunner.java index a34a3070f..6f3609120 100644 --- a/common/src/main/java/me/confuser/banmanager/common/storage/migration/MigrationRunner.java +++ b/common/src/main/java/me/confuser/banmanager/common/storage/migration/MigrationRunner.java @@ -35,12 +35,7 @@ public class MigrationRunner { private final String detectionTableKey; private final ClassLoader resourceLoader; - /** - * The instance-qualified scope used for database operations (version tracking, advisory locks). - * Combines the resource scope with the detection table name to isolate instances that share - * the same database but use different table prefixes (e.g. "local:bm_players" vs "local:bm_s2_players"). - */ - private String instanceScope; + private final String instanceScope; public MigrationRunner(BanManagerPlugin plugin, ConnectionSource connectionSource, DatabaseConfig dbConfig, String scope, String detectionTableKey, @@ -51,17 +46,19 @@ public MigrationRunner(BanManagerPlugin plugin, ConnectionSource connectionSourc this.scope = scope; this.detectionTableKey = detectionTableKey; this.resourceLoader = resourceLoader; + + String id = dbConfig.getInstanceId(); + this.instanceScope = (id != null && !id.isEmpty()) ? scope + ":" + id : scope; } public void migrate() throws SQLException { List migrations = loadManifest(); if (migrations.isEmpty()) { - plugin.getLogger().info("[Migration:" + scope + "] No migrations found in manifest"); + plugin.getLogger().info("[Migration:" + instanceScope + "] No migrations found in manifest"); return; } String detectionTableName = dbConfig.getTable(detectionTableKey).getTableName(); - instanceScope = scope + ":" + detectionTableName; int latestVersion = migrations.get(migrations.size() - 1).version; boolean isH2 = dbConfig.getStorageType().equals("h2"); diff --git a/common/src/main/resources/config.yml b/common/src/main/resources/config.yml index 335e7deb3..e57a438ca 100644 --- a/common/src/main/resources/config.yml +++ b/common/src/main/resources/config.yml @@ -14,6 +14,8 @@ databases: verifyServerCertificate: false maxLifetime: 1800000 connectionTimeout: 30000 + # Set a unique id when multiple BanManager instances share the same database + # instanceId: '' tables: players: bm_players playerBans: bm_player_bans @@ -57,6 +59,8 @@ databases: leakDetection: 3000 maxLifetime: 1800000 connectionTimeout: 30000 + # Set a unique id when multiple BanManager instances share the same database + # instanceId: '' tables: playerBans: bm_player_ban_all playerUnbans: bm_player_unban_all diff --git a/common/src/test/java/me/confuser/banmanager/common/storage/migration/MigrationIntegrationTest.java b/common/src/test/java/me/confuser/banmanager/common/storage/migration/MigrationIntegrationTest.java index c7dc59f01..62aa4729e 100644 --- a/common/src/test/java/me/confuser/banmanager/common/storage/migration/MigrationIntegrationTest.java +++ b/common/src/test/java/me/confuser/banmanager/common/storage/migration/MigrationIntegrationTest.java @@ -48,11 +48,10 @@ public void freshInstall_marksLatestVersion() throws Exception { boolean hasLocal = false; for (SchemaVersion sv : versions) { - if (sv.getScope().startsWith("local:")) { + if ("local".equals(sv.getScope())) { hasLocal = true; assertEquals("Fresh install should mark at latest version", 1, sv.getVersion()); assertTrue("Description should indicate fresh install", sv.getDescription().contains("fresh install")); - assertEquals("Scope should include detection table name", "local:bm_players", sv.getScope()); } } @@ -73,7 +72,7 @@ public void secondEnable_isIdempotent() throws Exception { Dao dao = DaoManager.createDao(plugin.getLocalConn(), SchemaVersion.class); String[] rawResults = dao.queryRaw( - "SELECT COUNT(*) FROM " + MigrationRunner.SCHEMA_TABLE + " WHERE scope = ?", "local:bm_players" + "SELECT COUNT(*) FROM " + MigrationRunner.SCHEMA_TABLE + " WHERE scope = ?", "local" ).getFirstResult(); int count = Integer.parseInt(rawResults[0]); From 74d8c40ca7712cafbeccb0789d541f22e20d070a Mon Sep 17 00:00:00 2001 From: James Mortemore Date: Sat, 4 Apr 2026 11:21:25 +0100 Subject: [PATCH 4/5] fix: close JDBC resources, make error tolerance opt-in per migration - Close CompiledStatement and DatabaseResults in finally blocks to prevent resource leaks under load - Add 'lenient' flag to manifest format; only lenient migrations continue on individual statement failures, strict migrations abort - Mark V1 baselines as lenient (idempotent DDL), future V2+ migrations default to strict - Use instanceScope consistently in all log messages - Remove unused SchemaVersion constructor --- .../storage/migration/MigrationRunner.java | 92 +++++++++++++------ .../storage/migration/SchemaVersion.java | 7 -- .../main/resources/db/global/migrations.list | 2 +- .../main/resources/db/local/migrations.list | 2 +- 4 files changed, 68 insertions(+), 35 deletions(-) diff --git a/common/src/main/java/me/confuser/banmanager/common/storage/migration/MigrationRunner.java b/common/src/main/java/me/confuser/banmanager/common/storage/migration/MigrationRunner.java index 6f3609120..6b358fda0 100644 --- a/common/src/main/java/me/confuser/banmanager/common/storage/migration/MigrationRunner.java +++ b/common/src/main/java/me/confuser/banmanager/common/storage/migration/MigrationRunner.java @@ -25,7 +25,7 @@ public class MigrationRunner { - private static final Pattern VERSION_PATTERN = Pattern.compile("^V(\\d+)__(.+)\\.sql$"); + private static final Pattern MANIFEST_PATTERN = Pattern.compile("^(V(\\d+)__(.+)\\.sql)(?:\\s+(\\S+))?$"); static final String SCHEMA_TABLE = "bm_schema_version"; private final BanManagerPlugin plugin; @@ -98,7 +98,7 @@ public void migrate() throws SQLException { throw new SQLException("[Migration:" + instanceScope + "] Migration file not found or empty: " + migration.filename); } sql = substitutePlaceholders(sql); - executeMigrationStatements(conn, sql); + executeMigrationStatements(conn, sql, migration.lenient); insertVersion(conn, migration.version, migration.description); applied++; } @@ -121,9 +121,17 @@ private void acquireAdvisoryLock(DatabaseConnection conn) throws SQLException { "SELECT GET_LOCK('bm_migration_" + instanceScope + "', 30)", StatementBuilder.StatementType.SELECT, null, DatabaseConnection.DEFAULT_RESULT_FLAGS, false); - DatabaseResults results = stmt.runQuery(null); - if (!results.next() || results.getInt(0) != 1) { - throw new SQLException("[Migration:" + instanceScope + "] Could not acquire advisory lock (another server may be migrating)"); + try { + DatabaseResults results = stmt.runQuery(null); + try { + if (!results.next() || results.getInt(0) != 1) { + throw new SQLException("[Migration:" + instanceScope + "] Could not acquire advisory lock (another server may be migrating)"); + } + } finally { + closeQuietly(results); + } + } finally { + closeQuietly(stmt); } } @@ -142,7 +150,7 @@ private List loadManifest() { try (InputStream is = resourceLoader.getResourceAsStream(manifestPath)) { if (is == null) { - plugin.getLogger().warning("[Migration:" + scope + "] No manifest found at " + manifestPath); + plugin.getLogger().warning("[Migration:" + instanceScope + "] No manifest found at " + manifestPath); return migrations; } @@ -154,18 +162,20 @@ private List loadManifest() { continue; } - Matcher matcher = VERSION_PATTERN.matcher(line); + Matcher matcher = MANIFEST_PATTERN.matcher(line); if (matcher.matches()) { - int version = Integer.parseInt(matcher.group(1)); - String description = matcher.group(2).replace('_', ' '); - migrations.add(new MigrationFile(line, version, description)); + String filename = matcher.group(1); + int version = Integer.parseInt(matcher.group(2)); + String description = matcher.group(3).replace('_', ' '); + boolean lenient = "lenient".equalsIgnoreCase(matcher.group(4)); + migrations.add(new MigrationFile(filename, version, description, lenient)); } else { - plugin.getLogger().warning("[Migration:" + scope + "] Skipping invalid manifest entry: " + line); + plugin.getLogger().warning("[Migration:" + instanceScope + "] Skipping invalid manifest entry: " + line); } } } } catch (IOException e) { - plugin.getLogger().warning("[Migration:" + scope + "] Failed to read manifest: " + e.getMessage()); + plugin.getLogger().warning("[Migration:" + instanceScope + "] Failed to read manifest: " + e.getMessage()); } migrations.sort(Comparator.comparingInt(m -> m.version)); @@ -188,10 +198,18 @@ private int getCurrentVersion(DatabaseConnection conn) throws SQLException { "SELECT COALESCE(MAX(version), 0) FROM " + SCHEMA_TABLE + " WHERE scope = ?", StatementBuilder.StatementType.SELECT, null, DatabaseConnection.DEFAULT_RESULT_FLAGS, false); - stmt.setObject(0, instanceScope, SqlType.STRING); - DatabaseResults results = stmt.runQuery(null); - if (results.next()) { - return results.getInt(0); + try { + stmt.setObject(0, instanceScope, SqlType.STRING); + DatabaseResults results = stmt.runQuery(null); + try { + if (results.next()) { + return results.getInt(0); + } + } finally { + closeQuietly(results); + } + } finally { + closeQuietly(stmt); } } catch (SQLException e) { // Table may not exist yet @@ -204,14 +222,14 @@ private String loadSqlFile(String filename) { try (InputStream is = resourceLoader.getResourceAsStream(path)) { if (is == null) { - plugin.getLogger().warning("[Migration:" + scope + "] SQL file not found: " + path); + plugin.getLogger().warning("[Migration:" + instanceScope + "] SQL file not found: " + path); return ""; } byte[] bytes = readAllBytes(is); return new String(bytes, StandardCharsets.UTF_8); } catch (IOException e) { - plugin.getLogger().warning("[Migration:" + scope + "] Failed to read SQL file: " + path); + plugin.getLogger().warning("[Migration:" + instanceScope + "] Failed to read SQL file: " + path); return ""; } } @@ -224,14 +242,18 @@ private String substitutePlaceholders(String sql) { return sql; } - private void executeMigrationStatements(DatabaseConnection conn, String sql) { + private void executeMigrationStatements(DatabaseConnection conn, String sql, boolean lenient) throws SQLException { List statements = splitStatements(sql); for (String statement : statements) { try { conn.executeStatement(statement, DatabaseConnection.DEFAULT_RESULT_FLAGS); } catch (SQLException e) { - plugin.getLogger().warning("[Migration:" + instanceScope + "] Statement failed (continuing): " + e.getMessage()); + if (lenient) { + plugin.getLogger().warning("[Migration:" + instanceScope + "] Statement failed (continuing): " + e.getMessage()); + } else { + throw new SQLException("[Migration:" + instanceScope + "] Statement failed: " + e.getMessage(), e); + } } } } @@ -316,11 +338,27 @@ private void insertVersion(DatabaseConnection conn, int version, String descript "INSERT INTO " + SCHEMA_TABLE + " (version, description, appliedAt, scope) VALUES (?, ?, ?, ?)", StatementBuilder.StatementType.UPDATE, null, DatabaseConnection.DEFAULT_RESULT_FLAGS, false); - stmt.setObject(0, version, SqlType.INTEGER); - stmt.setObject(1, description, SqlType.STRING); - stmt.setObject(2, appliedAt, SqlType.LONG); - stmt.setObject(3, instanceScope, SqlType.STRING); - stmt.runUpdate(); + try { + stmt.setObject(0, version, SqlType.INTEGER); + stmt.setObject(1, description, SqlType.STRING); + stmt.setObject(2, appliedAt, SqlType.LONG); + stmt.setObject(3, instanceScope, SqlType.STRING); + stmt.runUpdate(); + } finally { + closeQuietly(stmt); + } + } + + private static void closeQuietly(CompiledStatement stmt) { + if (stmt != null) { + try { stmt.close(); } catch (IOException ignored) { } + } + } + + private static void closeQuietly(DatabaseResults results) { + if (results != null) { + try { results.close(); } catch (IOException ignored) { } + } } private static byte[] readAllBytes(InputStream is) throws IOException { @@ -350,11 +388,13 @@ static class MigrationFile { final String filename; final int version; final String description; + final boolean lenient; - MigrationFile(String filename, int version, String description) { + MigrationFile(String filename, int version, String description, boolean lenient) { this.filename = filename; this.version = version; this.description = description; + this.lenient = lenient; } } } diff --git a/common/src/main/java/me/confuser/banmanager/common/storage/migration/SchemaVersion.java b/common/src/main/java/me/confuser/banmanager/common/storage/migration/SchemaVersion.java index 5ddedeb58..8d1622c9d 100644 --- a/common/src/main/java/me/confuser/banmanager/common/storage/migration/SchemaVersion.java +++ b/common/src/main/java/me/confuser/banmanager/common/storage/migration/SchemaVersion.java @@ -28,11 +28,4 @@ public class SchemaVersion { @DatabaseField(canBeNull = false, columnDefinition = "VARCHAR(50) NOT NULL") @Getter private String scope; - - public SchemaVersion(int version, String description, String scope) { - this.version = version; - this.description = description; - this.scope = scope; - this.appliedAt = System.currentTimeMillis() / 1000L; - } } diff --git a/common/src/main/resources/db/global/migrations.list b/common/src/main/resources/db/global/migrations.list index d6be464ff..a43ed9c9a 100644 --- a/common/src/main/resources/db/global/migrations.list +++ b/common/src/main/resources/db/global/migrations.list @@ -1 +1 @@ -V1__baseline.sql +V1__baseline.sql lenient diff --git a/common/src/main/resources/db/local/migrations.list b/common/src/main/resources/db/local/migrations.list index d6be464ff..a43ed9c9a 100644 --- a/common/src/main/resources/db/local/migrations.list +++ b/common/src/main/resources/db/local/migrations.list @@ -1 +1 @@ -V1__baseline.sql +V1__baseline.sql lenient From 0aa3664e7850426bef64080ac704db6d57377db4 Mon Sep 17 00:00:00 2001 From: James Mortemore Date: Sat, 4 Apr 2026 11:30:31 +0100 Subject: [PATCH 5/5] fix: remove instanceId comment from global database config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Global databases are shared by design — all instances should use the same migration scope so migrations aren't applied redundantly. --- common/src/main/resources/config.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/common/src/main/resources/config.yml b/common/src/main/resources/config.yml index e57a438ca..50f49665d 100644 --- a/common/src/main/resources/config.yml +++ b/common/src/main/resources/config.yml @@ -59,8 +59,6 @@ databases: leakDetection: 3000 maxLifetime: 1800000 connectionTimeout: 30000 - # Set a unique id when multiple BanManager instances share the same database - # instanceId: '' tables: playerBans: bm_player_ban_all playerUnbans: bm_player_unban_all