From cf583f481eb186ae7668fb4a288bdf5374d5f67f Mon Sep 17 00:00:00 2001 From: NotAlexNoyle Date: Wed, 29 Jul 2026 11:40:11 -0400 Subject: [PATCH] Old database migration --- .gitignore | 1 + CHANGELOG.md | 67 ++ README.md | 143 ++- build.gradle.kts | 3 +- .../java/net/trueog/unionsog/UnionsOG.java | 37 +- .../unionsog/managers/SettingsManager.java | 1 + .../unionsog/managers/StorageManager.java | 13 + .../legacy/LegacyUnionsDatabaseMigration.java | 860 ++++++++++++++++++ .../LegacyUnionsDatabaseMigrationRunner.java | 173 ++++ .../trueog/unionsog/storage/MySQLCore.java | 34 +- src/main/resources/config.yml | 12 +- ...cyUnionsDatabaseMigrationDatabaseTest.java | 336 +++++++ .../LegacyUnionsDatabaseMigrationTest.java | 89 ++ 13 files changed, 1676 insertions(+), 93 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 src/main/java/net/trueog/unionsog/migrations/legacy/LegacyUnionsDatabaseMigration.java create mode 100644 src/main/java/net/trueog/unionsog/migrations/legacy/LegacyUnionsDatabaseMigrationRunner.java create mode 100644 src/test/java/net/trueog/unionsog/migrations/legacy/LegacyUnionsDatabaseMigrationDatabaseTest.java create mode 100644 src/test/java/net/trueog/unionsog/migrations/legacy/LegacyUnionsDatabaseMigrationTest.java diff --git a/.gitignore b/.gitignore index 2c313b52..f7a2c4e2 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ build/ .settings .factorypath SimpleClans.iml +/unions.txt diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..44408533 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,67 @@ +# Unions-OG Changelog + +This changelog records only changes made by the Unions-OG soft fork relative +to upstream [SimpleClans](https://github.com/RoinujNosde/SimpleClans). Changes +inherited unchanged from SimpleClans are intentionally omitted. + +## [2.3] - Initial release + +### Added + +- Added `/union` and `/unions` as primary command aliases while retaining + `/clan` and `/clans`. +- Added `/u [message]` for union chat. Running `/u` without a message toggles + the player's union-chat channel. +- Added `/union color ` with named, legacy, and hex color support, + governed by `unionsog.leader.color`. +- Added `simpleunions` PlaceholderAPI aliases alongside the legacy + `simpleclans` identifier. +- Added MiniPlaceholders support through Utilities-OG, including clan/union + aliases and relational placeholders. +- Added a stable `&8None` result for the no-union color-tag placeholder. +- Added a Union Banking preview to the main GUI. +- Added a configuration-gated legacy database migration from `unions_parties` and + `unions_players` into the configured Unions-OG tables. The migration: + - Never modifies the legacy source tables. + - Requires empty, transactional destination tables. + - Runs in a transaction and records successful application for idempotency. + - Preserves membership and leader relationships, resolves duplicate tags, + and supplies deterministic player-name placeholders when needed. + - Aborts plugin startup on configuration, connection, validation, or import + failure so the server cannot continue with partially migrated data. + - Includes unit and database-backed tests for mapping, idempotency, safety, + and rollback behavior. +- Renamed the plugin and artifact to Unions-OG and changed user-facing clan + terminology to union. +- Moved the Java and Gradle namespace to `net.trueog.unionsog`, the plugin main + class to `net.trueog.unionsog.UnionsOG`, and permission nodes to + `unionsog.*`. +- Disabled verification as a gameplay gate; newly created unions are treated + as verified while the stored field remains for data compatibility. +- Reserved `None` as a union name or tag to avoid ambiguity with the + no-union display value. +- Disabled bulletin-board messages on login by default. +- Replaced Vault economy access with DiamondBank-OG and its shard-based player + balances. Legacy Vault-denominated union balances are zeroed once. +- Temporarily disabled union-bank commands and detail-screen controls until + DiamondBank-OG provides union-owned accounts; the main GUI shows a preview + in their place. +- Reordered the main GUI around the union overview, banking preview, lists, + leaderboards, and administrative tools. +- Replaced the legacy WorldGuard 6 reflection provider with the WorldEdit and + WorldGuard 7 APIs. +- Updated MySQL driver loading for Connector/J 8, the legacy Connector/J + class name, MariaDB, and JDBC service discovery. +- Migrated the build from Maven to Gradle with a Java 17 toolchain, + reproducible shaded jars, formatting checks, and the Utilities-OG and + DiamondBank-OG submodules. +- Removed the Vault dependency and the scheduled Vault-backed member-fee and + upkeep tasks. +- Removed the legacy WorldGuard 6 provider. +- Removed bStats telemetry from the fork. + +## Fork baseline + +The fork last merged upstream SimpleClans history through commit `0406c61b` +on 2026-03-30. Earlier SimpleClans release history belongs to the upstream +project. diff --git a/README.md b/README.md index e3d47006..fb9bceda 100644 --- a/README.md +++ b/README.md @@ -1,95 +1,84 @@ -[![Unions-OG Logo](https://i.imgur.com/9vgfVdX.png)](https://www.spigotmc.org/resources/simpleclans.71242/) +![Unions-OG Logo](https://i.imgur.com/9vgfVdX.png) -Unions-OG -========== +# Unions-OG -Full featured union system for PVP Minecraft servers! +A full-featured union system for PvP Minecraft servers. -[![Build Status](https://img.shields.io/jenkins/build?jobUrl=https%3A%2F%2Fci.roinujnosde.me%2Fjob%2FSimpleClans%2F)](https://ci.roinujnosde.me/job/SimpleClans/) -[![Spiget Downloads](https://img.shields.io/spiget/downloads/71242)](https://www.spigotmc.org/resources/simpleclans.71242/) -[![Issues](https://img.shields.io/github/issues/RoinujNosde/SimpleClans.svg)](https://github.com/RoinujNosde/SimpleClans/issues) -[![Crowdin](https://badges.crowdin.net/simpleclans/localized.svg)](https://crowdin.com/project/simpleclans) -[![Discord](https://img.shields.io/discord/719557355917934613?label=discord&logo=discord)](https://discord.gg/CkNwgdE) +> [!IMPORTANT] +> Unions-OG is an unofficial soft fork of +> [SimpleClans](https://github.com/RoinujNosde/SimpleClans), maintained for the +> TrueOG Network. Upstream behavior is retained where practical, while +> branding, integrations, and selected gameplay behavior intentionally differ. +Current fork version: **2.3** -#### Download Link +- [Fork-only changelog](CHANGELOG.md) +- [PlaceholderAPI and MiniPlaceholders reference](placeholder-info.txt) +- [Upstream SimpleClans project](https://github.com/RoinujNosde/SimpleClans) -* [Download from Spigot](https://www.spigotmc.org/resources/simpleclans.71242/) +## Main fork differences -#### Documentation +- Uses Unions-OG branding and union terminology. +- Provides `/union` and `/unions` while keeping `/clan` and `/clans` as + compatibility aliases. +- Uses `/u` for union chat; a bare `/u` toggles the union-chat channel. +- Adds `/union color ` with the `unionsog.leader.color` permission. +- Supports both `simpleclans` and `simpleunions` placeholder identifiers in + PlaceholderAPI and MiniPlaceholders. +- Uses DiamondBank-OG instead of Vault for player economy access. Union-owned + bank accounts remain under development, so their commands are currently + disabled. +- Treats unions as verified without requiring the upstream verification + workflow. +- Includes a configuration-gated, transactional importer for the legacy + unions database. -* [Documentation](https://simpleclans.gitbook.io/simpleclans/) -* Commands & Permissions - * [Permissions](https://simpleclans.gitbook.io/simpleclans/commands-and-permissions/permissions) - * [Commands](https://simpleclans.gitbook.io/simpleclans/commands-and-permissions/commands) - * [Union Alliances and Rivalries](https://simpleclans.gitbook.io/simpleclans/commands-and-permissions/aliances-and-rivalries) - * [Union Ranks with Permissions](https://simpleclans.gitbook.io/simpleclans/commands-and-permissions/ranks-with-permissions) -* How to Setup - * [Configuration](https://simpleclans.gitbook.io/simpleclans/how-to-setup/configuration) - * [Translation](https://simpleclans.gitbook.io/simpleclans/how-to-setup/translation) - * [Union Member Fee](https://simpleclans.gitbook.io/simpleclans/how-to-setup/member-fee) - * [Union Upkeep](https://simpleclans.gitbook.io/simpleclans/how-to-setup/clan-upkeep) - * [Union name above players head](https://simpleclans.gitbook.io/simpleclans/how-to-setup/clan-below-players-name) - * [Union name on Tablist](https://simpleclans.gitbook.io/simpleclans/how-to-setup/clan-on-tablist) -* Plugins & Development - * [Unions-OG API Example](https://simpleclans.gitbook.io/simpleclans/other/simpleclans-api) - * [Placeholder and MiniPlaceholder Reference](placeholder-info.txt) - * [Land Protection Plugins](https://simpleclans.gitbook.io/simpleclans/other/land-claims) - -#### Support & Suggestions +See the [changelog](CHANGELOG.md) for the complete list of changes maintained +by this fork. Upstream SimpleClans changes are intentionally not repeated +there. -* [Discord Support](https://discord.gg/CkNwgdE) -* [Bugs and Suggestions](https://github.com/RoinujNosde/SimpleClans/issues) +## Documentation -#### Current Changes over SimpleClans +The [SimpleClans documentation](https://simpleclans.gitbook.io/simpleclans/) +remains the baseline reference for inherited features. It uses upstream clan +terminology, and fork-specific commands, permissions, banking behavior, and +integrations may differ. -* Renamed to `Unions-OG`. -* Clans renamed to unions. -* Added /union commands. -* Union tag color changes now use `/union color `. -* The new permission node is `simpleclans.leader.color`. +- [Upstream commands](https://simpleclans.gitbook.io/simpleclans/commands-and-permissions/commands) +- [Upstream permissions](https://simpleclans.gitbook.io/simpleclans/commands-and-permissions/permissions) +- [Upstream configuration](https://simpleclans.gitbook.io/simpleclans/how-to-setup/configuration) +- [Upstream ranks](https://simpleclans.gitbook.io/simpleclans/commands-and-permissions/ranks-with-permissions) +- [Upstream land protection integrations](https://simpleclans.gitbook.io/simpleclans/other/land-claims) -#### Developers +For the unmodified upstream plugin and its public releases, use the +[SimpleClans Spigot page](https://www.spigotmc.org/resources/simpleclans.71242/). +That download is not an Unions-OG build. -Including Unions-OG with Maven: -```xml - - - roinujnosde-repo - https://repo.roinujnosde.me/releases/ - - -``` -```xml - - - net.sacredlabyrinth.phaed.simpleclans - SimpleClans - 2.19.2 - provided - - +## Building + +The build requires Java 17. Clone with submodules, then run: + +```bash +git submodule update --init --recursive +./gradlew build ``` -Using Gradle: -```groovy -repositories { - maven { - url "https://repo.roinujnosde.me/releases/" - } -} -dependencies { - compileOnly "net.sacredlabyrinth.phaed.simpleclans:SimpleClans:2.19.2" -} -``` +The shaded plugin is produced under `build/libs/` with the +`Unions-OG-2.3.jar` name. -Feel free to learn our [Javadoc](https://ci.roinujnosde.me/job/SimpleClans/Javadoc/). -Compatibility note: the published Maven coordinates, package names, and permissions still use `simpleclans`. -PlaceholderAPI and MiniPlaceholders support both `simpleclans` and `simpleunions`; see [placeholder-info.txt](placeholder-info.txt) for the full placeholder reference. -##### Latest version: -[![Maven metadata URL](https://img.shields.io/maven-metadata/v?metadataUrl=https%3A%2F%2Frepo.roinujnosde.me%2Freleases%2Fnet%2Fsacredlabyrinth%2Fphaed%2Fsimpleclans%2FSimpleClans%2Fmaven-metadata.xml)](https://repo.roinujnosde.me/#/releases/net/sacredlabyrinth/phaed/simpleclans/SimpleClans) -##### API Examples -* [Examples of how to use the API](https://simpleclans.gitbook.io/simpleclans/other/simpleclans-api) +## Developer compatibility +- Java and Gradle namespace: `net.trueog.unionsog` +- Bukkit main class: `net.trueog.unionsog.UnionsOG` +- Permission prefix: `unionsog.*` +- Command aliases: `union`, `unions`, `clan`, and `clans` +- Placeholder identifiers: `simpleunions` and the retained `simpleclans` + compatibility identifier -[![Mikasa Host](https://mikasa.host/images/partners/banners/SimpleClans.gif)](https://mikasa.host/ref/6) +The old SimpleClans Maven coordinates and Java packages do not describe this +fork and should not be used as Unions-OG API coordinates. + +## Credits and license + +Unions-OG is derived from SimpleClans by its original and upstream +contributors. This fork remains licensed under the [GNU GPL v3](LICENSE). diff --git a/build.gradle.kts b/build.gradle.kts index 645dff88..c76fe52d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -29,7 +29,7 @@ kotlin { jvmToolchain(17) } /* ----------------------------- Metadata ------------------------------ */ group = "net.trueog.unionsog" // Declare bundle identifier. -version = "2.20" // Declare plugin version (will be in .jar). +version = "2.3" // Declare plugin version (will be in .jar). val apiVersion = "1.19" // Declare minecraft server target version. @@ -93,6 +93,7 @@ dependencies { testImplementation("org.junit.jupiter:junit-jupiter-params:5.11.3") testImplementation("org.mockito:mockito-core:5.14.2") testImplementation("org.mockito:mockito-junit-jupiter:5.14.2") + testImplementation("com.h2database:h2:2.3.232") } apply(from = "eclipse.gradle.kts") // Import eclipse classpath support script. diff --git a/src/main/java/net/trueog/unionsog/UnionsOG.java b/src/main/java/net/trueog/unionsog/UnionsOG.java index 5262870c..41f12eeb 100644 --- a/src/main/java/net/trueog/unionsog/UnionsOG.java +++ b/src/main/java/net/trueog/unionsog/UnionsOG.java @@ -13,6 +13,7 @@ import net.trueog.unionsog.migrations.BbMigration; import net.trueog.unionsog.migrations.ChatFormatMigration; import net.trueog.unionsog.migrations.LanguageMigration; import net.trueog.unionsog.migrations.UnionBankZeroMigration; +import net.trueog.unionsog.migrations.legacy.LegacyUnionsDatabaseMigrationRunner.MigrationStartupException; import net.trueog.unionsog.proxy.BungeeManager; import net.trueog.unionsog.proxy.ProxyManager; import net.trueog.unionsog.tasks.*; @@ -60,6 +61,7 @@ public class UnionsOG extends JavaPlugin { private ChatManager chatManager; private ProxyManager proxyManager; private boolean hasUUID; + private boolean startupComplete; private static final Pattern ACF_PLACEHOLDER_PATTERN = Pattern.compile("\\{(?[a-zA-Z]+?)}"); private BankLogger bankLogger; @@ -125,7 +127,18 @@ public class UnionsOG extends JavaPlugin { requestManager = new RequestManager(); clanManager = new ClanManager(); proxyManager = new BungeeManager(this); - storageManager = new StorageManager(); + try { + + storageManager = new StorageManager(); + + } catch (MigrationStartupException ex) { + + getLogger().log(Level.SEVERE, ex.getMessage(), ex); + getServer().getPluginManager().disablePlugin(this); + return; + + } + teleportManager = new TeleportManager(); protectionManager = new ProtectionManager(); protectionManager.registerListeners(); @@ -146,6 +159,7 @@ public class UnionsOG extends JavaPlugin { hookIntoPAPI(); hookIntoMiniPlaceholders(); new UpdateChecker(this).check(); + startupComplete = true; } @@ -213,15 +227,26 @@ public class UnionsOG extends JavaPlugin { @Override public void onDisable() { - if (getSettingsManager().is(PERFORMANCE_SAVE_PERIODICALLY)) { + if (storageManager != null) { - getStorageManager().saveModified(); + if (startupComplete && settingsManager != null && settingsManager.is(PERFORMANCE_SAVE_PERIODICALLY)) { + + storageManager.saveModified(); + + } + + storageManager.closeConnection(); } - getStorageManager().closeConnection(); - getPermissionsManager().savePermissions(); - getSettingsManager().loadAndSave(); + if (!startupComplete) { + + return; + + } + + permissionsManager.savePermissions(); + settingsManager.loadAndSave(); } diff --git a/src/main/java/net/trueog/unionsog/managers/SettingsManager.java b/src/main/java/net/trueog/unionsog/managers/SettingsManager.java index f18e2b91..c32d13cf 100644 --- a/src/main/java/net/trueog/unionsog/managers/SettingsManager.java +++ b/src/main/java/net/trueog/unionsog/managers/SettingsManager.java @@ -591,6 +591,7 @@ public final class SettingsManager { MYSQL_USERNAME("mysql.username", ""), MYSQL_HOST("mysql.host", "localhost"), MYSQL_PORT("mysql.port", 3306), MYSQL_ENABLE("mysql.enable", false), MYSQL_PASSWORD("mysql.password", ""), MYSQL_DATABASE("mysql.database", ""), MYSQL_TABLE_PREFIX("mysql.table_prefix", "sc_"), + MYSQL_MIGRATE_LEGACY_UNIONS_DATABASE("mysql.migrate-legacy-unions-database", false), /* * ================ > Permissions Settings ================ */ diff --git a/src/main/java/net/trueog/unionsog/managers/StorageManager.java b/src/main/java/net/trueog/unionsog/managers/StorageManager.java index ddd7bb1f..c607a10b 100644 --- a/src/main/java/net/trueog/unionsog/managers/StorageManager.java +++ b/src/main/java/net/trueog/unionsog/managers/StorageManager.java @@ -4,6 +4,7 @@ import net.trueog.unionsog.*; import net.trueog.unionsog.events.ClanBalanceUpdateEvent; import net.trueog.unionsog.loggers.BankLogger; import net.trueog.unionsog.loggers.BankOperator; +import net.trueog.unionsog.migrations.legacy.LegacyUnionsDatabaseMigrationRunner; import net.trueog.unionsog.storage.DBCore; import net.trueog.unionsog.storage.MySQLCore; import net.trueog.unionsog.storage.SQLiteCore; @@ -50,8 +51,20 @@ public final class StorageManager { public StorageManager() { plugin = UnionsOG.getInstance(); + LegacyUnionsDatabaseMigrationRunner.validateConfiguration(plugin); initiateDB(); updateDatabase(); + try { + + LegacyUnionsDatabaseMigrationRunner.run(plugin, core.getConnection()); + + } catch (LegacyUnionsDatabaseMigrationRunner.MigrationStartupException ex) { + + core.close(); + throw ex; + + } + importFromDatabase(); } diff --git a/src/main/java/net/trueog/unionsog/migrations/legacy/LegacyUnionsDatabaseMigration.java b/src/main/java/net/trueog/unionsog/migrations/legacy/LegacyUnionsDatabaseMigration.java new file mode 100644 index 00000000..44ea8f47 --- /dev/null +++ b/src/main/java/net/trueog/unionsog/migrations/legacy/LegacyUnionsDatabaseMigration.java @@ -0,0 +1,860 @@ +package net.trueog.unionsog.migrations.legacy; + +import com.google.gson.JsonObject; +import org.jetbrains.annotations.Nullable; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.sql.SQLException; +import java.sql.Statement; +import java.sql.Types; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.TreeSet; +import java.util.UUID; +import java.util.function.Function; +import java.util.regex.Pattern; + +/** + * Imports the legacy unions database schema into Unions-OG. + *

