Skip to content
Draft
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions buildSrc/src/main/kotlin/Versions.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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"

}
3 changes: 3 additions & 0 deletions buildSrc/src/main/kotlin/eternalcode-java-test.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@
import com.eternalcode.core.injector.annotations.component.ConfigurationFile;
import com.eternalcode.core.translation.TranslationConfig;
import com.eternalcode.core.translation.TranslationSettings;
import com.eternalcode.core.user.database.UserRepositoryConfig;
import com.eternalcode.core.user.database.UserRepositorySettings;
import eu.okaeri.configs.OkaeriConfig;
import eu.okaeri.configs.annotation.Comment;
import eu.okaeri.configs.annotation.Header;
Expand Down Expand Up @@ -79,6 +81,12 @@ public class PluginConfiguration extends AbstractConfigurationFile {
@Comment("# Settings responsible for the database connection")
DatabaseConfig database = new DatabaseConfig();

@Bean(proxied = UserRepositorySettings.class)
@Comment("")
@Comment("# User Repository Configuration")
@Comment("# Settings for managing user data storage and retrieval")
UserRepositoryConfig userRepository = new UserRepositoryConfig();

@Bean(proxied = SpawnJoinSettings.class)
@Comment("")
@Comment("# Spawn & Join Configuration")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ protected <T> CompletableFuture<List<T>> selectAll(Class<T> type) {
return this.action(type, Dao::queryForAll);
}

protected <T> CompletableFuture<List<T>> selectBatch(Class<T> type, int offset, int limit) {
return this.action(type, dao -> dao.queryBuilder().offset((long) offset).limit((long) limit).query());
}

protected <T, ID, R> CompletableFuture<R> action(
Class<T> type,
ThrowingFunction<Dao<T, ID>, R, SQLException> action
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hm czemu tak? jak w testach coś nie gra to settuj fielda jest public


@Comment({"Hostname of the database server.", "For local databases, this is usually 'localhost'."})
public String hostname = "localhost";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

raczej bym unikał dodawania kawałków kodu które są tylko dla testów, jaki tutaj był problem? może da się to lepiej rozwiązać?


private final String driver;
private final String urlFormat;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ public void connect() {
settings.database(),
String.valueOf(settings.ssl())
);
case H2_TEST -> type.formatUrl();
};

this.dataSource.setJdbcUrl(jdbcUrl);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ public class User implements Viewer {
private final String name;
private final UUID uuid;

User(UUID uuid, String name) {
public User(UUID uuid, String name) {
this.name = name;
this.uuid = uuid;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,35 +1,59 @@
package com.eternalcode.core.user;

import com.eternalcode.core.injector.annotations.Inject;
import com.eternalcode.core.injector.annotations.component.Service;
import com.eternalcode.core.user.database.UserRepository;
import com.eternalcode.core.user.database.UserRepositorySettings;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import java.util.Collection;
import java.util.Collections;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CompletableFuture;
import java.util.function.Consumer;

@Service
public class UserManager {

private final Map<UUID, User> usersByUUID = new ConcurrentHashMap<>();
private final Map<String, User> usersByName = new ConcurrentHashMap<>();
private final Cache<UUID, User> usersByUUID;
private final Cache<String, User> usersByName;
private boolean fetched = false;

private final UserRepository userRepository;
private final UserRepositorySettings userRepositorySettings;

@Inject
public UserManager(UserRepository userRepository, UserRepositorySettings userRepositorySettings) {
this.userRepositorySettings = userRepositorySettings;
this.usersByUUID = Caffeine.newBuilder().build();
this.usersByName = Caffeine.newBuilder().build();

this.userRepository = userRepository;

fetchUsers().thenRun(() -> this.fetched = true);
}

public Optional<User> getUser(UUID uuid) {
return Optional.ofNullable(this.usersByUUID.get(uuid));
return Optional.ofNullable(this.usersByUUID.getIfPresent(uuid));
}

public Optional<User> getUser(String name) {
return Optional.ofNullable(this.usersByName.get(name));
return Optional.ofNullable(this.usersByName.getIfPresent(name.toLowerCase()));
}

public User getOrCreate(UUID uuid, String name) {
User userByUUID = this.usersByUUID.get(uuid);
if (!this.fetched) {
throw new IllegalStateException("Users have not been fetched from the database yet!");
}

User userByUUID = this.usersByUUID.getIfPresent(uuid);

if (userByUUID != null) {
return userByUUID;
}

User userByName = this.usersByName.get(name);
User userByName = this.usersByName.getIfPresent(name.toLowerCase());

if (userByName != null) {
return userByName;
Expand All @@ -39,18 +63,39 @@ public User getOrCreate(UUID uuid, String name) {
}

public User create(UUID uuid, String name) {
if (this.usersByUUID.containsKey(uuid) || this.usersByName.containsKey(name)) {
if (this.usersByName.getIfPresent(name.toLowerCase()) != null || this.usersByUUID.getIfPresent(uuid) != null) {
throw new IllegalStateException("User already exists");
}

User user = new User(uuid, name);
this.usersByUUID.put(uuid, user);
this.usersByName.put(name, user);
this.usersByName.put(name.toLowerCase(), user);

this.userRepository.saveUser(user);
return user;
}

public Collection<User> getUsers() {
return Collections.unmodifiableCollection(this.usersByUUID.values());
return Collections.unmodifiableCollection(this.usersByUUID.asMap().values());
}

private CompletableFuture<Void> fetchUsers() {
if (this.userRepositorySettings.batchDatabaseFetchSize() <= 0) {
throw new IllegalArgumentException("Value for batchDatabaseFetchSize must be greater than 0!");
}

Consumer<Collection<User>> batchSave = users -> users.forEach(user -> {
this.usersByName.put(user.getName(), user);
this.usersByUUID.put(user.getUniqueId(), user);
});

if (this.userRepositorySettings.useBatchDatabaseFetching()) {
this.userRepository.fetchUsersBatch(this.userRepositorySettings.batchDatabaseFetchSize()).thenAccept(batchSave);
}
else {
this.userRepository.fetchAllUsers().thenAccept(batchSave);
}

return CompletableFuture.completedFuture(null);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.eternalcode.core.user.database;

import com.eternalcode.core.user.User;
import java.util.Collection;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import org.jetbrains.annotations.Nullable;

public interface UserRepository {

CompletableFuture<Optional<User>> getUser(UUID uniqueId);

CompletableFuture<Void> saveUser(User player);

CompletableFuture<User> updateUser(User player);

CompletableFuture<Void> deleteUser(UUID uniqueId);

CompletableFuture<Collection<User>> fetchAllUsers();

CompletableFuture<Collection<User>> fetchUsersBatch(int batchSize);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.eternalcode.core.user.database;

import eu.okaeri.configs.OkaeriConfig;
import eu.okaeri.configs.annotation.Comment;
import lombok.Getter;
import lombok.experimental.Accessors;

@Getter
@Accessors(fluent = true)
public class UserRepositoryConfig extends OkaeriConfig implements UserRepositorySettings {

@Comment({
"# Should plugin use batches to fetch users from the database?",
"# We suggest turning this setting to TRUE for servers with more than 10k users",
"# Set this to false if you are using SQLITE or H2 database (local databases)"
})
public boolean useBatchDatabaseFetching = false;

@Comment({
"# Size of batches querried to the database",
"# Value must be greater than 0!"
})
public int batchDatabaseFetchSize = 1000;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
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 java.sql.SQLException;
import java.util.ArrayList;
import java.util.Collection;
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 {

@Inject
public UserRepositoryOrmLite(DatabaseManager databaseManager, Scheduler scheduler) throws SQLException {
super(databaseManager, scheduler);
TableUtils.createTableIfNotExists(databaseManager.connectionSource(), UserTable.class);
}

@Override
public CompletableFuture<Optional<User>> getUser(UUID uniqueId) {
return this.selectSafe(UserTable.class, uniqueId)
.thenApply(optional -> optional.map(UserTable::toUser));
}

@Override
public CompletableFuture<Collection<User>> fetchAllUsers() {
return this.selectAll(UserTable.class)
.thenApply(userTables -> userTables.stream()
.map(UserTable::toUser)
.toList());
}

@Override
public CompletableFuture<Collection<User>> fetchUsersBatch(int batchSize) {
return CompletableFuture.supplyAsync(() -> {
Copy link

@sadcenter sadcenter Sep 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you should supply a custom executor, since you are already using one in all AbstractRepositoryOrmLite methods

try {
var users = new ArrayList<User>();

int offset = 0;
while (true) {
List<UserTable> batch = this.selectBatch(UserTable.class, offset, batchSize).join();

if (batch.isEmpty()) {
break;
}

batch.stream()
.map(UserTable::toUser)
.forEach(users::add);

offset += batchSize;
}

return users;
} catch (Exception exception) {
throw new RuntimeException("Failed to fetch users in batches", exception);
}
});
}

@Override
public CompletableFuture<Void> saveUser(User user) {
return this.save(UserTable.class, UserTable.from(user)).thenApply(v -> null);
}

@Override
public CompletableFuture<User> updateUser(User user) {
return this.save(UserTable.class, UserTable.from(user)).thenApply(v -> user);
}

@Override
public CompletableFuture<Void> deleteUser(UUID uniqueId) {
return this.deleteById(UserTable.class, uniqueId).thenApply(v -> null);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.eternalcode.core.user.database;

public interface UserRepositorySettings {

boolean useBatchDatabaseFetching();

int batchDatabaseFetchSize();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.eternalcode.core.user.database;

import com.eternalcode.core.user.User;
import com.j256.ormlite.field.DatabaseField;
import com.j256.ormlite.table.DatabaseTable;
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;

UserTable() {}

UserTable(UUID uniqueId, String name) {
this.uniqueId = uniqueId;
this.name = name;
}

public User toUser() {
return new User(this.uniqueId, this.name);
}

public static UserTable from(User user) {
return new UserTable(user.getUniqueId(), user.getName());
}
}
Loading