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:
1 parent
62de32ecfc
commit
677b3f8013
60 files changed
+2376
-772
No files matched your search
@@ -0,0 +1,309 @@
|
||||
---
|
||||
sessionId: session-260925-080906-1d5c
|
||||
---
|
||||
|
||||
# Findings
|
||||
|
||||
### Correctness bugs (highest priority)
|
||||
| # | Where | Problem |
|
||||
|---|---|---|
|
||||
| B1 | `BlacklistRepository.delete/add` | `getWordFromDb` loads the entity in its **own session** (which it also closes twice). The caller then calls `session.remove(bl)` in another session on that **detached** entity, which Hibernate rejects with `IllegalArgumentException`. The exception is swallowed by `BlacklistService`, which returns `warn`. |
|
||||
| B2 | `GuildSettingRepository.setSettingValue` | Runs inside `JpaUtils.getWithSession`, which has **no transaction**, so `merge`/`remove` are never flushed or committed. Updating or resetting a setting is probably lost; only an IDENTITY `persist` happens to insert. |
|
||||
| B3 | `Repository<O>` | IDs are always `String`, but `OBlacklist.id` / `OGuildSetting.id` are `Integer`, so `deleteBy`/`findById` bind a type-mismatched parameter. `BlacklistRepository.findById` returns `null` outright. |
|
||||
| B4 | `Repository.save`, `JpaUtils.updateWithTransaction` | Every non-constraint exception is swallowed after `logger.error(e)`, so callers think the write succeeded. `runWithTransaction` never rolls back on failure. |
|
||||
| B5 | `JpaUtils.tryLoad` | Uses the deprecated `Session.load(Object, id)`. It closes a session it doesn't own and calls back into `GuildService` (a hidden circular dependency). |
|
||||
| B6 | `MessageRepository.getMessageString` | NPE when the message isn't found. |
|
||||
| B7 | `SessionManager.getSessionFactory` | Lazy initialization isn't thread-safe. The Hibernate factory is built **before** Flyway migrates, and the schema is never validated. |
|
||||
|
||||
### Design / best-practice issues
|
||||
- **Static service locator.** `JpaUtils.bind` / `bindGuildServiceLookup` (called from `App.java` l.119, l.138) hide dependencies and make repositories untestable.
|
||||
- **Duplicated CRUD.** Five repositories copy-paste `findAll` / `findById` / `deleteBy`. They use the "lambda + external `ArrayList`" workaround because the helpers can't return values.
|
||||
- **Duplicated JDBC config.** `SessionManager` and `DatabaseMigration` both build the URL, user and password from settings.
|
||||
- **Layering.** Repositories take JDA types (`Member`, `Message`, `MessageChannel`), and `MessageRepository.log` carries a `//TODO: move to a service`.
|
||||
- **Services create their own repositories** with `new`. `TaskService.persistTask`/`unpersistTask` even shadow the repository field it already holds.
|
||||
- **Caches.** `GuildSettingService.reloadSettingsCache` clears the map and then refills it, so readers can see it empty. `getSettings()` and `GuildServiceProviderImpl.getServices()` leak mutable maps. Guild services are never evicted.
|
||||
- **Logging mix.** The package uses both Log4j `LogManager` and SLF4J.
|
||||
- **Entities.** No `equals`/`hashCode`. The field is named `OLoggings`. `OWebUser` is missing from `hibernate.cfg.xml`.
|
||||
- **Hygiene.** Duplicated copyright headers and a broken `package ...;/*` line in `GuildRepository` and `GuildSettingRepository`. `GSettings` has a `//TODO: description`.
|
||||
- **No tests** for the `db` package.
|
||||
|
||||
# Requirements
|
||||
|
||||
### Overview & Goals
|
||||
Make the persistence layer (`me.bobert.netneutrality.db` and subpackages) correct, testable and idiomatic for Hibernate 7.1 / Java 25. Services become pure domain code with no JDA or command types. User-visible bot behavior stays the same except for the bug fixes (writes actually persist, errors surface).
|
||||
|
||||
> Chosen options: **Full redesign**, **injected generic base repository**, **Testcontainers MySQL** tests.
|
||||
|
||||
### Scope
|
||||
**In scope**
|
||||
- Fixes for bugs B1–B7 (see Findings).
|
||||
- Replace the static `JpaUtils` with constructor-injected `SessionProvider` and a generic `AbstractRepository<T, ID>`.
|
||||
- A single `DatabaseConfig` shared by Hibernate and Flyway. Migrate first, then build the factory with `hbm2ddl.auto=validate`.
|
||||
- Service cleanup: inject repositories, safe cache swaps, no JDA types in repositories.
|
||||
- **Decouple services from JDA and `CommandExecutionResult`.** Services take plain IDs and return domain results. The callers (`BlacklistCommand`, `ConfigureCommand`, `MusicCommand`, `MusicManager`, `EventsHandler`, `ActionRoutes`, `BotState`, `TaskManagerImpl`, `LoadPlaylistTask`) map those results to replies.
|
||||
- **Rework the guild-service cache.** `GuildServiceProvider` keys by guild ID only, evicts on `GuildLeaveEvent`, and exposes read-only snapshots.
|
||||
- Entity hygiene and consistent logging (Log4j API, matching `App` and `SessionManager`).
|
||||
- Testcontainers-based integration tests.
|
||||
|
||||
**Out of scope**
|
||||
- Switching to Spring or JPA `EntityManager`, or changing the Flyway schema (a migration is added only if validation shows an actual mismatch).
|
||||
- Changing command UX: slash command names and options, and the reply wording (messages stay equivalent).
|
||||
- Changes to `CommandExecutionResult` itself or to the command framework (`core.command`).
|
||||
|
||||
### Functional Requirements
|
||||
- Adding or removing a blacklist word persists, and the cache reflects it right away.
|
||||
- Setting a guild setting to a non-default value inserts or updates it. Setting it to the default or `null` deletes the row. All of this happens in one transaction.
|
||||
- Persistence failures propagate as exceptions and are rolled back. They are never silently logged.
|
||||
- When the bot starts: Flyway migrates first, then Hibernate validates the schema. If either fails, startup aborts with a clear error (Sentry is kept for non-dev environments). The password is never logged.
|
||||
- A missing `OGuild` gets recreated through `GuildService.findOrCreateGuild` instead of the `tryLoad` side channel.
|
||||
- No class in `db.service` or `db.repository` imports `net.dv8tion.jda.*` or `me.bobert.netneutrality.core.command.*`.
|
||||
- The blacklist add/remove replies stay equivalent: success, "already exists" and "not found" become distinct outcomes, and unexpected errors are shown as a warning by the command.
|
||||
- When the bot leaves a guild, its row is deleted and its cached `GuildService` is evicted.
|
||||
|
||||
# Technical Design
|
||||
|
||||
### Key Decisions
|
||||
1. **Constructor injection instead of a static locator.** Repositories receive the `SessionProvider` (not the `SessionFactory`), because the factory is only built in `start()`. `JpaUtils` is deleted.
|
||||
2. **Use Hibernate 7's native helpers** `SessionFactory.fromTransaction` / `inTransaction` / `fromSession`. They handle begin, commit, rollback and close correctly, so no hand-written transaction code is needed.
|
||||
3. **Generic ID type.** `AbstractRepository<T, ID>` takes the entity class and uses `session.find`, `merge` and `remove` on a managed instance.
|
||||
4. **Queries and writes run in the same session.** This fixes B1 and B2 by construction.
|
||||
5. **Migrate, then validate.** Flyway runs before the factory is built, and `hibernate.hbm2ddl.auto=validate` catches drift between entities and schema.
|
||||
6. **Services return domain outcomes; commands turn them into replies.** Services return small enums/records such as `BlacklistResult { ADDED, REENABLED, ALREADY_EXISTS, REMOVED, NOT_FOUND }` and never `CommandExecutionResult`. They take `String` IDs, not `Member`/`Guild`/`Message`. Unexpected persistence errors propagate, and the command layer catches them and shows a warning.
|
||||
7. **ID-only provider API.** `GuildServiceProvider.get(Guild)` is removed (callers pass `guild.getId()`). The provider adds `evict(String)`, and `getServices()` becomes `snapshot()`, which returns `Map.copyOf`.
|
||||
|
||||
### Data Models / Contracts
|
||||
```java
|
||||
public record DatabaseConfig(String url, String user, String password) {
|
||||
static DatabaseConfig from(SettingsProvider s) { ... } // MYSQL_* keys, same defaults
|
||||
}
|
||||
|
||||
public abstract class AbstractRepository<T, ID> {
|
||||
protected final SessionProvider sessions;
|
||||
private final Class<T> type;
|
||||
protected AbstractRepository(SessionProvider sessions, Class<T> type)
|
||||
public List<T> findAll() // fromSession + criteria/HQL
|
||||
public Optional<T> findById(ID id) // session.find
|
||||
public T save(T entity) // fromTransaction(s -> s.merge(entity))
|
||||
public void deleteById(ID id) // inTransaction: find + remove
|
||||
protected <R> R read(Function<Session, R> work) // sf.fromSession
|
||||
protected <R> R write(Function<Session, R> work) // sf.fromTransaction
|
||||
protected void writeVoid(Consumer<Session> work) // sf.inTransaction
|
||||
}
|
||||
```
|
||||
Repositories and their key queries:
|
||||
- `GuildRepository extends AbstractRepository<OGuild, String>`.
|
||||
- `BlacklistRepository<OBlacklist, Integer>`:
|
||||
- `findByGuildId`
|
||||
- `findEnabledWords(guildId)` returns `Set<String>`
|
||||
- `enableWord(OGuild, creatorId, word)`
|
||||
- `deleteWord(guildId, word)`
|
||||
- `GuildSettingRepository<OGuildSetting, Integer>`:
|
||||
- `findByGuild`
|
||||
- `upsertOrDelete(guildId, name, value, default)`, which queries the setting directly instead of using the lazy collection.
|
||||
- `TaskRepository<OTask, String>`.
|
||||
- `MessageRepository<OLogMessage, String>`:
|
||||
- `findByMessageId`
|
||||
- `updateEditedMessage`
|
||||
- `findByUser(... ORDER BY dateSent DESC)`
|
||||
- `log(...)` takes plain values (IDs, content, `Instant`) instead of JDA types.
|
||||
|
||||
Service contracts after the redesign:
|
||||
```java
|
||||
public enum BlacklistResult { ADDED, REENABLED, ALREADY_EXISTS, REMOVED, NOT_FOUND }
|
||||
|
||||
public class BlacklistService {
|
||||
BlacklistResult add(String creatorUserId, String word);
|
||||
BlacklistResult remove(String word);
|
||||
List<BlacklistEntry> list(); // record BlacklistEntry(String word, boolean enabled)
|
||||
boolean containsBlacklistedWord(String text);
|
||||
}
|
||||
|
||||
public class GuildSettingService {
|
||||
void set(GSettings key, @Nullable String value); // null/default => delete
|
||||
Optional<String> get(GSettings key);
|
||||
Map<String, String> snapshot(); // unmodifiable
|
||||
}
|
||||
|
||||
public interface GuildServiceProvider {
|
||||
GuildService get(String guildId);
|
||||
void evict(String guildId);
|
||||
Map<String, GuildService> snapshot();
|
||||
}
|
||||
```
|
||||
|
||||
### Proposed Changes
|
||||
- **`SessionManager`**
|
||||
- Hold a `DatabaseConfig`.
|
||||
- `start()` runs `DatabaseMigration.migrate(config)` and then builds the factory eagerly (`synchronized` / `volatile`).
|
||||
- `getSessionFactory()` throws if called before `start()`.
|
||||
- Remove `JpaUtils.bind`.
|
||||
- Add `hibernate.hbm2ddl.auto=validate` to `hibernate.cfg.xml` and map `OWebUser` there if it has a table.
|
||||
- **`DatabaseMigration`**: takes `DatabaseConfig`.
|
||||
- **Services** get their dependencies through constructors:
|
||||
- `GuildService(String guildId, GuildRepository, BlacklistRepository, GuildSettingRepository)`.
|
||||
- `updateDebugMode` becomes `oGuild = guildRepository.save(modified)`.
|
||||
- `BlacklistService.reloadBlacklistCache` uses `findEnabledWords` (no `tryLoad`).
|
||||
- `GuildSettingService` builds a new map and swaps it into a `volatile` field. `getSettings()` returns an unmodifiable view.
|
||||
- `TaskService` receives `TaskRepository` and uses the field. It parses the payload once.
|
||||
- **`GuildServiceProvider` / `GuildServiceProviderImpl`** follow the ID-only API: the provider is constructed with the repositories, uses `ConcurrentHashMap.computeIfAbsent`, and has `evict` and `snapshot`.
|
||||
- **Callers updated to the new service API:**
|
||||
- `BlacklistCommand` maps `BlacklistResult` to success/warn replies, passes `context.getGuildUser().getId()`, and lists entries through `list()`.
|
||||
- `ConfigureCommand` uses `set(...)` / `snapshot()`.
|
||||
- `MusicCommand`, `MusicManager`, `MusicServiceImpl`, `ActionRoutes`, `BotState`, `LoadPlaylistTask` and `TaskManagerImpl` use `get(String)` and `snapshot()`.
|
||||
- `EventsHandler`: on `GuildLeaveEvent` it calls `deleteGuild()` and then `evict(id)`.
|
||||
- **`App.java`**:
|
||||
- Build the repositories from the registered `SessionManager`.
|
||||
- Pass them to `GuildServiceProviderImpl` and `TaskService`.
|
||||
- Remove `JpaUtils.bindGuildServiceLookup`.
|
||||
- Update the callers of `MessageRepository.log`.
|
||||
- **Entities**:
|
||||
- Add ID-based `equals`/`hashCode` (with a null-safe `getClass`/proxy-aware pattern).
|
||||
- Rename `OLoggings` to `loggings`.
|
||||
- **Cleanup**: fix the headers, standardize on the Log4j API, and document `GSettings`.
|
||||
|
||||
### File Structure
|
||||
- **New**:
|
||||
- `db/DatabaseConfig.java`
|
||||
- `db/service/BlacklistResult.java`, `db/service/BlacklistEntry.java`
|
||||
- `db/AbstractRepository.java` (replaces `db/Repository.java`)
|
||||
- `src/test/java/me/bobert/netneutrality/db/**` tests
|
||||
- **Modified**:
|
||||
- `SessionManager`, `SessionProvider` (if needed), `DatabaseMigration`
|
||||
- all 5 repositories, all services, `GuildServiceProvider`
|
||||
- the callers: `BlacklistCommand`, `ConfigureCommand`, `MusicCommand`, `MusicManager`, `MusicServiceImpl`, `EventsHandler`, `ActionRoutes`, `BotState`, `TaskManagerImpl`, `LoadPlaylistTask`
|
||||
- the entities, `hibernate.cfg.xml`, `App.java`, `pom.xml` (test dependencies)
|
||||
- **Deleted**: `db/JpaUtils.java`, `db/Repository.java`.
|
||||
|
||||
### Architecture Diagram
|
||||
```mermaid
|
||||
graph LR
|
||||
App --> SessionManager
|
||||
SessionManager -->|migrate first| DatabaseMigration
|
||||
SessionManager -->|builds| SF[SessionFactory]
|
||||
App --> Repos[AbstractRepository subclasses]
|
||||
Repos -->|fromTransaction| SF
|
||||
App --> GSP[GuildServiceProviderImpl]
|
||||
GSP --> GuildService
|
||||
GuildService --> BlacklistService
|
||||
GuildService --> GuildSettingService
|
||||
BlacklistService --> Repos
|
||||
GuildSettingService --> Repos
|
||||
App --> TaskService
|
||||
TaskService --> Repos
|
||||
```
|
||||
|
||||
### Risks
|
||||
- **`validate` may fail on existing drift** between the entities and V1/V2. Mitigation: the integration test catches it; fix the mapping or add `V3__*.sql`.
|
||||
- **Callers relied on swallowed exceptions.** Commands such as `BlacklistCommand` and `ConfigureCommand` catch them and show a warning reply. The listener and `LogHandler` paths are reviewed so errors stay logged and don't crash event handling.
|
||||
- **Wide caller change set** from the API redesign. Mitigation: do it in its own stage, keep the method semantics identical, and rely on the compiler and `./mvnw test` to catch every call site.
|
||||
- **Lazy associations accessed outside a session** (for example `OGuild.getBlacklists()` elsewhere). Mitigation: search for the getters and replace them with repository queries.
|
||||
|
||||
# Testing
|
||||
|
||||
### Validation Approach
|
||||
- Run `./mvnw test` after each stage. Existing tests (for example `ApplicationContextTest`) must keep passing.
|
||||
- Add the `org.testcontainers:mysql` and `junit-jupiter` test dependencies.
|
||||
- Add a shared `MySqlTestBase` that:
|
||||
- starts `mysql:8`
|
||||
- builds a `DatabaseConfig` / `SettingsProvider` stub
|
||||
- starts a real `SessionManager` (Flyway plus validate)
|
||||
- uses `@Testcontainers(disabledWithoutDocker = true)`
|
||||
|
||||
### Key Scenarios
|
||||
- `SessionManager` starts against the container: the migrations apply and schema validation passes.
|
||||
- `BlacklistRepository`: adding a word and then deleting it removes the row (regression for B1). Re-adding a disabled word re-enables it.
|
||||
- `GuildSettingRepository`: setting, updating and resetting to default each persist the expected state (regression for B2).
|
||||
- `AbstractRepository`: `findById` / `deleteById` work with `Integer` and `String` IDs (B3). A failing write throws and leaves no partial data (B4).
|
||||
- `GuildService.findOrCreateGuild` creates the guild once, and a second call returns the existing row.
|
||||
- `GuildSettingService`: readers never observe an empty cache during a reload (unit test with Mockito).
|
||||
- `BlacklistService` returns `ADDED`, then `ALREADY_EXISTS`, then `REMOVED`, then `NOT_FOUND` for the matching sequence of calls (container test).
|
||||
- `BlacklistCommand` maps each `BlacklistResult` (and a thrown exception) to the expected reply type (Mockito unit test).
|
||||
- `GuildServiceProviderImpl.evict` removes the cached service, and the next `get` builds a new one.
|
||||
- Architecture check: a test scans `db.service` / `db.repository` classes to confirm there are no `net.dv8tion.jda` or `core.command` imports (a simple source-scan test).
|
||||
|
||||
### Edge Cases
|
||||
- `MessageRepository.findByMessageId` for an unknown ID returns empty (no NPE).
|
||||
- `SessionManager.getSessionFactory()` before `start()` throws `IllegalStateException`.
|
||||
- `TaskService.persistTask` with an unregistered type is a no-op.
|
||||
|
||||
# Delivery Steps
|
||||
|
||||
### ✓ Step 1: Rework session bootstrap and add a generic base repository
|
||||
SessionManager migrates first, then builds a validated SessionFactory, and repositories can extend a type-safe injected base class.
|
||||
|
||||
- Add `db/DatabaseConfig` record built from the `MYSQL_*` settings. Use it in both `SessionManager` and `DatabaseMigration`.
|
||||
- `SessionManager.start()`:
|
||||
- runs Flyway first
|
||||
- then builds the factory eagerly and thread-safely
|
||||
- keeps the Sentry/dev logic
|
||||
- does not log the password
|
||||
- `getSessionFactory()` throws before `start()`.
|
||||
- Add `hibernate.hbm2ddl.auto=validate` to `hibernate.cfg.xml` and map `OWebUser` if it has a table.
|
||||
- Add `AbstractRepository<T, ID>`, which takes a `SessionProvider` and implements:
|
||||
- `findAll`
|
||||
- `findById` returning `Optional`
|
||||
- `save` returning the merged entity
|
||||
- `deleteById`
|
||||
- `read`/`write` helpers built on `fromSession`/`fromTransaction`/`inTransaction`
|
||||
- Add the Testcontainers test dependencies and a `MySqlTestBase`, plus a startup and schema validation test.
|
||||
|
||||
### ✓ Step 2: Migrate repositories onto the base class and fix persistence bugs
|
||||
All five repositories use injected sessions with correct ID types, and the blacklist and setting write bugs are fixed and covered by tests.
|
||||
|
||||
- Convert `GuildRepository`, `BlacklistRepository`, `GuildSettingRepository`, `TaskRepository` and `MessageRepository` to `AbstractRepository` with proper ID types.
|
||||
- Blacklist:
|
||||
- `enableWord` / `deleteWord` query and modify inside the same transaction (fixes the detached remove).
|
||||
- Add `findEnabledWords(guildId)`.
|
||||
- Guild settings: `upsertOrDelete` runs in a transaction and queries the setting directly.
|
||||
- Messages:
|
||||
- JDA-free `log(...)`
|
||||
- `updateEditedMessage`
|
||||
- `Optional` lookups
|
||||
- ordered `findByUser`
|
||||
- Remove the swallowed-exception patterns and fix the duplicated headers and package lines.
|
||||
- Add repository integration tests for the blacklist, settings, ID types and rollback scenarios.
|
||||
|
||||
### ✓ Step 3: Refactor services and wiring, and remove JpaUtils
|
||||
Services receive their repositories through constructors, caches are thread-safe, and the static `JpaUtils` and `Repository` classes are gone.
|
||||
|
||||
- `GuildService`:
|
||||
- takes its repositories through the constructor
|
||||
- `updateDebugMode` uses `save`
|
||||
- `findOrCreateGuild` handles missing guilds, replacing `tryLoad`
|
||||
- `BlacklistService` reloads its cache through `findEnabledWords`.
|
||||
- `GuildSettingService` swaps in a new cache map and exposes it unmodifiable.
|
||||
- `TaskService`:
|
||||
- uses the injected `TaskRepository` field
|
||||
- parses the payload once
|
||||
- `GuildServiceProviderImpl` is constructed with the repositories and uses `computeIfAbsent`. The API changes come in Step 4.
|
||||
- Update `App.java`:
|
||||
- build the repositories from `SessionManager`
|
||||
- remove `JpaUtils.bindGuildServiceLookup`
|
||||
- update the `MessageRepository.log` callers
|
||||
- Delete `JpaUtils.java` and `Repository.java`. Replace any lazy-collection access outside a session with repository queries.
|
||||
- Add service tests: Mockito for the caches, and the container test for `findOrCreateGuild`.
|
||||
|
||||
### ✓ Step 4: Decouple services from JDA and command types, and update callers
|
||||
Services expose JDA-free, ID-based domain APIs, and every caller maps domain results to replies.
|
||||
|
||||
- Add `BlacklistResult` and `BlacklistEntry`. `BlacklistService.add/remove/list` take plain IDs and return domain results instead of `CommandExecutionResult`.
|
||||
- `GuildSettingService` exposes `set` / `get` / `snapshot`.
|
||||
- `GuildServiceProvider` becomes ID-only: remove `get(Guild)`, add `evict`, and replace `getServices` with `snapshot`.
|
||||
- Update the callers:
|
||||
- `BlacklistCommand` maps results and exceptions to replies
|
||||
- `ConfigureCommand`
|
||||
- `MusicCommand`, `MusicManager`, `MusicServiceImpl`
|
||||
- `ActionRoutes`, `BotState`
|
||||
- `TaskManagerImpl`, `LoadPlaylistTask`
|
||||
- `EventsHandler` evicts on guild leave
|
||||
- Add tests: the `BlacklistService` result sequence (container), `BlacklistCommand` reply mapping (Mockito), provider eviction, and the no-JDA-imports source scan.
|
||||
|
||||
### ✓ Step 5: Entity and package hygiene
|
||||
Entities follow JPA best practices and the package uses consistent conventions, with the full test suite green.
|
||||
|
||||
- Add ID-based, proxy-safe `equals`/`hashCode` to the entities.
|
||||
- Rename `OGuild.OLoggings` to `loggings`.
|
||||
- Standardize logging on the Log4j API across `db.service`.
|
||||
- Document the `GSettings` entries by resolving the TODO.
|
||||
- Re-run schema validation. If entity/schema drift shows up, add a `V3__*.sql` migration or fix the mapping.
|
||||
- Run `./mvnw test` to confirm the existing and new tests pass.
|
||||
@@ -18,6 +18,7 @@
|
||||
<maven.compiler.source>25</maven.compiler.source>
|
||||
<maven.compiler.target>25</maven.compiler.target>
|
||||
<mockito.version>5.23.0</mockito.version>
|
||||
<testcontainers.version>2.0.5</testcontainers.version>
|
||||
</properties>
|
||||
|
||||
<build>
|
||||
@@ -273,6 +274,18 @@
|
||||
<version>${mockito.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>testcontainers-junit-jupiter</artifactId>
|
||||
<version>${testcontainers.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>testcontainers-mysql</artifactId>
|
||||
<version>${testcontainers.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
</project>
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
@@ -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));
|
||||
|
||||
Reference in new issue
Block a user