diff --git a/buildSrc/src/main/kotlin/Versions.kt b/buildSrc/src/main/kotlin/Versions.kt index 4f3c23e32..b0d6a327a 100644 --- a/buildSrc/src/main/kotlin/Versions.kt +++ b/buildSrc/src/main/kotlin/Versions.kt @@ -48,5 +48,7 @@ object Versions { // tests const val JUNIT_BOM = "5.13.4" const val MOCKITO_CORE = "5.19.0" + const val TEST_CONTAINERS = "1.21.3" + const val MYSQL_CONNECTOR = "8.0.33" } diff --git a/buildSrc/src/main/kotlin/eternalcode-java-test.gradle.kts b/buildSrc/src/main/kotlin/eternalcode-java-test.gradle.kts index 96e7d8cd9..0e7901ed6 100644 --- a/buildSrc/src/main/kotlin/eternalcode-java-test.gradle.kts +++ b/buildSrc/src/main/kotlin/eternalcode-java-test.gradle.kts @@ -10,6 +10,9 @@ dependencies { testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine") testRuntimeOnly("org.junit.platform:junit-platform-launcher") + testImplementation("org.testcontainers:junit-jupiter:${Versions.TEST_CONTAINERS}") + testImplementation("org.testcontainers:mysql:${Versions.TEST_CONTAINERS}") + testImplementation("mysql:mysql-connector-java:${Versions.MYSQL_CONNECTOR}") testImplementation("org.mockito:mockito-core:${Versions.MOCKITO_CORE}") testImplementation("net.kyori:adventure-platform-facet:${Versions.ADVENTURE_PLATFORM}") testImplementation("org.spigotmc:spigot-api:${Versions.SPIGOT_API}") diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/database/AbstractRepositoryOrmLite.java b/eternalcore-core/src/main/java/com/eternalcode/core/database/AbstractRepositoryOrmLite.java index 262249442..1914a7e52 100644 --- a/eternalcore-core/src/main/java/com/eternalcode/core/database/AbstractRepositoryOrmLite.java +++ b/eternalcore-core/src/main/java/com/eternalcode/core/database/AbstractRepositoryOrmLite.java @@ -50,6 +50,10 @@ protected CompletableFuture> selectAll(Class type) { return this.action(type, Dao::queryForAll); } + protected CompletableFuture> selectBatch(Class type, int offset, int limit) { + return this.action(type, dao -> dao.queryBuilder().offset((long) offset).limit((long) limit).query()); + } + protected CompletableFuture action( Class type, ThrowingFunction, R, SQLException> action diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/database/DatabaseConfig.java b/eternalcore-core/src/main/java/com/eternalcode/core/database/DatabaseConfig.java index 869ef687c..eb41b7e22 100644 --- a/eternalcore-core/src/main/java/com/eternalcode/core/database/DatabaseConfig.java +++ b/eternalcore-core/src/main/java/com/eternalcode/core/database/DatabaseConfig.java @@ -13,7 +13,7 @@ public class DatabaseConfig extends OkaeriConfig implements DatabaseSettings { @Comment({"Type of the database driver (e.g., SQLITE, H2, MYSQL, MARIADB, POSTGRESQL).", "Determines the " + "database type " + "to be used."}) - public DatabaseDriverType databaseType = DatabaseDriverType.SQLITE; + public DatabaseDriverType databaseType = DatabaseDriverType.MYSQL; @Comment({"Hostname of the database server.", "For local databases, this is usually 'localhost'."}) public String hostname = "localhost"; diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/database/DatabaseDriverType.java b/eternalcore-core/src/main/java/com/eternalcode/core/database/DatabaseDriverType.java index c40f84ebd..00407fb3d 100644 --- a/eternalcore-core/src/main/java/com/eternalcode/core/database/DatabaseDriverType.java +++ b/eternalcore-core/src/main/java/com/eternalcode/core/database/DatabaseDriverType.java @@ -8,7 +8,9 @@ public enum DatabaseDriverType { MARIADB(MARIADB_DRIVER, MARIADB_JDBC_URL), POSTGRESQL(POSTGRESQL_DRIVER, POSTGRESQL_JDBC_URL), H2(H2_DRIVER, H2_JDBC_URL), - SQLITE(SQLITE_DRIVER, SQLITE_JDBC_URL); + SQLITE(SQLITE_DRIVER, SQLITE_JDBC_URL), + + H2_TEST(H2_DRIVER, "jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;MODE=MYSQL"); private final String driver; private final String urlFormat; diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/database/DatabaseManager.java b/eternalcore-core/src/main/java/com/eternalcode/core/database/DatabaseManager.java index badb85de0..195cb801f 100644 --- a/eternalcore-core/src/main/java/com/eternalcode/core/database/DatabaseManager.java +++ b/eternalcore-core/src/main/java/com/eternalcode/core/database/DatabaseManager.java @@ -67,6 +67,7 @@ public void connect() { settings.database(), String.valueOf(settings.ssl()) ); + case H2_TEST -> type.formatUrl(); }; this.dataSource.setJdbcUrl(jdbcUrl); diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/afk/AfkKickController.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/afk/AfkKickController.java index c1bc2c32a..fd9a1441b 100644 --- a/eternalcore-core/src/main/java/com/eternalcode/core/feature/afk/AfkKickController.java +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/afk/AfkKickController.java @@ -9,6 +9,7 @@ import com.eternalcode.core.translation.TranslationManager; import com.eternalcode.core.user.User; import com.eternalcode.core.user.UserManager; +import java.util.Optional; import java.util.UUID; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.minimessage.MiniMessage; @@ -56,7 +57,8 @@ void onAfkSwitch(AfkSwitchEvent event) { return; } - User user = this.userManager.getOrCreate(playerUUID, player.getName()); + Optional optionalUser = this.userManager.getUser(playerUUID); + User user = optionalUser.get(); Translation translation = this.translationManager.getMessages(user.getUniqueId()); Component component = this.miniMessage.deserialize(translation.afk().afkKickReason()); diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/fullserverbypass/FullServerBypassController.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/fullserverbypass/FullServerBypassController.java index dc9b388d7..523cf3188 100644 --- a/eternalcore-core/src/main/java/com/eternalcode/core/feature/fullserverbypass/FullServerBypassController.java +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/fullserverbypass/FullServerBypassController.java @@ -5,8 +5,6 @@ import com.eternalcode.core.injector.annotations.Inject; import com.eternalcode.core.injector.annotations.component.Controller; import com.eternalcode.core.translation.TranslationManager; -import com.eternalcode.core.user.User; -import com.eternalcode.core.user.UserManager; import com.eternalcode.commons.adventure.AdventureUtil; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.minimessage.MiniMessage; @@ -16,8 +14,6 @@ import org.bukkit.event.player.PlayerLoginEvent; import panda.utilities.text.Joiner; -import java.util.Optional; - @PermissionDocs( name = "Bypass Full Server", description = "This feature allows you to bypass the full server, example for vip rank.", @@ -29,13 +25,11 @@ class FullServerBypassController implements Listener { static final String SLOT_BYPASS = "eternalcore.slot.bypass"; private final TranslationManager translationManager; - private final UserManager userManager; private final MiniMessage miniMessage; @Inject - FullServerBypassController(TranslationManager translationManager, UserManager userManager, MiniMessage miniMessage) { + FullServerBypassController(TranslationManager translationManager, MiniMessage miniMessage) { this.translationManager = translationManager; - this.userManager = userManager; this.miniMessage = miniMessage; } @@ -50,26 +44,16 @@ void onLogin(PlayerLoginEvent event) { return; } - String serverFullMessage = this.getServerFullMessage(player); + String serverFullMessage = this.getServerFullMessage(); Component serverFullMessageComponent = this.miniMessage.deserialize(serverFullMessage); event.disallow(PlayerLoginEvent.Result.KICK_FULL, AdventureUtil.SECTION_SERIALIZER.serialize(serverFullMessageComponent)); } } - private String getServerFullMessage(Player player) { - Optional userOption = this.userManager.getUser(player.getUniqueId()); - - if (userOption.isEmpty()) { - return Joiner.on("\n") - .join(this.translationManager.getMessages().player().fullServerSlots()) - .toString(); - } - - User user = userOption.get(); - + private String getServerFullMessage() { return Joiner.on("\n") - .join(this.translationManager.getMessages(user.getUniqueId()).player().fullServerSlots()) + .join(this.translationManager.getMessages().player().fullServerSlots()) .toString(); } } diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/msg/MsgServiceImpl.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/msg/MsgServiceImpl.java index ae9b4566c..18ae9bea2 100644 --- a/eternalcore-core/src/main/java/com/eternalcode/core/feature/msg/MsgServiceImpl.java +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/msg/MsgServiceImpl.java @@ -13,9 +13,9 @@ import com.google.common.cache.CacheBuilder; import java.time.Duration; import java.util.HashSet; -import java.util.Optional; import java.util.Set; import java.util.UUID; +import org.bukkit.Server; import org.bukkit.entity.Player; @Service @@ -27,6 +27,7 @@ class MsgServiceImpl implements MsgService { private final MsgPresenter presenter; private final EventCaller eventCaller; private final MsgToggleService msgToggleService; + private final Server server; private final Cache replies = CacheBuilder.newBuilder() .expireAfterWrite(Duration.ofHours(1)) @@ -40,7 +41,7 @@ class MsgServiceImpl implements MsgService { IgnoreService ignoreService, UserManager userManager, EventCaller eventCaller, - MsgToggleService msgToggleService + MsgToggleService msgToggleService, Server server ) { this.noticeService = noticeService; this.ignoreService = ignoreService; @@ -49,15 +50,10 @@ class MsgServiceImpl implements MsgService { this.msgToggleService = msgToggleService; this.presenter = new MsgPresenter(noticeService); + this.server = server; } void privateMessage(User sender, User target, String message) { - if (target.getClientSettings().isOffline()) { - this.noticeService.player(sender.getUniqueId(), translation -> translation.argument().offlinePlayer()); - - return; - } - UUID uniqueId = target.getUniqueId(); this.msgToggleService.getState(uniqueId).thenAccept(msgState -> { @@ -89,17 +85,14 @@ void reply(User sender, String message) { return; } - Optional targetOption = this.userManager.getUser(uuid); - - if (targetOption.isEmpty()) { + Player target = this.server.getPlayer(uuid); + if (target == null) { this.noticeService.player(sender.getUniqueId(), translation -> translation.argument().offlinePlayer()); return; } - User target = targetOption.get(); - - this.privateMessage(sender, target, message); + this.privateMessage(sender, toUser(target), message); } @Override @@ -119,12 +112,18 @@ public boolean isSpy(UUID player) { @Override public void reply(Player sender, String message) { - this.reply(this.userManager.getOrCreate(sender.getUniqueId(), sender.getName()), message); + this.reply(toUser(sender), message); } @Override public void sendMessage(Player sender, Player target, String message) { - User user = this.userManager.getOrCreate(target.getUniqueId(), target.getName()); - this.privateMessage(this.userManager.getOrCreate(sender.getUniqueId(), sender.getName()), user, message); + User user = toUser(target); + this.privateMessage(toUser(sender), user, message); + } + + private User toUser(Player target) { + return this.userManager.getUser(target.getUniqueId()).get(); } + + } diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/litecommand/argument/UserArgument.java b/eternalcore-core/src/main/java/com/eternalcode/core/litecommand/argument/UserArgument.java index ff54a7a14..883885d68 100644 --- a/eternalcore-core/src/main/java/com/eternalcode/core/litecommand/argument/UserArgument.java +++ b/eternalcore-core/src/main/java/com/eternalcode/core/litecommand/argument/UserArgument.java @@ -8,9 +8,12 @@ import com.eternalcode.core.user.UserManager; import dev.rollczi.litecommands.argument.Argument; import dev.rollczi.litecommands.argument.parser.ParseResult; +import static dev.rollczi.litecommands.argument.parser.ParseResult.failure; +import static dev.rollczi.litecommands.argument.parser.ParseResult.success; import dev.rollczi.litecommands.invocation.Invocation; import dev.rollczi.litecommands.suggestion.SuggestionContext; import dev.rollczi.litecommands.suggestion.SuggestionResult; +import java.util.regex.Pattern; import org.bukkit.Server; import org.bukkit.command.CommandSender; import org.bukkit.entity.HumanEntity; @@ -18,6 +21,8 @@ @LiteArgument(type = User.class) class UserArgument extends AbstractViewerArgument { + private static final Pattern USERNAME_PATTERN = Pattern.compile("^[a-zA-Z0-9_]{1,16}$"); + private final Server server; private final UserManager userManager; @@ -28,6 +33,17 @@ class UserArgument extends AbstractViewerArgument { this.userManager = userManager; } + @Override + public ParseResult parse(Invocation invocation, String argument, Translation translation) { + return ParseResult.completableFuture(this.userManager.getUserFromRepository(argument), maybeUser -> maybeUser.map(user -> success(user)) + .orElse(failure(translation.argument().offlinePlayer()))); + } + + @Override + protected boolean match(Invocation invocation, Argument context, String argument) { + return USERNAME_PATTERN.matcher(argument).matches(); + } + @Override public SuggestionResult suggest(Invocation invocation, Argument argument, SuggestionContext context) { return this.server.getOnlinePlayers().stream() @@ -35,11 +51,4 @@ public SuggestionResult suggest(Invocation invocation, Argument parse(Invocation invocation, String argument, Translation translation) { - return this.userManager.getUser(argument) - .map(ParseResult::success) - .orElseGet(() -> ParseResult.failure(translation.argument().offlinePlayer())); - } - } diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/user/LoadUserController.java b/eternalcore-core/src/main/java/com/eternalcode/core/user/LoadUserController.java deleted file mode 100644 index db8e33b79..000000000 --- a/eternalcore-core/src/main/java/com/eternalcode/core/user/LoadUserController.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.eternalcode.core.user; - -import com.eternalcode.core.injector.annotations.Inject; -import com.eternalcode.core.injector.annotations.component.Controller; -import org.bukkit.Server; -import org.bukkit.entity.Player; -import org.bukkit.event.EventHandler; -import org.bukkit.event.Listener; -import org.bukkit.event.server.ServerLoadEvent; - -@Controller -class LoadUserController implements Listener { - - private final UserManager userManager; - private final Server server; - - @Inject - LoadUserController(UserManager userManager, Server server) { - this.userManager = userManager; - this.server = server; - } - - @EventHandler - void onReload(ServerLoadEvent event) { - if (event.getType() != ServerLoadEvent.LoadType.RELOAD) { - return; - } - - for (Player player : this.server.getOnlinePlayers()) { - this.userManager.create(player.getUniqueId(), player.getName()); - } - } - -} diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/user/PrepareUserController.java b/eternalcore-core/src/main/java/com/eternalcode/core/user/PrepareUserController.java deleted file mode 100644 index 890667def..000000000 --- a/eternalcore-core/src/main/java/com/eternalcode/core/user/PrepareUserController.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.eternalcode.core.user; - -import com.eternalcode.core.injector.annotations.Inject; -import com.eternalcode.core.injector.annotations.component.Controller; -import org.bukkit.Server; -import org.bukkit.entity.Player; -import org.bukkit.event.EventHandler; -import org.bukkit.event.Listener; -import org.bukkit.event.player.PlayerJoinEvent; -import org.bukkit.event.player.PlayerKickEvent; -import org.bukkit.event.player.PlayerQuitEvent; - -@Controller -class PrepareUserController implements Listener { - - private final UserManager userManager; - private final Server server; - - @Inject - PrepareUserController(UserManager userManager, Server server) { - this.userManager = userManager; - this.server = server; - } - - @EventHandler - void onJoin(PlayerJoinEvent event) { - Player player = event.getPlayer(); - User user = this.userManager.getOrCreate(player.getUniqueId(), player.getName()); - UserClientBukkitSettings clientSettings = new UserClientBukkitSettings(this.server, user.getUniqueId()); - - user.setClientSettings(clientSettings); - } - - @EventHandler - void onQuit(PlayerQuitEvent event) { - Player player = event.getPlayer(); - - User user = this.userManager.getUser(player.getUniqueId()) - .orElseThrow(() -> new IllegalStateException("User not found")); - - user.setClientSettings(UserClientSettings.NONE); - } - - @EventHandler - void onKick(PlayerKickEvent event) { - Player player = event.getPlayer(); - - User user = this.userManager.getUser(player.getUniqueId()) - .orElseThrow(() -> new IllegalStateException("User not found")); - - user.setClientSettings(UserClientSettings.NONE); - } -} diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/user/User.java b/eternalcore-core/src/main/java/com/eternalcode/core/user/User.java index 0536b877a..2d3d1d2c8 100644 --- a/eternalcore-core/src/main/java/com/eternalcode/core/user/User.java +++ b/eternalcore-core/src/main/java/com/eternalcode/core/user/User.java @@ -2,19 +2,23 @@ import com.eternalcode.core.viewer.Viewer; +import java.time.Instant; import java.util.Objects; import java.util.UUID; public class User implements Viewer { - private UserClientSettings userClientSettings = UserClientSettings.NONE; private final String name; private final UUID uuid; + private final Instant created; + private final Instant lastLogin; - User(UUID uuid, String name) { + public User(UUID uuid, String name, Instant created, Instant lastLogin) { this.name = name; this.uuid = uuid; + this.created = created; + this.lastLogin = lastLogin; } @Override @@ -27,17 +31,17 @@ public UUID getUniqueId() { return this.uuid; } - @Override - public boolean isConsole() { - return false; + public Instant getCreated() { + return created; } - public UserClientSettings getClientSettings() { - return this.userClientSettings; + public Instant getLastLogin() { + return lastLogin; } - public void setClientSettings(UserClientSettings userClientSettings) { - this.userClientSettings = userClientSettings; + @Override + public boolean isConsole() { + return false; } @Override diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/user/UserClientBukkitSettings.java b/eternalcore-core/src/main/java/com/eternalcode/core/user/UserClientBukkitSettings.java deleted file mode 100644 index 088e4698e..000000000 --- a/eternalcore-core/src/main/java/com/eternalcode/core/user/UserClientBukkitSettings.java +++ /dev/null @@ -1,45 +0,0 @@ -package com.eternalcode.core.user; - -import org.bukkit.Server; -import org.bukkit.entity.Player; -import panda.std.Option; - -import java.lang.ref.WeakReference; -import java.util.Locale; -import java.util.UUID; - -class UserClientBukkitSettings implements UserClientSettings { - - private final Server server; - private final UUID uuid; - private WeakReference playerReference; - - UserClientBukkitSettings(Server server, UUID uuid) { - this.server = server; - this.uuid = uuid; - this.playerReference = new WeakReference<>(server.getPlayer(uuid)); - } - - @Override - public boolean isOnline() { - return this.getPlayer().isPresent(); - } - - private Option getPlayer() { - Player player = this.playerReference.get(); - - if (player == null) { - Player playerFromServer = this.server.getPlayer(this.uuid); - - if (playerFromServer == null) { - return Option.none(); - } - - this.playerReference = new WeakReference<>(playerFromServer); - return Option.of(playerFromServer); - } - - return Option.of(player); - } - -} diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/user/UserClientNoneSettings.java b/eternalcore-core/src/main/java/com/eternalcode/core/user/UserClientNoneSettings.java deleted file mode 100644 index 62fdb60a0..000000000 --- a/eternalcore-core/src/main/java/com/eternalcode/core/user/UserClientNoneSettings.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.eternalcode.core.user; - -class UserClientNoneSettings implements UserClientSettings { - - @Override - public boolean isOnline() { - return false; - } - - -} diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/user/UserClientSettings.java b/eternalcore-core/src/main/java/com/eternalcode/core/user/UserClientSettings.java deleted file mode 100644 index 7a0012bf3..000000000 --- a/eternalcore-core/src/main/java/com/eternalcode/core/user/UserClientSettings.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.eternalcode.core.user; - -public interface UserClientSettings { - - UserClientSettings NONE = new UserClientNoneSettings(); - - boolean isOnline(); - - default boolean isOffline() { - return !this.isOnline(); - } - -} diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/user/UserController.java b/eternalcore-core/src/main/java/com/eternalcode/core/user/UserController.java new file mode 100644 index 000000000..ab082d279 --- /dev/null +++ b/eternalcore-core/src/main/java/com/eternalcode/core/user/UserController.java @@ -0,0 +1,42 @@ +package com.eternalcode.core.user; + +import com.eternalcode.core.injector.annotations.Inject; +import com.eternalcode.core.injector.annotations.component.Controller; +import org.bukkit.Server; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.event.server.ServerLoadEvent; + +@Controller +public class UserController { + + private final UserManager userManager; + private final Server server; + + @Inject + public UserController(UserManager userManager, Server server) { + this.userManager = userManager; + this.server = server; + } + + @EventHandler + public void onJoin(PlayerJoinEvent event) { + this.userManager.fetchUser(event.getPlayer().getUniqueId()); + } + + @EventHandler + public void onQuit(PlayerQuitEvent event) { + Player player = event.getPlayer(); + this.userManager.updateLastSeen(player.getUniqueId(), player.getName()); + } + + @EventHandler + public void onReload(ServerLoadEvent event) { + if (event.getType() == ServerLoadEvent.LoadType.RELOAD) { + this.server.getOnlinePlayers().forEach(player -> this.userManager.updateLastSeen(player.getUniqueId(), player.getName())); + } + } + +} diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/user/UserManager.java b/eternalcore-core/src/main/java/com/eternalcode/core/user/UserManager.java index e6bcfc0f2..bb91eb0ef 100644 --- a/eternalcore-core/src/main/java/com/eternalcode/core/user/UserManager.java +++ b/eternalcore-core/src/main/java/com/eternalcode/core/user/UserManager.java @@ -1,56 +1,94 @@ package com.eternalcode.core.user; +import com.eternalcode.core.injector.annotations.Inject; import com.eternalcode.core.injector.annotations.component.Service; -import java.util.Collection; -import java.util.Collections; -import java.util.Map; +import com.eternalcode.core.user.database.UserRepository; +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; import java.util.Optional; import java.util.UUID; -import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CompletableFuture; @Service public class UserManager { - private final Map usersByUUID = new ConcurrentHashMap<>(); - private final Map usersByName = new ConcurrentHashMap<>(); + private final Cache usersByUUID; + private final Cache usersByName; - public Optional getUser(UUID uuid) { - return Optional.ofNullable(this.usersByUUID.get(uuid)); + private final UserRepository userRepository; + + @Inject + public UserManager(UserRepository userRepository) { + this.usersByUUID = Caffeine.newBuilder().build(); + this.usersByName = Caffeine.newBuilder().build(); + + this.userRepository = userRepository; + this.fetchActiveUsers(); + } + + public Optional getUser(UUID uniqueId) { + return Optional.ofNullable(this.usersByUUID.getIfPresent(uniqueId)); } public Optional getUser(String name) { - return Optional.ofNullable(this.usersByName.get(name)); + return Optional.ofNullable(this.usersByName.getIfPresent(name)); } - public User getOrCreate(UUID uuid, String name) { - User userByUUID = this.usersByUUID.get(uuid); + public CompletableFuture> getUserFromRepository(UUID uniqueId) { - if (userByUUID != null) { - return userByUUID; + User userFromCache = this.usersByUUID.getIfPresent(uniqueId); + + if (userFromCache != null) { + return CompletableFuture.completedFuture(Optional.of(userFromCache)); } - User userByName = this.usersByName.get(name); + CompletableFuture> userFuture = this.userRepository.getUser(uniqueId); + userFuture.thenAccept(optionalUser -> optionalUser.ifPresent(user -> { + this.usersByUUID.put(uniqueId, user); + this.usersByName.put(user.getName(), user); + })); + + return userFuture; + } + + public CompletableFuture> getUserFromRepository(String name) { - if (userByName != null) { - return userByName; + User userFromCache = this.usersByName.getIfPresent(name); + + if (userFromCache != null) { + return CompletableFuture.completedFuture(Optional.of(userFromCache)); } - return this.create(uuid, name); + CompletableFuture> userFuture = this.userRepository.getUser(name); + userFuture.thenAccept(optionalUser -> optionalUser.ifPresent(user -> { + this.usersByUUID.put(user.getUniqueId(), user); + this.usersByName.put(name, user); + })); + + return userFuture; } - public User create(UUID uuid, String name) { - if (this.usersByUUID.containsKey(uuid) || this.usersByName.containsKey(name)) { - throw new IllegalStateException("User already exists"); - } + public void saveUser(User user) { + this.saveInCache(user); + this.userRepository.saveUser(user); + } - User user = new User(uuid, name); - this.usersByUUID.put(uuid, user); - this.usersByName.put(name, user); + public void updateLastSeen(UUID uniqueId, String name) { + this.userRepository.updateUser(uniqueId, name).thenAccept(this::saveInCache); + } + + private void fetchActiveUsers() { + this.userRepository.getActiveUsers().thenAccept(list -> list.forEach(this::saveInCache)); + } - return user; + void fetchUser(UUID uniqueId) { + this.userRepository.getUser(uniqueId).thenAccept(optionalUser -> { + optionalUser.ifPresent(user -> this.saveInCache(user)); + }); } - public Collection getUsers() { - return Collections.unmodifiableCollection(this.usersByUUID.values()); + private void saveInCache(User user) { + this.usersByUUID.put(user.getUniqueId(), user); + this.usersByName.put(user.getName(), user); } } diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/user/database/UserRepository.java b/eternalcore-core/src/main/java/com/eternalcode/core/user/database/UserRepository.java new file mode 100644 index 000000000..75393fc10 --- /dev/null +++ b/eternalcore-core/src/main/java/com/eternalcode/core/user/database/UserRepository.java @@ -0,0 +1,23 @@ +package com.eternalcode.core.user.database; + +import com.eternalcode.core.user.User; +import java.time.Duration; +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import org.jetbrains.annotations.Nullable; + +public interface UserRepository { + + CompletableFuture> getActiveUsers(); + + CompletableFuture> getUser(UUID uniqueId); + + CompletableFuture> getUser(String name); + + CompletableFuture saveUser(User user); + + CompletableFuture updateUser(UUID uniqueId, String name); +} diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/user/database/UserRepositoryOrmLite.java b/eternalcore-core/src/main/java/com/eternalcode/core/user/database/UserRepositoryOrmLite.java new file mode 100644 index 000000000..50ff908e6 --- /dev/null +++ b/eternalcore-core/src/main/java/com/eternalcode/core/user/database/UserRepositoryOrmLite.java @@ -0,0 +1,75 @@ +package com.eternalcode.core.user.database; + +import com.eternalcode.commons.scheduler.Scheduler; +import com.eternalcode.core.database.AbstractRepositoryOrmLite; +import com.eternalcode.core.database.DatabaseManager; +import com.eternalcode.core.injector.annotations.Inject; +import com.eternalcode.core.injector.annotations.component.Repository; +import com.eternalcode.core.user.User; +import com.j256.ormlite.table.TableUtils; +import org.jetbrains.annotations.Blocking; + +import java.sql.SQLException; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +@Repository +public class UserRepositoryOrmLite extends AbstractRepositoryOrmLite implements UserRepository { + + private static final Duration WEEK = Duration.ofDays(7); + private static final String NAME_COLUMN = "name"; + + @Inject + public UserRepositoryOrmLite(DatabaseManager databaseManager, Scheduler scheduler) throws SQLException { + super(databaseManager, scheduler); + TableUtils.createTableIfNotExists(databaseManager.connectionSource(), UserTable.class); + } + + @Blocking + public CompletableFuture> getActiveUsers() { + return this.selectAll(UserTable.class) + .thenApply(userTables -> userTables.stream().map(UserTable::toUser).toList()) + .thenApply(users -> users.stream().filter(user -> user.getLastLogin().isAfter(Instant.now().minus(WEEK))).toList()); + } + + @Override + @Blocking + public CompletableFuture> getUser(UUID uniqueId) { + return this.selectSafe(UserTable.class, uniqueId) + .thenApply(optional -> optional.map(UserTable::toUser)); + } + + @Override + @Blocking + public CompletableFuture> getUser(String name) { + return this.action(UserTable.class, dao -> Optional.ofNullable(dao.queryBuilder() + .where() + .eq(NAME_COLUMN, name) + .queryForFirst().toUser()) + ); + } + + @Override + @Blocking + public CompletableFuture saveUser(User user) { + return this.saveIfNotExist(UserTable.class, UserTable.from(user)).thenApply(UserTable::toUser); + } + + @Override + @Blocking + public CompletableFuture updateUser(UUID uniqueId, String name) { + Instant now = Instant.now(); + return this.selectSafe(UserTable.class, uniqueId) + .thenApply(optional -> optional.map(UserTable::toUser)) + .thenApply(optionalUser -> optionalUser.orElse(new User(uniqueId, name, now, now))) + .thenApply(user -> new User(user.getUniqueId(), user.getName(), user.getCreated(), now)) + .thenApply(user -> { + this.save(UserTable.class, UserTable.from(user)); + return user; + }); + } +} diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/user/database/UserTable.java b/eternalcore-core/src/main/java/com/eternalcode/core/user/database/UserTable.java new file mode 100644 index 000000000..2816f62bc --- /dev/null +++ b/eternalcore-core/src/main/java/com/eternalcode/core/user/database/UserTable.java @@ -0,0 +1,41 @@ +package com.eternalcode.core.user.database; + +import com.eternalcode.core.user.User; +import com.j256.ormlite.field.DataType; +import com.j256.ormlite.field.DatabaseField; +import com.j256.ormlite.table.DatabaseTable; +import java.util.Date; +import java.util.UUID; + +@DatabaseTable(tableName = "eternal_core_users") +public class UserTable { + + @DatabaseField(columnName = "id", id = true) + private UUID uniqueId; + + @DatabaseField(columnName = "name") + private String name; + + @DatabaseField(columnName = "created", dataType = DataType.DATE) + private Date created; + + @DatabaseField(columnName = "last_login", dataType = DataType.DATE) + private Date lastLogin; + + UserTable() {} + + UserTable(UUID uniqueId, String name, Date created, Date lastLogin) { + this.uniqueId = uniqueId; + this.name = name; + this.created = created; + this.lastLogin = lastLogin; + } + + public User toUser() { + return new User(this.uniqueId, this.name, this.created.toInstant(), this.lastLogin.toInstant()); + } + + public static UserTable from(User user) { + return new UserTable(user.getUniqueId(), user.getName(), Date.from(user.getCreated()), Date.from(user.getLastLogin())); + } +} diff --git a/eternalcore-core/src/test/java/com/eternalcode/core/test/MockUserRepository.java b/eternalcore-core/src/test/java/com/eternalcode/core/test/MockUserRepository.java new file mode 100644 index 000000000..1efec7eda --- /dev/null +++ b/eternalcore-core/src/test/java/com/eternalcode/core/test/MockUserRepository.java @@ -0,0 +1,34 @@ +package com.eternalcode.core.test; + +import com.eternalcode.core.user.User; +import com.eternalcode.core.user.database.UserRepository; +import java.time.Duration; +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +public class MockUserRepository implements UserRepository { + + @Override + public CompletableFuture> getActiveUsers() { + return null; + } + + @Override + public CompletableFuture> getUser(UUID uniqueId) { + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletableFuture saveUser(User player) { + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletableFuture updateUser(UUID uniqueId, String name) { + return CompletableFuture.completedFuture(null); + } + +} diff --git a/eternalcore-core/src/test/java/com/eternalcode/core/user/PrepareUserControllerTest.java b/eternalcore-core/src/test/java/com/eternalcode/core/user/PrepareUserControllerTest.java index 2b0be982a..b9bd53fa7 100644 --- a/eternalcore-core/src/test/java/com/eternalcode/core/user/PrepareUserControllerTest.java +++ b/eternalcore-core/src/test/java/com/eternalcode/core/user/PrepareUserControllerTest.java @@ -1,6 +1,8 @@ package com.eternalcode.core.user; import com.eternalcode.core.test.MockServer; +import com.eternalcode.core.test.MockUserRepository; +import com.eternalcode.core.test.MockUserRepositorySettings; import org.bukkit.entity.Player; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -24,10 +26,13 @@ class PrepareUserControllerTest { @BeforeEach void setUp() { + MockUserRepositorySettings mockUserRepositorySettings = new MockUserRepositorySettings(); + MockUserRepository mockUserRepository = new MockUserRepository(); + this.mockServer = new MockServer(); - this.userManager = new UserManager(); + this.userManager = new UserManager(mockUserRepository, mockUserRepositorySettings); - PrepareUserController controller = new PrepareUserController(this.userManager, this.mockServer.getServer()); + PrepareUserController controller = new PrepareUserController(this.userManager); this.mockServer.listenJoin(controller::onJoin); this.mockServer.listenQuit(controller::onQuit); diff --git a/eternalcore-core/src/test/java/com/eternalcode/core/user/UserBatchFetchTest.java b/eternalcore-core/src/test/java/com/eternalcode/core/user/UserBatchFetchTest.java new file mode 100644 index 000000000..759e112d1 --- /dev/null +++ b/eternalcore-core/src/test/java/com/eternalcode/core/user/UserBatchFetchTest.java @@ -0,0 +1,107 @@ +package com.eternalcode.core.user; + +import com.eternalcode.core.database.DatabaseConfig; +import com.eternalcode.core.database.DatabaseDriverType; +import com.eternalcode.core.database.DatabaseManager; +import com.eternalcode.core.user.database.UserRepository; +import com.eternalcode.core.user.database.UserRepositoryConfig; +import com.eternalcode.core.user.database.UserRepositoryOrmLite; +import com.eternalcode.core.util.IntegrationTestSpec; +import com.eternalcode.core.util.TestScheduler; +import java.nio.file.Path; +import java.sql.SQLException; +import java.time.Duration; +import java.util.UUID; +import java.util.logging.Logger; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +@Testcontainers +class UserBatchFetchTest extends IntegrationTestSpec { + + @Container + private static final MySQLContainer container = new MySQLContainer<>(DockerImageName.parse("mysql:8.0")) + .withUsername("test") + .withPassword("test") + .withDatabaseName("testdb"); + + private final TestScheduler testScheduler = new TestScheduler(); + + private DatabaseManager databaseManager; + private final Logger logger = Logger.getLogger("test"); + + @Test + void testWithMySQL(@TempDir Path tempDir) throws SQLException { + DatabaseConfig config = new DatabaseConfig(); + config.username = container.getUsername(); + config.password = container.getPassword(); + config.database = container.getDatabaseName(); + + config.hostname = container.getHost(); + config.port = container.getFirstMappedPort(); + + databaseManager = new DatabaseManager(this.logger, tempDir.toFile(), config); + databaseManager.connect(); + + UserRepository userRepository = new UserRepositoryOrmLite(databaseManager, this.testScheduler); + UserManager userManager = new UserManager(userRepository, new UserRepositoryConfig()); + + Assertions.assertEquals(0, userManager.getUsers().size()); + + UUID randomUUID = UUID.randomUUID(); + userManager.getOrCreate(randomUUID, "test1"); + + Assertions.assertEquals(1, userManager.getUsers().size()); + + userRepository.getUser(randomUUID).thenAccept(user -> { + Assertions.assertNotNull(user); + Assertions.assertEquals("test1", user.get().getName()); + }); + + databaseManager.close(); + } + + @Test + void testBatchVsAllFetch(@TempDir Path tempDir) throws Exception { + DatabaseConfig config = new DatabaseConfig(); + config.databaseType = DatabaseDriverType.H2_TEST; + config.username = "sa"; + config.password = ""; + + config.hostname = null; + config.port = 0; + config.database = "eternalcode"; + + DatabaseManager db = new DatabaseManager(Logger.getLogger("test"), tempDir.toFile(), config); + db.connect(); + + UserRepository userRepo = new UserRepositoryOrmLite(db, new TestScheduler()); + + for (int i = 0; i < 50000; i++) { + userRepo.saveUser(new User(UUID.randomUUID(), "user" + i)).join(); + } + + IntegrationTestSpec spec = new IntegrationTestSpec(); + + long start = System.nanoTime(); + var allUsers = spec.await(userRepo.fetchAllUsers(Duration.ofDays(7))); + long allFetchTime = System.nanoTime() - start; + + start = System.nanoTime(); + var batchedUsers = spec.await(userRepo.fetchUsersBatch(500)); + long batchFetchTime = System.nanoTime() - start; + + + this.logger.info(String.format("All users fetch time: %d ms", allFetchTime / 1_000_000)); + this.logger.info(String.format("Batched users fetch time: %d ms", batchFetchTime / 1_000_000)); + + Assertions.assertEquals(allUsers.size(), batchedUsers.size()); + } + + +} diff --git a/eternalcore-core/src/test/java/com/eternalcode/core/user/UserManagerTest.java b/eternalcore-core/src/test/java/com/eternalcode/core/user/UserManagerTest.java index 1c4c3fcfa..52c165452 100644 --- a/eternalcore-core/src/test/java/com/eternalcode/core/user/UserManagerTest.java +++ b/eternalcore-core/src/test/java/com/eternalcode/core/user/UserManagerTest.java @@ -1,5 +1,8 @@ package com.eternalcode.core.user; +import com.eternalcode.core.test.MockUserRepository; +import com.eternalcode.core.test.MockUserRepositorySettings; +import com.eternalcode.core.user.database.UserRepositoryConfig; import org.junit.jupiter.api.Test; import java.util.UUID; @@ -10,9 +13,12 @@ class UserManagerTest { + private final UserRepositoryConfig mockUserRepositorySettings = new UserRepositoryConfig(); + private final MockUserRepository mockUserRepository = new MockUserRepository(); + @Test void testUsersCreate() { - UserManager manager = new UserManager(); + UserManager manager = new UserManager(this.mockUserRepository, this.mockUserRepositorySettings); assertEquals(0, manager.getUsers().size()); @@ -27,7 +33,7 @@ void testUsersCreate() { @Test void testCreateSameUser() { - UserManager manager = new UserManager(); + UserManager manager = new UserManager(this.mockUserRepository, this.mockUserRepositorySettings); manager.create(UUID.randomUUID(), "Piotr"); assertThrows(IllegalStateException.class, () -> manager.create(UUID.randomUUID(), "Piotr")); @@ -39,7 +45,7 @@ void testCreateSameUser() { @Test void testGetUsers() { - UserManager manager = new UserManager(); + UserManager manager = new UserManager(this.mockUserRepository, this.mockUserRepositorySettings); assertEquals(0, manager.getUsers().size()); @@ -52,7 +58,7 @@ void testGetUsers() { @Test void testGetUser() { - UserManager manager = new UserManager(); + UserManager manager = new UserManager(this.mockUserRepository, this.mockUserRepositorySettings); assertEquals(0, manager.getUsers().size()); @@ -65,7 +71,7 @@ void testGetUser() { @Test void testGetOrCreate() { - UserManager manager = new UserManager(); + UserManager manager = new UserManager(this.mockUserRepository, this.mockUserRepositorySettings); UUID uuid = UUID.randomUUID(); User user = manager.getOrCreate(uuid, "MichaƂ"); diff --git a/eternalcore-core/src/test/java/com/eternalcode/core/util/IntegrationTestSpec.java b/eternalcore-core/src/test/java/com/eternalcode/core/util/IntegrationTestSpec.java new file mode 100644 index 000000000..7a8be3638 --- /dev/null +++ b/eternalcore-core/src/test/java/com/eternalcode/core/util/IntegrationTestSpec.java @@ -0,0 +1,13 @@ +package com.eternalcode.core.util; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +public class IntegrationTestSpec { + + public T await(CompletableFuture future) { + return future + .orTimeout(10, TimeUnit.SECONDS) + .join(); + } +} diff --git a/eternalcore-core/src/test/java/com/eternalcode/core/util/TestScheduler.java b/eternalcore-core/src/test/java/com/eternalcode/core/util/TestScheduler.java new file mode 100644 index 000000000..485b10386 --- /dev/null +++ b/eternalcore-core/src/test/java/com/eternalcode/core/util/TestScheduler.java @@ -0,0 +1,92 @@ +package com.eternalcode.core.util; + +import com.eternalcode.commons.scheduler.Scheduler; +import com.eternalcode.commons.scheduler.Task; +import java.time.Duration; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; + +public class TestScheduler implements Scheduler { + + private final ScheduledExecutorService executorService = Executors.newScheduledThreadPool(8); + + public void shutdown() { + this.executorService.shutdown(); + } + + @Override + public Task run(Runnable runnable) { + Future future = this.executorService.submit(runnable); + return new TestTask(future, false); + } + + @Override + public Task runAsync(Runnable runnable) { + Future future = CompletableFuture.runAsync(runnable, this.executorService); + return new TestTask(future, false); + } + + @Override + public Task runLater(Runnable runnable, Duration duration) { + ScheduledFuture future = this.executorService.schedule(runnable, duration.toMillis(), TimeUnit.MILLISECONDS); + return new TestTask(future, false); + } + + @Override + public Task runLaterAsync(Runnable runnable, Duration duration) { + ScheduledFuture future = this.executorService.schedule(() -> CompletableFuture.runAsync(runnable, + this.executorService), duration.toMillis(), TimeUnit.MILLISECONDS); + return new TestTask(future, false); + } + + @Override + public Task timer(Runnable runnable, Duration initialDelay, Duration period) { + ScheduledFuture future = this.executorService.scheduleAtFixedRate(runnable, initialDelay.toMillis(), period.toMillis(), TimeUnit.MILLISECONDS); + return new TestTask(future, true); + } + + @Override + public Task timerAsync(Runnable runnable, Duration initialDelay, Duration period) { + ScheduledFuture future = this.executorService.scheduleAtFixedRate(() -> CompletableFuture.runAsync(runnable, + this.executorService), initialDelay.toMillis(), period.toMillis(), TimeUnit.MILLISECONDS); + return new TestTask(future, true); + } + + @Override + public CompletableFuture complete(Supplier supplier) { + return CompletableFuture.supplyAsync(supplier, this.executorService); + } + + @Override + public CompletableFuture completeAsync(Supplier supplier) { + return CompletableFuture.supplyAsync(supplier, this.executorService); + } + + private record TestTask(Future future, boolean isRepeating) implements Task { + + @Override + public void cancel() { + this.future.cancel(false); + } + + @Override + public boolean isCanceled() { + return this.future.isCancelled(); + } + + @Override + public boolean isAsync() { + return this.future instanceof CompletableFuture || this.future instanceof ScheduledFuture; + } + + @Override + public boolean isRunning() { + return !this.future.isDone(); + } + } +}