Skip to content
Draft
Show file tree
Hide file tree
Changes from 2 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
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:1.21.3")
testImplementation("org.testcontainers:mysql:1.21.3")
testImplementation("mysql:mysql-connector-java:8.0.33")
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;

@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");

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,56 +1,107 @@
package com.eternalcode.core.user;

import com.eternalcode.commons.algorithm.BatchProcessor;
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.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 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();
}

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));
}

public User getOrCreate(UUID uuid, String name) {
User userByUUID = this.usersByUUID.get(uuid);
User userByUUID = this.usersByUUID.getIfPresent(uuid);

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

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

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

this.userRepository.saveUser(new User(uuid, name));
return this.create(uuid, name);
}

public User create(UUID uuid, String name) {
if (this.usersByUUID.containsKey(uuid) || this.usersByName.containsKey(name)) {
if (this.usersByName.getIfPresent(name) != 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.userRepository.saveUser(user);
return user;
}

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

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

Consumer<Collection<User>> batchSave = users ->
{
BatchProcessor<User> batchProcessor = new BatchProcessor<>(users, this.userRepositorySettings.batchDatabaseFetchSize());

do {
batchProcessor.processNext(user -> {
usersByName.put(user.getName(), user);
usersByUUID.put(user.getUniqueId(), user);
});

} while (!batchProcessor.isComplete());
};

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

this.userRepository.fetchAllUsers()
.thenAccept(batchSave);

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

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

public interface UserRepository {

@Nullable CompletableFuture<User> getUser(UUID uniqueId);

CompletableFuture<Void> saveUser(User player);

CompletableFuture<User> updateUser(UUID uniqueId, 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.Collection;
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<User> getUser(UUID uniqueId) {
return this.selectSafe(UserTable.class, uniqueId)
.thenApply(optional -> optional.map(userTable -> userTable.toUser()).orElseGet(null));
}

@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 dao = this.databaseManager.getDao(UserTable.class);
var users = new java.util.ArrayList<User>();

int offset = 0;
while (true) {
var queryBuilder = dao.queryBuilder();
queryBuilder.limit((long) batchSize);
queryBuilder.offset((long) offset);

var batch = dao.query(queryBuilder.prepare());

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(UUID uniqueId, 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