9
0
mirror of https://github.com/WiIIiam278/HuskSync.git synced 2026-01-03 22:16:17 +00:00

v3.0: New modular, more compatible data format, new API, better UX (#160)

* Start work on v3

* More work on task scheduling

* Add comment to notification display slot

* Synchronise branches

* Use new HuskHomes-style task system

* Bump to 2.3

* Remove HuskSyncInitializationException.java

* Optimise database for MariaDB

* Update libraries, move some around

* Tweak command registration

* Remove dummyhusksync

* Fixup core synchronisation logic to use new task system

* Implement new event dispatch subsystem

* Remove last remaining future calls

* Remove `Event#fire()`

* Refactor startup process

* New command subsystem, more initialization improvements, locale fixes

* Update docs, tweak command perms

* Reduce task number during data setting

* add todo

* Start work on data format / serialization refactor

* More work on Bukkit impl

* More serialization work

* Fixes to serialization, data preview system

* Start legacy conversion skeleton

* Handle setting empty inventories

* Start on-the-fly legacy conversion work

* Add advancement conversion

* Rewrite advancement get / apply logic

* Start work on locked map persistence

* More map persistence work

* More work on map serialization

* Move around persistence logic

* Add testing suite

* Fix item synchronisation

* Finalize more reliable locked map persistence

* Remove deprecated method call

* remove sync feature enum

* Fix held item slot syncing

* Make data types modular and API-extensible

* Remove some excessive debugging, minor refactor

* Fixup date formatting, improve menu UIs

* Finish up legacy data converting

* Null safety in item stack serializaiton

* Fix relocation of nbtapi, update dumping docs

* Add v1/MPDB Migrators back in

* Fix pinning/unpinning data not working

* Consumer instead of Function for editing data

* Show file size in DataSnapshotOverview

* Fix getIdentifier always returning empty

* Re-add items and inventory GUI commands

* Improve config file, fixup data restoration

* Add min time between backups (more useful backups!)

* More work on backups

* Fixup backup rotation frequency

* Remove stdout debug print in `#getEventPriority`

* Improve sync complete locale logic, fix synchronization spelling

* Remove `static` on exception

* Use dedicated thread for Redis, properly unsubscribe

* Refactor `player` package -> `user`

* `PlayerDataHolder` -> `UserDataHolder`

* Make `StatisticsMap` public, but `@ApiStatus.Internal`

* Suppress unused warnings on `Data`

* Add option to disable Plan hook

* Decompress legacy data before converting

* Decompress bytes in fromBytes

* Check permission node before serving TAB suggestions

* Actually convert legacy item stack data

* Fix syntax errors

* Minor method refactor in items command

* Fixup case-sensitive parsing in HuskSync command

* Start API work

* More work on API, fix potion effects

* Fix cross-server, config formatting for auto-pinned issue

* Fix confusion with UserData command, update docs images

* Update commands docs

* More docs updating

* Fix sync feature enabled/disabled checking logic

* Fix `#isCustom()`

* Enable persistent_data syncing by default

* docs: update Sync-Features config snippet

* docs: correct typo in Sync Features

* More API work

* bukkit: slightly optimized schedulers

* More API work, various refactorings

* docs: Start new API docs

* bump dependencies

* Add some basic unit tests

* docs: Correct typos

* More docs work, annotate DB methods as `@Blocking`

* Encapsulate `RedisMessage`, minor optimisations

* api: Simplify `#getCurrentData`

* api: Simplify `editCurrentData`, using `ThrowingConsumers` for better error handling

* docs: More Data Snapshot API documenting

* docs: add TOC to Data Snapshot API page

* bukkit: Make data types extend BukkitData

* Move where custom data is stored, finish up Custom Data API docs

* Optimise imports

* Fix `data_manager_advancements_preview_remaining` locale

* Fix advancement and playtime previews

* Fix potion effect deserialization

* Make snapshot_backup_frequency default to 4, more error handling/logging

* docs: Add ToC to Custom Data API

* docs: Minor legacy API tweaks

* Remove some unneeded catch logic

* Suppress a few warnings

* Fix Effect constructor being supplied in wrong order
This commit is contained in:
William
2023-09-20 14:02:26 +01:00
committed by GitHub
parent 9018ad02e1
commit 105f65c93a
149 changed files with 10067 additions and 7470 deletions

View File

@@ -21,20 +21,20 @@ package net.william278.husksync.database;
import net.william278.husksync.HuskSync;
import net.william278.husksync.config.Settings;
import net.william278.husksync.data.DataSaveCause;
import net.william278.husksync.data.UserData;
import net.william278.husksync.data.UserDataSnapshot;
import net.william278.husksync.migrator.Migrator;
import net.william278.husksync.player.User;
import net.william278.husksync.data.DataSnapshot;
import net.william278.husksync.data.DataSnapshot.SaveCause;
import net.william278.husksync.data.UserDataHolder;
import net.william278.husksync.user.User;
import org.jetbrains.annotations.Blocking;
import org.jetbrains.annotations.NotNull;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
/**
* An abstract representation of the plugin database, storing player data.
@@ -57,17 +57,19 @@ public abstract class Database {
* @throws IOException if the resource could not be read
*/
@SuppressWarnings("SameParameterValue")
@NotNull
protected final String[] getSchemaStatements(@NotNull String schemaFileName) throws IOException {
return formatStatementTables(new String(Objects.requireNonNull(plugin.getResource(schemaFileName))
.readAllBytes(), StandardCharsets.UTF_8)).split(";");
}
/**
* Format all table name placeholder strings in a SQL statement
* Format all table name placeholder strings in an SQL statement
*
* @param sql the SQL statement with un-formatted table name placeholders
* @param sql the SQL statement with unformatted table name placeholders
* @return the formatted statement, with table placeholders replaced with the correct names
*/
@NotNull
protected final String formatStatementTables(@NotNull String sql) {
return sql.replaceAll("%users_table%", plugin.getSettings().getTableName(Settings.TableName.USERS))
.replaceAll("%user_data_table%", plugin.getSettings().getTableName(Settings.TableName.USER_DATA));
@@ -75,57 +77,67 @@ public abstract class Database {
/**
* Initialize the database and ensure tables are present; create tables if they do not exist.
*
* @throws IllegalStateException if the database could not be initialized
*/
public abstract void initialize();
@Blocking
public abstract void initialize() throws IllegalStateException;
/**
* 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);
@Blocking
public abstract 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
* @return An optional with the {@link User} present if they exist
*/
public abstract CompletableFuture<Optional<User>> getUser(@NotNull UUID uuid);
@Blocking
public abstract 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
* @return An optional with the {@link User} present if they exist
*/
public abstract CompletableFuture<Optional<User>> getUserByName(@NotNull String username);
@Blocking
public abstract Optional<User> getUserByName(@NotNull String username);
/**
* Get the current uniquely versioned user data for a given user, if it exists.
*
* @param user the user to get data for
* @return an optional containing the {@link UserDataSnapshot}, if it exists, or an empty optional if it does not
*/
public abstract CompletableFuture<Optional<UserDataSnapshot>> getCurrentUserData(@NotNull User user);
/**
* Get all {@link UserDataSnapshot} entries for a user from the database.
* Get the latest data snapshot for a user.
*
* @param user The user to get data for
* @return A future returning a list of a user's {@link UserDataSnapshot} entries
* @return an optional containing the {@link DataSnapshot}, if it exists, or an empty optional if it does not
*/
public abstract CompletableFuture<List<UserDataSnapshot>> getUserData(@NotNull User user);
@Blocking
public abstract Optional<DataSnapshot.Packed> getLatestSnapshot(@NotNull User user);
/**
* Gets a specific {@link UserDataSnapshot} entry for a user from the database, by its UUID.
* Get all {@link DataSnapshot} entries for a user from the database.
*
* @param user The user to get data for
* @return The list of a user's {@link DataSnapshot} entries
*/
@Blocking
@NotNull
public abstract List<DataSnapshot.Packed> getAllSnapshots(@NotNull User user);
/**
* Gets a specific {@link DataSnapshot} entry for a user from the database, by its UUID.
*
* @param user The user to get data for
* @param versionUuid The UUID of the {@link UserDataSnapshot} entry to get
* @return A future returning an optional containing the {@link UserDataSnapshot}, if it exists, or an empty optional if it does not
* @param versionUuid The UUID of the {@link DataSnapshot} entry to get
* @return An optional containing the {@link DataSnapshot}, if it exists
*/
public abstract CompletableFuture<Optional<UserDataSnapshot>> getUserData(@NotNull User user, @NotNull UUID versionUuid);
@Blocking
public abstract Optional<DataSnapshot.Packed> getSnapshot(@NotNull User user, @NotNull UUID versionUuid);
/**
* <b>(Internal)</b> Prune user data for a given user to the maximum value as configured.
@@ -133,61 +145,131 @@ public abstract class Database {
* @param user The user to prune data for
* @implNote Data snapshots marked as {@code pinned} are exempt from rotation
*/
protected abstract void rotateUserData(@NotNull User user);
@Blocking
protected abstract void rotateSnapshots(@NotNull User user);
/**
* Deletes a specific {@link UserDataSnapshot} entry for a user from the database, by its UUID.
* Deletes a specific {@link DataSnapshot} entry for a user from the database, by its UUID.
*
* @param user The user to get data for
* @param versionUuid The UUID of the {@link UserDataSnapshot} entry to delete
* @return A future returning void when complete
* @param versionUuid The UUID of the {@link DataSnapshot} entry to delete
*/
public abstract CompletableFuture<Boolean> deleteUserData(@NotNull User user, @NotNull UUID versionUuid);
@Blocking
public abstract boolean deleteSnapshot(@NotNull User user, @NotNull UUID versionUuid);
/**
* Save user data to the database<p>
* Save 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 {@link UserData} to set. The implementation should version it with a random UUID and the current timestamp during insertion.
* @return A future returning void when complete
* @see UserDataSnapshot#create(UserData)
* @param snapshot The {@link DataSnapshot} to set.
* The implementation should version it with a random UUID and the current timestamp during insertion.
* @see UserDataHolder#createSnapshot(SaveCause)
*/
public abstract CompletableFuture<Void> setUserData(@NotNull User user, @NotNull UserData userData, @NotNull DataSaveCause dataSaveCause);
@Blocking
public void addSnapshot(@NotNull User user, @NotNull DataSnapshot.Packed snapshot) {
if (snapshot.getSaveCause() != SaveCause.SERVER_SHUTDOWN) {
plugin.fireEvent(
plugin.getDataSaveEvent(user, snapshot),
(event) -> this.addAndRotateSnapshot(user, snapshot)
);
return;
}
this.addAndRotateSnapshot(user, snapshot);
}
/**
* Pin a saved {@link UserDataSnapshot} by given version UUID, setting it's {@code pinned} state to {@code true}.
* <b>Internal</b> - Save user data to the database. This will:
* <ol>
* <li>Delete their most recent snapshot, if it was created before the backup frequency time</li>
* <li>Create the snapshot</li>
* <li>Rotate snapshot backups</li>
* </ol>
*
* @param user The user to pin the data for
* @param versionUuid The UUID of the user's {@link UserDataSnapshot} entry to pin
* @return A future returning a boolean; {@code true} if the operation completed successfully, {@code false} if it failed
* @see UserDataSnapshot#pinned()
* @param user The user to add data for
* @param snapshot The {@link DataSnapshot} to set.
*/
public abstract CompletableFuture<Void> pinUserData(@NotNull User user, @NotNull UUID versionUuid);
@Blocking
private void addAndRotateSnapshot(@NotNull User user, @NotNull DataSnapshot.Packed snapshot) {
final int backupFrequency = plugin.getSettings().getBackupFrequency();
if (!snapshot.isPinned() && backupFrequency > 0) {
this.rotateLatestSnapshot(user, snapshot.getTimestamp().minusHours(backupFrequency));
}
this.createSnapshot(user, snapshot);
this.rotateSnapshots(user);
}
/**
* Unpin a saved {@link UserDataSnapshot} by given version UUID, setting it's {@code pinned} state to {@code false}.
* Deletes the most recent data snapshot by the given {@link User user}
* The snapshot must have been created after {@link OffsetDateTime time} and NOT be pinned
* Facilities the backup frequency feature, reducing redundant snapshots from being saved longer than needed
*
* @param user The user to delete a snapshot for
* @param within The time to delete a snapshot after
*/
@Blocking
protected abstract void rotateLatestSnapshot(@NotNull User user, @NotNull OffsetDateTime within);
/**
* <b>Internal</b> - Create user data in the database
*
* @param user The user to add data for
* @param data The {@link DataSnapshot} to set.
*/
@Blocking
protected abstract void createSnapshot(@NotNull User user, @NotNull DataSnapshot.Packed data);
/**
* Update a saved {@link DataSnapshot} by given version UUID
*
* @param user The user whose data snapshot
* @param snapshot The {@link DataSnapshot} to update
*/
@Blocking
public abstract void updateSnapshot(@NotNull User user, @NotNull DataSnapshot.Packed snapshot);
/**
* Unpin a saved {@link DataSnapshot} by given version UUID, setting it's {@code pinned} state to {@code false}.
*
* @param user The user to unpin the data for
* @param versionUuid The UUID of the user's {@link UserDataSnapshot} entry to unpin
* @return A future returning a boolean; {@code true} if the operation completed successfully, {@code false} if it failed
* @see UserDataSnapshot#pinned()
* @param versionUuid The UUID of the user's {@link DataSnapshot} entry to unpin
* @see DataSnapshot#isPinned()
*/
public abstract CompletableFuture<Void> unpinUserData(@NotNull User user, @NotNull UUID versionUuid);
@Blocking
public final void unpinSnapshot(@NotNull User user, @NotNull UUID versionUuid) {
this.getSnapshot(user, versionUuid).ifPresent(data -> {
data.edit(plugin, (snapshot) -> snapshot.setPinned(false));
this.updateSnapshot(user, data);
});
}
/**
* Wipes <b>all</b> {@link UserData} entries from the database.
* <b>This should never be used</b>, except when preparing tables for migration.
* Pin a saved {@link DataSnapshot} by given version UUID, setting it's {@code pinned} state to {@code true}.
*
* @return A future returning void when complete
* @see Migrator#start()
* @param user The user to pin the data for
* @param versionUuid The UUID of the user's {@link DataSnapshot} entry to pin
*/
public abstract CompletableFuture<Void> wipeDatabase();
@Blocking
public final void pinSnapshot(@NotNull User user, @NotNull UUID versionUuid) {
this.getSnapshot(user, versionUuid).ifPresent(data -> {
data.edit(plugin, (snapshot) -> snapshot.setPinned(true));
this.updateSnapshot(user, data);
});
}
/**
* Wipes <b>all</b> {@link User} entries from the database.
* <b>This should only be used when preparing tables for a data migration.</b>
*/
@Blocking
public abstract void wipeDatabase();
/**
* Close the database connection
*/
public abstract void close();
public abstract void terminate();
/**
* Identifies types of databases

View File

@@ -21,20 +21,17 @@ package net.william278.husksync.database;
import com.zaxxer.hikari.HikariDataSource;
import net.william278.husksync.HuskSync;
import net.william278.husksync.data.DataAdaptionException;
import net.william278.husksync.data.DataSaveCause;
import net.william278.husksync.data.UserData;
import net.william278.husksync.data.UserDataSnapshot;
import net.william278.husksync.event.DataSaveEvent;
import net.william278.husksync.player.User;
import net.william278.husksync.adapter.DataAdapter;
import net.william278.husksync.data.DataSnapshot;
import net.william278.husksync.user.User;
import org.jetbrains.annotations.Blocking;
import org.jetbrains.annotations.NotNull;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.sql.*;
import java.util.Date;
import java.time.OffsetDateTime;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.logging.Level;
public class MySqlDatabase extends Database {
@@ -46,8 +43,7 @@ public class MySqlDatabase extends Database {
public MySqlDatabase(@NotNull HuskSync plugin) {
super(plugin);
this.flavor = plugin.getSettings().getDatabaseType() == Type.MARIADB
? "mariadb" : "mysql";
this.flavor = plugin.getSettings().getDatabaseType().getProtocol();
this.driverClass = plugin.getSettings().getDatabaseType() == Type.MARIADB
? "org.mariadb.jdbc.Driver" : "com.mysql.cj.jdbc.Driver";
}
@@ -58,10 +54,16 @@ public class MySqlDatabase extends Database {
* @return The {@link Connection} to the MySQL database
* @throws SQLException if the connection fails for some reason
*/
@Blocking
@NotNull
private Connection getConnection() throws SQLException {
if (dataSource == null) {
throw new IllegalStateException("The database has not been initialized");
}
return dataSource.getConnection();
}
@Blocking
@Override
public void initialize() throws IllegalStateException {
// Initialize the Hikari pooled connection
@@ -124,192 +126,175 @@ public class MySqlDatabase extends Database {
}
}
@Blocking
@Override
public CompletableFuture<Void> ensureUser(@NotNull User user) {
return 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 `%users_table%`
SET `username`=?
WHERE `uuid`=?"""))) {
public void ensureUser(@NotNull User user) {
getUser(user.getUuid()).ifPresentOrElse(
existingUser -> {
if (!existingUser.getUsername().equals(user.getUsername())) {
// Update a user's name if it has changed in the database
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
UPDATE `%users_table%`
SET `username`=?
WHERE `uuid`=?"""))) {
statement.setString(1, user.username);
statement.setString(2, existingUser.uuid.toString());
statement.executeUpdate();
}
plugin.log(Level.INFO, "Updated " + user.username + "'s name in the database (" + existingUser.username + " -> " + user.username + ")");
} catch (SQLException e) {
plugin.log(Level.SEVERE, "Failed to update a user's name on the database", e);
}
statement.setString(1, user.getUsername());
statement.setString(2, existingUser.getUuid().toString());
statement.executeUpdate();
}
},
() -> {
// Insert new player data into the database
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
INSERT INTO `%users_table%` (`uuid`,`username`)
VALUES (?,?);"""))) {
plugin.log(Level.INFO, "Updated " + user.getUsername() + "'s name in the database (" + existingUser.getUsername() + " -> " + user.getUsername() + ")");
} catch (SQLException e) {
plugin.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 `%users_table%` (`uuid`,`username`)
VALUES (?,?);"""))) {
statement.setString(1, user.uuid.toString());
statement.setString(2, user.username);
statement.executeUpdate();
}
} catch (SQLException e) {
plugin.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 `%users_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")));
statement.setString(1, user.getUuid().toString());
statement.setString(2, user.getUsername());
statement.executeUpdate();
}
} catch (SQLException e) {
plugin.log(Level.SEVERE, "Failed to insert a user into the database", e);
}
}
} catch (SQLException e) {
plugin.log(Level.SEVERE, "Failed to fetch a user from uuid from the database", e);
}
return Optional.empty();
});
);
}
@Blocking
@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 `%users_table%`
WHERE `username`=?"""))) {
statement.setString(1, username);
public Optional<User> getUser(@NotNull UUID uuid) {
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
SELECT `uuid`, `username`
FROM `%users_table%`
WHERE `uuid`=?"""))) {
final ResultSet resultSet = statement.executeQuery();
if (resultSet.next()) {
return Optional.of(new User(UUID.fromString(resultSet.getString("uuid")),
resultSet.getString("username")));
}
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) {
plugin.log(Level.SEVERE, "Failed to fetch a user by name from the database", e);
}
return Optional.empty();
});
} catch (SQLException e) {
plugin.log(Level.SEVERE, "Failed to fetch a user from uuid from the database", e);
}
return Optional.empty();
}
@Blocking
@Override
public CompletableFuture<Optional<UserDataSnapshot>> getCurrentUserData(@NotNull User user) {
return CompletableFuture.supplyAsync(() -> {
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
SELECT `version_uuid`, `timestamp`, `save_cause`, `pinned`, `data`
FROM `%user_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 Blob blob = resultSet.getBlob("data");
final byte[] dataByteArray = blob.getBytes(1, (int) blob.length());
blob.free();
return Optional.of(new UserDataSnapshot(
UUID.fromString(resultSet.getString("version_uuid")),
Date.from(resultSet.getTimestamp("timestamp").toInstant()),
DataSaveCause.getCauseByName(resultSet.getString("save_cause")),
resultSet.getBoolean("pinned"),
plugin.getDataAdapter().fromBytes(dataByteArray)));
}
public Optional<User> getUserByName(@NotNull String username) {
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
SELECT `uuid`, `username`
FROM `%users_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 | DataAdaptionException e) {
plugin.log(Level.SEVERE, "Failed to fetch a user's current user data from the database", e);
}
return Optional.empty();
});
} catch (SQLException e) {
plugin.log(Level.SEVERE, "Failed to fetch a user by name from the database", e);
}
return Optional.empty();
}
@Blocking
@Override
public CompletableFuture<List<UserDataSnapshot>> getUserData(@NotNull User user) {
return CompletableFuture.supplyAsync(() -> {
final List<UserDataSnapshot> retrievedData = new ArrayList<>();
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
SELECT `version_uuid`, `timestamp`, `save_cause`, `pinned`, `data`
FROM `%user_data_table%`
WHERE `player_uuid`=?
ORDER BY `timestamp` DESC;"""))) {
statement.setString(1, user.uuid.toString());
final ResultSet resultSet = statement.executeQuery();
while (resultSet.next()) {
final Blob blob = resultSet.getBlob("data");
final byte[] dataByteArray = blob.getBytes(1, (int) blob.length());
blob.free();
final UserDataSnapshot data = new UserDataSnapshot(
UUID.fromString(resultSet.getString("version_uuid")),
Date.from(resultSet.getTimestamp("timestamp").toInstant()),
DataSaveCause.getCauseByName(resultSet.getString("save_cause")),
resultSet.getBoolean("pinned"),
plugin.getDataAdapter().fromBytes(dataByteArray));
retrievedData.add(data);
}
return retrievedData;
public Optional<DataSnapshot.Packed> getLatestSnapshot(@NotNull User user) {
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
SELECT `version_uuid`, `timestamp`, `save_cause`, `pinned`, `data`
FROM `%user_data_table%`
WHERE `player_uuid`=?
ORDER BY `timestamp` DESC
LIMIT 1;"""))) {
statement.setString(1, user.getUuid().toString());
final ResultSet resultSet = statement.executeQuery();
if (resultSet.next()) {
final Blob blob = resultSet.getBlob("data");
final byte[] dataByteArray = blob.getBytes(1, (int) blob.length());
blob.free();
return Optional.of(DataSnapshot.deserialize(plugin, dataByteArray));
}
} catch (SQLException | DataAdaptionException e) {
plugin.log(Level.SEVERE, "Failed to fetch a user's current user data from the database", e);
}
return retrievedData;
});
} catch (SQLException | DataAdapter.AdaptionException e) {
plugin.log(Level.SEVERE, "Failed to fetch a user's current user data from the database", e);
}
return Optional.empty();
}
@Blocking
@Override
public CompletableFuture<Optional<UserDataSnapshot>> getUserData(@NotNull User user, @NotNull UUID versionUuid) {
return CompletableFuture.supplyAsync(() -> {
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
SELECT `version_uuid`, `timestamp`, `save_cause`, `pinned`, `data`
FROM `%user_data_table%`
WHERE `player_uuid`=? AND `version_uuid`=?
ORDER BY `timestamp` DESC
LIMIT 1;"""))) {
statement.setString(1, user.uuid.toString());
statement.setString(2, versionUuid.toString());
final ResultSet resultSet = statement.executeQuery();
if (resultSet.next()) {
final Blob blob = resultSet.getBlob("data");
final byte[] dataByteArray = blob.getBytes(1, (int) blob.length());
blob.free();
return Optional.of(new UserDataSnapshot(
UUID.fromString(resultSet.getString("version_uuid")),
Date.from(resultSet.getTimestamp("timestamp").toInstant()),
DataSaveCause.getCauseByName(resultSet.getString("save_cause")),
resultSet.getBoolean("pinned"),
plugin.getDataAdapter().fromBytes(dataByteArray)));
}
@NotNull
public List<DataSnapshot.Packed> getAllSnapshots(@NotNull User user) {
final List<DataSnapshot.Packed> retrievedData = new ArrayList<>();
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
SELECT `version_uuid`, `timestamp`, `save_cause`, `pinned`, `data`
FROM `%user_data_table%`
WHERE `player_uuid`=?
ORDER BY `timestamp` DESC;"""))) {
statement.setString(1, user.getUuid().toString());
final ResultSet resultSet = statement.executeQuery();
while (resultSet.next()) {
final Blob blob = resultSet.getBlob("data");
final byte[] dataByteArray = blob.getBytes(1, (int) blob.length());
blob.free();
retrievedData.add(DataSnapshot.deserialize(plugin, dataByteArray));
}
} catch (SQLException | DataAdaptionException e) {
plugin.log(Level.SEVERE, "Failed to fetch specific user data by UUID from the database", e);
return retrievedData;
}
return Optional.empty();
});
} catch (SQLException | DataAdapter.AdaptionException e) {
plugin.log(Level.SEVERE, "Failed to fetch a user's current user data from the database", e);
}
return retrievedData;
}
@Blocking
@Override
protected void rotateUserData(@NotNull User user) {
final List<UserDataSnapshot> unpinnedUserData = getUserData(user).join().stream()
.filter(dataSnapshot -> !dataSnapshot.pinned()).toList();
public Optional<DataSnapshot.Packed> getSnapshot(@NotNull User user, @NotNull UUID versionUuid) {
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
SELECT `version_uuid`, `timestamp`, `save_cause`, `pinned`, `data`
FROM `%user_data_table%`
WHERE `player_uuid`=? AND `version_uuid`=?
ORDER BY `timestamp` DESC
LIMIT 1;"""))) {
statement.setString(1, user.getUuid().toString());
statement.setString(2, versionUuid.toString());
final ResultSet resultSet = statement.executeQuery();
if (resultSet.next()) {
final Blob blob = resultSet.getBlob("data");
final byte[] dataByteArray = blob.getBytes(1, (int) blob.length());
blob.free();
return Optional.of(DataSnapshot.deserialize(plugin, dataByteArray));
}
}
} catch (SQLException | DataAdapter.AdaptionException e) {
plugin.log(Level.SEVERE, "Failed to fetch specific user data by UUID from the database", e);
}
return Optional.empty();
}
@Blocking
@Override
protected void rotateSnapshots(@NotNull User user) {
final List<DataSnapshot.Packed> unpinnedUserData = getAllSnapshots(user).stream()
.filter(dataSnapshot -> !dataSnapshot.isPinned()).toList();
if (unpinnedUserData.size() > plugin.getSettings().getMaxUserDataSnapshots()) {
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
@@ -319,7 +304,7 @@ public class MySqlDatabase extends Database {
ORDER BY `timestamp` ASC
LIMIT %entry_count%;""".replace("%entry_count%",
Integer.toString(unpinnedUserData.size() - plugin.getSettings().getMaxUserDataSnapshots()))))) {
statement.setString(1, user.uuid.toString());
statement.setString(1, user.getUuid().toString());
statement.executeUpdate();
}
} catch (SQLException e) {
@@ -328,105 +313,97 @@ public class MySqlDatabase extends Database {
}
}
@Blocking
@Override
public CompletableFuture<Boolean> deleteUserData(@NotNull User user, @NotNull UUID versionUuid) {
return CompletableFuture.supplyAsync(() -> {
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
DELETE FROM `%user_data_table%`
WHERE `player_uuid`=? AND `version_uuid`=?
LIMIT 1;"""))) {
statement.setString(1, user.uuid.toString());
statement.setString(2, versionUuid.toString());
return statement.executeUpdate() > 0;
}
} catch (SQLException e) {
plugin.log(Level.SEVERE, "Failed to delete specific user data from the database", e);
public boolean deleteSnapshot(@NotNull User user, @NotNull UUID versionUuid) {
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
DELETE FROM `%user_data_table%`
WHERE `player_uuid`=? AND `version_uuid`=?
LIMIT 1;"""))) {
statement.setString(1, user.getUuid().toString());
statement.setString(2, versionUuid.toString());
return statement.executeUpdate() > 0;
}
return false;
});
} catch (SQLException e) {
plugin.log(Level.SEVERE, "Failed to delete specific user data from the database", e);
}
return false;
}
@Blocking
@Override
protected void rotateLatestSnapshot(@NotNull User user, @NotNull OffsetDateTime within) {
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
DELETE FROM `%user_data_table%`
WHERE `player_uuid`=? AND `timestamp`>? AND `pinned` IS FALSE
ORDER BY `timestamp` ASC
LIMIT 1;"""))) {
statement.setString(1, user.getUuid().toString());
statement.setTimestamp(2, Timestamp.from(within.toInstant()));
statement.executeUpdate();
}
} catch (SQLException e) {
plugin.log(Level.SEVERE, "Failed to delete a user's data from the database", e);
}
}
@Blocking
@Override
protected void createSnapshot(@NotNull User user, @NotNull DataSnapshot.Packed data) {
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
INSERT INTO `%user_data_table%`
(`player_uuid`,`version_uuid`,`timestamp`,`save_cause`,`pinned`,`data`)
VALUES (?,?,?,?,?,?);"""))) {
statement.setString(1, user.getUuid().toString());
statement.setString(2, data.getId().toString());
statement.setTimestamp(3, Timestamp.from(data.getTimestamp().toInstant()));
statement.setString(4, data.getSaveCause().name());
statement.setBoolean(5, data.isPinned());
statement.setBlob(6, new ByteArrayInputStream(data.asBytes(plugin)));
statement.executeUpdate();
}
} catch (SQLException | DataAdapter.AdaptionException e) {
plugin.log(Level.SEVERE, "Failed to set user data in the database", e);
}
}
@Blocking
@Override
public void updateSnapshot(@NotNull User user, @NotNull DataSnapshot.Packed data) {
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
UPDATE `%user_data_table%`
SET `save_cause`=?,`pinned`=?,`data`=?
WHERE `player_uuid`=? AND `version_uuid`=?
LIMIT 1;"""))) {
statement.setString(1, data.getSaveCause().name());
statement.setBoolean(2, data.isPinned());
statement.setBlob(3, new ByteArrayInputStream(data.asBytes(plugin)));
statement.setString(4, user.getUuid().toString());
statement.setString(5, data.getId().toString());
statement.executeUpdate();
}
} catch (SQLException e) {
plugin.log(Level.SEVERE, "Failed to pin user data in the database", e);
}
}
@Override
public CompletableFuture<Void> setUserData(@NotNull User user, @NotNull UserData userData,
@NotNull DataSaveCause saveCause) {
return CompletableFuture.runAsync(() -> {
final DataSaveEvent dataSaveEvent = (DataSaveEvent) plugin.getEventCannon().fireDataSaveEvent(user,
userData, saveCause).join();
if (!dataSaveEvent.isCancelled()) {
final UserData finalData = dataSaveEvent.getUserData();
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
INSERT INTO `%user_data_table%`
(`player_uuid`,`version_uuid`,`timestamp`,`save_cause`,`data`)
VALUES (?,UUID(),NOW(),?,?);"""))) {
statement.setString(1, user.uuid.toString());
statement.setString(2, saveCause.name());
statement.setBlob(3, new ByteArrayInputStream(
plugin.getDataAdapter().toBytes(finalData)));
statement.executeUpdate();
}
} catch (SQLException | DataAdaptionException e) {
plugin.log(Level.SEVERE, "Failed to set user data in the database", e);
}
public void wipeDatabase() {
try (Connection connection = getConnection()) {
try (Statement statement = connection.createStatement()) {
statement.executeUpdate(formatStatementTables("DELETE FROM `%user_data_table%`;"));
}
this.rotateUserData(user);
});
} catch (SQLException e) {
plugin.log(Level.SEVERE, "Failed to wipe the database", e);
}
}
@Override
public CompletableFuture<Void> pinUserData(@NotNull User user, @NotNull UUID versionUuid) {
return CompletableFuture.runAsync(() -> {
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
UPDATE `%user_data_table%`
SET `pinned`=TRUE
WHERE `player_uuid`=? AND `version_uuid`=?
LIMIT 1;"""))) {
statement.setString(1, user.uuid.toString());
statement.setString(2, versionUuid.toString());
statement.executeUpdate();
}
} catch (SQLException e) {
plugin.log(Level.SEVERE, "Failed to pin user data in the database", e);
}
});
}
@Override
public CompletableFuture<Void> unpinUserData(@NotNull User user, @NotNull UUID versionUuid) {
return CompletableFuture.runAsync(() -> {
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
UPDATE `%user_data_table%`
SET `pinned`=FALSE
WHERE `player_uuid`=? AND `version_uuid`=?
LIMIT 1;"""))) {
statement.setString(1, user.uuid.toString());
statement.setString(2, versionUuid.toString());
statement.executeUpdate();
}
} catch (SQLException e) {
plugin.log(Level.SEVERE, "Failed to unpin user data in the database", e);
}
});
}
@Override
public CompletableFuture<Void> wipeDatabase() {
return CompletableFuture.runAsync(() -> {
try (Connection connection = getConnection()) {
try (Statement statement = connection.createStatement()) {
statement.executeUpdate(formatStatementTables("DELETE FROM `%user_data_table%`;"));
}
} catch (SQLException e) {
plugin.log(Level.SEVERE, "Failed to wipe the database", e);
}
});
}
@Override
public void close() {
public void terminate() {
if (dataSource != null) {
if (!dataSource.isClosed()) {
dataSource.close();