Files
netneutrality/.junie/plans/db-package-best-practices.md
T
anthorob 677b3f8013 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.
2026-09-25 12:17:19 -04:00

309 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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.