mirror of
https://github.com/WiIIiam278/HuskSync.git
synced 2025-12-30 12:19:20 +00:00
Start 2.0 rewrite
Use redis key caching, remove need for proxy plugin Make platform independent to allow porting to other platforms
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
package net.william278.husksync.database;
|
||||
|
||||
import net.william278.husksync.data.UserData;
|
||||
import net.william278.husksync.player.User;
|
||||
import net.william278.husksync.util.Logger;
|
||||
import net.william278.husksync.util.ResourceReader;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
/**
|
||||
* An abstract representation of the plugin database, storing player data.
|
||||
* <p>
|
||||
* Implemented by different database platforms - MySQL, SQLite, etc. - as configured by the administrator.
|
||||
*/
|
||||
public abstract class Database {
|
||||
|
||||
/**
|
||||
* Name of the table that stores player information
|
||||
*/
|
||||
protected final String playerTableName;
|
||||
|
||||
/**
|
||||
* Name of the table that stores data
|
||||
*/
|
||||
protected final String dataTableName;
|
||||
|
||||
/**
|
||||
* The maximum number of user records to store in the database at once per user
|
||||
*/
|
||||
protected final int maxUserDataRecords;
|
||||
|
||||
/**
|
||||
* Logger instance used for database error logging
|
||||
*/
|
||||
private final Logger logger;
|
||||
|
||||
/**
|
||||
* Returns the {@link Logger} used to log database errors
|
||||
*
|
||||
* @return the {@link Logger} instance
|
||||
*/
|
||||
protected Logger getLogger() {
|
||||
return logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* The {@link ResourceReader} used to read internal resource files by name
|
||||
*/
|
||||
private final ResourceReader resourceReader;
|
||||
|
||||
protected Database(@NotNull String playerTableName, @NotNull String dataTableName, final int maxUserDataRecords,
|
||||
@NotNull ResourceReader resourceReader, @NotNull Logger logger) {
|
||||
this.playerTableName = playerTableName;
|
||||
this.dataTableName = dataTableName;
|
||||
this.maxUserDataRecords = maxUserDataRecords;
|
||||
this.resourceReader = resourceReader;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads SQL table creation schema statements from a resource file as a string array
|
||||
*
|
||||
* @param schemaFileName database script resource file to load from
|
||||
* @return Array of string-formatted table creation schema statements
|
||||
* @throws IOException if the resource could not be read
|
||||
*/
|
||||
protected final String[] getSchemaStatements(@NotNull String schemaFileName) throws IOException {
|
||||
return formatStatementTables(
|
||||
new String(resourceReader.getResource(schemaFileName)
|
||||
.readAllBytes(), StandardCharsets.UTF_8))
|
||||
.split(";");
|
||||
}
|
||||
|
||||
/**
|
||||
* Format all table name placeholder strings in a SQL statement
|
||||
*
|
||||
* @param sql the SQL statement with un-formatted table name placeholders
|
||||
* @return the formatted statement, with table placeholders replaced with the correct names
|
||||
*/
|
||||
protected final String formatStatementTables(@NotNull String sql) {
|
||||
return sql.replaceAll("%players_table%", playerTableName)
|
||||
.replaceAll("%data_table%", dataTableName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the database and ensure tables are present; create tables if they do not exist.
|
||||
*
|
||||
* @return A future returning void when complete
|
||||
*/
|
||||
public abstract CompletableFuture<Void> initialize();
|
||||
|
||||
/**
|
||||
* Ensure a {@link User} has an entry in the database and that their username is up-to-date
|
||||
*
|
||||
* @param user The {@link User} to ensure
|
||||
* @return A future returning void when complete
|
||||
*/
|
||||
public abstract CompletableFuture<Void> ensureUser(@NotNull User user);
|
||||
|
||||
/**
|
||||
* Get a player by their Minecraft account {@link UUID}
|
||||
*
|
||||
* @param uuid Minecraft account {@link UUID} of the {@link User} to get
|
||||
* @return A future returning an optional with the {@link User} present if they exist
|
||||
*/
|
||||
public abstract CompletableFuture<Optional<User>> getUser(@NotNull UUID uuid);
|
||||
|
||||
/**
|
||||
* Get a user by their username (<i>case-insensitive</i>)
|
||||
*
|
||||
* @param username Username of the {@link User} to get (<i>case-insensitive</i>)
|
||||
* @return A future returning an optional with the {@link User} present if they exist
|
||||
*/
|
||||
public abstract CompletableFuture<Optional<User>> getUserByName(@NotNull String username);
|
||||
|
||||
/**
|
||||
* Get the current user data for a given user, if it exists.
|
||||
*
|
||||
* @param user the user to get data for
|
||||
* @return an optional containing the user data, if it exists, or an empty optional if it does not
|
||||
*/
|
||||
public abstract CompletableFuture<Optional<UserData>> getCurrentUserData(@NotNull User user);
|
||||
|
||||
/**
|
||||
* Get all UserData entries for a user from the database.
|
||||
*
|
||||
* @param user The user to get data for
|
||||
* @return A future returning a list of a user's data
|
||||
*/
|
||||
public abstract CompletableFuture<List<UserData>> getUserData(@NotNull User user);
|
||||
|
||||
/**
|
||||
* Prune user data records for a given user to the maximum value as configured
|
||||
*
|
||||
* @param user The user to prune data for
|
||||
* @return A future returning void when complete
|
||||
*/
|
||||
protected abstract CompletableFuture<Void> pruneUserDataRecords(@NotNull User user);
|
||||
|
||||
/**
|
||||
* Add user data to the database<p>
|
||||
* This will remove the oldest data for the user if the amount of data exceeds the limit as configured
|
||||
*
|
||||
* @param user The user to add data for
|
||||
* @param userData The data to add
|
||||
* @return A future returning void when complete
|
||||
*/
|
||||
public abstract CompletableFuture<Void> setUserData(@NotNull User user, @NotNull UserData userData);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
package net.william278.husksync.database;
|
||||
|
||||
import com.zaxxer.hikari.HikariDataSource;
|
||||
import net.william278.husksync.config.Settings;
|
||||
import net.william278.husksync.data.UserData;
|
||||
import net.william278.husksync.player.User;
|
||||
import net.william278.husksync.util.Logger;
|
||||
import net.william278.husksync.util.ResourceReader;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.sql.*;
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.logging.Level;
|
||||
|
||||
public class MySqlDatabase extends Database {
|
||||
/**
|
||||
* MySQL server hostname
|
||||
*/
|
||||
private final String mySqlHost;
|
||||
|
||||
/**
|
||||
* MySQL server port
|
||||
*/
|
||||
private final int mySqlPort;
|
||||
|
||||
/**
|
||||
* Database to use on the MySQL server
|
||||
*/
|
||||
private final String mySqlDatabaseName;
|
||||
private final String mySqlUsername;
|
||||
private final String mySqlPassword;
|
||||
private final String mySqlConnectionParameters;
|
||||
|
||||
private final int hikariMaximumPoolSize;
|
||||
private final int hikariMinimumIdle;
|
||||
private final int hikariMaximumLifetime;
|
||||
private final int hikariKeepAliveTime;
|
||||
private final int hikariConnectionTimeOut;
|
||||
|
||||
private static final String DATA_POOL_NAME = "HuskHomesHikariPool";
|
||||
|
||||
private HikariDataSource dataSource;
|
||||
|
||||
public MySqlDatabase(@NotNull Settings settings, @NotNull ResourceReader resourceReader, @NotNull Logger logger) {
|
||||
super(settings.getStringValue(Settings.ConfigOption.DATABASE_PLAYERS_TABLE_NAME),
|
||||
settings.getStringValue(Settings.ConfigOption.DATABASE_DATA_TABLE_NAME),
|
||||
settings.getIntegerValue(Settings.ConfigOption.SYNCHRONIZATION_MAX_USER_DATA_RECORDS),
|
||||
resourceReader, logger);
|
||||
mySqlHost = settings.getStringValue(Settings.ConfigOption.DATABASE_HOST);
|
||||
mySqlPort = settings.getIntegerValue(Settings.ConfigOption.DATABASE_PORT);
|
||||
mySqlDatabaseName = settings.getStringValue(Settings.ConfigOption.DATABASE_NAME);
|
||||
mySqlUsername = settings.getStringValue(Settings.ConfigOption.DATABASE_USERNAME);
|
||||
mySqlPassword = settings.getStringValue(Settings.ConfigOption.DATABASE_PASSWORD);
|
||||
mySqlConnectionParameters = settings.getStringValue(Settings.ConfigOption.DATABASE_CONNECTION_PARAMS);
|
||||
hikariMaximumPoolSize = settings.getIntegerValue(Settings.ConfigOption.DATABASE_CONNECTION_POOL_MAX_SIZE);
|
||||
hikariMinimumIdle = settings.getIntegerValue(Settings.ConfigOption.DATABASE_CONNECTION_POOL_MIN_IDLE);
|
||||
hikariMaximumLifetime = settings.getIntegerValue(Settings.ConfigOption.DATABASE_CONNECTION_POOL_MAX_LIFETIME);
|
||||
hikariKeepAliveTime = settings.getIntegerValue(Settings.ConfigOption.DATABASE_CONNECTION_POOL_KEEPALIVE);
|
||||
hikariConnectionTimeOut = settings.getIntegerValue(Settings.ConfigOption.DATABASE_CONNECTION_POOL_TIMEOUT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the auto-closeable connection from the hikariDataSource
|
||||
*
|
||||
* @return The {@link Connection} to the MySQL database
|
||||
* @throws SQLException if the connection fails for some reason
|
||||
*/
|
||||
private Connection getConnection() throws SQLException {
|
||||
return dataSource.getConnection();
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<Void> initialize() {
|
||||
return CompletableFuture.runAsync(() -> {
|
||||
// Create jdbc driver connection url
|
||||
final String jdbcUrl = "jdbc:mysql://" + mySqlHost + ":" + mySqlPort + "/" + mySqlDatabaseName + mySqlConnectionParameters;
|
||||
dataSource = new HikariDataSource();
|
||||
dataSource.setJdbcUrl(jdbcUrl);
|
||||
|
||||
// Authenticate
|
||||
dataSource.setUsername(mySqlUsername);
|
||||
dataSource.setPassword(mySqlPassword);
|
||||
|
||||
// Set various additional parameters
|
||||
dataSource.setMaximumPoolSize(hikariMaximumPoolSize);
|
||||
dataSource.setMinimumIdle(hikariMinimumIdle);
|
||||
dataSource.setMaxLifetime(hikariMaximumLifetime);
|
||||
dataSource.setKeepaliveTime(hikariKeepAliveTime);
|
||||
dataSource.setConnectionTimeout(hikariConnectionTimeOut);
|
||||
dataSource.setPoolName(DATA_POOL_NAME);
|
||||
|
||||
// Prepare database schema; make tables if they don't exist
|
||||
try (Connection connection = dataSource.getConnection()) {
|
||||
// Load database schema CREATE statements from schema file
|
||||
final String[] databaseSchema = getSchemaStatements("database/mysql_schema.sql");
|
||||
try (Statement statement = connection.createStatement()) {
|
||||
for (String tableCreationStatement : databaseSchema) {
|
||||
statement.execute(tableCreationStatement);
|
||||
}
|
||||
}
|
||||
} catch (SQLException | IOException e) {
|
||||
getLogger().log(Level.SEVERE, "An error occurred creating tables on the MySQL database: ", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<Void> ensureUser(@NotNull User user) {
|
||||
return CompletableFuture.runAsync(() -> getUser(user.uuid).thenAccept(optionalUser ->
|
||||
optionalUser.ifPresentOrElse(existingUser -> {
|
||||
if (!existingUser.username.equals(user.username)) {
|
||||
// Update a user's name if it has changed in the database
|
||||
try (Connection connection = getConnection()) {
|
||||
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
|
||||
UPDATE `%players_table%`
|
||||
SET `username`=?
|
||||
WHERE `uuid`=?"""))) {
|
||||
|
||||
statement.setString(1, user.username);
|
||||
statement.setString(2, existingUser.uuid.toString());
|
||||
statement.executeUpdate();
|
||||
}
|
||||
getLogger().log(Level.INFO, "Updated " + user.username + "'s name in the database (" + existingUser.username + " -> " + user.username + ")");
|
||||
} catch (SQLException e) {
|
||||
getLogger().log(Level.SEVERE, "Failed to update a user's name on the database", e);
|
||||
}
|
||||
}
|
||||
},
|
||||
() -> {
|
||||
// Insert new player data into the database
|
||||
try (Connection connection = getConnection()) {
|
||||
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
|
||||
INSERT INTO `%players_table%` (`uuid`,`username`)
|
||||
VALUES (?,?);"""))) {
|
||||
|
||||
statement.setString(1, user.uuid.toString());
|
||||
statement.setString(2, user.username);
|
||||
statement.executeUpdate();
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
getLogger().log(Level.SEVERE, "Failed to insert a user into the database", e);
|
||||
}
|
||||
})));
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<Optional<User>> getUser(@NotNull UUID uuid) {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
try (Connection connection = getConnection()) {
|
||||
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
|
||||
SELECT `uuid`, `username`
|
||||
FROM `%players_table%`
|
||||
WHERE `uuid`=?"""))) {
|
||||
|
||||
statement.setString(1, uuid.toString());
|
||||
|
||||
final ResultSet resultSet = statement.executeQuery();
|
||||
if (resultSet.next()) {
|
||||
return Optional.of(new User(UUID.fromString(resultSet.getString("uuid")),
|
||||
resultSet.getString("username")));
|
||||
}
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
getLogger().log(Level.SEVERE, "Failed to fetch a user from uuid from the database", e);
|
||||
}
|
||||
return Optional.empty();
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<Optional<User>> getUserByName(@NotNull String username) {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
try (Connection connection = getConnection()) {
|
||||
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
|
||||
SELECT `uuid`, `username`
|
||||
FROM `%players_table%`
|
||||
WHERE `username`=?"""))) {
|
||||
statement.setString(1, username);
|
||||
|
||||
final ResultSet resultSet = statement.executeQuery();
|
||||
if (resultSet.next()) {
|
||||
return Optional.of(new User(UUID.fromString(resultSet.getString("uuid")),
|
||||
resultSet.getString("username")));
|
||||
}
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
getLogger().log(Level.SEVERE, "Failed to fetch a user by name from the database", e);
|
||||
}
|
||||
return Optional.empty();
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<Optional<UserData>> getCurrentUserData(@NotNull User user) {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
try (Connection connection = getConnection()) {
|
||||
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
|
||||
SELECT `version_uuid`, `timestamp`, `data`
|
||||
FROM `%data_table%`
|
||||
WHERE `player_uuid`=?
|
||||
ORDER BY `timestamp` DESC
|
||||
LIMIT 1;"""))) {
|
||||
statement.setString(1, user.uuid.toString());
|
||||
final ResultSet resultSet = statement.executeQuery();
|
||||
if (resultSet.next()) {
|
||||
final UserData data = UserData.fromJson(resultSet.getString("data"));
|
||||
data.setMetadata(UUID.fromString(resultSet.getString("version_uuid")),
|
||||
resultSet.getTimestamp("timestamp").toInstant().toEpochMilli());
|
||||
return Optional.of(data);
|
||||
}
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
getLogger().log(Level.SEVERE, "Failed to fetch a user's current user data from the database", e);
|
||||
}
|
||||
return Optional.empty();
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<List<UserData>> getUserData(@NotNull User user) {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
final ArrayList<UserData> retrievedData = new ArrayList<>();
|
||||
try (Connection connection = getConnection()) {
|
||||
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
|
||||
SELECT `version_uuid`, `timestamp`, `data`
|
||||
FROM `%data_table%`
|
||||
WHERE `player_uuid`=?
|
||||
ORDER BY `timestamp` DESC;"""))) {
|
||||
statement.setString(1, user.uuid.toString());
|
||||
final ResultSet resultSet = statement.executeQuery();
|
||||
while (resultSet.next()) {
|
||||
final UserData data = UserData.fromJson(resultSet.getString("data"));
|
||||
data.setMetadata(UUID.fromString(resultSet.getString("version_uuid")),
|
||||
resultSet.getTimestamp("timestamp").toInstant().toEpochMilli());
|
||||
retrievedData.add(data);
|
||||
}
|
||||
return retrievedData;
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
getLogger().log(Level.SEVERE, "Failed to fetch a user's current user data from the database", e);
|
||||
}
|
||||
return retrievedData;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
protected CompletableFuture<Void> pruneUserDataRecords(@NotNull User user) {
|
||||
return CompletableFuture.runAsync(() -> getUserData(user).thenAccept(data -> {
|
||||
if (data.size() > maxUserDataRecords) {
|
||||
Collections.reverse(data);
|
||||
data.subList(0, data.size() - maxUserDataRecords).forEach(dataToDelete -> {
|
||||
try (Connection connection = getConnection()) {
|
||||
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
|
||||
DELETE FROM `%data_table%`
|
||||
WHERE `version_uuid`=?"""))) {
|
||||
statement.setString(1, dataToDelete.getDataUuidVersion().toString());
|
||||
statement.executeUpdate();
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
getLogger().log(Level.SEVERE, "Failed to prune user data from the database", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<Void> setUserData(@NotNull User user, @NotNull UserData userData) {
|
||||
return CompletableFuture.runAsync(() -> {
|
||||
try (Connection connection = getConnection()) {
|
||||
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
|
||||
INSERT INTO `%data_table%`
|
||||
(`player_uuid`,`version_uuid`,`timestamp`,`data`)
|
||||
VALUES (?,?,?,?);"""))) {
|
||||
statement.setString(1, user.uuid.toString());
|
||||
statement.setString(2, userData.getDataUuidVersion().toString());
|
||||
statement.setTimestamp(3, Timestamp.from(Instant.ofEpochMilli(userData.getCreationTimestamp())));
|
||||
statement.setString(4, userData.toJson());
|
||||
statement.executeUpdate();
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
getLogger().log(Level.SEVERE, "Failed to set user data in the database", e);
|
||||
}
|
||||
}).thenRunAsync(() -> pruneUserDataRecords(user).join());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user