+ * The importer is intentionally based only on the database schema. It never + * changes the source tables, requires empty destination tables, and records a + * successful import in a destination-owned history table. + *

+ */ +public final class LegacyUnionsDatabaseMigration { + + static final String SOURCE_UNIONS_TABLE = "unions_parties"; + static final String SOURCE_PLAYERS_TABLE = "unions_players"; + private static final String MIGRATION_ID = "legacy-unions-database-v1"; + private static final int MAX_TAG_LENGTH = 25; + private static final int MAX_UNION_NAME_LENGTH = 100; + private static final int MAX_DESCRIPTION_LENGTH = 255; + private static final int MAX_PLAYER_NAME_LENGTH = 16; + private static final Pattern IDENTIFIER = Pattern.compile("[A-Za-z0-9_]+"); + private static final Pattern COLOR_CODE = Pattern.compile("(?i)[&\u00a7][0-9A-FK-OR]"); + private static final Pattern HEX_COLOR_CODE = Pattern + .compile("(?i)(?:[&\u00a7]#[0-9A-F]{6}|[&\u00a7]x(?:[&\u00a7][0-9A-F]){6})"); + + private static final Set SOURCE_UNION_COLUMNS = Set.of("id", "name", "tag", "leader", "description", "motd", + "color", "home"); + private static final Set SOURCE_PLAYER_COLUMNS = Set.of("uuid", "party"); + + private final Connection connection; + private final String targetUnionsTable; + private final String targetPlayersTable; + private final String targetKillsTable; + private final String historyTable; + private final String ranksJson; + private final Function playerNameResolver; + private final long migrationTime; + + public LegacyUnionsDatabaseMigration(Connection connection, String targetTablePrefix, String ranksJson, + Function playerNameResolver) + { + + this.connection = Objects.requireNonNull(connection, "connection"); + Objects.requireNonNull(targetTablePrefix, "targetTablePrefix"); + this.ranksJson = Objects.requireNonNull(ranksJson, "ranksJson"); + this.playerNameResolver = Objects.requireNonNull(playerNameResolver, "playerNameResolver"); + validateTargetTablePrefix(targetTablePrefix); + targetUnionsTable = validateIdentifier(targetTablePrefix + "clans"); + targetPlayersTable = validateIdentifier(targetTablePrefix + "players"); + targetKillsTable = validateIdentifier(targetTablePrefix + "kills"); + historyTable = validateIdentifier(targetTablePrefix + "unionsog_migrations"); + migrationTime = System.currentTimeMillis(); + + } + + public static void validateTargetTablePrefix(String targetTablePrefix) { + + Objects.requireNonNull(targetTablePrefix, "targetTablePrefix"); + validateIdentifier(targetTablePrefix + "clans"); + final String playersTable = validateIdentifier(targetTablePrefix + "players"); + validateIdentifier(targetTablePrefix + "kills"); + validateIdentifier(targetTablePrefix + "unionsog_migrations"); + if (playersTable.equalsIgnoreCase(SOURCE_PLAYERS_TABLE)) { + + throw new IllegalArgumentException("The target table prefix makes the destination players table collide " + + "with the legacy source table. Use a different mysql.table_prefix."); + + } + + } + + /** + * Executes the import once. + * + * @return a summary of the imported records + * @throws SQLException when validation or import fails + */ + public Result migrate() throws SQLException { + + validateColumns(targetUnionsTable, + Set.of("verified", "tag", "color_tag", "name", "description", "friendly_fire", "founded", "last_used", + "packed_allies", "packed_rivals", "packed_bb", "cape_url", "flags", "balance", "fee_enabled", + "fee_value", "ranks", "banner")); + validateColumns(targetPlayersTable, + Set.of("uuid", "name", "leader", "tag", "friendly_fire", "neutral_kills", "rival_kills", + "civilian_kills", "ally_kills", "deaths", "last_seen", "join_date", "trusted", "flags", + "packed_past_clans", "resign_times", "locale")); + validateColumns(targetKillsTable, Set.of("kill_id")); + createHistoryTable(); + + if (wasApplied()) { + + return Result.previouslyApplied(); + + } + + requireTransactionalDestination(); + validateColumns(SOURCE_UNIONS_TABLE, SOURCE_UNION_COLUMNS); + validateColumns(SOURCE_PLAYERS_TABLE, SOURCE_PLAYER_COLUMNS); + + final boolean originalAutoCommit = connection.getAutoCommit(); + try { + + connection.setAutoCommit(false); + // Reserving the migration id first serializes concurrent startup attempts. + // The row is rolled back with the imported data if anything fails. + recordApplied(); + requireEmptyTarget(); + final List legacyUnions = readLegacyUnions(); + final List legacyPlayers = readLegacyPlayers(); + final TagAssignment tagAssignment = assignTags(legacyUnions); + final NameAssignment nameAssignment = assignPlayerNames(legacyPlayers); + + insertUnions(legacyUnions, tagAssignment.tags()); + final PlayerImportCounts playerCounts = insertPlayers(legacyPlayers, legacyUnions, tagAssignment.tags(), + nameAssignment.names()); + connection.commit(); + + return new Result(false, legacyUnions.size(), legacyPlayers.size(), nameAssignment.placeholderCount(), + tagAssignment.adjustedCount(), playerCounts.orphanedMemberships(), + playerCounts.unionsWithoutLeaders()); + + } catch (SQLException | RuntimeException ex) { + + connection.rollback(); + throw ex; + + } finally { + + connection.setAutoCommit(originalAutoCommit); + + } + + } + + private void createHistoryTable() throws SQLException { + + final String sql = "CREATE TABLE IF NOT EXISTS " + quote(historyTable) + " (" + + " `migration` varchar(100) NOT NULL," + " `applied_at` bigint NOT NULL," + + " PRIMARY KEY (`migration`)" + ") ENGINE=InnoDB;"; + try (Statement statement = connection.createStatement()) { + + statement.execute(sql); + + } + + } + + private boolean wasApplied() throws SQLException { + + final String sql = "SELECT 1 FROM " + quote(historyTable) + " WHERE `migration` = ?;"; + try (PreparedStatement statement = connection.prepareStatement(sql)) { + + statement.setString(1, MIGRATION_ID); + try (ResultSet result = statement.executeQuery()) { + + return result.next(); + + } + + } + + } + + private void recordApplied() throws SQLException { + + final String sql = "INSERT INTO " + quote(historyTable) + " (`migration`, `applied_at`) VALUES (?, ?);"; + try (PreparedStatement statement = connection.prepareStatement(sql)) { + + statement.setString(1, MIGRATION_ID); + statement.setLong(2, migrationTime); + statement.executeUpdate(); + + } + + } + + private void requireEmptyTarget() throws SQLException { + + final long unions = countRows(targetUnionsTable); + final long players = countRows(targetPlayersTable); + final long kills = countRows(targetKillsTable); + if (unions != 0 || players != 0 || kills != 0) { + + throw new SQLException(String.format("Legacy database migration requires empty destination tables " + + "(found %d union(s), %d player(s), and %d kill(s)).", unions, players, kills)); + + } + + } + + private void requireTransactionalDestination() throws SQLException { + + if (!connection.getMetaData().supportsTransactions()) { + + throw new SQLException("Legacy database migration requires a transaction-capable MySQL connection."); + + } + + // The plugin uses MySQL/MariaDB in production. Other transactional JDBC + // databases are accepted here so the complete migration can be tested + // in-memory. + final String databaseProduct = connection.getMetaData().getDatabaseProductName().toLowerCase(Locale.ROOT); + if (!databaseProduct.contains("mysql") && !databaseProduct.contains("mariadb")) { + + return; + + } + + final String sql = "SELECT `ENGINE` FROM `information_schema`.`TABLES`" + + " WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = ?;"; + for (final String table : List.of(targetUnionsTable, targetPlayersTable, targetKillsTable, historyTable)) { + + try (PreparedStatement statement = connection.prepareStatement(sql)) { + + statement.setString(1, table); + try (ResultSet result = statement.executeQuery()) { + + if (!result.next() || !"InnoDB".equalsIgnoreCase(result.getString("ENGINE"))) { + + throw new SQLException("Legacy database migration requires the destination table `" + table + + "` to use the transactional InnoDB engine."); + + } + + } + + } + + } + + } + + private long countRows(String table) throws SQLException { + + try (Statement statement = connection.createStatement(); + ResultSet result = statement.executeQuery("SELECT COUNT(*) FROM " + quote(table) + ";")) + { + + result.next(); + return result.getLong(1); + + } + + } + + private List readLegacyUnions() throws SQLException { + + final String sql = "SELECT `id`, `name`, `tag`, `leader`, `description`, `motd`, `color`, `home` FROM " + + quote(SOURCE_UNIONS_TABLE) + " ORDER BY `id`;"; + final List unions = new ArrayList<>(); + try (Statement statement = connection.createStatement(); ResultSet result = statement.executeQuery(sql)) { + + while (result.next()) { + + final UUID id = requiredUuid(result.getString("id"), SOURCE_UNIONS_TABLE + ".id"); + final UUID leader = optionalUuid(result.getString("leader"), SOURCE_UNIONS_TABLE + ".leader"); + unions.add(new LegacyUnion(id, result.getString("name"), result.getString("tag"), leader, + result.getString("description"), result.getString("motd"), result.getString("color"), + result.getString("home"))); + + } + + } + + return unions; + + } + + private List readLegacyPlayers() throws SQLException { + + final String sql = "SELECT `uuid`, `party` FROM " + quote(SOURCE_PLAYERS_TABLE) + " ORDER BY `uuid`;"; + final List players = new ArrayList<>(); + try (Statement statement = connection.createStatement(); ResultSet result = statement.executeQuery(sql)) { + + while (result.next()) { + + final UUID uuid = requiredUuid(result.getString("uuid"), SOURCE_PLAYERS_TABLE + ".uuid"); + final UUID unionId = optionalUuid(result.getString("party"), SOURCE_PLAYERS_TABLE + ".party"); + players.add(new LegacyPlayer(uuid, unionId)); + + } + + } + + return players; + + } + + private void insertUnions(List unions, Map tags) throws SQLException { + + final String sql = "INSERT INTO " + quote(targetUnionsTable) + + " (`verified`, `tag`, `color_tag`, `name`, `description`, `friendly_fire`, `founded`," + + " `last_used`, `packed_allies`, `packed_rivals`, `packed_bb`, `cape_url`, `flags`, `balance`," + + " `fee_enabled`, `fee_value`, `ranks`, `banner`) VALUES" + + " (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);"; + try (PreparedStatement statement = connection.prepareStatement(sql)) { + + for (LegacyUnion union : unions) { + + final String tag = tags.get(union.id()); + statement.setInt(1, 1); + statement.setString(2, tag); + statement.setString(3, coloredTag(union.color(), tag)); + statement.setString(4, truncate(nonBlankOr(union.name(), tag), MAX_UNION_NAME_LENGTH)); + statement.setString(5, truncateNullable(union.description(), MAX_DESCRIPTION_LENGTH)); + statement.setInt(6, 0); + statement.setLong(7, migrationTime); + statement.setLong(8, migrationTime); + statement.setString(9, ""); + statement.setString(10, ""); + statement.setString(11, packedBulletin(union.motd())); + statement.setString(12, ""); + statement.setString(13, homeFlags(union.home())); + statement.setDouble(14, 0); + statement.setInt(15, 0); + statement.setDouble(16, 0); + statement.setString(17, ranksJson); + statement.setNull(18, Types.LONGVARCHAR); + statement.addBatch(); + + } + + statement.executeBatch(); + + } + + } + + private PlayerImportCounts insertPlayers(List players, List unions, + Map tags, Map names) throws SQLException + { + + final Map unionsById = new HashMap<>(); + for (LegacyUnion union : unions) { + + unionsById.put(union.id(), union); + + } + + final Set unionsWithLeaders = new HashSet<>(); + int orphanedMemberships = 0; + final String sql = "INSERT INTO " + quote(targetPlayersTable) + + " (`uuid`, `name`, `leader`, `tag`, `friendly_fire`, `neutral_kills`, `rival_kills`," + + " `civilian_kills`, `ally_kills`, `deaths`, `last_seen`, `join_date`, `trusted`, `flags`," + + " `packed_past_clans`, `resign_times`, `locale`) VALUES" + + " (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);"; + try (PreparedStatement statement = connection.prepareStatement(sql)) { + + for (LegacyPlayer player : players) { + + final LegacyUnion union = unionsById.get(player.unionId()); + final boolean orphaned = player.unionId() != null && union == null; + if (orphaned) { + + orphanedMemberships++; + + } + + final boolean leader = union != null && player.uuid().equals(union.leader()); + if (leader) { + + unionsWithLeaders.add(union.id()); + + } + + final String tag = union != null ? tags.get(union.id()) : ""; + + statement.setString(1, player.uuid().toString()); + statement.setString(2, names.get(player.uuid())); + statement.setInt(3, leader ? 1 : 0); + statement.setString(4, tag); + statement.setInt(5, 0); + statement.setInt(6, 0); + statement.setInt(7, 0); + statement.setInt(8, 0); + statement.setInt(9, 0); + statement.setInt(10, 0); + statement.setLong(11, migrationTime); + statement.setLong(12, migrationTime); + statement.setInt(13, leader ? 1 : 0); + statement.setString(14, "{}"); + statement.setString(15, ""); + statement.setString(16, "{}"); + statement.setNull(17, Types.VARCHAR); + statement.addBatch(); + + } + + statement.executeBatch(); + + } + + return new PlayerImportCounts(orphanedMemberships, unions.size() - unionsWithLeaders.size()); + + } + + private TagAssignment assignTags(List unions) { + + final Map tags = new LinkedHashMap<>(); + final Set used = new HashSet<>(); + int adjusted = 0; + for (LegacyUnion union : unions) { + + final String base = normalizedTag(union.tag(), union.name(), union.id()); + final String assigned = uniqueTag(base, union.id(), used); + if (!assigned.equals(base)) { + + adjusted++; + + } + + tags.put(union.id(), assigned); + + } + + return new TagAssignment(tags, adjusted); + + } + + private NameAssignment assignPlayerNames(List players) { + + final Map names = new LinkedHashMap<>(); + final Set used = new HashSet<>(); + int placeholders = 0; + for (LegacyPlayer player : players) { + + String resolved = null; + try { + + resolved = playerNameResolver.apply(player.uuid()); + + } catch (RuntimeException ignored) { + + // A missing or unavailable server profile cache must not lose membership. + + } + + if (!isUsablePlayerName(resolved) || !used.add(resolved.toLowerCase(Locale.ROOT))) { + + resolved = uniquePlaceholderName(player.uuid(), used); + placeholders++; + + } + + names.put(player.uuid(), resolved); + + } + + return new NameAssignment(names, placeholders); + + } + + private void validateColumns(String table, Set requiredColumns) throws SQLException { + + final Set available = new HashSet<>(); + final String sql = "SELECT * FROM " + quote(validateIdentifier(table)) + " WHERE 1 = 0;"; + try (Statement statement = connection.createStatement(); ResultSet result = statement.executeQuery(sql)) { + + final ResultSetMetaData metadata = result.getMetaData(); + for (int i = 1; i <= metadata.getColumnCount(); i++) { + + available.add(metadata.getColumnLabel(i).toLowerCase(Locale.ROOT)); + + } + + } catch (SQLException ex) { + + throw new SQLException("Required migration table `" + table + "` is missing or unreadable.", ex); + + } + + final Set missing = new TreeSet<>(requiredColumns); + missing.removeAll(available); + if (!missing.isEmpty()) { + + throw new SQLException( + "Table `" + table + "` is missing required column(s): " + String.join(", ", missing)); + + } + + } + + static String normalizedTag(@Nullable String preferredTag, @Nullable String unionName, UUID unionId) { + + String candidate = nonBlankOr(preferredTag, unionName); + candidate = candidate == null ? "" : stripColors(candidate); + final StringBuilder normalized = new StringBuilder(); + for (int i = 0; i < candidate.length(); i++) { + + final char character = candidate.charAt(i); + if ((character >= 'a' && character <= 'z') || (character >= 'A' && character <= 'Z') + || (character >= '0' && character <= '9')) + { + + normalized.append(Character.toLowerCase(character)); + + } + + } + + if (normalized.length() == 0) { + + normalized.append("union"); + normalized.append(uuidCharacters(unionId).substring(0, 8)); + + } + + return truncate(normalized.toString(), MAX_TAG_LENGTH); + + } + + static String uniqueTag(String base, UUID unionId, Set usedTags) { + + final String loweredBase = base.toLowerCase(Locale.ROOT); + if (usedTags.add(loweredBase)) { + + return loweredBase; + + } + + final String uuid = uuidCharacters(unionId); + for (int suffixLength = 8; suffixLength <= Math.min(uuid.length(), MAX_TAG_LENGTH); suffixLength += 4) { + + final String suffix = uuid.substring(0, suffixLength); + final String candidate = truncate(loweredBase, MAX_TAG_LENGTH - suffix.length()) + suffix; + if (usedTags.add(candidate)) { + + return candidate; + + } + + } + + throw new IllegalStateException("Could not derive a unique tag for legacy union " + unionId); + + } + + static String coloredTag(@Nullable String colorName, String tag) { + + final String code; + final String normalizedColor = colorName == null ? "" + : colorName.toLowerCase(Locale.ROOT).replace("_", "").replace("-", "").replace(" ", ""); + switch (normalizedColor) { + + case "black": + code = "0"; + break; + case "darkblue": + code = "1"; + break; + case "darkgreen": + code = "2"; + break; + case "darkaqua": + code = "3"; + break; + case "darkred": + code = "4"; + break; + case "darkpurple": + code = "5"; + break; + case "gold": + code = "6"; + break; + case "gray": + case "grey": + code = "7"; + break; + case "darkgray": + case "darkgrey": + code = "8"; + break; + case "blue": + code = "9"; + break; + case "green": + code = "a"; + break; + case "aqua": + code = "b"; + break; + case "red": + code = "c"; + break; + case "lightpurple": + code = "d"; + break; + case "yellow": + code = "e"; + break; + case "white": + code = "f"; + break; + default: + return tag; + + } + + return "&" + code + tag; + + } + + static String fallbackPlayerName(UUID uuid) { + + return uuidCharacters(uuid).substring(0, MAX_PLAYER_NAME_LENGTH); + + } + + static String homeFlags(@Nullable String packedHome) { + + final JsonObject flags = new JsonObject(); + if (packedHome == null || packedHome.trim().isEmpty()) { + + return flags.toString(); + + } + + final String[] parts = packedHome.split(",", -1); + if (parts.length < 7 || parts[1].trim().isEmpty()) { + + return flags.toString(); + + } + + try { + + final double x = finiteDouble(parts[2]); + final double y = finiteDouble(parts[3]); + final double z = finiteDouble(parts[4]); + final double yaw = finiteDouble(parts[5]); + final double pitch = finiteDouble(parts[6]); + flags.addProperty("homeWorld", parts[1].trim()); + flags.addProperty("homeX", x); + flags.addProperty("homeY", y); + flags.addProperty("homeZ", z); + flags.addProperty("homeYaw", yaw); + flags.addProperty("homePitch", pitch); + + } catch (IllegalArgumentException ignored) { + + return "{}"; + + } + + return flags.toString(); + + } + + private String packedBulletin(@Nullable String motd) { + + if (motd == null || motd.trim().isEmpty()) { + + return ""; + + } + + return migrationTime + "_" + motd.replace('|', '\u00a6'); + + } + + private static boolean isUsablePlayerName(@Nullable String playerName) { + + if (playerName == null || playerName.trim().isEmpty() || playerName.length() > MAX_PLAYER_NAME_LENGTH) { + + return false; + + } + + for (int i = 0; i < playerName.length(); i++) { + + if (Character.isISOControl(playerName.charAt(i))) { + + return false; + + } + + } + + return true; + + } + + private static double finiteDouble(String value) { + + final double parsed = Double.parseDouble(value.trim()); + if (!Double.isFinite(parsed)) { + + throw new IllegalArgumentException("Non-finite coordinate"); + + } + + return parsed; + + } + + private static String uniquePlaceholderName(UUID uuid, Set usedNames) { + + final String characters = uuidCharacters(uuid); + for (int start = 0; start <= characters.length() - MAX_PLAYER_NAME_LENGTH; start++) { + + final String candidate = characters.substring(start, start + MAX_PLAYER_NAME_LENGTH); + if (usedNames.add(candidate.toLowerCase(Locale.ROOT))) { + + return candidate; + + } + + } + + throw new IllegalStateException("Could not derive a unique placeholder name for player " + uuid); + + } + + private static UUID requiredUuid(@Nullable String value, String field) throws SQLException { + + final UUID uuid = optionalUuid(value, field); + if (uuid == null) { + + throw new SQLException("Required UUID field `" + field + "` is null or empty."); + + } + + return uuid; + + } + + private static @Nullable UUID optionalUuid(@Nullable String value, String field) throws SQLException { + + if (value == null || value.trim().isEmpty()) { + + return null; + + } + + try { + + return UUID.fromString(value.trim()); + + } catch (IllegalArgumentException ex) { + + throw new SQLException("Invalid UUID in `" + field + "`: " + value, ex); + + } + + } + + private static String validateIdentifier(String identifier) { + + if (!IDENTIFIER.matcher(identifier).matches() || identifier.length() > 64) { + + throw new IllegalArgumentException("Unsafe or unsupported SQL identifier: " + identifier); + + } + + return identifier; + + } + + private static String quote(String identifier) { + + return "`" + validateIdentifier(identifier) + "`"; + + } + + private static String stripColors(String value) { + + return COLOR_CODE.matcher(HEX_COLOR_CODE.matcher(value).replaceAll("")).replaceAll(""); + + } + + private static String uuidCharacters(UUID uuid) { + + return uuid.toString().replace("-", ""); + + } + + private static @Nullable String nonBlankOr(@Nullable String preferred, @Nullable String fallback) { + + if (preferred != null && !preferred.trim().isEmpty()) { + + return preferred.trim(); + + } + + return fallback == null ? null : fallback.trim(); + + } + + private static String truncate(String value, int maxLength) { + + return value.length() <= maxLength ? value : value.substring(0, maxLength); + + } + + private static @Nullable String truncateNullable(@Nullable String value, int maxLength) { + + return value == null ? null : truncate(value, maxLength); + + } + + public record Result(boolean alreadyApplied, int unions, int players, int placeholderNames, int adjustedTags, + int orphanedMemberships, int unionsWithoutLeaders) + { + + private static Result previouslyApplied() { + + return new Result(true, 0, 0, 0, 0, 0, 0); + + } + + } + + private record LegacyUnion(UUID id, @Nullable String name, @Nullable String tag, @Nullable UUID leader, + @Nullable String description, @Nullable String motd, @Nullable String color, @Nullable String home) + { + + } + + private record LegacyPlayer(UUID uuid, @Nullable UUID unionId) { + + } + + private record TagAssignment(Map tags, int adjustedCount) { + + } + + private record NameAssignment(Map names, int placeholderCount) { + + } + + private record PlayerImportCounts(int orphanedMemberships, int unionsWithoutLeaders) { + + } + +} diff --git a/src/main/java/net/trueog/unionsog/migrations/legacy/LegacyUnionsDatabaseMigrationRunner.java b/src/main/java/net/trueog/unionsog/migrations/legacy/LegacyUnionsDatabaseMigrationRunner.java new file mode 100644 index 00000000..fd6c81b9 --- /dev/null +++ b/src/main/java/net/trueog/unionsog/migrations/legacy/LegacyUnionsDatabaseMigrationRunner.java @@ -0,0 +1,173 @@ +package net.trueog.unionsog.migrations.legacy; + +import net.trueog.unionsog.Helper; +import net.trueog.unionsog.Rank; +import net.trueog.unionsog.UnionsOG; +import net.trueog.unionsog.managers.SettingsManager; + +import java.sql.Connection; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.function.Function; + +import static net.trueog.unionsog.managers.SettingsManager.ConfigField.CLAN_DEFAULT_RANK; +import static net.trueog.unionsog.managers.SettingsManager.ConfigField.MYSQL_ENABLE; +import static net.trueog.unionsog.managers.SettingsManager.ConfigField.MYSQL_MIGRATE_LEGACY_UNIONS_DATABASE; +import static net.trueog.unionsog.managers.SettingsManager.ConfigField.MYSQL_TABLE_PREFIX; + +/** + * Contains the temporary plugin lifecycle integration for the legacy database + * migration. + */ +public final class LegacyUnionsDatabaseMigrationRunner { + + private LegacyUnionsDatabaseMigrationRunner() { + + } + + public static void validateConfiguration(UnionsOG plugin) { + + final SettingsManager settings = plugin.getSettingsManager(); + if (!settings.is(MYSQL_ENABLE) || !settings.is(MYSQL_MIGRATE_LEGACY_UNIONS_DATABASE)) { + + return; + + } + + try { + + LegacyUnionsDatabaseMigration.validateTargetTablePrefix(settings.getString(MYSQL_TABLE_PREFIX)); + + } catch (RuntimeException ex) { + + throw new MigrationStartupException( + "Legacy unions database migration configuration is invalid. Plugin " + + "startup was aborted so the destination database cannot be populated before a retry.", + ex); + + } + + } + + public static void run(UnionsOG plugin, Connection connection) { + + final SettingsManager settings = plugin.getSettingsManager(); + if (!settings.is(MYSQL_MIGRATE_LEGACY_UNIONS_DATABASE)) { + + return; + + } + + if (!settings.is(MYSQL_ENABLE)) { + + throw new MigrationStartupException("Legacy unions database migration requires mysql.enable to be true. " + + "Plugin startup was aborted so the destination database cannot be populated before a retry."); + + } + + if (connection == null) { + + throw new MigrationStartupException("Legacy unions database migration could not open the MySQL " + + "connection. Plugin startup was aborted so the destination database cannot be populated before " + + "a retry."); + + } + + plugin.getLogger().info("Legacy unions database migration starting."); + + final List starterRanks = new ArrayList<>(settings.getStarterRanks()); + final String configuredDefaultRank = settings.getString(CLAN_DEFAULT_RANK); + final String defaultRank = starterRanks.stream().anyMatch(rank -> rank.getName().equals(configuredDefaultRank)) + ? configuredDefaultRank + : null; + + final LegacyUnionsDatabaseMigration.Result result = migrateOrThrow(connection, + settings.getString(MYSQL_TABLE_PREFIX), Helper.ranksToJson(starterRanks, defaultRank), + uuid -> plugin.getServer().getOfflinePlayer(uuid).getName()); + logResult(plugin, result); + settings.set(MYSQL_MIGRATE_LEGACY_UNIONS_DATABASE, false); + settings.save(); + + } + + static LegacyUnionsDatabaseMigration.Result migrateOrThrow(Connection connection, String targetTablePrefix, + String ranksJson, Function playerNameResolver) + { + + try { + + return new LegacyUnionsDatabaseMigration(connection, targetTablePrefix, ranksJson, playerNameResolver) + .migrate(); + + } catch (SQLException | RuntimeException ex) { + + throw new MigrationStartupException("Legacy unions database migration failed. Plugin startup was aborted " + + "so the destination database cannot be populated before a retry.", ex); + + } + + } + + private static void logResult(UnionsOG plugin, LegacyUnionsDatabaseMigration.Result result) { + + if (result.alreadyApplied()) { + + plugin.getLogger().info("Legacy unions database migration was already applied."); + return; + + } + + plugin.getLogger().info(String.format("Legacy unions database migration imported %d union(s) and %d player(s).", + result.unions(), result.players())); + if (result.placeholderNames() > 0) { + + plugin.getLogger().warning(String.format("%d migrated player name(s) were unavailable in the server " + + "profile cache. Stable placeholders were used and will be replaced when those players next " + + "join.", result.placeholderNames())); + + } + + if (result.adjustedTags() > 0) { + + plugin.getLogger().warning( + String.format("%d duplicate union tag(s) received deterministic suffixes.", result.adjustedTags())); + + } + + if (result.orphanedMemberships() > 0) { + + plugin.getLogger().warning(String.format("%d player membership(s) referenced a missing union and were " + + "imported without an active union.", result.orphanedMemberships())); + + } + + if (result.unionsWithoutLeaders() > 0) { + + plugin.getLogger().warning(String.format("%d imported union(s) did not have a matching leader row.", + result.unionsWithoutLeaders())); + + } + + } + + public static final class MigrationStartupException extends IllegalStateException { + + private static final long serialVersionUID = 1L; + + private MigrationStartupException(String message) { + + super(message); + + } + + private MigrationStartupException(String message, Throwable cause) { + + super(message, cause); + + } + + } + +} diff --git a/src/main/java/net/trueog/unionsog/storage/MySQLCore.java b/src/main/java/net/trueog/unionsog/storage/MySQLCore.java index e296ef00..31e64cd7 100644 --- a/src/main/java/net/trueog/unionsog/storage/MySQLCore.java +++ b/src/main/java/net/trueog/unionsog/storage/MySQLCore.java @@ -5,6 +5,7 @@ import net.trueog.unionsog.UnionsOG; import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; +import java.util.List; import java.util.logging.Logger; /** @@ -40,18 +41,15 @@ public class MySQLCore implements DBCore { private void initialize() { + loadDriver(); + try { - Class.forName("com.mysql.jdbc.Driver"); connection = DriverManager.getConnection( "jdbc:mysql://" + host + ":" + port + "/" + database + "?useUnicode=true&characterEncoding=utf-8&autoReconnect=true&useSSL=false", username, password); - } catch (ClassNotFoundException e) { - - log.severe("ClassNotFoundException! " + e.getMessage()); - } catch (SQLException e) { log.severe("SQLException! " + e.getMessage()); @@ -60,6 +58,32 @@ public class MySQLCore implements DBCore { } + /** + * Loads whichever driver class the server provides. Connector/J 8 dropped the + * legacy class name, so a missing class must not stop the connection attempt: + * JDBC service discovery still finds a driver that is on the classpath. + */ + private void loadDriver() { + + for (String driver : List.of("com.mysql.cj.jdbc.Driver", "com.mysql.jdbc.Driver", "org.mariadb.jdbc.Driver")) { + + try { + + Class.forName(driver); + return; + + } catch (ClassNotFoundException ignored) { + + // Try the next known driver class name. + + } + + } + + log.warning("No known MySQL driver class was found, falling back to JDBC service discovery."); + + } + @Override public Connection getConnection() { diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index ea6a8581..464d8755 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -214,13 +214,17 @@ purge: inactive-clan-days: 7 unverified-clan-days: 2 mysql: - username: '' + username: '^{MARIADB_USER}' host: localhost port: 3306 - enable: false - password: '' - database: '' + enable: true + password: '^{MARIADB_PASSWORD}' + database: 'unions' table_prefix: 'sc_' + # One-time, source-read-only import from the legacy unions schema. + # The clans, players, and kills + # destination tables must be empty and use InnoDB. + migrate-legacy-unions-database: false permissions: auto-group-groupname: false YourClanNameHere: diff --git a/src/test/java/net/trueog/unionsog/migrations/legacy/LegacyUnionsDatabaseMigrationDatabaseTest.java b/src/test/java/net/trueog/unionsog/migrations/legacy/LegacyUnionsDatabaseMigrationDatabaseTest.java new file mode 100644 index 00000000..42f8e306 --- /dev/null +++ b/src/test/java/net/trueog/unionsog/migrations/legacy/LegacyUnionsDatabaseMigrationDatabaseTest.java @@ -0,0 +1,336 @@ +package net.trueog.unionsog.migrations.legacy; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.Map; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class LegacyUnionsDatabaseMigrationDatabaseTest { + + private static final String RANKS_JSON = "{\"ranks\":[],\"defaultRank\":null}"; + private Connection connection; + + @BeforeEach + void createDatabase() throws SQLException { + + final String database = "legacy_migration_" + UUID.randomUUID().toString().replace("-", ""); + connection = DriverManager + .getConnection("jdbc:h2:mem:" + database + ";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1"); + createSourceTables(); + createDestinationTables(); + + } + + @AfterEach + void closeDatabase() throws SQLException { + + connection.close(); + + } + + @Test + void migratesLegacyRowsAndRecordsIdempotency() throws SQLException { + + final UUID firstUnion = UUID.fromString("11111111-1111-1111-1111-111111111111"); + final UUID secondUnion = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + final UUID leader = UUID.fromString("11111111-2222-3333-4444-555555555555"); + final UUID member = UUID.fromString("22222222-3333-4444-5555-666666666666"); + final UUID orphan = UUID.fromString("33333333-4444-5555-6666-777777777777"); + final UUID missingUnion = UUID.fromString("ffffffff-ffff-ffff-ffff-ffffffffffff"); + + insertLegacyUnion(firstUnion, "Alpha Union", "&4Alpha-Tag", leader, "First description", "Legacy|MOTD", + "dark_red", "default,world,-12.5,64,23.25,90,10"); + insertLegacyUnion(secondUnion, "Second Union", "Alpha Tag", null, null, null, "yellow", null); + insertLegacyPlayer(leader, firstUnion); + insertLegacyPlayer(member, secondUnion); + insertLegacyPlayer(orphan, missingUnion); + + final Map playerNames = Map.of(leader, "Alice", orphan, "Orphan"); + final LegacyUnionsDatabaseMigration.Result result = migration(playerNames).migrate(); + + assertFalse(result.alreadyApplied()); + assertEquals(2, result.unions()); + assertEquals(3, result.players()); + assertEquals(1, result.placeholderNames()); + assertEquals(1, result.adjustedTags()); + assertEquals(1, result.orphanedMemberships()); + assertEquals(1, result.unionsWithoutLeaders()); + + try (PreparedStatement statement = connection.prepareStatement( + "SELECT `color_tag`, `description`, `packed_bb`, `flags`, `balance` FROM `sc_clans` WHERE `tag` = ?;")) + { + + statement.setString(1, "alphatag"); + try (ResultSet row = statement.executeQuery()) { + + assertTrue(row.next()); + assertEquals("&4alphatag", row.getString("color_tag")); + assertEquals("First description", row.getString("description")); + assertTrue(row.getString("packed_bb").matches("\\d+_Legacy\u00a6MOTD")); + assertEquals(0, row.getDouble("balance")); + + final JsonObject flags = JsonParser.parseString(row.getString("flags")).getAsJsonObject(); + assertEquals("world", flags.get("homeWorld").getAsString()); + assertEquals(-12.5, flags.get("homeX").getAsDouble()); + assertEquals(64, flags.get("homeY").getAsDouble()); + + } + + } + + assertEquals(1, countRows("sc_clans", "`tag` = 'alphatagaaaaaaaa'")); + assertPlayer(leader, "Alice", "alphatag", true, true); + assertPlayer(member, "2222222233334444", "alphatagaaaaaaaa", false, false); + assertPlayer(orphan, "Orphan", "", false, false); + assertEquals(1, countRows("sc_unionsog_migrations", null)); + + final LegacyUnionsDatabaseMigration.Result repeated = migration(playerNames).migrate(); + assertTrue(repeated.alreadyApplied()); + assertEquals(2, countRows("sc_clans", null)); + assertEquals(3, countRows("sc_players", null)); + assertEquals(1, countRows("sc_unionsog_migrations", null)); + + } + + @Test + void rollsBackAllDestinationRowsWhenAnInsertFails() throws SQLException { + + try (Statement statement = connection.createStatement()) { + + statement.execute( + "ALTER TABLE `sc_players` ADD CONSTRAINT `reject_blocked_name` CHECK (`name` <> 'Blocked');"); + + } + + final UUID union = UUID.fromString("11111111-1111-1111-1111-111111111111"); + final UUID player = UUID.fromString("11111111-2222-3333-4444-555555555555"); + insertLegacyUnion(union, "Alpha Union", "alpha", player, null, null, null, null); + insertLegacyPlayer(player, union); + + assertThrows(SQLException.class, () -> migration(Map.of(player, "Blocked")).migrate()); + + assertEquals(0, countRows("sc_clans", null)); + assertEquals(0, countRows("sc_players", null)); + assertEquals(0, countRows("sc_unionsog_migrations", null)); + assertEquals(1, countRows("unions_parties", null)); + assertEquals(1, countRows("unions_players", null)); + assertTrue(connection.getAutoCommit()); + + } + + @Test + void refusesToOverwritePopulatedDestinationAndRollsBackReservation() throws SQLException { + + try (Statement statement = connection.createStatement()) { + + statement.executeUpdate("INSERT INTO `sc_clans` (`tag`) VALUES ('existing');"); + + } + + final SQLException failure = assertThrows(SQLException.class, () -> migration(Map.of()).migrate()); + + assertTrue(failure.getMessage().contains("requires empty destination tables")); + assertEquals(1, countRows("sc_clans", "`tag` = 'existing'")); + assertEquals(0, countRows("sc_unionsog_migrations", null)); + assertTrue(connection.getAutoCommit()); + + } + + @Test + void wrapsDatabaseFailureToAbortPluginStartup() throws SQLException { + + try (Statement statement = connection.createStatement()) { + + statement.execute("DROP TABLE `unions_players`;"); + + } + + final LegacyUnionsDatabaseMigrationRunner.MigrationStartupException failure = assertThrows( + LegacyUnionsDatabaseMigrationRunner.MigrationStartupException.class, + () -> LegacyUnionsDatabaseMigrationRunner.migrateOrThrow(connection, "sc_", RANKS_JSON, uuid -> null)); + + assertTrue(failure.getMessage().contains("Plugin startup was aborted")); + assertTrue(failure.getCause() instanceof SQLException); + + } + + private LegacyUnionsDatabaseMigration migration(Map playerNames) { + + return new LegacyUnionsDatabaseMigration(connection, "sc_", RANKS_JSON, playerNames::get); + + } + + private void createSourceTables() throws SQLException { + + try (Statement statement = connection.createStatement()) { + + statement.execute(""" + CREATE TABLE `unions_parties` ( + `id` varchar(36) PRIMARY KEY, + `name` varchar(255), + `tag` varchar(255), + `leader` varchar(36), + `description` varchar(255), + `motd` varchar(500), + `color` varchar(255), + `home` varchar(255) + ); + """); + statement.execute(""" + CREATE TABLE `unions_players` ( + `uuid` varchar(36) PRIMARY KEY, + `party` varchar(36) + ); + """); + + } + + } + + private void createDestinationTables() throws SQLException { + + try (Statement statement = connection.createStatement()) { + + statement.execute(""" + CREATE TABLE `sc_clans` ( + `id` bigint AUTO_INCREMENT PRIMARY KEY, + `verified` tinyint, + `tag` varchar(25) UNIQUE, + `color_tag` varchar(255), + `name` varchar(100), + `description` varchar(255), + `friendly_fire` tinyint, + `founded` bigint, + `last_used` bigint, + `packed_allies` clob, + `packed_rivals` clob, + `packed_bb` clob, + `cape_url` varchar(255), + `flags` clob, + `balance` double, + `fee_enabled` tinyint, + `fee_value` double, + `ranks` clob, + `banner` clob + ); + """); + statement.execute(""" + CREATE TABLE `sc_players` ( + `id` bigint AUTO_INCREMENT PRIMARY KEY, + `uuid` varchar(255) UNIQUE, + `name` varchar(16), + `leader` tinyint, + `tag` varchar(25), + `friendly_fire` tinyint, + `neutral_kills` int, + `rival_kills` int, + `civilian_kills` int, + `ally_kills` int, + `deaths` int, + `last_seen` bigint, + `join_date` bigint, + `trusted` tinyint, + `flags` clob, + `packed_past_clans` clob, + `resign_times` clob, + `locale` varchar(10) + ); + """); + statement.execute(""" + CREATE TABLE `sc_kills` ( + `kill_id` bigint AUTO_INCREMENT PRIMARY KEY + ); + """); + + } + + } + + private void insertLegacyUnion(UUID id, String name, String tag, UUID leader, String description, String motd, + String color, String home) throws SQLException + { + + try (PreparedStatement statement = connection.prepareStatement( + "INSERT INTO `unions_parties` (`id`, `name`, `tag`, `leader`, `description`, `motd`, `color`, `home`)" + + " VALUES (?, ?, ?, ?, ?, ?, ?, ?);")) + { + + statement.setString(1, id.toString()); + statement.setString(2, name); + statement.setString(3, tag); + statement.setString(4, leader == null ? null : leader.toString()); + statement.setString(5, description); + statement.setString(6, motd); + statement.setString(7, color); + statement.setString(8, home); + statement.executeUpdate(); + + } + + } + + private void insertLegacyPlayer(UUID player, UUID union) throws SQLException { + + try (PreparedStatement statement = connection + .prepareStatement("INSERT INTO `unions_players` (`uuid`, `party`) VALUES (?, ?);")) + { + + statement.setString(1, player.toString()); + statement.setString(2, union == null ? null : union.toString()); + statement.executeUpdate(); + + } + + } + + private void assertPlayer(UUID uuid, String name, String tag, boolean leader, boolean trusted) throws SQLException { + + try (PreparedStatement statement = connection + .prepareStatement("SELECT `name`, `tag`, `leader`, `trusted` FROM `sc_players` WHERE `uuid` = ?;")) + { + + statement.setString(1, uuid.toString()); + try (ResultSet row = statement.executeQuery()) { + + assertTrue(row.next()); + assertEquals(name, row.getString("name")); + assertEquals(tag, row.getString("tag")); + assertEquals(leader, row.getBoolean("leader")); + assertEquals(trusted, row.getBoolean("trusted")); + + } + + } + + } + + private long countRows(String table, String condition) throws SQLException { + + final String where = condition == null ? "" : " WHERE " + condition; + try (Statement statement = connection.createStatement(); + ResultSet result = statement.executeQuery("SELECT COUNT(*) FROM `" + table + "`" + where + ";")) + { + + result.next(); + return result.getLong(1); + + } + + } + +} diff --git a/src/test/java/net/trueog/unionsog/migrations/legacy/LegacyUnionsDatabaseMigrationTest.java b/src/test/java/net/trueog/unionsog/migrations/legacy/LegacyUnionsDatabaseMigrationTest.java new file mode 100644 index 00000000..8ca0f316 --- /dev/null +++ b/src/test/java/net/trueog/unionsog/migrations/legacy/LegacyUnionsDatabaseMigrationTest.java @@ -0,0 +1,89 @@ +package net.trueog.unionsog.migrations.legacy; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.junit.jupiter.api.Test; + +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class LegacyUnionsDatabaseMigrationTest { + + private static final UUID FIRST_ID = UUID.fromString("11111111-2222-3333-4444-555555555555"); + private static final UUID SECOND_ID = UUID.fromString("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); + + @Test + void normalizesTagFromSchemaValues() { + + assertEquals("legacytag", LegacyUnionsDatabaseMigration.normalizedTag("&4Legacy-Tag", "Ignored", FIRST_ID)); + assertEquals("unionname", LegacyUnionsDatabaseMigration.normalizedTag(null, "Union Name", FIRST_ID)); + assertEquals("union11111111", LegacyUnionsDatabaseMigration.normalizedTag(null, "!@#", FIRST_ID)); + + } + + @Test + void assignsDeterministicSuffixToDuplicateTag() { + + final Set used = new HashSet<>(); + assertEquals("same", LegacyUnionsDatabaseMigration.uniqueTag("same", FIRST_ID, used)); + assertEquals("sameaaaaaaaa", LegacyUnionsDatabaseMigration.uniqueTag("same", SECOND_ID, used)); + + } + + @Test + void mapsNamedColorsWithoutChangingUnknownColors() { + + assertEquals("&4union", LegacyUnionsDatabaseMigration.coloredTag("dark_red", "union")); + assertEquals("&eunion", LegacyUnionsDatabaseMigration.coloredTag("yellow", "union")); + assertEquals("union", LegacyUnionsDatabaseMigration.coloredTag("not-a-color", "union")); + assertEquals("union", LegacyUnionsDatabaseMigration.coloredTag(null, "union")); + + } + + @Test + void mapsPackedHomeFromObservedDatabaseShape() { + + final JsonObject flags = JsonParser + .parseString(LegacyUnionsDatabaseMigration.homeFlags("default,world,-12.5,64,23.25,90,10")) + .getAsJsonObject(); + + assertEquals("world", flags.get("homeWorld").getAsString()); + assertEquals(-12.5, flags.get("homeX").getAsDouble()); + assertEquals(64, flags.get("homeY").getAsDouble()); + assertEquals(23.25, flags.get("homeZ").getAsDouble()); + assertEquals(90, flags.get("homeYaw").getAsDouble()); + assertEquals(10, flags.get("homePitch").getAsDouble()); + + } + + @Test + void ignoresMalformedPackedHome() { + + assertEquals("{}", LegacyUnionsDatabaseMigration.homeFlags("not,a,valid,home")); + + } + + @Test + void createsStableSixteenCharacterFallbackName() { + + assertEquals("1111111122223333", LegacyUnionsDatabaseMigration.fallbackPlayerName(FIRST_ID)); + + } + + @Test + void rejectsSourceAndDestinationTableCollision() { + + assertThrows(IllegalArgumentException.class, + () -> LegacyUnionsDatabaseMigration.validateTargetTablePrefix("unions_")); + assertThrows(IllegalArgumentException.class, + () -> LegacyUnionsDatabaseMigration.validateTargetTablePrefix("x".repeat(60))); + assertDoesNotThrow(() -> LegacyUnionsDatabaseMigration.validateTargetTablePrefix("sc_")); + + } + +}