Old database migration

This commit is contained in:
NotAlexNoyle committed 2026-07-29 12:18:58 -04:00
1 parent 7091da2d3d
commit cf583f481e
13 files changed
+1676 -93

No files matched your search

+1
View File
@@ -8,3 +8,4 @@ build/
.settings
.factorypath
SimpleClans.iml
/unions.txt
+67
View File
@@ -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 <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.
+66 -77
View File
@@ -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 <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 <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
<repositories>
<repository>
<id>roinujnosde-repo</id>
<url>https://repo.roinujnosde.me/releases/</url>
</repository>
</repositories>
```
```xml
<dependencies>
<dependency>
<groupId>net.sacredlabyrinth.phaed.simpleclans</groupId>
<artifactId>SimpleClans</artifactId>
<version>2.19.2</version> <!-- Current stable release; master is 2.19.3-SNAPSHOT -->
<scope>provided</scope>
</dependency>
</dependencies>
## 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).
+2 -1
View File
@@ -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.
@@ -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("\\{(?<key>[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();
}
@@ -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 ================
*/
@@ -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();
}
@@ -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.
* <p>
* 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.
* </p>
*/
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<String> SOURCE_UNION_COLUMNS = Set.of("id", "name", "tag", "leader", "description", "motd",
"color", "home");
private static final Set<String> 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<UUID, @Nullable String> playerNameResolver;
private final long migrationTime;
public LegacyUnionsDatabaseMigration(Connection connection, String targetTablePrefix, String ranksJson,
Function<UUID, @Nullable String> 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<LegacyUnion> legacyUnions = readLegacyUnions();
final List<LegacyPlayer> 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<LegacyUnion> 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<LegacyUnion> 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<LegacyPlayer> readLegacyPlayers() throws SQLException {
final String sql = "SELECT `uuid`, `party` FROM " + quote(SOURCE_PLAYERS_TABLE) + " ORDER BY `uuid`;";
final List<LegacyPlayer> 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<LegacyUnion> unions, Map<UUID, String> 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<LegacyPlayer> players, List<LegacyUnion> unions,
Map<UUID, String> tags, Map<UUID, String> names) throws SQLException
{
final Map<UUID, LegacyUnion> unionsById = new HashMap<>();
for (LegacyUnion union : unions) {
unionsById.put(union.id(), union);
}
final Set<UUID> 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<LegacyUnion> unions) {
final Map<UUID, String> tags = new LinkedHashMap<>();
final Set<String> 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<LegacyPlayer> players) {
final Map<UUID, String> names = new LinkedHashMap<>();
final Set<String> 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<String> requiredColumns) throws SQLException {
final Set<String> 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<String> 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<String> 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<String> 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<UUID, String> tags, int adjustedCount) {
}
private record NameAssignment(Map<UUID, String> names, int placeholderCount) {
}
private record PlayerImportCounts(int orphanedMemberships, int unionsWithoutLeaders) {
}
}
@@ -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<Rank> 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<UUID, String> 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);
}
}
}
@@ -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() {
+8 -4
View File
@@ -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 <table_prefix>clans, <table_prefix>players, and <table_prefix>kills
# destination tables must be empty and use InnoDB.
migrate-legacy-unions-database: false
permissions:
auto-group-groupname: false
YourClanNameHere:
@@ -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<UUID, String> 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<UUID, String> 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);
}
}
}
@@ -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<String> 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_"));
}
}