Remove JpaUtils and refactor repository access

Eliminated the static `JpaUtils` utility and transitioned to explicit repository management for improved modularity and testability. Updated `GuildService`, `TaskManagerImpl`, and related classes to use dependency-injected repositories. Simplified configuration by aligning repository definitions with existing service patterns and ensuring proper session handling through `SessionManager`. Verified that all affected components function as expected after refactoring.
This commit is contained in:
anthorob committed 2026-09-25 12:17:19 -04:00
1 parent 62de32ecfc
commit 677b3f8013
60 files changed
+2384 -780

No files matched your search

+12 -7
View File
@@ -28,7 +28,10 @@ import me.bobert.netneutrality.core.thread.ExecutorProvider;
import me.bobert.netneutrality.utils.SettingsProvider;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.utils.ChannelMessagePurger;
import me.bobert.netneutrality.db.JpaUtils;
import me.bobert.netneutrality.db.repository.BlacklistRepository;
import me.bobert.netneutrality.db.repository.GuildRepository;
import me.bobert.netneutrality.db.repository.GuildSettingRepository;
import me.bobert.netneutrality.db.repository.TaskRepository;
import me.bobert.netneutrality.utils.Settings;
import me.bobert.netneutrality.http.WebModule;
import net.dv8tion.jda.api.JDA;
@@ -114,9 +117,11 @@ public class App {
// Allow ApplicationContext itself to be injected into components that need it.
context.register(context);
// Persistence: build the SessionFactory + run migrations; released last on shutdown.
// SessionManager binds itself into JpaUtils for repository access.
context.register(new SessionManager(settings));
// Persistence: run migrations + build the SessionFactory; released last on shutdown.
SessionManager sessions = context.register(new SessionManager(settings));
GuildRepository guildRepository = new GuildRepository(sessions);
BlacklistRepository blacklistRepository = new BlacklistRepository(sessions);
GuildSettingRepository guildSettingRepository = new GuildSettingRepository(sessions);
// Executors: no explicit start today; shut down after music players are disconnected.
ExecutorProvider executors = context.register(new ThreadManager());
@@ -130,12 +135,12 @@ public class App {
CommandRegistry commands = context.register(new CommandManager(context));
// Task registry (priority 30).
TaskManager tasks = context.register(new TaskManagerImpl(context));
TaskManager tasks = context.register(new TaskManagerImpl(context, new TaskRepository(sessions)));
// Per-guild service cache (not a Lifecycle component).
GuildServiceProvider guildServices = context.register(new GuildServiceProviderImpl());
GuildServiceProvider guildServices = context.register(
new GuildServiceProviderImpl(guildRepository, blacklistRepository, guildSettingRepository));
LogHandler.bind(guildServices);
JpaUtils.bindGuildServiceLookup(guildServices::get);
// Music sources (priority 40): initialise the shared player manager; disconnect on shutdown.
music = context.register(new MusicServiceImpl(settings, guildServices, tasks, executors));
@@ -42,7 +42,7 @@ public class BotState {
return;
}
Map<String, GuildService> services = guildServices.getServices();
Map<String, GuildService> services = guildServices.snapshot();
ObjectNode main = Json.object();
ArrayNode guildsArray = Json.array();
@@ -56,7 +56,7 @@ public class BotState {
ObjectNode guild = Json.object();
ObjectNode music = Json.object();
guild.put("id", service.getEntity().getGuildId());
guild.put("id", service.getGuildId());
// MessageChannel currentChannel = trackScheduler.getTextChannel();
// if (currentChannel == null) {
@@ -10,16 +10,20 @@ import me.bobert.netneutrality.core.command.CommandOptionsBuilder;
import me.bobert.netneutrality.core.command.impl.AdminCommand;
import me.bobert.netneutrality.core.command.options.CommandOption;
import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.db.entities.OBlacklist;
import me.bobert.netneutrality.db.service.BlacklistEntry;
import me.bobert.netneutrality.db.service.BlacklistResult;
import me.bobert.netneutrality.db.service.BlacklistService;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
import net.dv8tion.jda.api.interactions.commands.OptionType;
import net.dv8tion.jda.api.interactions.commands.build.SubcommandData;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import java.util.List;
import java.util.Objects;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
public class BlacklistCommand extends AdminCommand {
private static final Logger logger = LogManager.getLogger(BlacklistCommand.class);
private final GuildServiceProvider guildServices;
@@ -64,16 +68,16 @@ public class BlacklistCommand extends AdminCommand {
public CommandExecutionResult execute(CommandContext context) {
String sub = context.getSubcommand();
String word = context.getOption("word", "", CommandOption::getAsString);
BlacklistService service = guildServices.get(context.getGuild()).getBlacklistService();
BlacklistService service = guildServices.get(context.getGuild().getId()).getBlacklistService();
switch (sub) {
case "get" -> context.async("Retrieving list, please wait...", asyncMessage -> {
List<OBlacklist> blacklists = service.getBlacklistForGuild();
List<BlacklistEntry> blacklists = service.list();
StringBuilder sb = new StringBuilder();
sb.append("List of blacklisted words for this server: \n");
for (OBlacklist bl : blacklists) {
sb.append(bl.getWord()).append(" -> ").append(bl.getEnabled() ? "enabled" : "disabled").append("\n");
for (BlacklistEntry bl : blacklists) {
sb.append(bl.word()).append(" -> ").append(bl.enabled() ? "enabled" : "disabled").append("\n");
}
if (blacklists.isEmpty()) {
@@ -83,13 +87,32 @@ public class BlacklistCommand extends AdminCommand {
asyncMessage.edit(sb.toString());
});
case "add" -> {
return service.add(context.getGuildUser(), word);
try {
return toReply(service.add(context.getGuildUser().getId(), word), word);
} catch (RuntimeException e) {
logger.error("Failed to add word to blacklist", e);
return CommandExecutionResult.warn("Failed to add word to blacklist: " + e.getMessage());
}
}
case "remove" -> {
return service.delete(word);
try {
return toReply(service.remove(word), word);
} catch (RuntimeException e) {
logger.error("Failed to remove word from blacklist", e);
return CommandExecutionResult.warn("Failed to remove word from blacklist: " + e.getMessage());
}
}
}
return CommandExecutionResult.none();
}
static CommandExecutionResult toReply(BlacklistResult result, String word) {
return switch (result) {
case ADDED, REENABLED -> CommandExecutionResult.success("Added the word [%s] to the blacklist.", word);
case ALREADY_EXISTS -> CommandExecutionResult.warn(String.format("The word [%s] is already in the blacklist.", word));
case REMOVED -> CommandExecutionResult.success("Removed the word [%s] from the blacklist.", word);
case NOT_FOUND -> CommandExecutionResult.warn(String.format("The word [%s] is not in the blacklist.", word));
};
}
}
@@ -12,19 +12,23 @@ import me.bobert.netneutrality.core.command.options.CommandOption;
import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.db.GSettings;
import me.bobert.netneutrality.db.service.GuildService;
import me.bobert.netneutrality.db.service.GuildSettingService;
import net.dv8tion.jda.api.entities.Role;
import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
import net.dv8tion.jda.api.entities.channel.unions.GuildChannelUnion;
import net.dv8tion.jda.api.interactions.commands.OptionType;
import net.dv8tion.jda.api.interactions.commands.build.OptionData;
import net.dv8tion.jda.api.interactions.commands.build.SubcommandData;
import net.dv8tion.jda.api.utils.messages.MessageCreateBuilder;
import java.util.Map;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.music.MusicService;
import net.dv8tion.jda.api.utils.messages.MessageCreateBuilder;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import java.util.Map;
public class ConfigureCommand extends AdminCommand {
private static final Logger logger = LogManager.getLogger(ConfigureCommand.class);
private final GuildServiceProvider guildServices;
private final MusicService musicService;
@@ -76,8 +80,18 @@ public class ConfigureCommand extends AdminCommand {
@Override
public CommandExecutionResult execute(CommandContext context) {
try {
return configure(context);
} catch (RuntimeException e) {
logger.error("Failed to update configuration", e);
return CommandExecutionResult.warn("Failed to update configuration: " + e.getMessage());
}
}
private CommandExecutionResult configure(CommandContext context) {
String sub = context.getSubcommand();
GuildService guildService = guildServices.get(context.getGuild().getId());
GuildSettingService settingService = guildService.getGuildSettingService();
context.ephemeral();
if ("music-role".equals(sub)) {
@@ -85,13 +99,13 @@ public class ConfigureCommand extends AdminCommand {
assert role != null;
if (role.getPosition() == -1) {
guildService.getGuildSettingService().setSettingValue(GSettings.MUSIC_ROLE, null);
settingService.set(GSettings.MUSIC_ROLE, null);
return CommandExecutionResult.success("Everyone can now use music commands");
}
if (role.getGuild().getId().equals(context.getGuild().getId())) {
guildService.getGuildSettingService().setSettingValue(GSettings.MUSIC_ROLE, role.getId());
settingService.set(GSettings.MUSIC_ROLE, role.getId());
return CommandExecutionResult.success(String.format("Successfully set %s as the role needed for music command", role.getAsMention()));
}
@@ -101,14 +115,14 @@ public class ConfigureCommand extends AdminCommand {
GuildChannelUnion channel = context.getOption("channel", CommandOption::getAsChannel);
if (channel == null) {
guildService.getGuildSettingService().setSettingValue(GSettings.MUSIC_CHANNEL, null);
settingService.set(GSettings.MUSIC_CHANNEL, null);
return CommandExecutionResult.success("No channel restrictions for music commands");
}
if (channel instanceof TextChannel) {
if (channel.getGuild().getId().equals(context.getGuild().getId())) {
guildService.getGuildSettingService().setSettingValue(GSettings.MUSIC_CHANNEL, channel.getId());
settingService.set(GSettings.MUSIC_CHANNEL, channel.getId());
return CommandExecutionResult.success(String.format("Successfully set %s as the channel required to execute music command", channel.getAsMention()));
}
@@ -118,7 +132,7 @@ public class ConfigureCommand extends AdminCommand {
if ("music-volume".equals(sub)) {
int volume = context.getOption("volume", 30, CommandOption::getAsInt);
guildService.getGuildSettingService().setSettingValue(GSettings.MUSIC_VOLUME, String.valueOf(volume));
settingService.set(GSettings.MUSIC_VOLUME, String.valueOf(volume));
musicService.get(context.getGuild()).setVolume(volume);
return CommandExecutionResult.success(String.format("Default volume for music player is now %d", volume));
@@ -132,7 +146,7 @@ public class ConfigureCommand extends AdminCommand {
}
if ("show-all".equals(sub)) {
Map<String, String> settings = guildService.getGuildSettingService().getSettings();
Map<String, String> settings = settingService.snapshot();
MessageCreateBuilder builder = new MessageCreateBuilder();
for (GSettings o : GSettings.values()) {
@@ -92,7 +92,7 @@ public class LogHandler {
if (context.getGuild() != null) {
GuildServiceProvider provider = guildServices;
if (provider != null && provider.get(context.getGuild()).isDebugEnabled()) {
if (provider != null && provider.get(context.getGuild().getId()).isDebugEnabled()) {
context.getGuild()
.getTextChannels()
.stream()
@@ -43,7 +43,7 @@ public abstract class MusicCommand extends AbstractCommand {
* @return the guild service
*/
protected final GuildService guildService(Guild guild) {
return guildServices.get(guild);
return guildServices.get(guild.getId());
}
/**
@@ -79,7 +79,7 @@ public abstract class MusicCommand extends AbstractCommand {
Guild guild = member.getGuild();
String roleDb = guildService(guild.getId()).getGuildSettingService().getSetting(GSettings.MUSIC_ROLE);
String roleDb = guildService(guild.getId()).getGuildSettingService().getOrDefault(GSettings.MUSIC_ROLE);
if (roleDb != null && !roleDb.isEmpty()) {
if (UserFunction.isAdmin(member)) {
@@ -89,7 +89,7 @@ public abstract class MusicCommand extends AbstractCommand {
Role neededRole = guild.getRoleById(roleDb);
if (neededRole == null) {
// Role doesn't exist on guild anymore, remove from db
guildService(guild.getId()).getGuildSettingService().setSettingValue(GSettings.MUSIC_ROLE, null);
guildService(guild.getId()).getGuildSettingService().set(GSettings.MUSIC_ROLE, null);
return true;
}
@@ -106,7 +106,7 @@ public abstract class MusicCommand extends AbstractCommand {
return false;
}
String channelDb = guildService(guildMessageChannel.getGuild()).getGuildSettingService().getSetting(GSettings.MUSIC_CHANNEL);
String channelDb = guildService(guildMessageChannel.getGuild()).getGuildSettingService().getOrDefault(GSettings.MUSIC_CHANNEL);
if (channelDb != null && !channelDb.isEmpty()) {
return channelDb.equals(channel.getId());
}
@@ -16,6 +16,7 @@ import me.bobert.netneutrality.core.tasks.trait.WithButton;
import me.bobert.netneutrality.core.tasks.trait.WithChildren;
import me.bobert.netneutrality.core.tasks.trait.WithParent;
import me.bobert.netneutrality.core.tasks.persistence.PersistableTaskRegistry;
import me.bobert.netneutrality.db.repository.TaskRepository;
import me.bobert.netneutrality.db.service.TaskService;
import me.bobert.netneutrality.exceptions.GracefulContextError;
import me.bobert.netneutrality.exceptions.TaskExecutionException;
@@ -52,9 +53,10 @@ public class TaskManagerImpl implements TaskManager {
* Creates a task manager that can inject context-registered dependencies into reflectively
* loaded tasks.
*
* @param context the application context; must not be {@code null}
* @param context the application context; must not be {@code null}
* @param taskRepository the repository persisting {@link PersistableTask}s
*/
public TaskManagerImpl(ApplicationContext context) {
public TaskManagerImpl(ApplicationContext context, TaskRepository taskRepository) {
Objects.requireNonNull(context, "ApplicationContext must not be null");
this.tasks = new ConcurrentHashMap<>();
this.pendingTasks = new ConcurrentHashMap<>();
@@ -62,7 +64,7 @@ public class TaskManagerImpl implements TaskManager {
this.tasksLock = new ConcurrentHashMap<>();
this.context = context;
this.persistableTaskRegistry = new PersistableTaskRegistry(context);
this.taskService = new TaskService(this, persistableTaskRegistry);
this.taskService = new TaskService(this, persistableTaskRegistry, taskRepository);
}
@Override
@@ -0,0 +1,95 @@
package me.bobert.netneutrality.db;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import jakarta.persistence.criteria.CriteriaQuery;
import org.hibernate.Session;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.function.Consumer;
import java.util.function.Function;
/**
* Generic, constructor-injected Hibernate repository.
*
* <p>All work runs through Hibernate's native {@code fromSession}/{@code fromTransaction}/
* {@code inTransaction} helpers, which open and close the session and commit or roll back the
* transaction. Exceptions are never swallowed: a failed write is rolled back and rethrown.
*
* @param <T> the entity type
* @param <ID> the entity identifier type
*/
public abstract class AbstractRepository<T, ID> {
protected final SessionProvider sessions;
private final Class<T> type;
protected AbstractRepository(SessionProvider sessions, Class<T> type) {
this.sessions = Objects.requireNonNull(sessions, "sessions");
this.type = Objects.requireNonNull(type, "type");
}
protected Class<T> type() {
return type;
}
public List<T> findAll() {
return read(session -> {
CriteriaQuery<T> query = session.getCriteriaBuilder().createQuery(type);
query.from(type);
return session.createSelectionQuery(query).getResultList();
});
}
public Optional<T> findById(ID id) {
Objects.requireNonNull(id, "id");
return read(session -> Optional.ofNullable(session.find(type, id)));
}
/**
* Inserts or updates the entity.
*
* @param entity the entity to save
* @return the persistent (merged) state, which callers should use from then on
*/
public T save(T entity) {
Objects.requireNonNull(entity, "entity");
return write(session -> session.merge(entity));
}
/**
* Deletes the entity with the given id, if it exists.
*
* @param id the identifier
* @return {@code true} if a row was deleted
*/
public boolean deleteById(ID id) {
Objects.requireNonNull(id, "id");
return write(session -> {
T entity = session.find(type, id);
if (entity == null) {
return false;
}
session.remove(entity);
return true;
});
}
protected <R> R read(Function<Session, R> work) {
return sessions.getSessionFactory().fromSession(work);
}
protected <R> R write(Function<Session, R> work) {
return sessions.getSessionFactory().fromTransaction(work);
}
protected void writeVoid(Consumer<Session> work) {
sessions.getSessionFactory().inTransaction(work);
}
}
@@ -0,0 +1,48 @@
package me.bobert.netneutrality.db;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.utils.SettingsProvider;
import java.util.Objects;
/**
* JDBC connection settings shared by Flyway ({@link DatabaseMigration}) and Hibernate
* ({@link SessionManager}), so both always connect to the same database.
*
* @param url the JDBC url
* @param user the database user
* @param password the database password (never logged, see {@link #toString()})
*/
public record DatabaseConfig(String url, String user, String password) {
public DatabaseConfig {
Objects.requireNonNull(url, "url");
Objects.requireNonNull(user, "user");
password = password == null ? "" : password;
}
/**
* Builds the config from the {@code MYSQL_*} settings.
*
* @param settings the bot settings
* @return the database config
*/
public static DatabaseConfig from(SettingsProvider settings) {
String dbName = settings.get("MYSQL_DATABASE", "netneutrality");
String dbUser = settings.get("MYSQL_USER", "root");
String dbPass = settings.get("MYSQL_PASSWORD", "");
String dbHost = settings.get("MYSQL_HOST", "localhost");
String dbPort = settings.get("MYSQL_PORT", "3306");
return new DatabaseConfig(String.format("jdbc:mysql://%s:%s/%s", dbHost, dbPort, dbName), dbUser, dbPass);
}
@Override
public String toString() {
return "DatabaseConfig[url=" + url + ", user=" + user + ", password=***]";
}
}
@@ -1,37 +1,38 @@
package me.bobert.netneutrality.db;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.utils.SettingsProvider;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.flywaydb.core.Flyway;
import org.flywaydb.core.api.output.MigrateResult;
public class DatabaseMigration {
private final static Logger logger = LogManager.getLogger(DatabaseMigration.class);
public final class DatabaseMigration {
private static final Logger logger = LogManager.getLogger(DatabaseMigration.class);
public static void migrate(SettingsProvider settings) {
String dbName = settings.get("MYSQL_DATABASE", "netneutrality");
String dbUser = settings.get("MYSQL_USER", "root");
String dbPass = settings.get("MYSQL_PASSWORD", "");
String dbHost = settings.get("MYSQL_HOST", "localhost");
String dbPort = settings.get("MYSQL_PORT", "3306");
String dbUrl = String.format("jdbc:mysql://%s:%s/%s", dbHost, dbPort, dbName);
private DatabaseMigration() {
}
/**
* Applies all pending Flyway migrations to the configured database.
*
* @param config the database connection settings
* @throws IllegalStateException if the migration fails
*/
public static void migrate(DatabaseConfig config) {
try {
logger.info("Running database migrations..");
Flyway flyway = Flyway.configure()
.dataSource(dbUrl, dbUser, dbPass)
.dataSource(config.url(), config.user(), config.password())
.load();
MigrateResult result = flyway.migrate();
logger.info("Database migrations finished successfully! (total: {})", result.migrationsExecuted);
} catch (Exception e) {
logger.error(e);
throw new IllegalStateException("Database migration failed", e);
}
}
@@ -1,15 +1,26 @@
package me.bobert.netneutrality.db;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
/**
* Per-guild settings stored in the {@code guild_settings} table.
*
* <p>Only values that differ from the default are stored; setting a value back to its default (or
* {@code null}) deletes the row. {@link #getName()} is the key persisted in the {@code name}
* column and must never change for an existing setting.
*/
public enum GSettings {
//TODO: description
/** Id of the role required to use music commands; empty means everyone may use them. */
MUSIC_ROLE("MUSIC_ROLE", ""),
/** Id of the only text channel music commands may be used in; empty means any channel. */
MUSIC_CHANNEL("MUSIC_CHANNEL", ""),
/** Default playback volume (0-100) applied when a music player is created. */
MUSIC_VOLUME("MUSIC_VOLUME", "30"),
/** Playlist size above which the bot asks for confirmation before queueing every track. */
MUSIC_MAXPLAYLISTSIZE("MUSIC_MAXPLAYLISTSIZE", "20");
private final String name;
@@ -1,158 +0,0 @@
package me.bobert.netneutrality.db;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.db.entities.OGuild;
import me.bobert.netneutrality.db.service.GuildService;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.hibernate.HibernateException;
import org.hibernate.ObjectNotFoundException;
import org.hibernate.Session;
import org.hibernate.SessionFactory;
import org.hibernate.Transaction;
import org.hibernate.exception.ConstraintViolationException;
import java.io.Serializable;
import java.util.Objects;
import java.util.function.Consumer;
import java.util.function.Function;
/**
* Static Hibernate helpers used by repositories and domain services.
*
* <p>Because repositories are created deep in the object graph without constructor injection,
* this class holds a single {@link SessionProvider} bound once during bootstrap via
* {@link #bind(SessionProvider)}. That is the only global access point for sessions — there is no
* {@code SessionManager.instance()} shim.
*/
public class JpaUtils {
private static final Logger logger = LogManager.getLogger(JpaUtils.class);
private static volatile SessionProvider sessions;
private static volatile Function<String, GuildService> guildServiceLookup;
private JpaUtils() {
}
/**
* Binds the application {@link SessionProvider}. Called once from {@code SessionManager}'s
* constructor during context registration.
*
* @param provider the live session provider
*/
public static void bind(SessionProvider provider) {
sessions = Objects.requireNonNull(provider, "SessionProvider");
}
/**
* Optional guild-service lookup used when recovering a missing {@link OGuild} entity. Bound from
* bootstrap once the {@code GuildServiceProvider} is available.
*
* @param lookup guild-id → service resolver
*/
public static void bindGuildServiceLookup(Function<String, GuildService> lookup) {
guildServiceLookup = Objects.requireNonNull(lookup, "guildServiceLookup");
}
private static SessionFactory factory() {
SessionProvider provider = sessions;
if (provider == null) {
throw new IllegalStateException("JpaUtils has not been bound to a SessionProvider");
}
return provider.getSessionFactory();
}
/**
* Tries to read the persistent state into the object.
* <p>
* If the object is not found in the database and is of type {@link OGuild}, tries to recreate entity and throws
* an exception to block execution.
* <p>
*
* @param session The session in which to load the persistent object
* @param object The persistent object
* @param id Identifier
*/
public static void tryLoad(Session session, Object object, Serializable id) {
try {
session.load(object, id);
} catch (HibernateException exception) {
if (exception instanceof ObjectNotFoundException) {
if (object instanceof OGuild oGuild) {
String cachedId = oGuild.getGuildId();
logger.error("Trying to load Guild[{}] while entity doesn't exist in database, reloading..", cachedId);
Function<String, GuildService> lookup = guildServiceLookup;
if (lookup == null) {
session.close();
throw exception;
}
GuildService service = lookup.apply(cachedId);
service.reloadEntity();
service.getBlacklistService().removeGuildBlacklist();
logger.info("Guild[{}] reloaded", cachedId);
}
}
session.close();
throw exception;
}
}
public static Session openSession() {
return factory().openSession();
}
public static void runWithSession(Consumer<Session> consumer) {
try (Session session = factory().openSession()) {
consumer.accept(session);
}
}
public static void runWithTransaction(Consumer<Session> consumer) {
try (Session session = factory().openSession()) {
session.beginTransaction();
consumer.accept(session);
session.getTransaction().commit();
}
}
public static void updateWithTransaction(Object persistantEntity, Runnable runnable) {
try (Session session = factory().openSession()) {
Transaction transaction = session.beginTransaction();
try {
runnable.run();
session.merge(persistantEntity);
transaction.commit();
} catch (ConstraintViolationException e) {
logger.error(e);
throw e;
} catch (Exception e) {
logger.error(e);
transaction.rollback();
}
}
}
/**
* Loads the entity and relations from the database and executes the given consumer.
* This will load all Lazy relationships defined in the entity.
*
* @param persistantEntity The entity to load
* @param id The identifier for the entity
* @param consumer The comsumer, which exposes the session
*/
public static void getWithSession(Object persistantEntity, Serializable id, Consumer<Session> consumer) {
try (Session session = factory().openSession()) {
tryLoad(session, persistantEntity, id);
consumer.accept(session);
}
}
}
@@ -1,43 +0,0 @@
package me.bobert.netneutrality.db;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.hibernate.Session;
import org.hibernate.Transaction;
import org.hibernate.exception.ConstraintViolationException;
import java.util.List;
public interface Repository<O> {
Logger logger = LogManager.getLogger(Repository.class);
List<O> findAll();
O findById(String id);
default void save(O entity) {
Transaction transaction = null;
try (Session session = JpaUtils.openSession()) {
transaction = session.beginTransaction();
session.merge(entity);
transaction.commit();
} catch (ConstraintViolationException e) {
logger.error(e);
throw e;
} catch (Exception e) {
logger.error(e);
if (transaction != null) {
transaction.rollback();
}
}
}
void deleteBy(String id);
}
@@ -1,4 +1,5 @@
package me.bobert.netneutrality.db;
/*
* Copyright (c) anthorob
*
@@ -10,7 +11,6 @@ import me.bobert.netneutrality.utils.SettingsProvider;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.hibernate.SessionFactory;
import org.hibernate.boot.Metadata;
import org.hibernate.boot.MetadataSources;
import org.hibernate.boot.registry.StandardServiceRegistry;
import org.hibernate.boot.registry.StandardServiceRegistryBuilder;
@@ -18,24 +18,34 @@ import org.hibernate.cfg.Environment;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
/**
* Instance-based {@link SessionProvider} owning the Hibernate {@link SessionFactory} and its
* service registry.
*
* <p>On construction the instance is bound into {@link JpaUtils} so repositories and other static
* persistence helpers can open sessions without a global {@code SessionManager} singleton.
* <p>{@link #start()} first applies the Flyway migrations and only then builds the session factory,
* which validates the mapped entities against the migrated schema ({@code hbm2ddl.auto=validate}).
*/
public class SessionManager implements SessionProvider {
private static final Logger logger = LogManager.getLogger(SessionManager.class);
private final SettingsProvider settings;
private final DatabaseConfig config;
private final boolean reportErrors;
private StandardServiceRegistry registry;
private SessionFactory sessionFactory;
private volatile SessionFactory sessionFactory;
public SessionManager(SettingsProvider settings) {
this.settings = settings;
JpaUtils.bind(this);
this(DatabaseConfig.from(settings), !"dev".equals(settings.get("NETNEUTRALITY_BOT_ENV", "dev")));
}
/**
* @param config the database connection settings
* @param reportErrors whether startup failures are reported to Sentry
*/
public SessionManager(DatabaseConfig config, boolean reportErrors) {
this.config = Objects.requireNonNull(config, "config");
this.reportErrors = reportErrors;
}
@Override
@@ -44,55 +54,58 @@ public class SessionManager implements SessionProvider {
}
@Override
public void start() {
getSessionFactory();
DatabaseMigration.migrate(settings);
public synchronized void start() {
if (sessionFactory != null) {
return;
}
try {
logger.info("Connecting to database '{}' with user '{}'", config.url(), config.user());
DatabaseMigration.migrate(config);
sessionFactory = buildSessionFactory();
} catch (RuntimeException e) {
if (reportErrors) {
Sentry.captureException(e);
}
stop();
logger.error("Error connecting to database. Shutting down..", e);
throw new IllegalStateException("Unable to connect to the database", e);
}
}
private SessionFactory buildSessionFactory() {
Map<String, Object> settings = new HashMap<>();
settings.put(Environment.JAKARTA_JDBC_URL, config.url());
settings.put(Environment.JAKARTA_JDBC_USER, config.user());
settings.put(Environment.JAKARTA_JDBC_PASSWORD, config.password());
registry = new StandardServiceRegistryBuilder()
.configure()
.applySettings(settings)
.build();
return new MetadataSources(registry).buildMetadata().buildSessionFactory();
}
@Override
public void stop() {
public synchronized void stop() {
SessionFactory factory = sessionFactory;
sessionFactory = null;
if (factory != null) {
factory.close();
}
if (registry != null) {
StandardServiceRegistryBuilder.destroy(registry);
registry = null;
sessionFactory = null;
}
}
@Override
public SessionFactory getSessionFactory() {
if (sessionFactory == null) {
try {
StandardServiceRegistryBuilder registryBuilder = new StandardServiceRegistryBuilder().configure();
Map<String, Object> config = new HashMap<>();
String dbName = settings.get("MYSQL_DATABASE", "netneutrality");
String dbUser = settings.get("MYSQL_USER", "root");
String dbPass = settings.get("MYSQL_PASSWORD", "");
String dbHost = settings.get("MYSQL_HOST", "localhost");
String dbPort = settings.get("MYSQL_PORT", "3306");
logger.info("Connecting to database '{}' at {}:{} with user '{}'", dbName, dbHost, dbPort, dbUser);
config.put(Environment.JAKARTA_JDBC_URL, String.format("jdbc:mysql://%s:%s/%s", dbHost, dbPort, dbName));
config.put(Environment.JAKARTA_JDBC_USER, dbUser);
config.put(Environment.JAKARTA_JDBC_PASSWORD, dbPass);
registryBuilder.applySettings(config);
registry = registryBuilder.build();
MetadataSources sources = new MetadataSources(registry);
Metadata metadata = sources.getMetadataBuilder().build();
sessionFactory = metadata.getSessionFactoryBuilder().build();
} catch (Exception e) {
String env = settings.get("NETNEUTRALITY_BOT_ENV", "dev");
if (!"dev".equals(env)) {
Sentry.captureException(e);
}
stop();
logger.error("Error connecting to database. Shutting down..");
throw new IllegalStateException("Unable to connect to the database", e);
}
SessionFactory factory = sessionFactory;
if (factory == null) {
throw new IllegalStateException("SessionManager has not been started");
}
return sessionFactory;
return factory;
}
}
@@ -15,6 +15,9 @@ import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import org.hibernate.Hibernate;
import java.util.Objects;
@Entity
@Table(name = "blacklist")
@@ -76,4 +79,26 @@ public class OBlacklist {
public void setId(Integer id) {
this.id = id;
}
}
/**
* Identity is the database id: two instances are equal when they have the same non-null id,
* regardless of Hibernate proxies. Transient instances (no id yet) are only equal to themselves.
*/
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) {
return false;
}
OBlacklist other = (OBlacklist) o;
return getId() != null && Objects.equals(getId(), other.getId());
}
@Override
public int hashCode() {
// Constant per class so the hash never changes when the id is assigned on persist.
return Hibernate.getClass(this).hashCode();
}
}
@@ -11,9 +11,10 @@ import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
import org.hibernate.Hibernate;
import java.util.LinkedHashSet;
import java.util.Optional;
import java.util.Objects;
import java.util.Set;
@Entity
@@ -43,14 +44,14 @@ public class OGuild {
private Set<OLogMessage> logMessages = new LinkedHashSet<>();
@OneToMany(mappedBy = "guild")
private Set<OLogging> OLoggings = new LinkedHashSet<>();
private Set<OLogging> loggings = new LinkedHashSet<>();
public Set<OLogging> getLoggings() {
return OLoggings;
return loggings;
}
public void setLoggings(Set<OLogging> OLoggings) {
this.OLoggings = OLoggings;
public void setLoggings(Set<OLogging> loggings) {
this.loggings = loggings;
}
public Set<OLogMessage> getLogMessages() {
@@ -110,13 +111,25 @@ public class OGuild {
this.guildId = guildId;
}
public OGuildSetting getSettingByName(String name) {
Optional<OGuildSetting> setting = getGuildSettings()
.stream()
.filter(setting1 -> setting1.getName().equalsIgnoreCase(name))
.findFirst();
return setting.orElse(null);
/**
* Identity is the database id: two instances are equal when they have the same non-null id,
* regardless of Hibernate proxies. Transient instances (no id yet) are only equal to themselves.
*/
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) {
return false;
}
OGuild other = (OGuild) o;
return getGuildId() != null && Objects.equals(getGuildId(), other.getGuildId());
}
}
@Override
public int hashCode() {
// Constant per class so the hash never changes when the id is assigned on persist.
return Hibernate.getClass(this).hashCode();
}
}
@@ -13,11 +13,13 @@ import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.Lob;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.NamedQueries;
import jakarta.persistence.NamedQuery;
import jakarta.persistence.Table;
import org.hibernate.Hibernate;
import java.util.Objects;
@Entity
@Table(name = "guild_settings")
@@ -33,8 +35,7 @@ public class OGuildSetting {
@Column(name = "name", nullable = false)
private String name;
@Lob
@Column(name = "value", nullable = false)
@Column(name = "value", nullable = false, columnDefinition = "text")
private String value;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@@ -72,4 +73,26 @@ public class OGuildSetting {
public void setId(Integer id) {
this.id = id;
}
}
/**
* Identity is the database id: two instances are equal when they have the same non-null id,
* regardless of Hibernate proxies. Transient instances (no id yet) are only equal to themselves.
*/
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) {
return false;
}
OGuildSetting other = (OGuildSetting) o;
return getId() != null && Objects.equals(getId(), other.getId());
}
@Override
public int hashCode() {
// Constant per class so the hash never changes when the id is assigned on persist.
return Hibernate.getClass(this).hashCode();
}
}
@@ -16,8 +16,10 @@ import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import org.hibernate.Hibernate;
import java.time.Instant;
import java.util.Objects;
@Entity
@Table(name = "log_message")
@@ -113,4 +115,26 @@ public class OLogMessage {
public void setId(Integer id) {
this.id = id;
}
}
/**
* Identity is the database id: two instances are equal when they have the same non-null id,
* regardless of Hibernate proxies. Transient instances (no id yet) are only equal to themselves.
*/
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) {
return false;
}
OLogMessage other = (OLogMessage) o;
return getId() != null && Objects.equals(getId(), other.getId());
}
@Override
public int hashCode() {
// Constant per class so the hash never changes when the id is assigned on persist.
return Hibernate.getClass(this).hashCode();
}
}
@@ -14,6 +14,9 @@ import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.MapsId;
import jakarta.persistence.Table;
import org.hibernate.Hibernate;
import java.util.Objects;
@Entity
@Table(name = "logging")
@@ -52,4 +55,26 @@ public class OLogging {
public void setId(OLoggingId id) {
this.id = id;
}
}
/**
* Identity is the database id: two instances are equal when they have the same non-null id,
* regardless of Hibernate proxies. Transient instances (no id yet) are only equal to themselves.
*/
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) {
return false;
}
OLogging other = (OLogging) o;
return getId() != null && Objects.equals(getId(), other.getId());
}
@Override
public int hashCode() {
// Constant per class so the hash never changes when the id is assigned on persist.
return Hibernate.getClass(this).hashCode();
}
}
@@ -10,9 +10,11 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import org.hibernate.Hibernate;
import java.util.Objects;
@Entity
@Table(name = "tasks", uniqueConstraints = {@UniqueConstraint(columnNames = {"task_id"})})
@@ -31,8 +33,7 @@ public class OTask {
@Column(name = "task_class", nullable = false)
private String taskClass;
@Lob
@Column(name = "payload")
@Column(name = "payload", columnDefinition = "text")
private String payload;
public Integer getId() {
@@ -67,4 +68,25 @@ public class OTask {
this.payload = payload;
}
}
/**
* Identity is the database id: two instances are equal when they have the same non-null id,
* regardless of Hibernate proxies. Transient instances (no id yet) are only equal to themselves.
*/
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) {
return false;
}
OTask other = (OTask) o;
return getId() != null && Objects.equals(getId(), other.getId());
}
@Override
public int hashCode() {
// Constant per class so the hash never changes when the id is assigned on persist.
return Hibernate.getClass(this).hashCode();
}
}
@@ -9,11 +9,12 @@ package me.bobert.netneutrality.db.entities;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Table;
import org.hibernate.annotations.ColumnDefault;
import org.hibernate.Hibernate;
import java.time.Instant;
import java.util.Objects;
@Entity
@Table(name = "web_users")
@@ -22,8 +23,7 @@ public class OWebUser {
@Column(name = "id", nullable = false)
private String id;
@Lob
@Column(name = "email", nullable = false)
@Column(name = "email", nullable = false, columnDefinition = "mediumtext")
private String email;
@Column(name = "token", nullable = false)
@@ -87,4 +87,25 @@ public class OWebUser {
this.rememberToken = rememberToken;
}
}
/**
* Identity is the database id: two instances are equal when they have the same non-null id,
* regardless of Hibernate proxies. Transient instances (no id yet) are only equal to themselves.
*/
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) {
return false;
}
OWebUser other = (OWebUser) o;
return getId() != null && Objects.equals(getId(), other.getId());
}
@Override
public int hashCode() {
// Constant per class so the hash never changes when the id is assigned on persist.
return Hibernate.getClass(this).hashCode();
}
}
@@ -1,104 +1,107 @@
package me.bobert.netneutrality.db.repository;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.db.Repository;
import me.bobert.netneutrality.db.AbstractRepository;
import me.bobert.netneutrality.db.SessionProvider;
import me.bobert.netneutrality.db.entities.OBlacklist;
import me.bobert.netneutrality.db.entities.OGuild;
import me.bobert.netneutrality.db.JpaUtils;
import net.dv8tion.jda.api.entities.Member;
import org.hibernate.Session;
import org.hibernate.query.Query;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
public class BlacklistRepository implements Repository<OBlacklist> {
public class BlacklistRepository extends AbstractRepository<OBlacklist, Integer> {
@Override
public List<OBlacklist> findAll() {
List<OBlacklist> blacklists = new ArrayList<>();
JpaUtils.runWithSession(session -> {
Query<OBlacklist> query = session.createQuery("FROM OBlacklist", OBlacklist.class);
blacklists.addAll(query.list());
});
return blacklists;
/**
* Outcome of {@link #enableWord(String, String, String)}.
*/
public enum EnableOutcome {
/** A new row was inserted. */
CREATED,
/** An existing, disabled row was re-enabled. */
ENABLED,
/** The word was already enabled. */
UNCHANGED
}
@Override
public OBlacklist findById(String id) {
return null;
}
@Override
public void deleteBy(String id) {
JpaUtils.runWithTransaction(session -> {
Query<OBlacklist> query = session.createQuery("SELECT o FROM OBlacklist o WHERE o.id = :blacklistId", OBlacklist.class);
query.setParameter("blacklistId", id);
OBlacklist oTask = query.uniqueResult();
if (oTask != null) {
session.remove(oTask);
}
});
public BlacklistRepository(SessionProvider sessions) {
super(sessions, OBlacklist.class);
}
public List<OBlacklist> findByGuildId(String guildId) {
List<OBlacklist> blacklists = new ArrayList<>();
JpaUtils.runWithSession(session -> {
Query<OBlacklist> query = session.createQuery("SELECT o FROM OBlacklist o WHERE o.guild.guildId = :guildId", OBlacklist.class);
query.setParameter("guildId", guildId);
blacklists.addAll(query.list());
});
return blacklists;
return read(session -> session
.createSelectionQuery("FROM OBlacklist o WHERE o.guild.guildId = :guildId ORDER BY o.id", OBlacklist.class)
.setParameter("guildId", guildId)
.getResultList());
}
public void add(OGuild oGuild, Member member, String word) {
JpaUtils.runWithTransaction(session -> {
OBlacklist bl = getWordFromDb(oGuild.getGuildId(), word);
public Set<String> findEnabledWords(String guildId) {
return read(session -> session
.createSelectionQuery("SELECT o.word FROM OBlacklist o WHERE o.guild.guildId = :guildId AND o.enabled = true", String.class)
.setParameter("guildId", guildId)
.getResultStream()
.collect(Collectors.toUnmodifiableSet()));
}
if (bl == null) {
bl = new OBlacklist();
bl.setGuild(oGuild);
bl.setCreatorId(member.getId());
bl.setWord(word);
bl.setEnabled(true);
session.persist(bl);
} else {
bl.setEnabled(true);
session.merge(bl);
/**
* Adds the word to the guild's blacklist, or re-enables it if it already exists. Lookup and
* write happen in the same transaction.
*
* @param guildId the guild id (the guild row must exist)
* @param creatorId the user adding the word
* @param word the word
* @return what changed
*/
public EnableOutcome enableWord(String guildId, String creatorId, String word) {
return write(session -> {
List<OBlacklist> existing = findWord(session, guildId, word);
if (existing.isEmpty()) {
OBlacklist blacklist = new OBlacklist();
blacklist.setGuild(session.getReference(OGuild.class, guildId));
blacklist.setCreatorId(creatorId);
blacklist.setWord(word);
blacklist.setEnabled(true);
session.persist(blacklist);
return EnableOutcome.CREATED;
}
});
}
public void delete(OGuild oGuild, String word) {
JpaUtils.runWithTransaction(session -> {
OBlacklist bl = getWordFromDb(oGuild.getGuildId(), word);
if (bl != null) {
session.remove(bl);
boolean changed = false;
for (OBlacklist blacklist : existing) {
if (!Boolean.TRUE.equals(blacklist.getEnabled())) {
blacklist.setEnabled(true);
changed = true;
}
}
return changed ? EnableOutcome.ENABLED : EnableOutcome.UNCHANGED;
});
}
private OBlacklist getWordFromDb(String guildId, String word) {
try (Session session = JpaUtils.openSession()) {
Query<OBlacklist> query = session.createQuery("SELECT o FROM OBlacklist o WHERE upper(o.word) = upper(:word) AND o.guild.guildId = :guildId", OBlacklist.class);
query.setParameter("guildId", guildId);
query.setParameter("word", word);
OBlacklist blacklist = query.uniqueResult();
/**
* Deletes the word (case-insensitive) from the guild's blacklist.
*
* @param guildId the guild id
* @param word the word
* @return {@code true} if at least one row was deleted
*/
public boolean deleteWord(String guildId, String word) {
return write(session -> {
List<OBlacklist> existing = findWord(session, guildId, word);
existing.forEach(session::remove);
return !existing.isEmpty();
});
}
session.close();
return blacklist;
}
private static List<OBlacklist> findWord(Session session, String guildId, String word) {
return session
.createSelectionQuery("FROM OBlacklist o WHERE upper(o.word) = upper(:word) AND o.guild.guildId = :guildId", OBlacklist.class)
.setParameter("guildId", guildId)
.setParameter("word", word)
.getResultList();
}
}
@@ -1,8 +1,4 @@
package me.bobert.netneutrality.db.repository;/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
package me.bobert.netneutrality.db.repository;
/*
* Copyright (c) anthorob
@@ -10,49 +6,31 @@ package me.bobert.netneutrality.db.repository;/*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.db.Repository;
import me.bobert.netneutrality.db.AbstractRepository;
import me.bobert.netneutrality.db.SessionProvider;
import me.bobert.netneutrality.db.entities.OGuild;
import me.bobert.netneutrality.db.JpaUtils;
import org.hibernate.Session;
import org.hibernate.query.Query;
import java.util.ArrayList;
import java.util.List;
public class GuildRepository extends AbstractRepository<OGuild, String> {
public class GuildRepository implements Repository<OGuild> {
@Override
public List<OGuild> findAll() {
List<OGuild> guilds = new ArrayList<>();
JpaUtils.runWithSession(session -> {
Query<OGuild> query = session.createQuery("FROM OGuild", OGuild.class);
guilds.addAll(query.list());
});
return guilds;
public GuildRepository(SessionProvider sessions) {
super(sessions, OGuild.class);
}
@Override
public OGuild findById(String id) {
try (Session session = JpaUtils.openSession()) {
Query<OGuild> query = session.createQuery("SELECT o FROM OGuild o WHERE o.guildId = :guildId", OGuild.class);
query.setParameter("guildId", id);
return query.uniqueResult();
}
}
@Override
public void deleteBy(String id) {
JpaUtils.runWithTransaction(session -> {
Query<OGuild> query = session.createQuery("SELECT o FROM OGuild o WHERE o.guildId = :guildId", OGuild.class);
query.setParameter("guildId", id);
OGuild oGuild = query.uniqueResult();
if (oGuild != null) {
session.remove(oGuild);
/**
* Returns the guild row, inserting it first if it does not exist yet.
*
* @param guildId the Discord guild id
* @return the persistent guild
*/
public OGuild findOrCreate(String guildId) {
return write(session -> {
OGuild guild = session.find(OGuild.class, guildId);
if (guild == null) {
guild = new OGuild();
guild.setGuildId(guildId);
session.persist(guild);
}
return guild;
});
}
}
@@ -1,8 +1,4 @@
package me.bobert.netneutrality.db.repository;/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
package me.bobert.netneutrality.db.repository;
/*
* Copyright (c) anthorob
@@ -10,88 +6,56 @@ package me.bobert.netneutrality.db.repository;/*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.db.AbstractRepository;
import me.bobert.netneutrality.db.GSettings;
import me.bobert.netneutrality.db.Repository;
import me.bobert.netneutrality.db.SessionProvider;
import me.bobert.netneutrality.db.entities.OGuild;
import me.bobert.netneutrality.db.entities.OGuildSetting;
import me.bobert.netneutrality.db.JpaUtils;
import org.hibernate.Session;
import org.hibernate.query.Query;
import java.util.ArrayList;
import java.util.List;
public class GuildSettingRepository implements Repository<OGuildSetting> {
@Override
public List<OGuildSetting> findAll() {
List<OGuildSetting> settings = new ArrayList<>();
public class GuildSettingRepository extends AbstractRepository<OGuildSetting, Integer> {
JpaUtils.runWithSession(session -> {
Query<OGuildSetting> query = session.createQuery("FROM OGuildSetting", OGuildSetting.class);
settings.addAll(query.list());
});
return settings;
}
@Override
public OGuildSetting findById(String id) {
try (Session session = JpaUtils.openSession()) {
Query<OGuildSetting> query = session.createQuery("SELECT o FROM OGuildSetting o WHERE o.id = :id", OGuildSetting.class);
query.setParameter("id", id);
return query.uniqueResult();
}
}
@Override
public void deleteBy(String id) {
JpaUtils.runWithTransaction(session -> {
Query<OGuildSetting> query = session.createQuery("SELECT o FROM OGuildSetting o WHERE o.id = :id", OGuildSetting.class);
query.setParameter("id", id);
OGuildSetting setting = query.uniqueResult();
if (setting != null) {
session.remove(setting);
}
});
public GuildSettingRepository(SessionProvider sessions) {
super(sessions, OGuildSetting.class);
}
public List<OGuildSetting> findByGuild(String guildId) {
List<OGuildSetting> settings = new ArrayList<>();
JpaUtils.runWithSession(session -> {
Query<OGuildSetting> query = session.createQuery("SELECT o FROM OGuildSetting o WHERE o.guild.guildId = :guildId", OGuildSetting.class);
query.setParameter("guildId", guildId);
settings.addAll(query.list());
});
return settings;
return read(session -> session
.createSelectionQuery("FROM OGuildSetting o WHERE o.guild.guildId = :guildId", OGuildSetting.class)
.setParameter("guildId", guildId)
.getResultList());
}
public void setSettingValue(OGuild oGuild, GSettings setting, String value) {
JpaUtils.getWithSession(oGuild, oGuild.getGuildId(), session -> {
OGuildSetting oGuildSetting = oGuild.getSettingByName(setting.getName());
/**
* Stores a non-default value, or deletes the row when the value is {@code null} or equal to the
* setting's default. Runs in a single transaction.
*
* @param guildId the guild id (the guild row must exist)
* @param setting the setting
* @param value the new value, or {@code null} to reset
*/
public void upsertOrDelete(String guildId, GSettings setting, String value) {
writeVoid(session -> {
List<OGuildSetting> existing = session
.createNamedSelectionQuery("settings_findByName", OGuildSetting.class)
.setParameter("id", guildId)
.setParameter("name", setting.getName())
.getResultList();
if (value == null || setting.getDefaultValue().equals(value)) {
if (oGuildSetting != null) {
session.remove(oGuildSetting);
}
existing.forEach(session::remove);
return;
}
if (existing.isEmpty()) {
OGuildSetting guildSetting = new OGuildSetting();
guildSetting.setGuild(session.getReference(OGuild.class, guildId));
guildSetting.setName(setting.getName());
guildSetting.setValue(value);
session.persist(guildSetting);
} else {
if (oGuildSetting == null) {
oGuildSetting = new OGuildSetting();
oGuildSetting.setGuild(oGuild);
oGuildSetting.setName(setting.getName());
oGuildSetting.setValue(value);
session.persist(oGuildSetting);
} else {
oGuildSetting.setValue(value);
session.merge(oGuildSetting);
}
existing.forEach(guildSetting -> guildSetting.setValue(value));
}
});
}
@@ -1,105 +1,104 @@
package me.bobert.netneutrality.db.repository;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.db.Repository;
import me.bobert.netneutrality.db.AbstractRepository;
import me.bobert.netneutrality.db.SessionProvider;
import me.bobert.netneutrality.db.entities.OGuild;
import me.bobert.netneutrality.db.entities.OLogMessage;
import me.bobert.netneutrality.db.JpaUtils;
import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.entities.Message;
import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel;
import org.hibernate.Session;
import org.hibernate.query.Query;
import java.util.ArrayList;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
public class MessageRepository implements Repository<OLogMessage> {
@Override
public List<OLogMessage> findAll() {
List<OLogMessage> messages = new ArrayList<>();
public class MessageRepository extends AbstractRepository<OLogMessage, Integer> {
JpaUtils.runWithSession(session -> {
Query<OLogMessage> query = session.createQuery("FROM OLogMessage", OLogMessage.class);
messages.addAll(query.list());
});
return messages;
public MessageRepository(SessionProvider sessions) {
super(sessions, OLogMessage.class);
}
@Override
public OLogMessage findById(String id) {
try (Session session = JpaUtils.openSession()) {
Query<OLogMessage> query = session.createQuery("SELECT o FROM OLogMessage o WHERE o.messageId = :messageId", OLogMessage.class);
query.setParameter("messageId", id);
return query.uniqueResult();
}
}
@Override
public void deleteBy(String id) {
JpaUtils.runWithTransaction(session -> {
Query<OLogMessage> query = session.createQuery("SELECT o FROM OLogMessage o WHERE o.messageId = :messageId", OLogMessage.class);
query.setParameter("messageId", id);
OLogMessage oLogMessage = query.uniqueResult();
if (oLogMessage != null) {
session.remove(oLogMessage);
}
});
}
//TODO: move to a service
public void log(OGuild oGuild, MessageChannel channel, Member author, Message message) {
JpaUtils.runWithTransaction(session -> {
/**
* Stores a logged message.
*
* @param guildId the guild id (the guild row must exist)
* @param channelId the channel id
* @param userId the author id
* @param messageId the Discord message id
* @param content the raw message content
* @param sentAt when the message was sent
* @return the persistent log entry
*/
public OLogMessage log(String guildId, String channelId, String userId, String messageId, String content, Instant sentAt) {
return write(session -> {
OLogMessage logMessage = new OLogMessage();
logMessage.setGuild(oGuild);
logMessage.setMessageId(message.getId());
logMessage.setMessage(message.getContentRaw().trim());
logMessage.setChannelId(channel.getId());
logMessage.setUserId(author.getId());
logMessage.setDateSent(message.getTimeCreated().toInstant());
logMessage.setGuild(session.getReference(OGuild.class, guildId));
logMessage.setChannelId(channelId);
logMessage.setUserId(userId);
logMessage.setMessageId(messageId);
logMessage.setMessage(content == null ? null : content.trim());
logMessage.setDateSent(sentAt);
session.persist(logMessage);
return logMessage;
});
}
public void logEdit(String messageId, String newMessage) {
JpaUtils.runWithTransaction(session -> {
Query<OLogMessage> query = session.createQuery("SELECT o FROM OLogMessage o WHERE o.messageId = :messageId", OLogMessage.class);
query.setParameter("messageId", messageId);
public Optional<OLogMessage> findByMessageId(String messageId) {
return read(session -> findByMessageId(session, messageId));
}
OLogMessage message = query.uniqueResult();
if (message != null) {
public Optional<String> findMessageContent(String messageId) {
return findByMessageId(messageId).map(OLogMessage::getMessage);
}
/**
* Records the edited content of a logged message.
*
* @param messageId the Discord message id
* @param newMessage the edited content
* @return {@code true} if the message was found and updated
*/
public boolean updateEditedMessage(String messageId, String newMessage) {
return write(session -> findByMessageId(session, messageId)
.map(message -> {
message.setEditedMessage(newMessage);
session.merge(message);
}
});
return true;
})
.orElse(false));
}
public List<OLogMessage> getMessagesFromUser(String userId, String guildId, int max) {
List<OLogMessage> messages = new ArrayList<>();
JpaUtils.runWithSession(session -> {
Query<OLogMessage> query = session.createQuery("SELECT o FROM OLogMessage o WHERE o.guild.guildId = :guildId AND o.userId = :userId", OLogMessage.class);
query.setParameter("guildId", guildId);
query.setParameter("userId", userId);
query.setMaxResults(max);
messages.addAll(query.list());
});
return messages;
public boolean deleteByMessageId(String messageId) {
return write(session -> session
.createMutationQuery("DELETE FROM OLogMessage o WHERE o.messageId = :messageId")
.setParameter("messageId", messageId)
.executeUpdate() > 0);
}
public String getMessageString(String messageId) {
return findById(messageId).getMessage();
/**
* Returns the most recent messages of a user in a guild, newest first.
*
* @param userId the user id
* @param guildId the guild id
* @param max the maximum number of messages
* @return the messages
*/
public List<OLogMessage> findByUser(String userId, String guildId, int max) {
return read(session -> session
.createSelectionQuery("FROM OLogMessage o WHERE o.guild.guildId = :guildId AND o.userId = :userId ORDER BY o.dateSent DESC", OLogMessage.class)
.setParameter("guildId", guildId)
.setParameter("userId", userId)
.setMaxResults(max)
.getResultList());
}
private static Optional<OLogMessage> findByMessageId(Session session, String messageId) {
return session
.createSelectionQuery("FROM OLogMessage o WHERE o.messageId = :messageId", OLogMessage.class)
.setParameter("messageId", messageId)
.uniqueResultOptional();
}
}
@@ -1,55 +1,63 @@
package me.bobert.netneutrality.db.repository;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.db.Repository;
import me.bobert.netneutrality.db.AbstractRepository;
import me.bobert.netneutrality.db.SessionProvider;
import me.bobert.netneutrality.db.entities.OTask;
import me.bobert.netneutrality.db.JpaUtils;
import org.hibernate.Session;
import org.hibernate.query.Query;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
public class TaskRepository implements Repository<OTask> {
public class TaskRepository extends AbstractRepository<OTask, Integer> {
@Override
public List<OTask> findAll() {
List<OTask> tasks = new ArrayList<>();
JpaUtils.runWithSession(session -> {
Query<OTask> query = session.createQuery("FROM OTask", OTask.class);
tasks.addAll(query.list());
});
return tasks;
public TaskRepository(SessionProvider sessions) {
super(sessions, OTask.class);
}
@Override
public OTask findById(String id) {
try (Session session = JpaUtils.openSession()) {
Query<OTask> query = session.createQuery("SELECT o FROM OTask o WHERE o.taskId = :taskId", OTask.class);
query.setParameter("taskId", id);
return query.uniqueResult();
}
public Optional<OTask> findByTaskId(String taskId) {
return read(session -> findByTaskId(session, taskId));
}
@Override
public void deleteBy(String id) {
JpaUtils.runWithTransaction(session -> {
Query<OTask> query = session.createQuery("SELECT o FROM OTask o WHERE o.taskId = :taskId", OTask.class);
query.setParameter("taskId", id);
OTask oTask = query.uniqueResult();
if (oTask != null) {
session.remove(oTask);
/**
* Inserts the task or updates its payload, in a single transaction.
*
* @param taskId the logical task id
* @param typeId the persistable task type id
* @param payload the serialized payload
* @return the persistent task
*/
public OTask upsert(String taskId, String typeId, String payload) {
return write(session -> {
OTask task = findByTaskId(session, taskId).orElse(null);
if (task == null) {
task = new OTask();
task.setTaskId(taskId);
task.setTaskClass(typeId);
task.setPayload(payload);
session.persist(task);
} else {
task.setPayload(payload);
}
return task;
});
}
public boolean deleteByTaskId(String taskId) {
return write(session -> session
.createMutationQuery("DELETE FROM OTask o WHERE o.taskId = :taskId")
.setParameter("taskId", taskId)
.executeUpdate() > 0);
}
private static Optional<OTask> findByTaskId(Session session, String taskId) {
return session
.createSelectionQuery("FROM OTask o WHERE o.taskId = :taskId", OTask.class)
.setParameter("taskId", taskId)
.uniqueResultOptional();
}
}
@@ -0,0 +1,16 @@
package me.bobert.netneutrality.db.service;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
/**
* Read-only view of a blacklisted word.
*
* @param word the word
* @param enabled whether the word is currently enforced
*/
public record BlacklistEntry(String word, boolean enabled) {
}
@@ -0,0 +1,23 @@
package me.bobert.netneutrality.db.service;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
/**
* Outcome of a blacklist change made through {@link BlacklistService}.
*/
public enum BlacklistResult {
/** The word was added to the blacklist. */
ADDED,
/** The word existed but was disabled, and is now enabled again. */
REENABLED,
/** The word was already blacklisted; nothing changed. */
ALREADY_EXISTS,
/** The word was removed from the blacklist. */
REMOVED,
/** The word was not blacklisted; nothing changed. */
NOT_FOUND
}
@@ -6,93 +6,108 @@ package me.bobert.netneutrality.db.service;
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.core.command.CommandExecutionResult;
import me.bobert.netneutrality.db.entities.OBlacklist;
import me.bobert.netneutrality.db.entities.OGuild;
import me.bobert.netneutrality.db.repository.BlacklistRepository;
import me.bobert.netneutrality.db.JpaUtils;
import net.dv8tion.jda.api.entities.Member;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
/**
* Per-guild blacklist, backed by the {@code blacklist} table and an in-memory cache of the enabled
* words. Persistence failures propagate to the caller.
*/
public class BlacklistService {
private static final Logger logger = LoggerFactory.getLogger(BlacklistService.class);
private static final Map<String, Set<String>> blacklistCache = new ConcurrentHashMap<>();
private static final Logger logger = LogManager.getLogger(BlacklistService.class);
private static final Pattern WORD_SEPARATOR = Pattern.compile("[^\\p{L}\\p{N}]+");
private final BlacklistRepository blacklistRepository;
private final GuildService guildService;
private volatile Set<String> enabledWords = Set.of();
public BlacklistService(GuildService guildService) {
this.blacklistRepository = new BlacklistRepository();
this.guildService = guildService;
init();
}
private void init() {
public BlacklistService(GuildService guildService, BlacklistRepository blacklistRepository) {
this.guildService = Objects.requireNonNull(guildService, "guildService");
this.blacklistRepository = Objects.requireNonNull(blacklistRepository, "blacklistRepository");
reloadBlacklistCache();
}
public void reloadBlacklistCache() {
OGuild oGuild = guildService.getEntity();
logger.debug("Reloading blacklist cache for guild: {}", oGuild.getGuildId());
JpaUtils.getWithSession(oGuild, oGuild.getGuildId(), session -> {
Set<String> blacklistWords = oGuild.getBlacklists().stream()
.filter(OBlacklist::getEnabled)
.map(OBlacklist::getWord)
.collect(Collectors.toSet());
blacklistCache.put(oGuild.getGuildId(), blacklistWords);
});
logger.debug("Reloading blacklist cache for guild: {}", guildService.getGuildId());
enabledWords = blacklistRepository.findEnabledWords(guildService.getGuildId()).stream()
.map(BlacklistService::normalize)
.collect(Collectors.toUnmodifiableSet());
}
public CommandExecutionResult add(Member member, String word) {
try {
blacklistRepository.add(guildService.getEntity(), member, word);
reloadBlacklistCache();
/**
* Adds the word to the blacklist, or re-enables it.
*
* @param creatorUserId the id of the user adding the word
* @param word the word
* @return {@link BlacklistResult#ADDED}, {@link BlacklistResult#REENABLED} or
* {@link BlacklistResult#ALREADY_EXISTS}
*/
public BlacklistResult add(String creatorUserId, String word) {
guildService.ensureEntity();
BlacklistRepository.EnableOutcome outcome =
blacklistRepository.enableWord(guildService.getGuildId(), creatorUserId, word);
reloadBlacklistCache();
return CommandExecutionResult.success("Added the word [%s] to the blacklist.", word);
} catch (Exception e) {
return CommandExecutionResult.warn("Failed to add word to blacklist: " + e.getMessage());
}
return switch (outcome) {
case CREATED -> BlacklistResult.ADDED;
case ENABLED -> BlacklistResult.REENABLED;
case UNCHANGED -> BlacklistResult.ALREADY_EXISTS;
};
}
public CommandExecutionResult delete(String word) {
try {
blacklistRepository.delete(guildService.getEntity(), word);
reloadBlacklistCache();
/**
* Removes the word from the blacklist.
*
* @param word the word
* @return {@link BlacklistResult#REMOVED} or {@link BlacklistResult#NOT_FOUND}
*/
public BlacklistResult remove(String word) {
boolean deleted = blacklistRepository.deleteWord(guildService.getGuildId(), word);
reloadBlacklistCache();
return CommandExecutionResult.success("Removed the word [%s] to the blacklist.", word);
} catch (Exception e) {
return CommandExecutionResult.warn("Failed to remove word from blacklist: " + e.getMessage());
}
return deleted ? BlacklistResult.REMOVED : BlacklistResult.NOT_FOUND;
}
public List<OBlacklist> getBlacklistForGuild() {
try {
return blacklistRepository.findByGuildId(guildService.getEntity().getGuildId());
} catch (Exception e) {
return List.of();
public List<BlacklistEntry> list() {
return blacklistRepository.findByGuildId(guildService.getGuildId()).stream()
.map(blacklist -> new BlacklistEntry(blacklist.getWord(), Boolean.TRUE.equals(blacklist.getEnabled())))
.toList();
}
/**
* Returns whether any word of the text is an enabled blacklisted word (case-insensitive).
*
* @param text the text to check
* @return {@code true} if the text contains a blacklisted word
*/
public boolean containsBlacklistedWord(String text) {
Set<String> words = enabledWords;
if (text == null || words.isEmpty()) {
return false;
}
for (String token : WORD_SEPARATOR.split(text)) {
if (!token.isEmpty() && words.contains(normalize(token))) {
return true;
}
}
return false;
}
public void removeGuildBlacklist() {
logger.info("Clearing blacklist cache for guild: {}", guildService.getEntity().getGuildId());
blacklistCache.remove(guildService.getEntity().getGuildId());
logger.info("Clearing blacklist cache for guild: {}", guildService.getGuildId());
enabledWords = Set.of();
}
public static boolean isWordBlacklisted(String guildId, String word) {
Set<String> blacklistWords = blacklistCache.get(guildId);
return blacklistWords != null && blacklistWords.contains(word);
}
public static Set<String> getBlacklist(String guildId) {
return blacklistCache.getOrDefault(guildId, Collections.emptySet());
private static String normalize(String word) {
return word.toLowerCase(Locale.ROOT);
}
}
@@ -7,10 +7,13 @@ package me.bobert.netneutrality.db.service;
*/
import me.bobert.netneutrality.db.entities.OGuild;
import me.bobert.netneutrality.db.repository.BlacklistRepository;
import me.bobert.netneutrality.db.repository.GuildRepository;
import me.bobert.netneutrality.db.JpaUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import me.bobert.netneutrality.db.repository.GuildSettingRepository;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import java.util.Objects;
/**
* Per-guild domain service (blacklist, settings, entity access).
@@ -19,16 +22,24 @@ import org.slf4j.LoggerFactory;
* {@link me.bobert.netneutrality.db.service.GuildServiceProvider}.
*/
public class GuildService {
private static final Logger logger = LoggerFactory.getLogger(GuildService.class);
private final GuildRepository guildRepository = new GuildRepository();
private static final Logger logger = LogManager.getLogger(GuildService.class);
private final String guildId;
private final GuildRepository guildRepository;
private final BlacklistService blacklistService;
private final GuildSettingService guildSettingService;
private OGuild oGuild;
private volatile OGuild oGuild;
GuildService(String guildId) {
public GuildService(String guildId, GuildRepository guildRepository, BlacklistRepository blacklistRepository,
GuildSettingRepository guildSettingRepository) {
this.guildId = Objects.requireNonNull(guildId, "guildId");
this.guildRepository = Objects.requireNonNull(guildRepository, "guildRepository");
this.oGuild = findOrCreateGuild(guildId);
this.blacklistService = new BlacklistService(this);
this.guildSettingService = new GuildSettingService(this);
this.blacklistService = new BlacklistService(this, blacklistRepository);
this.guildSettingService = new GuildSettingService(this, guildSettingRepository);
}
public String getGuildId() {
return guildId;
}
public OGuild getEntity() {
@@ -43,17 +54,23 @@ public class GuildService {
return guildSettingService;
}
/**
* Returns the guild row, creating it when it does not exist (for example after it was deleted
* while the service was cached).
*
* @param guildId the guild id
* @return the persistent guild
*/
public OGuild findOrCreateGuild(String guildId) {
OGuild guildEntity = guildRepository.findById(guildId);
if (guildEntity == null) {
logger.debug("Guild[{}] doesn't exist in DB, creating entity..", guildId);
logger.debug("Loading Guild[{}]", guildId);
return guildRepository.findOrCreate(guildId);
}
guildEntity = new OGuild();
guildEntity.setGuildId(guildId);
guildRepository.save(guildEntity);
}
return guildEntity;
/**
* Makes sure the guild row exists before writing rows that reference it.
*/
void ensureEntity() {
this.oGuild = findOrCreateGuild(guildId);
}
public boolean isDebugEnabled() {
@@ -61,14 +78,17 @@ public class GuildService {
}
public void updateDebugMode(boolean debug) {
JpaUtils.updateWithTransaction(oGuild, () -> oGuild.setDebug(debug));
OGuild current = findOrCreateGuild(guildId);
current.setDebug(debug);
this.oGuild = guildRepository.save(current);
}
public void deleteGuild() {
guildRepository.deleteBy(oGuild.getGuildId());
guildRepository.deleteById(guildId);
blacklistService.removeGuildBlacklist();
}
public void reloadEntity() {
this.oGuild = findOrCreateGuild(oGuild.getGuildId());
ensureEntity();
}
}
@@ -6,8 +6,6 @@ package me.bobert.netneutrality.db.service;
* https://discord.gg/qUa6zNFZsd
*/
import net.dv8tion.jda.api.entities.Guild;
import java.util.Map;
/**
@@ -24,17 +22,16 @@ public interface GuildServiceProvider {
GuildService get(String guildId);
/**
* Returns (creating on first use) the service for the given guild.
* Drops the cached service for the guild; the next {@link #get(String)} builds a new one.
*
* @param guild the Discord guild
* @return the guild service
* @param guildId the Discord guild id
*/
GuildService get(Guild guild);
void evict(String guildId);
/**
* Returns the live cache of guild services.
* Returns a read-only snapshot of the cached guild services.
*
* @return the services keyed by guild id
*/
Map<String, GuildService> getServices();
Map<String, GuildService> snapshot();
}
@@ -6,9 +6,12 @@ package me.bobert.netneutrality.db.service;
* https://discord.gg/qUa6zNFZsd
*/
import net.dv8tion.jda.api.entities.Guild;
import me.bobert.netneutrality.db.repository.BlacklistRepository;
import me.bobert.netneutrality.db.repository.GuildRepository;
import me.bobert.netneutrality.db.repository.GuildSettingRepository;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
/**
@@ -17,22 +20,30 @@ import java.util.concurrent.ConcurrentHashMap;
public class GuildServiceProviderImpl implements GuildServiceProvider {
private final Map<String, GuildService> services = new ConcurrentHashMap<>();
private final GuildRepository guildRepository;
private final BlacklistRepository blacklistRepository;
private final GuildSettingRepository guildSettingRepository;
public GuildServiceProviderImpl() {
public GuildServiceProviderImpl(GuildRepository guildRepository, BlacklistRepository blacklistRepository,
GuildSettingRepository guildSettingRepository) {
this.guildRepository = Objects.requireNonNull(guildRepository, "guildRepository");
this.blacklistRepository = Objects.requireNonNull(blacklistRepository, "blacklistRepository");
this.guildSettingRepository = Objects.requireNonNull(guildSettingRepository, "guildSettingRepository");
}
@Override
public GuildService get(String guildId) {
return services.computeIfAbsent(guildId, GuildService::new);
return services.computeIfAbsent(guildId,
id -> new GuildService(id, guildRepository, blacklistRepository, guildSettingRepository));
}
@Override
public GuildService get(Guild guild) {
return get(guild.getId());
public void evict(String guildId) {
services.remove(guildId);
}
@Override
public Map<String, GuildService> getServices() {
return services;
public Map<String, GuildService> snapshot() {
return Map.copyOf(services);
}
}
@@ -9,52 +9,73 @@ package me.bobert.netneutrality.db.service;
import me.bobert.netneutrality.db.GSettings;
import me.bobert.netneutrality.db.entities.OGuildSetting;
import me.bobert.netneutrality.db.repository.GuildSettingRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import java.util.List;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.Objects;
import java.util.Optional;
/**
* Per-guild settings, backed by the {@code guild_settings} table. Only non-default values are
* stored; reads come from an immutable snapshot that is swapped atomically on reload.
*/
public class GuildSettingService {
private static final Logger logger = LoggerFactory.getLogger(GuildSettingService.class);
private final GuildSettingRepository guildSettingRepository = new GuildSettingRepository();
private static final Logger logger = LogManager.getLogger(GuildSettingService.class);
private final GuildSettingRepository guildSettingRepository;
private final GuildService guildService;
private final Map<String, String> settingsCache = new ConcurrentHashMap<>();
private volatile Map<String, String> settingsCache = Map.of();
public GuildSettingService(GuildService guildService) {
this.guildService = guildService;
public GuildSettingService(GuildService guildService, GuildSettingRepository guildSettingRepository) {
this.guildService = Objects.requireNonNull(guildService, "guildService");
this.guildSettingRepository = Objects.requireNonNull(guildSettingRepository, "guildSettingRepository");
reloadSettingsCache();
}
public Map<String, String> getSettings() {
/**
* @return an unmodifiable snapshot of the stored (non-default) settings, keyed by setting name
*/
public Map<String, String> snapshot() {
return settingsCache;
}
public String getSetting(GSettings setting) {
String value = settingsCache.get(setting.getName());
if (value == null) {
value = setting.getDefaultValue();
}
return value;
/**
* @param key the setting
* @return the stored value, or empty when the setting uses its default
*/
public Optional<String> get(GSettings key) {
return Optional.ofNullable(settingsCache.get(key.getName()));
}
public void setSettingValue(GSettings setting, String value) {
guildSettingRepository.setSettingValue(guildService.getEntity(), setting, value);
/**
* @param key the setting
* @return the stored value, or the setting's default
*/
public String getOrDefault(GSettings key) {
return get(key).orElse(key.getDefaultValue());
}
/**
* Stores the value; {@code null} or the default value deletes the stored setting.
*
* @param key the setting
* @param value the new value, or {@code null} to reset it
*/
public void set(GSettings key, String value) {
guildService.ensureEntity();
guildSettingRepository.upsertOrDelete(guildService.getGuildId(), key, value);
reloadSettingsCache();
}
public void reloadSettingsCache() {
logger.debug("Reloading settings cache for guild: {}", guildService.getEntity().getGuildId());
settingsCache.clear();
logger.debug("Reloading settings cache for guild: {}", guildService.getGuildId());
List<OGuildSetting> settings = guildSettingRepository.findByGuild(guildService.getEntity().getGuildId());
for (OGuildSetting setting : settings) {
settingsCache.put(setting.getName(), setting.getValue());
Map<String, String> fresh = new HashMap<>();
for (OGuildSetting setting : guildSettingRepository.findByGuild(guildService.getGuildId())) {
fresh.put(setting.getName(), setting.getValue());
}
settingsCache = Map.copyOf(fresh);
}
}
@@ -24,10 +24,10 @@ public class TaskService {
private final TaskRepository taskRepository;
private final PersistableTaskRegistry registry;
public TaskService(TaskManager taskManager, PersistableTaskRegistry registry) {
this.taskManager = taskManager;
this.taskRepository = new TaskRepository();
this.registry = registry;
public TaskService(TaskManager taskManager, PersistableTaskRegistry registry, TaskRepository taskRepository) {
this.taskManager = Objects.requireNonNull(taskManager, "taskManager");
this.registry = Objects.requireNonNull(registry, "registry");
this.taskRepository = Objects.requireNonNull(taskRepository, "taskRepository");
}
public void loadTasks() {
@@ -40,13 +40,11 @@ public class TaskService {
String typeId = oTask.getTaskClass();
logger.debug("Trying to load task with ID: {} and type: {}", taskId, typeId);
String payload = oTask.getPayload();
try {
ObjectNode json = Json.readObject(payload);
ObjectNode json = Json.readObject(oTask.getPayload());
PersistableTask task = registry.create(typeId, json);
validateTask(task, oTask);
validateTask(task, taskId, json);
taskManager.insertPersistableTask(task);
} catch (Exception e) {
logger.error("Error loading task with ID {}: {}", taskId, e.getMessage());
@@ -54,15 +52,11 @@ public class TaskService {
}
}
private void validateTask(PersistableTask task, OTask oTask) throws IllegalArgumentException {
String taskId = oTask.getTaskId();
String payload = oTask.getPayload();
private static void validateTask(PersistableTask task, String taskId, ObjectNode json) throws IllegalArgumentException {
if (!Objects.equals(taskId, task.getId())) {
throw new IllegalArgumentException("Task ID mismatch, expected: " + task.getId());
}
ObjectNode json = Json.readObject(payload);
String payloadId = json.path("id").asText(null);
if (!Objects.equals(payloadId, task.getId())) {
throw new IllegalArgumentException("Task payload ID mismatch, expected: " + task.getId());
@@ -81,24 +75,12 @@ public class TaskService {
return;
}
TaskRepository taskRepository = new TaskRepository();
OTask oTask = taskRepository.findById(task.getId());
if (oTask == null) {
oTask = new OTask();
oTask.setTaskId(task.getId());
oTask.setTaskClass(typeId);
}
oTask.setPayload(task.toPayload());
logger.info("Persisting task: {}", oTask.getTaskId());
taskRepository.save(oTask);
logger.info("Persisting task: {}", task.getId());
taskRepository.upsert(task.getId(), typeId, task.toPayload());
}
public void unpersistTask(PersistableTask task) {
logger.debug("Unpersisting task: {}", task.getId());
TaskRepository taskRepository = new TaskRepository();
taskRepository.deleteBy(task.getId());
taskRepository.deleteByTaskId(task.getId());
}
}
@@ -95,13 +95,29 @@ public class EventsHandler implements EventListener {
addListener(ButtonInteractionEvent.class, ButtonTaskContext::new);
addListener(MessageReactionAddEvent.class, EmojiTaskContext::new);
addGenericListener(GuildJoinEvent.class, event -> {
GuildService service = guildServices.get(event.getGuild());
GuildService service = guildServices.get(event.getGuild().getId());
if (service.isDebugEnabled()) {
LogHandler.sendMessageToDebugChannel(event.getGuild(), "Error logging enabled on this channel");
}
});
addGenericListener(GuildLeaveEvent.class, event -> guildServices.get(event.getGuild()).deleteGuild());
addGenericListener(GuildLeaveEvent.class, event -> onGuildLeave(event.getGuild().getId()));
}
/**
* Deletes the guild's data and drops its cached service. The service is evicted even when the
* delete fails, so a stale cache entry never outlives the guild membership.
*
* @param guildId the id of the guild the bot left
*/
void onGuildLeave(String guildId) {
try {
guildServices.get(guildId).deleteGuild();
} catch (RuntimeException e) {
logger.error("Failed to delete data of guild {}", guildId, e);
} finally {
guildServices.evict(guildId);
}
}
/**
@@ -52,14 +52,14 @@ public final class ActionRoutes {
routes.get("/actions/settings/current/{guildId}", ctx -> {
Guild guild = GuildRoutes.guild(jdaSupplier, ctx);
Map<String, String> settings = guildServices.get(guild).getGuildSettingService().getSettings();
Map<String, String> settings = guildServices.get(guild.getId()).getGuildSettingService().snapshot();
ctx.json(Map.of("status", "ok", "settings", settings));
});
routes.post("/actions/settings/reload/{guildId}", ctx -> {
Guild guild = GuildRoutes.guild(jdaSupplier, ctx);
guildServices.get(guild).getGuildSettingService().reloadSettingsCache();
guildServices.get(guild.getId()).getGuildSettingService().reloadSettingsCache();
ctx.json(Map.of("status", "true"));
});
@@ -259,7 +259,7 @@ public class MusicManager implements MusicManagerCallback {
private static int parseBaseVolume(GuildService guildService) {
try {
return Integer.parseInt(guildService.getGuildSettingService().getSetting(GSettings.MUSIC_VOLUME));
return Integer.parseInt(guildService.getGuildSettingService().getOrDefault(GSettings.MUSIC_VOLUME));
} catch (NumberFormatException e) {
logger.warn("Invalid MUSIC_VOLUME setting, falling back to default ({})", DEFAULT_VOLUME, e);
return DEFAULT_VOLUME;
@@ -105,7 +105,7 @@ public class MusicServiceImpl implements MusicService {
public MusicManager get(Guild guild) {
return managers.computeIfAbsent(
guild.getId(),
id -> new MusicManager(guild, client, guildServices.get(guild), taskManager)
id -> new MusicManager(guild, client, guildServices.get(id), taskManager)
);
}
@@ -63,7 +63,7 @@ public class LoadPlaylistTask extends ButtonTask implements WithChildren, Deleta
int searchResultsLimit = 5; // Max number of buttons per row
int maxPlaylistSizeBeforeAsking = Integer.parseInt(musicManager.getGuildService()
.getGuildSettingService()
.getSetting(GSettings.MUSIC_MAXPLAYLISTSIZE));
.getOrDefault(GSettings.MUSIC_MAXPLAYLISTSIZE));
// Search results
if (searchResult) {
+4 -1
View File
@@ -7,6 +7,8 @@
<property name="hibernate.dialect">org.hibernate.dialect.MySQLDialect</property>
<property name="hibernate.connection.driver_class">com.mysql.cj.jdbc.Driver</property>
<property name="hibernate.connection.provider_class">org.hibernate.hikaricp.internal.HikariCPConnectionProvider</property>
<!-- The schema is owned by Flyway; Hibernate only checks that the entities match it. -->
<property name="hibernate.hbm2ddl.auto">validate</property>
<mapping class="me.bobert.netneutrality.db.entities.OBlacklist"/>
<mapping class="me.bobert.netneutrality.db.entities.OGuild"/>
@@ -15,6 +17,7 @@
<mapping class="me.bobert.netneutrality.db.entities.OLoggingId"/>
<mapping class="me.bobert.netneutrality.db.entities.OLogMessage"/>
<mapping class="me.bobert.netneutrality.db.entities.OTask"/>
<mapping class="me.bobert.netneutrality.db.entities.OWebUser"/>
</session-factory>
</hibernate-configuration>
</hibernate-configuration>
@@ -0,0 +1,100 @@
package me.bobert.netneutrality.command.admin;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.core.command.CommandExecutionResult;
import me.bobert.netneutrality.core.command.CommandExecutionResult.Result;
import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.db.service.BlacklistResult;
import me.bobert.netneutrality.db.service.BlacklistService;
import me.bobert.netneutrality.db.service.GuildService;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.Member;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import java.util.function.Function;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class BlacklistCommandTest {
private BlacklistService blacklist;
private CommandContext context;
private BlacklistCommand command;
@BeforeEach
void setUp() {
blacklist = mock(BlacklistService.class);
GuildService guildService = mock(GuildService.class);
when(guildService.getBlacklistService()).thenReturn(blacklist);
GuildServiceProvider provider = mock(GuildServiceProvider.class);
when(provider.get("guild-1")).thenReturn(guildService);
Guild guild = mock(Guild.class);
when(guild.getId()).thenReturn("guild-1");
Member member = mock(Member.class);
when(member.getId()).thenReturn("user-1");
context = mock(CommandContext.class);
when(context.getGuild()).thenReturn(guild);
when(context.getGuildUser()).thenReturn(member);
when(context.getOption(eq("word"), eq(""), any(Function.class))).thenReturn("foo");
command = new BlacklistCommand(provider);
}
@ParameterizedTest
@CsvSource({
"ADDED, SUCCESS",
"REENABLED, SUCCESS",
"ALREADY_EXISTS, WARN",
"REMOVED, SUCCESS",
"NOT_FOUND, WARN"
})
void mapsEveryResult(BlacklistResult result, Result expected) {
CommandExecutionResult reply = BlacklistCommand.toReply(result, "foo");
assertEquals(expected, reply.getResult());
assertTrue(reply.getMessage().contains("[foo]"));
}
@Test
void addPassesPlainIds() {
when(context.getSubcommand()).thenReturn("add");
when(blacklist.add("user-1", "foo")).thenReturn(BlacklistResult.ADDED);
assertEquals(Result.SUCCESS, command.execute(context).getResult());
}
@Test
void removeNotFoundWarns() {
when(context.getSubcommand()).thenReturn("remove");
when(blacklist.remove("foo")).thenReturn(BlacklistResult.NOT_FOUND);
assertEquals(Result.WARN, command.execute(context).getResult());
}
@Test
void persistenceFailureBecomesWarning() {
when(context.getSubcommand()).thenReturn("add");
when(blacklist.add("user-1", "foo")).thenThrow(new IllegalStateException("db down"));
CommandExecutionResult reply = command.execute(context);
assertEquals(Result.WARN, reply.getResult());
assertTrue(reply.getMessage().contains("db down"));
}
}
@@ -16,6 +16,7 @@ import me.bobert.netneutrality.core.tasks.trait.Deletable;
import me.bobert.netneutrality.core.tasks.trait.WithButton;
import me.bobert.netneutrality.core.tasks.trait.WithChildren;
import me.bobert.netneutrality.core.tasks.trait.WithParent;
import me.bobert.netneutrality.db.repository.TaskRepository;
import me.bobert.netneutrality.db.service.TaskService;
import me.bobert.netneutrality.scheduled.cleanup.TaskCleanup;
import net.dv8tion.jda.api.components.buttons.Button;
@@ -61,7 +62,7 @@ class TaskManagerImplTest {
@BeforeEach
void setUp() throws Exception {
taskManager = new TaskManagerImpl(applicationContext);
taskManager = new TaskManagerImpl(applicationContext, mock(TaskRepository.class));
Field taskServiceField = TaskManagerImpl.class.getDeclaredField("taskService");
taskServiceField.setAccessible(true);
@@ -0,0 +1,105 @@
package me.bobert.netneutrality.db;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.db.entities.OBlacklist;
import me.bobert.netneutrality.db.entities.OGuild;
import me.bobert.netneutrality.db.repository.BlacklistRepository;
import me.bobert.netneutrality.db.repository.GuildRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class AbstractRepositoryTest extends MySqlTestBase {
private GuildRepository guilds;
private BlacklistRepository blacklist;
@BeforeEach
void setUp() {
guilds = new GuildRepository(sessions());
blacklist = new BlacklistRepository(sessions());
}
@Test
void stringIdsFindAndDelete() {
guilds.findOrCreate("guild-1");
assertTrue(guilds.findById("guild-1").isPresent());
assertEquals(1, guilds.findAll().size());
assertTrue(guilds.deleteById("guild-1"));
assertTrue(guilds.findById("guild-1").isEmpty());
assertFalse(guilds.deleteById("guild-1"));
}
@Test
void integerIdsFindAndDelete() {
guilds.findOrCreate("guild-1");
blacklist.enableWord("guild-1", "user-1", "foo");
Integer id = blacklist.findByGuildId("guild-1").getFirst().getId();
assertEquals("foo", blacklist.findById(id).orElseThrow().getWord());
assertTrue(blacklist.deleteById(id));
assertTrue(blacklist.findById(id).isEmpty());
}
@Test
void saveReturnsMergedState() {
OGuild guild = guilds.findOrCreate("guild-1");
guild.setDebug(false);
OGuild saved = guilds.save(guild);
assertFalse(saved.getDebug());
assertFalse(guilds.findById("guild-1").orElseThrow().getDebug());
}
@Test
void failingWriteThrowsAndRollsBack() {
RuntimeException failure = new IllegalStateException("boom");
FailingGuildRepository failing = new FailingGuildRepository(sessions());
RuntimeException thrown = assertThrows(RuntimeException.class, () -> failing.insertThenFail("guild-1", failure));
assertEquals(failure, thrown);
assertTrue(guilds.findById("guild-1").isEmpty());
}
@Test
void constraintViolationPropagates() {
OGuild missing = new OGuild();
missing.setGuildId("does-not-exist");
OBlacklist orphan = new OBlacklist();
orphan.setGuild(missing);
orphan.setWord("foo");
orphan.setCreatorId("user-1");
orphan.setEnabled(true);
assertThrows(RuntimeException.class, () -> blacklist.save(orphan));
assertTrue(blacklist.findAll().isEmpty());
}
private static final class FailingGuildRepository extends AbstractRepository<OGuild, String> {
FailingGuildRepository(SessionProvider sessions) {
super(sessions, OGuild.class);
}
void insertThenFail(String guildId, RuntimeException failure) {
writeVoid(session -> {
OGuild guild = new OGuild();
guild.setGuildId(guildId);
session.persist(guild);
session.flush();
throw failure;
});
}
}
}
@@ -0,0 +1,61 @@
package me.bobert.netneutrality.db;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
/**
* Keeps the persistence layer free of Discord (JDA) and command-framework types.
*/
class DbLayeringTest {
private static final Path DB_SOURCES = Path.of("src/main/java/me/bobert/netneutrality/db");
private static final List<String> FORBIDDEN = List.of(
"import net.dv8tion.jda.",
"import me.bobert.netneutrality.core.command."
);
@Test
void servicesAndRepositoriesDoNotImportJdaOrCommandTypes() throws IOException {
List<String> violations;
try (Stream<Path> files = Stream.concat(Files.walk(DB_SOURCES.resolve("service")), Files.walk(DB_SOURCES.resolve("repository")))) {
violations = files
.filter(path -> path.toString().endsWith(".java"))
.flatMap(DbLayeringTest::violations)
.toList();
}
assertEquals(List.of(), violations);
}
@Test
void scanFindsSources() throws IOException {
try (Stream<Path> files = Files.walk(DB_SOURCES.resolve("service"))) {
assertFalse(files.filter(path -> path.toString().endsWith(".java")).toList().isEmpty());
}
}
private static Stream<String> violations(Path file) {
try {
return Files.readAllLines(file).stream()
.map(String::trim)
.filter(line -> FORBIDDEN.stream().anyMatch(line::startsWith))
.map(line -> file.getFileName() + ": " + line);
} catch (IOException e) {
throw new IllegalStateException(e);
}
}
}
@@ -0,0 +1,69 @@
package me.bobert.netneutrality.db;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.mysql.MySQLContainer;
import java.util.List;
/**
* Shared base for database integration tests.
*
* <p>Starts one throwaway MySQL 8 container for the whole test run and a real {@link SessionManager}
* on top of it, so Flyway migrations and Hibernate schema validation run exactly as in production.
* Tests are skipped when Docker is not available. Every table is emptied before each test.
*/
@Testcontainers(disabledWithoutDocker = true)
public abstract class MySqlTestBase {
private static final List<String> TABLES = List.of(
"blacklist", "guild_settings", "logging", "log_message", "tasks", "web_users", "guilds");
private static MySQLContainer mysql;
private static SessionManager sessionManager;
@BeforeAll
static void startDatabase() {
synchronized (MySqlTestBase.class) {
if (sessionManager != null) {
return;
}
// V1 runs CREATE DATABASE, so connect as root to the real schema name.
mysql = new MySQLContainer("mysql:8.4")
.withDatabaseName("netneutrality")
.withUsername("root")
.withPassword("test");
mysql.start();
SessionManager manager = new SessionManager(databaseConfig(), false);
manager.start();
sessionManager = manager;
}
}
protected static DatabaseConfig databaseConfig() {
return new DatabaseConfig(mysql.getJdbcUrl(), mysql.getUsername(), mysql.getPassword());
}
protected static SessionManager sessions() {
return sessionManager;
}
@BeforeEach
void cleanDatabase() {
sessionManager.getSessionFactory().inTransaction(session -> {
session.createNativeMutationQuery("SET FOREIGN_KEY_CHECKS = 0").executeUpdate();
for (String table : TABLES) {
session.createNativeMutationQuery("DELETE FROM `" + table + "`").executeUpdate();
}
session.createNativeMutationQuery("SET FOREIGN_KEY_CHECKS = 1").executeUpdate();
});
}
}
@@ -0,0 +1,34 @@
package me.bobert.netneutrality.db;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
/**
* Database-free checks of the {@link SessionManager} / {@link DatabaseConfig} contract.
*/
class SessionManagerLifecycleTest {
@Test
void getSessionFactoryBeforeStartThrows() {
SessionManager manager = new SessionManager(new DatabaseConfig("jdbc:mysql://localhost/x", "u", "p"), false);
assertThrows(IllegalStateException.class, manager::getSessionFactory);
assertDoesNotThrow(manager::stop);
}
@Test
void configNeverPrintsPassword() {
DatabaseConfig config = new DatabaseConfig("jdbc:mysql://localhost/x", "user", "hunter2");
assertFalse(config.toString().contains("hunter2"));
}
}
@@ -0,0 +1,36 @@
package me.bobert.netneutrality.db;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
class SessionManagerTest extends MySqlTestBase {
@Test
void startAppliesMigrationsAndValidatesSchema() {
assertFalse(sessions().getSessionFactory().isClosed());
Long applied = sessions().getSessionFactory().fromSession(session -> session
.createNativeQuery("SELECT COUNT(*) FROM flyway_schema_history WHERE success = 1", Long.class)
.getSingleResult());
assertEquals(2L, applied);
}
@Test
void startFailsClearlyForUnreachableDatabase() {
SessionManager manager = new SessionManager(
new DatabaseConfig("jdbc:mysql://localhost:1/none?connectTimeout=500", "nobody", "secret"), false);
IllegalStateException e = assertThrows(IllegalStateException.class, manager::start);
assertEquals("Unable to connect to the database", e.getMessage());
assertThrows(IllegalStateException.class, manager::getSessionFactory);
}
}
@@ -0,0 +1,60 @@
package me.bobert.netneutrality.db.entities;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import org.junit.jupiter.api.Test;
import java.util.HashSet;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class EntityEqualityTest {
@Test
void sameIdIsEqual() {
OBlacklist a = new OBlacklist();
a.setId(1);
OBlacklist b = new OBlacklist();
b.setId(1);
assertEquals(a, b);
assertEquals(a.hashCode(), b.hashCode());
}
@Test
void transientInstancesAreOnlyEqualToThemselves() {
OBlacklist a = new OBlacklist();
OBlacklist b = new OBlacklist();
assertEquals(a, a);
assertNotEquals(a, b);
}
@Test
void hashIsStableWhenIdIsAssigned() {
OGuildSetting setting = new OGuildSetting();
Set<OGuildSetting> set = new HashSet<>();
set.add(setting);
setting.setId(42);
assertTrue(set.contains(setting));
}
@Test
void differentTypesAreNotEqual() {
OGuild guild = new OGuild();
guild.setGuildId("1");
OWebUser user = new OWebUser();
user.setId("1");
assertNotEquals(guild, user);
}
}
@@ -0,0 +1,76 @@
package me.bobert.netneutrality.db.repository;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.db.MySqlTestBase;
import me.bobert.netneutrality.db.entities.OBlacklist;
import me.bobert.netneutrality.db.repository.BlacklistRepository.EnableOutcome;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class BlacklistRepositoryTest extends MySqlTestBase {
private static final String GUILD = "guild-1";
private BlacklistRepository blacklist;
private GuildRepository guilds;
@BeforeEach
void setUp() {
blacklist = new BlacklistRepository(sessions());
guilds = new GuildRepository(sessions());
guilds.findOrCreate(GUILD);
}
@Test
void addThenDeleteRemovesRow() {
assertEquals(EnableOutcome.CREATED, blacklist.enableWord(GUILD, "user-1", "foo"));
assertEquals(Set.of("foo"), blacklist.findEnabledWords(GUILD));
// Regression: the delete used to run on a detached entity and silently fail.
assertTrue(blacklist.deleteWord(GUILD, "FOO"));
assertTrue(blacklist.findByGuildId(GUILD).isEmpty());
assertFalse(blacklist.deleteWord(GUILD, "foo"));
}
@Test
void addingExistingWordIsUnchanged() {
blacklist.enableWord(GUILD, "user-1", "foo");
assertEquals(EnableOutcome.UNCHANGED, blacklist.enableWord(GUILD, "user-2", "Foo"));
assertEquals(1, blacklist.findByGuildId(GUILD).size());
}
@Test
void addingDisabledWordReenablesIt() {
OBlacklist disabled = new OBlacklist();
disabled.setGuild(guilds.findById(GUILD).orElseThrow());
disabled.setWord("bar");
disabled.setCreatorId("user-1");
disabled.setEnabled(false);
blacklist.save(disabled);
assertTrue(blacklist.findEnabledWords(GUILD).isEmpty());
assertEquals(EnableOutcome.ENABLED, blacklist.enableWord(GUILD, "user-2", "bar"));
assertEquals(Set.of("bar"), blacklist.findEnabledWords(GUILD));
assertEquals(1, blacklist.findByGuildId(GUILD).size());
}
@Test
void wordsAreScopedPerGuild() {
guilds.findOrCreate("guild-2");
blacklist.enableWord(GUILD, "user-1", "foo");
assertTrue(blacklist.findEnabledWords("guild-2").isEmpty());
assertFalse(blacklist.deleteWord("guild-2", "foo"));
}
}
@@ -0,0 +1,70 @@
package me.bobert.netneutrality.db.repository;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.db.GSettings;
import me.bobert.netneutrality.db.MySqlTestBase;
import me.bobert.netneutrality.db.entities.OGuildSetting;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class GuildSettingRepositoryTest extends MySqlTestBase {
private static final String GUILD = "guild-1";
private GuildSettingRepository settings;
@BeforeEach
void setUp() {
settings = new GuildSettingRepository(sessions());
new GuildRepository(sessions()).findOrCreate(GUILD);
}
@Test
void setUpdateAndResetPersist() {
settings.upsertOrDelete(GUILD, GSettings.MUSIC_VOLUME, "50");
assertEquals("50", single().getValue());
// Regression: updates used to run without a transaction and were never committed.
settings.upsertOrDelete(GUILD, GSettings.MUSIC_VOLUME, "70");
assertEquals("70", single().getValue());
settings.upsertOrDelete(GUILD, GSettings.MUSIC_VOLUME, GSettings.MUSIC_VOLUME.getDefaultValue());
assertTrue(settings.findByGuild(GUILD).isEmpty());
}
@Test
void nullValueDeletesSetting() {
settings.upsertOrDelete(GUILD, GSettings.MUSIC_ROLE, "role-1");
settings.upsertOrDelete(GUILD, GSettings.MUSIC_ROLE, null);
assertTrue(settings.findByGuild(GUILD).isEmpty());
}
@Test
void settingsAreIndependent() {
settings.upsertOrDelete(GUILD, GSettings.MUSIC_ROLE, "role-1");
settings.upsertOrDelete(GUILD, GSettings.MUSIC_CHANNEL, "channel-1");
settings.upsertOrDelete(GUILD, GSettings.MUSIC_ROLE, null);
OGuildSetting remaining = single();
assertEquals(GSettings.MUSIC_CHANNEL.getName(), remaining.getName());
assertEquals("channel-1", remaining.getValue());
}
private OGuildSetting single() {
List<OGuildSetting> all = settings.findByGuild(GUILD);
assertEquals(1, all.size());
return all.getFirst();
}
}
@@ -0,0 +1,64 @@
package me.bobert.netneutrality.db.repository;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.db.MySqlTestBase;
import me.bobert.netneutrality.db.entities.OLogMessage;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class MessageRepositoryTest extends MySqlTestBase {
private static final String GUILD = "guild-1";
private MessageRepository messages;
@BeforeEach
void setUp() {
messages = new MessageRepository(sessions());
new GuildRepository(sessions()).findOrCreate(GUILD);
}
@Test
void unknownMessageIsEmpty() {
assertTrue(messages.findByMessageId("nope").isEmpty());
assertTrue(messages.findMessageContent("nope").isEmpty());
assertFalse(messages.updateEditedMessage("nope", "edited"));
}
@Test
void logAndEdit() {
messages.log(GUILD, "channel-1", "user-1", "message-1", " hello ", Instant.now());
assertEquals("hello", messages.findMessageContent("message-1").orElseThrow());
assertTrue(messages.updateEditedMessage("message-1", "hello!"));
assertEquals("hello!", messages.findByMessageId("message-1").orElseThrow().getEditedMessage());
assertTrue(messages.deleteByMessageId("message-1"));
assertTrue(messages.findByMessageId("message-1").isEmpty());
}
@Test
void findByUserReturnsNewestFirst() {
Instant now = Instant.now().truncatedTo(ChronoUnit.SECONDS);
messages.log(GUILD, "c", "user-1", "old", "old", now.minusSeconds(60));
messages.log(GUILD, "c", "user-1", "new", "new", now);
messages.log(GUILD, "c", "user-1", "mid", "mid", now.minusSeconds(30));
messages.log(GUILD, "c", "user-2", "other", "other", now);
List<String> ids = messages.findByUser("user-1", GUILD, 2).stream().map(OLogMessage::getMessageId).toList();
assertEquals(List.of("new", "mid"), ids);
}
}
@@ -0,0 +1,46 @@
package me.bobert.netneutrality.db.repository;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.db.MySqlTestBase;
import me.bobert.netneutrality.db.entities.OTask;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class TaskRepositoryTest extends MySqlTestBase {
private TaskRepository tasks;
@BeforeEach
void setUp() {
tasks = new TaskRepository(sessions());
}
@Test
void upsertInsertsThenUpdates() {
tasks.upsert("task-1", "emoji-reaction-role", "{\"v\":1}");
tasks.upsert("task-1", "emoji-reaction-role", "{\"v\":2}");
assertEquals(1, tasks.findAll().size());
OTask stored = tasks.findByTaskId("task-1").orElseThrow();
assertEquals("{\"v\":2}", stored.getPayload());
assertEquals("emoji-reaction-role", stored.getTaskClass());
}
@Test
void deleteByTaskId() {
tasks.upsert("task-1", "type", "{}");
assertTrue(tasks.deleteByTaskId("task-1"));
assertTrue(tasks.findByTaskId("task-1").isEmpty());
assertFalse(tasks.deleteByTaskId("task-1"));
}
}
@@ -0,0 +1,54 @@
package me.bobert.netneutrality.db.service;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.db.MySqlTestBase;
import me.bobert.netneutrality.db.repository.BlacklistRepository;
import me.bobert.netneutrality.db.repository.GuildRepository;
import me.bobert.netneutrality.db.repository.GuildSettingRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class BlacklistServiceTest extends MySqlTestBase {
private BlacklistService service;
@BeforeEach
void setUp() {
GuildService guildService = new GuildService("guild-1", new GuildRepository(sessions()),
new BlacklistRepository(sessions()), new GuildSettingRepository(sessions()));
service = guildService.getBlacklistService();
}
@Test
void resultSequence() {
assertEquals(BlacklistResult.ADDED, service.add("user-1", "foo"));
assertEquals(BlacklistResult.ALREADY_EXISTS, service.add("user-1", "foo"));
assertEquals(BlacklistResult.REMOVED, service.remove("foo"));
assertEquals(BlacklistResult.NOT_FOUND, service.remove("foo"));
}
@Test
void cacheFollowsChanges() {
service.add("user-1", "Foo");
assertEquals(List.of(new BlacklistEntry("Foo", true)), service.list());
assertTrue(service.containsBlacklistedWord("well, FOO bar"));
assertFalse(service.containsBlacklistedWord("foobar"));
service.remove("foo");
assertFalse(service.containsBlacklistedWord("foo"));
assertTrue(service.list().isEmpty());
}
}
@@ -0,0 +1,53 @@
package me.bobert.netneutrality.db.service;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.db.repository.BlacklistRepository;
import me.bobert.netneutrality.db.repository.GuildRepository;
import me.bobert.netneutrality.db.repository.GuildSettingRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertNotSame;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
class GuildServiceProviderImplTest {
private GuildServiceProviderImpl provider;
@BeforeEach
void setUp() {
provider = new GuildServiceProviderImpl(mock(GuildRepository.class), mock(BlacklistRepository.class),
mock(GuildSettingRepository.class));
}
@Test
void getCachesPerGuild() {
assertSame(provider.get("guild-1"), provider.get("guild-1"));
assertNotSame(provider.get("guild-1"), provider.get("guild-2"));
}
@Test
void evictBuildsNewServiceOnNextGet() {
GuildService first = provider.get("guild-1");
provider.evict("guild-1");
assertTrue(provider.snapshot().isEmpty());
assertNotSame(first, provider.get("guild-1"));
}
@Test
void snapshotIsReadOnly() {
provider.get("guild-1");
assertThrows(UnsupportedOperationException.class, () -> provider.snapshot().clear());
}
}
@@ -0,0 +1,71 @@
package me.bobert.netneutrality.db.service;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.db.GSettings;
import me.bobert.netneutrality.db.MySqlTestBase;
import me.bobert.netneutrality.db.repository.BlacklistRepository;
import me.bobert.netneutrality.db.repository.GuildRepository;
import me.bobert.netneutrality.db.repository.GuildSettingRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class GuildServiceTest extends MySqlTestBase {
private GuildRepository guilds;
private BlacklistRepository blacklist;
private GuildSettingRepository settings;
@BeforeEach
void setUp() {
guilds = new GuildRepository(sessions());
blacklist = new BlacklistRepository(sessions());
settings = new GuildSettingRepository(sessions());
}
private GuildService newService(String guildId) {
return new GuildService(guildId, guilds, blacklist, settings);
}
@Test
void findOrCreateGuildCreatesOnce() {
GuildService service = newService("guild-1");
service.findOrCreateGuild("guild-1");
newService("guild-1");
assertEquals(1, guilds.findAll().size());
assertTrue(service.isDebugEnabled());
}
@Test
void updateDebugModePersists() {
GuildService service = newService("guild-1");
service.updateDebugMode(false);
assertFalse(service.isDebugEnabled());
assertFalse(guilds.findById("guild-1").orElseThrow().getDebug());
}
@Test
void writesRecreateDeletedGuild() {
GuildService service = newService("guild-1");
service.deleteGuild();
assertTrue(guilds.findById("guild-1").isEmpty());
// Replaces the old tryLoad side channel: the cached service recreates its row on write.
service.getGuildSettingService().set(GSettings.MUSIC_VOLUME, "55");
assertTrue(guilds.findById("guild-1").isPresent());
assertEquals("55", service.getGuildSettingService().getOrDefault(GSettings.MUSIC_VOLUME));
}
}
@@ -0,0 +1,99 @@
package me.bobert.netneutrality.db.service;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.db.GSettings;
import me.bobert.netneutrality.db.entities.OGuildSetting;
import me.bobert.netneutrality.db.repository.GuildSettingRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class GuildSettingServiceTest {
private GuildService guildService;
private GuildSettingRepository repository;
@BeforeEach
void setUp() {
guildService = mock(GuildService.class);
repository = mock(GuildSettingRepository.class);
when(guildService.getGuildId()).thenReturn("guild-1");
}
@Test
void readersNeverSeeEmptyCacheDuringReload() throws Exception {
CountDownLatch reloading = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1);
when(repository.findByGuild("guild-1"))
.thenReturn(List.of(setting(GSettings.MUSIC_VOLUME, "50")))
.thenAnswer(invocation -> {
reloading.countDown();
assertTrue(release.await(5, TimeUnit.SECONDS));
return List.of(setting(GSettings.MUSIC_VOLUME, "70"));
});
GuildSettingService service = new GuildSettingService(guildService, repository);
CompletableFuture<Void> reload = CompletableFuture.runAsync(service::reloadSettingsCache);
assertTrue(reloading.await(5, TimeUnit.SECONDS));
// While the reload is in flight, readers still get the previous, complete snapshot.
assertEquals("50", service.getOrDefault(GSettings.MUSIC_VOLUME));
assertEquals(Map.of("MUSIC_VOLUME", "50"), service.snapshot());
release.countDown();
reload.get(5, TimeUnit.SECONDS);
assertEquals("70", service.getOrDefault(GSettings.MUSIC_VOLUME));
}
@Test
void settingsSnapshotIsUnmodifiable() {
when(repository.findByGuild("guild-1")).thenReturn(List.of(setting(GSettings.MUSIC_ROLE, "role-1")));
GuildSettingService service = new GuildSettingService(guildService, repository);
assertThrows(UnsupportedOperationException.class, () -> service.snapshot().put("x", "y"));
}
@Test
void missingSettingFallsBackToDefault() {
when(repository.findByGuild("guild-1")).thenReturn(List.of());
GuildSettingService service = new GuildSettingService(guildService, repository);
assertEquals(GSettings.MUSIC_MAXPLAYLISTSIZE.getDefaultValue(), service.getOrDefault(GSettings.MUSIC_MAXPLAYLISTSIZE));
assertTrue(service.get(GSettings.MUSIC_MAXPLAYLISTSIZE).isEmpty());
}
@Test
void setEnsuresGuildAndWritesThrough() {
when(repository.findByGuild("guild-1")).thenReturn(List.of());
GuildSettingService service = new GuildSettingService(guildService, repository);
service.set(GSettings.MUSIC_VOLUME, "40");
verify(guildService).ensureEntity();
verify(repository).upsertOrDelete("guild-1", GSettings.MUSIC_VOLUME, "40");
}
private static OGuildSetting setting(GSettings key, String value) {
OGuildSetting setting = new OGuildSetting();
setting.setName(key.getName());
setting.setValue(value);
return setting;
}
}
@@ -0,0 +1,63 @@
package me.bobert.netneutrality.db.service;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.core.tasks.PersistableTask;
import me.bobert.netneutrality.core.tasks.TaskManager;
import me.bobert.netneutrality.core.tasks.persistence.PersistableTaskRegistry;
import me.bobert.netneutrality.db.repository.TaskRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
class TaskServiceTest {
private PersistableTaskRegistry registry;
private TaskRepository repository;
private TaskService service;
private PersistableTask task;
@BeforeEach
void setUp() {
registry = mock(PersistableTaskRegistry.class);
repository = mock(TaskRepository.class);
service = new TaskService(mock(TaskManager.class), registry, repository);
task = mock(PersistableTask.class);
when(task.getId()).thenReturn("task-1");
when(task.typeId()).thenReturn("some-type");
}
@Test
void persistUnregisteredTypeIsNoop() {
when(registry.isRegistered("some-type")).thenReturn(false);
service.persistTask(task);
verifyNoInteractions(repository);
}
@Test
void persistRegisteredTypeUpserts() {
when(registry.isRegistered("some-type")).thenReturn(true);
when(task.toPayload()).thenReturn("{}");
service.persistTask(task);
verify(repository).upsert("task-1", "some-type", "{}");
}
@Test
void unpersistDeletesByTaskId() {
service.unpersistTask(task);
verify(repository).deleteByTaskId("task-1");
}
}
@@ -58,13 +58,13 @@ class WebApiTest {
when(jda.getGatewayPing()).thenReturn(12L);
settingService = mock(GuildSettingService.class);
when(settingService.getSettings()).thenReturn(Map.of("prefix", "ü!"));
when(settingService.snapshot()).thenReturn(Map.of("prefix", "ü!"));
GuildService guildService = mock(GuildService.class);
when(guildService.getGuildSettingService()).thenReturn(settingService);
GuildServiceProvider provider = mock(GuildServiceProvider.class);
when(provider.get(guild)).thenReturn(guildService);
when(provider.get(GUILD_ID)).thenReturn(guildService);
SettingsProvider settings = mock(SettingsProvider.class);
when(settings.get("NETNEUTRALITY_API_PORT", "9000")).thenReturn(String.valueOf(port));