Refactor repository layer using Jakarta Data

Replaced custom repository logic with Jakarta Data interfaces to reduce boilerplate and enhance compile-time query validation. Removed `AbstractRepository` and introduced `TransactionRunner` for consistent transactional support. Updated all repository facades to delegate query operations to generated interfaces while retaining the same API, ensuring backward compatibility. Added dependencies for `jakarta.data-api` and Hibernate's `hibernate-processor` for annotation-based query generation. Verified functional parity and improved testability across the repository layer.
This commit is contained in:
anthorob committed 2026-09-25 12:48:48 -04:00
1 parent 677b3f8013
commit bceab4b2b9
16 files changed
+598 -224

No files matched your search

+117
View File
@@ -0,0 +1,117 @@
---
sessionId: session-260925-095225-4rh7
---
# Requirements
### Overview & Goals
Swap the hand-written HQL in `db/repository/*` for **Jakarta Data 1.0** repository interfaces. Hibernate's `hibernate-processor` generates the implementations at compile time. Queries become compile-time checked and the boilerplate goes away. The public repository API used by `App`, services and tests stays the same.
### Scope
**In scope**
- Build setup: `jakarta.data-api` plus `hibernate-processor` as an annotation processor.
- Generated query interfaces for `OGuild`, `OGuildSetting`, `OTask`, `OBlacklist` and `OLogMessage`.
- The existing repository classes become thin transactional facades over the generated code.
- `AbstractRepository` is removed.
**Out of scope**
- Moving to Spring or CDI.
- Schema or Flyway changes.
- Changing any service behaviour.
### Functional Requirements
- Every current public method keeps the same signature and behaviour. This includes `findOrCreate`, `upsertOrDelete`, `upsert`, `enableWord`, `deleteWord`, `deleteByTaskId`, `findEnabledWords`, `findAll`, `findById`, `save` and `deleteById`.
- Each read-modify-write operation still runs in a single transaction. On error the transaction rolls back and the exception is rethrown.
- An invalid query must fail `mvn compile`.
# Technical Design
### Current Implementation
- `AbstractRepository<T,ID>` gives each repository `read`/`write`/`writeVoid` helpers over a stateful `Session`, using `SessionFactory.fromSession`/`fromTransaction`.
- The repositories build HQL inline (for example `TaskRepository.findByTaskId`) or use named queries (`settings_findByName`).
- They are wired in `App.java` (lines 122-124 and 138) as `new XRepository(sessions)`. Tests construct them the same way.
### Key Decisions
1. **Generated interfaces run on a `StatelessSession`.** This is the Hibernate Data default: the generated `*_` class takes a `StatelessSession` in its constructor. There's no container, so transactions stay our responsibility.
2. **The facade pattern keeps the public API.** Classes like `GuildSettingRepository` stay as the public API. Each call opens a stateless transaction and creates the generated DAO. `App`, the services and the tests don't change.
3. **No dirty checking, so writes are explicit.** Code that changes a loaded entity must call `@Update`. New rows are written with `@Insert`, and removals use `@Delete` or a `@Query("delete ...")`. `session.getReference(OGuild…)` becomes a detached `OGuild` with only its id set, which is enough for the foreign key on insert.
### Proposed Changes
- `db/TransactionRunner.java` (new) replaces `AbstractRepository`. It exposes `<R> R inTx(Function<StatelessSession,R>)` using `SessionFactory.fromStatelessTransaction`, and `read(...)` using `fromStatelessSession`.
- `db/data/*Queries.java` (new) holds the `@Repository` interfaces, for example:
```java
@Repository
public interface TaskQueries extends CrudRepository<OTask,Integer> {
@Find Optional<OTask> byTaskId(String taskId);
@Query("delete from OTask where taskId = :taskId") int deleteByTaskId(String taskId);
}
@Repository
public interface GuildSettingQueries extends CrudRepository<OGuildSetting,Integer> {
@Query("where guild.guildId = :guildId") List<OGuildSetting> byGuild(String guildId);
@Query("where guild.guildId = :id and name = :name") List<OGuildSetting> byName(String id, String name);
}
```
- Repository facades, for example `upsertOrDelete`:
```java
tx.inTx(ss -> { var q = new GuildSettingQueries_(ss); var ex = q.byName(id, s.getName());
if (reset) { q.deleteAll(ex); return null; }
if (ex.isEmpty()) q.insert(newSetting(id, s, value)); else { ex.forEach(e -> e.setValue(value)); q.updateAll(ex); }
return null; });
```
- `BlacklistQueries` covers `byGuild` (ordered by id), `enabledWords` (a `select o.word` projection) and `findWord` (case-insensitive `upper()`).
- `MessageQueries` holds the same kind of migration for `MessageRepository`.
- `pom.xml` gets:
- `jakarta.data:jakarta.data-api:1.0.1`
- `maven-compiler-plugin` with `annotationProcessorPaths` pointing to `org.hibernate.orm:hibernate-processor:7.1.9.Final`
- The shade plugin keeps working unchanged.
### Risks
- **Lazy associations:** stateless sessions don't lazy-load outside the session. The facades must return fully initialised data, which the current code already assumes since its sessions are closed anyway.
- **`save()` semantics:** `merge` becomes `upsert`, available on `CrudRepository`.
- **Named query `settings_findByName`:** it's replaced by `@Query`. The `@NamedQuery` on `OGuildSetting` can be removed.
# Testing
### Validation Approach
Run the existing suites unchanged with `./mvnw test`: `AbstractRepositoryTest`, `*RepositoryTest`, `GuildServiceTest` and `BlacklistServiceTest`. They cover the public repository behaviour.
### Key Scenarios
- `upsertOrDelete`: insert, update and reset-to-default delete.
- `TaskRepository.upsert` followed by `deleteByTaskId`.
- `enableWord` returns `CREATED`, then `UNCHANGED`, and `ENABLED` after the word has been disabled.
- `findOrCreate` is idempotent.
### Edge Cases
- A failing write rolls back.
- An HQL typo breaks `mvn compile`, which confirms the processor is active.
### Test Changes
- Rename `AbstractRepositoryTest` to `TransactionRunnerTest`, or rework it so it no longer depends on the removed base class.
# Delivery Steps
### ✓ Step 1: Add Jakarta Data and hibernate-processor to the build
The project compiles with the annotation processor generating the metamodel and repository classes.
- Add the `jakarta.data-api` dependency to `pom.xml`.
- Configure `maven-compiler-plugin` with `annotationProcessorPaths` pointing to `hibernate-processor` 7.1.9.Final.
- Add `db/TransactionRunner` with stateless read and transaction helpers.
- Check that `./mvnw compile` produces classes like `OGuild_`.
### ✓ Step 2: Migrate Guild and Task repositories
`GuildRepository` and `TaskRepository` delegate to the generated `GuildQueries_` and `TaskQueries_`.
- Create `db/data/GuildQueries` and `db/data/TaskQueries`.
- Rewrite `findOrCreate`, `upsert`, `findByTaskId` and `deleteByTaskId` using explicit `@Insert`/`@Update`.
- Run `GuildServiceTest` and `TaskRepositoryTest`.
### ✓ Step 3: Migrate GuildSetting, Blacklist and Message repositories
All remaining repositories use the generated queries, with transactional logic kept in the facades.
- Create `GuildSettingQueries`, `BlacklistQueries` and `MessageQueries`.
- Port `upsertOrDelete`, `enableWord` and `deleteWord` to explicit writes.
- Remove the `settings_findByName` named query.
- Run the matching repository and service tests.
### ✓ Step 4: Remove AbstractRepository and finish
`AbstractRepository` is gone and the full test suite passes.
- Delete `db/AbstractRepository.java` and move the generic `findAll`/`findById`/`save`/`deleteById` onto `CrudRepository`.
- Rework `AbstractRepositoryTest` into `TransactionRunnerTest`.
- Run `./mvnw verify`, including checkstyle and the shaded jar.
+21
View File
@@ -30,6 +30,22 @@
</resources>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.14.1</version>
<configuration>
<release>${java.version}</release>
<proc>full</proc>
<annotationProcessorPaths>
<path>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-processor</artifactId>
<version>7.1.9.Final</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
@@ -176,6 +192,11 @@
<artifactId>hibernate-hikaricp</artifactId>
<version>7.1.9.Final</version>
</dependency>
<dependency>
<groupId>jakarta.data</groupId>
<artifactId>jakarta.data-api</artifactId>
<version>1.0.1</version>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
@@ -1,95 +0,0 @@
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,35 @@
package me.bobert.netneutrality.db;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import org.hibernate.StatelessSession;
import java.util.Objects;
import java.util.function.Function;
/**
* Runs work on a Hibernate {@link StatelessSession}, the session type used by the generated
* Jakarta Data repositories.
*
* <p>{@link #inTx(Function)} commits on success and rolls back and rethrows on failure.
*/
public class TransactionRunner {
private final SessionProvider sessions;
public TransactionRunner(SessionProvider sessions) {
this.sessions = Objects.requireNonNull(sessions, "sessions");
}
public <R> R read(Function<StatelessSession, R> work) {
return sessions.getSessionFactory().fromStatelessSession(work);
}
public <R> R inTx(Function<StatelessSession, R> work) {
return sessions.getSessionFactory().fromStatelessTransaction(work);
}
}
@@ -0,0 +1,27 @@
package me.bobert.netneutrality.db.data;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import jakarta.data.repository.CrudRepository;
import jakarta.data.repository.Query;
import jakarta.data.repository.Repository;
import me.bobert.netneutrality.db.entities.OBlacklist;
import java.util.List;
@Repository
public interface BlacklistQueries extends CrudRepository<OBlacklist, Integer> {
@Query("from OBlacklist where guild.guildId = :guildId order by id")
List<OBlacklist> byGuild(String guildId);
@Query("select word from OBlacklist where guild.guildId = :guildId and enabled = true")
List<String> enabledWords(String guildId);
@Query("from OBlacklist where upper(word) = upper(:word) and guild.guildId = :guildId")
List<OBlacklist> findWord(String guildId, String word);
}
@@ -0,0 +1,15 @@
package me.bobert.netneutrality.db.data;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import jakarta.data.repository.CrudRepository;
import jakarta.data.repository.Repository;
import me.bobert.netneutrality.db.entities.OGuild;
@Repository
public interface GuildQueries extends CrudRepository<OGuild, String> {
}
@@ -0,0 +1,24 @@
package me.bobert.netneutrality.db.data;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import jakarta.data.repository.CrudRepository;
import jakarta.data.repository.Query;
import jakarta.data.repository.Repository;
import me.bobert.netneutrality.db.entities.OGuildSetting;
import java.util.List;
@Repository
public interface GuildSettingQueries extends CrudRepository<OGuildSetting, Integer> {
@Query("from OGuildSetting where guild.guildId = :guildId")
List<OGuildSetting> byGuild(String guildId);
@Query("from OGuildSetting where guild.guildId = :guildId and upper(name) = upper(:name)")
List<OGuildSetting> byName(String guildId, String name);
}
@@ -0,0 +1,30 @@
package me.bobert.netneutrality.db.data;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import jakarta.data.Limit;
import jakarta.data.repository.CrudRepository;
import jakarta.data.repository.Find;
import jakarta.data.repository.Query;
import jakarta.data.repository.Repository;
import me.bobert.netneutrality.db.entities.OLogMessage;
import java.util.List;
import java.util.Optional;
@Repository
public interface MessageQueries extends CrudRepository<OLogMessage, Integer> {
@Find
Optional<OLogMessage> byMessageId(String messageId);
@Query("delete from OLogMessage where messageId = :messageId")
int deleteByMessageId(String messageId);
@Query("from OLogMessage where guild.guildId = :guildId and userId = :userId order by dateSent desc")
List<OLogMessage> byUser(String userId, String guildId, Limit limit);
}
@@ -0,0 +1,25 @@
package me.bobert.netneutrality.db.data;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import jakarta.data.repository.CrudRepository;
import jakarta.data.repository.Find;
import jakarta.data.repository.Query;
import jakarta.data.repository.Repository;
import me.bobert.netneutrality.db.entities.OTask;
import java.util.Optional;
@Repository
public interface TaskQueries extends CrudRepository<OTask, Integer> {
@Find
Optional<OTask> byTaskId(String taskId);
@Query("delete from OTask where taskId = :taskId")
int deleteByTaskId(String taskId);
}
@@ -14,8 +14,6 @@ import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.NamedQueries;
import jakarta.persistence.NamedQuery;
import jakarta.persistence.Table;
import org.hibernate.Hibernate;
@@ -23,9 +21,6 @@ import java.util.Objects;
@Entity
@Table(name = "guild_settings")
@NamedQueries({
@NamedQuery(name = "settings_findByName", query = "select o from OGuildSetting o where o.guild.id = :id and upper(o.name) = upper(:name)")
})
public class OGuildSetting {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@@ -6,17 +6,19 @@ package me.bobert.netneutrality.db.repository;
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.db.AbstractRepository;
import me.bobert.netneutrality.db.SessionProvider;
import me.bobert.netneutrality.db.TransactionRunner;
import me.bobert.netneutrality.db.data.BlacklistQueries_;
import me.bobert.netneutrality.db.entities.OBlacklist;
import me.bobert.netneutrality.db.entities.OGuild;
import org.hibernate.Session;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
public class BlacklistRepository extends AbstractRepository<OBlacklist, Integer> {
public class BlacklistRepository {
/**
* Outcome of {@link #enableWord(String, String, String)}.
@@ -30,23 +32,18 @@ public class BlacklistRepository extends AbstractRepository<OBlacklist, Integer>
UNCHANGED
}
private final TransactionRunner tx;
public BlacklistRepository(SessionProvider sessions) {
super(sessions, OBlacklist.class);
this.tx = new TransactionRunner(sessions);
}
public List<OBlacklist> findByGuildId(String guildId) {
return read(session -> session
.createSelectionQuery("FROM OBlacklist o WHERE o.guild.guildId = :guildId ORDER BY o.id", OBlacklist.class)
.setParameter("guildId", guildId)
.getResultList());
return tx.read(session -> new BlacklistQueries_(session).byGuild(guildId));
}
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()));
return tx.read(session -> Set.copyOf(new BlacklistQueries_(session).enabledWords(guildId)));
}
/**
@@ -59,26 +56,33 @@ public class BlacklistRepository extends AbstractRepository<OBlacklist, Integer>
* @return what changed
*/
public EnableOutcome enableWord(String guildId, String creatorId, String word) {
return write(session -> {
List<OBlacklist> existing = findWord(session, guildId, word);
return tx.inTx(session -> {
BlacklistQueries_ queries = new BlacklistQueries_(session);
List<OBlacklist> existing = queries.findWord(guildId, word);
if (existing.isEmpty()) {
OGuild guild = new OGuild();
guild.setGuildId(guildId);
OBlacklist blacklist = new OBlacklist();
blacklist.setGuild(session.getReference(OGuild.class, guildId));
blacklist.setGuild(guild);
blacklist.setCreatorId(creatorId);
blacklist.setWord(word);
blacklist.setEnabled(true);
session.persist(blacklist);
queries.insert(blacklist);
return EnableOutcome.CREATED;
}
boolean changed = false;
List<OBlacklist> changed = new ArrayList<>();
for (OBlacklist blacklist : existing) {
if (!Boolean.TRUE.equals(blacklist.getEnabled())) {
blacklist.setEnabled(true);
changed = true;
changed.add(blacklist);
}
}
return changed ? EnableOutcome.ENABLED : EnableOutcome.UNCHANGED;
if (changed.isEmpty()) {
return EnableOutcome.UNCHANGED;
}
queries.updateAll(changed);
return EnableOutcome.ENABLED;
});
}
@@ -90,18 +94,50 @@ public class BlacklistRepository extends AbstractRepository<OBlacklist, Integer>
* @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 tx.inTx(session -> {
BlacklistQueries_ queries = new BlacklistQueries_(session);
List<OBlacklist> existing = queries.findWord(guildId, word);
queries.deleteAll(existing);
return !existing.isEmpty();
});
}
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();
public List<OBlacklist> findAll() {
return tx.read(session -> new BlacklistQueries_(session).findAll().toList());
}
public Optional<OBlacklist> findById(Integer id) {
Objects.requireNonNull(id, "id");
return tx.read(session -> new BlacklistQueries_(session).findById(id));
}
/**
* Inserts or updates the entity.
*
* @param entity the entity to save
* @return the saved state, which callers should use from then on
*/
public OBlacklist save(OBlacklist entity) {
Objects.requireNonNull(entity, "entity");
return tx.inTx(session -> new BlacklistQueries_(session).save(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(Integer id) {
Objects.requireNonNull(id, "id");
return tx.inTx(session -> {
BlacklistQueries_ queries = new BlacklistQueries_(session);
return queries.findById(id)
.map(entity -> {
queries.delete(entity);
return true;
})
.orElse(false);
});
}
}
@@ -6,14 +6,21 @@ package me.bobert.netneutrality.db.repository;
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.db.AbstractRepository;
import me.bobert.netneutrality.db.SessionProvider;
import me.bobert.netneutrality.db.TransactionRunner;
import me.bobert.netneutrality.db.data.GuildQueries_;
import me.bobert.netneutrality.db.entities.OGuild;
public class GuildRepository extends AbstractRepository<OGuild, String> {
import java.util.List;
import java.util.Objects;
import java.util.Optional;
public class GuildRepository {
private final TransactionRunner tx;
public GuildRepository(SessionProvider sessions) {
super(sessions, OGuild.class);
this.tx = new TransactionRunner(sessions);
}
/**
@@ -23,14 +30,52 @@ public class GuildRepository extends AbstractRepository<OGuild, String> {
* @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();
return tx.inTx(session -> {
GuildQueries_ queries = new GuildQueries_(session);
return queries.findById(guildId).orElseGet(() -> {
OGuild guild = new OGuild();
guild.setGuildId(guildId);
session.persist(guild);
return queries.insert(guild);
});
});
}
return guild;
public List<OGuild> findAll() {
return tx.read(session -> new GuildQueries_(session).findAll().toList());
}
public Optional<OGuild> findById(String id) {
Objects.requireNonNull(id, "id");
return tx.read(session -> new GuildQueries_(session).findById(id));
}
/**
* Inserts or updates the entity.
*
* @param entity the entity to save
* @return the saved state, which callers should use from then on
*/
public OGuild save(OGuild entity) {
Objects.requireNonNull(entity, "entity");
return tx.inTx(session -> new GuildQueries_(session).save(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(String id) {
Objects.requireNonNull(id, "id");
return tx.inTx(session -> {
GuildQueries_ queries = new GuildQueries_(session);
return queries.findById(id)
.map(entity -> {
queries.delete(entity);
return true;
})
.orElse(false);
});
}
}
@@ -6,25 +6,27 @@ 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.SessionProvider;
import me.bobert.netneutrality.db.TransactionRunner;
import me.bobert.netneutrality.db.data.GuildSettingQueries_;
import me.bobert.netneutrality.db.entities.OGuild;
import me.bobert.netneutrality.db.entities.OGuildSetting;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
public class GuildSettingRepository extends AbstractRepository<OGuildSetting, Integer> {
public class GuildSettingRepository {
private final TransactionRunner tx;
public GuildSettingRepository(SessionProvider sessions) {
super(sessions, OGuildSetting.class);
this.tx = new TransactionRunner(sessions);
}
public List<OGuildSetting> findByGuild(String guildId) {
return read(session -> session
.createSelectionQuery("FROM OGuildSetting o WHERE o.guild.guildId = :guildId", OGuildSetting.class)
.setParameter("guildId", guildId)
.getResultList());
return tx.read(session -> new GuildSettingQueries_(session).byGuild(guildId));
}
/**
@@ -36,27 +38,67 @@ public class GuildSettingRepository extends AbstractRepository<OGuildSetting, In
* @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();
tx.inTx(session -> {
GuildSettingQueries_ queries = new GuildSettingQueries_(session);
List<OGuildSetting> existing = queries.byName(guildId, setting.getName());
if (value == null || setting.getDefaultValue().equals(value)) {
existing.forEach(session::remove);
return;
queries.deleteAll(existing);
return null;
}
if (existing.isEmpty()) {
OGuild guild = new OGuild();
guild.setGuildId(guildId);
OGuildSetting guildSetting = new OGuildSetting();
guildSetting.setGuild(session.getReference(OGuild.class, guildId));
guildSetting.setGuild(guild);
guildSetting.setName(setting.getName());
guildSetting.setValue(value);
session.persist(guildSetting);
queries.insert(guildSetting);
} else {
existing.forEach(guildSetting -> guildSetting.setValue(value));
queries.updateAll(existing);
}
return null;
});
}
public List<OGuildSetting> findAll() {
return tx.read(session -> new GuildSettingQueries_(session).findAll().toList());
}
public Optional<OGuildSetting> findById(Integer id) {
Objects.requireNonNull(id, "id");
return tx.read(session -> new GuildSettingQueries_(session).findById(id));
}
/**
* Inserts or updates the entity.
*
* @param entity the entity to save
* @return the saved state, which callers should use from then on
*/
public OGuildSetting save(OGuildSetting entity) {
Objects.requireNonNull(entity, "entity");
return tx.inTx(session -> new GuildSettingQueries_(session).save(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(Integer id) {
Objects.requireNonNull(id, "id");
return tx.inTx(session -> {
GuildSettingQueries_ queries = new GuildSettingQueries_(session);
return queries.findById(id)
.map(entity -> {
queries.delete(entity);
return true;
})
.orElse(false);
});
}
}
@@ -6,20 +6,24 @@ package me.bobert.netneutrality.db.repository;
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.db.AbstractRepository;
import jakarta.data.Limit;
import me.bobert.netneutrality.db.SessionProvider;
import me.bobert.netneutrality.db.TransactionRunner;
import me.bobert.netneutrality.db.data.MessageQueries_;
import me.bobert.netneutrality.db.entities.OGuild;
import me.bobert.netneutrality.db.entities.OLogMessage;
import org.hibernate.Session;
import java.time.Instant;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
public class MessageRepository extends AbstractRepository<OLogMessage, Integer> {
public class MessageRepository {
private final TransactionRunner tx;
public MessageRepository(SessionProvider sessions) {
super(sessions, OLogMessage.class);
this.tx = new TransactionRunner(sessions);
}
/**
@@ -34,21 +38,22 @@ public class MessageRepository extends AbstractRepository<OLogMessage, Integer>
* @return the persistent log entry
*/
public OLogMessage log(String guildId, String channelId, String userId, String messageId, String content, Instant sentAt) {
return write(session -> {
return tx.inTx(session -> {
OGuild guild = new OGuild();
guild.setGuildId(guildId);
OLogMessage logMessage = new OLogMessage();
logMessage.setGuild(session.getReference(OGuild.class, guildId));
logMessage.setGuild(guild);
logMessage.setChannelId(channelId);
logMessage.setUserId(userId);
logMessage.setMessageId(messageId);
logMessage.setMessage(content == null ? null : content.trim());
logMessage.setDateSent(sentAt);
session.persist(logMessage);
return logMessage;
return new MessageQueries_(session).insert(logMessage);
});
}
public Optional<OLogMessage> findByMessageId(String messageId) {
return read(session -> findByMessageId(session, messageId));
return tx.read(session -> new MessageQueries_(session).byMessageId(messageId));
}
public Optional<String> findMessageContent(String messageId) {
@@ -63,19 +68,20 @@ public class MessageRepository extends AbstractRepository<OLogMessage, Integer>
* @return {@code true} if the message was found and updated
*/
public boolean updateEditedMessage(String messageId, String newMessage) {
return write(session -> findByMessageId(session, messageId)
return tx.inTx(session -> {
MessageQueries_ queries = new MessageQueries_(session);
return queries.byMessageId(messageId)
.map(message -> {
message.setEditedMessage(newMessage);
queries.update(message);
return true;
})
.orElse(false));
.orElse(false);
});
}
public boolean deleteByMessageId(String messageId) {
return write(session -> session
.createMutationQuery("DELETE FROM OLogMessage o WHERE o.messageId = :messageId")
.setParameter("messageId", messageId)
.executeUpdate() > 0);
return tx.inTx(session -> new MessageQueries_(session).deleteByMessageId(messageId) > 0);
}
/**
@@ -87,18 +93,45 @@ public class MessageRepository extends AbstractRepository<OLogMessage, Integer>
* @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());
return tx.read(session -> new MessageQueries_(session).byUser(userId, guildId, Limit.of(max)));
}
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();
public List<OLogMessage> findAll() {
return tx.read(session -> new MessageQueries_(session).findAll().toList());
}
public Optional<OLogMessage> findById(Integer id) {
Objects.requireNonNull(id, "id");
return tx.read(session -> new MessageQueries_(session).findById(id));
}
/**
* Inserts or updates the entity.
*
* @param entity the entity to save
* @return the saved state, which callers should use from then on
*/
public OLogMessage save(OLogMessage entity) {
Objects.requireNonNull(entity, "entity");
return tx.inTx(session -> new MessageQueries_(session).save(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(Integer id) {
Objects.requireNonNull(id, "id");
return tx.inTx(session -> {
MessageQueries_ queries = new MessageQueries_(session);
return queries.findById(id)
.map(entity -> {
queries.delete(entity);
return true;
})
.orElse(false);
});
}
}
@@ -6,21 +6,25 @@ package me.bobert.netneutrality.db.repository;
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.db.AbstractRepository;
import me.bobert.netneutrality.db.SessionProvider;
import me.bobert.netneutrality.db.TransactionRunner;
import me.bobert.netneutrality.db.data.TaskQueries_;
import me.bobert.netneutrality.db.entities.OTask;
import org.hibernate.Session;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
public class TaskRepository extends AbstractRepository<OTask, Integer> {
public class TaskRepository {
private final TransactionRunner tx;
public TaskRepository(SessionProvider sessions) {
super(sessions, OTask.class);
this.tx = new TransactionRunner(sessions);
}
public Optional<OTask> findByTaskId(String taskId) {
return read(session -> findByTaskId(session, taskId));
return tx.read(session -> new TaskQueries_(session).byTaskId(taskId));
}
/**
@@ -32,32 +36,62 @@ public class TaskRepository extends AbstractRepository<OTask, Integer> {
* @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();
return tx.inTx(session -> {
TaskQueries_ queries = new TaskQueries_(session);
Optional<OTask> existing = queries.byTaskId(taskId);
if (existing.isPresent()) {
OTask task = existing.get();
task.setPayload(payload);
return queries.update(task);
}
OTask task = new OTask();
task.setTaskId(taskId);
task.setTaskClass(typeId);
task.setPayload(payload);
session.persist(task);
} else {
task.setPayload(payload);
}
return task;
return queries.insert(task);
});
}
public boolean deleteByTaskId(String taskId) {
return write(session -> session
.createMutationQuery("DELETE FROM OTask o WHERE o.taskId = :taskId")
.setParameter("taskId", taskId)
.executeUpdate() > 0);
return tx.inTx(session -> new TaskQueries_(session).deleteByTaskId(taskId) > 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();
public List<OTask> findAll() {
return tx.read(session -> new TaskQueries_(session).findAll().toList());
}
public Optional<OTask> findById(Integer id) {
Objects.requireNonNull(id, "id");
return tx.read(session -> new TaskQueries_(session).findById(id));
}
/**
* Inserts or updates the entity.
*
* @param entity the entity to save
* @return the saved state, which callers should use from then on
*/
public OTask save(OTask entity) {
Objects.requireNonNull(entity, "entity");
return tx.inTx(session -> new TaskQueries_(session).save(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(Integer id) {
Objects.requireNonNull(id, "id");
return tx.inTx(session -> {
TaskQueries_ queries = new TaskQueries_(session);
return queries.findById(id)
.map(entity -> {
queries.delete(entity);
return true;
})
.orElse(false);
});
}
}
@@ -6,6 +6,7 @@ package me.bobert.netneutrality.db;
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.db.data.GuildQueries_;
import me.bobert.netneutrality.db.entities.OBlacklist;
import me.bobert.netneutrality.db.entities.OGuild;
import me.bobert.netneutrality.db.repository.BlacklistRepository;
@@ -18,7 +19,7 @@ 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 {
class TransactionRunnerTest extends MySqlTestBase {
private GuildRepository guilds;
private BlacklistRepository blacklist;
@@ -65,9 +66,14 @@ class AbstractRepositoryTest extends MySqlTestBase {
@Test
void failingWriteThrowsAndRollsBack() {
RuntimeException failure = new IllegalStateException("boom");
FailingGuildRepository failing = new FailingGuildRepository(sessions());
TransactionRunner tx = new TransactionRunner(sessions());
RuntimeException thrown = assertThrows(RuntimeException.class, () -> failing.insertThenFail("guild-1", failure));
RuntimeException thrown = assertThrows(RuntimeException.class, () -> tx.inTx(session -> {
OGuild guild = new OGuild();
guild.setGuildId("guild-1");
new GuildQueries_(session).insert(guild);
throw failure;
}));
assertEquals(failure, thrown);
assertTrue(guilds.findById("guild-1").isEmpty());
@@ -86,20 +92,4 @@ class AbstractRepositoryTest extends MySqlTestBase {
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;
});
}
}
}