diff --git a/.junie/plans/jakarta-data-repositories.md b/.junie/plans/jakarta-data-repositories.md new file mode 100644 index 0000000..5bac8ab --- /dev/null +++ b/.junie/plans/jakarta-data-repositories.md @@ -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` 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 inTx(Function)` 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 { + @Find Optional byTaskId(String taskId); + @Query("delete from OTask where taskId = :taskId") int deleteByTaskId(String taskId); +} +@Repository +public interface GuildSettingQueries extends CrudRepository { + @Query("where guild.guildId = :guildId") List byGuild(String guildId); + @Query("where guild.guildId = :id and name = :name") List 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. \ No newline at end of file diff --git a/pom.xml b/pom.xml index 5406bed..cfd3372 100644 --- a/pom.xml +++ b/pom.xml @@ -30,6 +30,22 @@ + + org.apache.maven.plugins + maven-compiler-plugin + 3.14.1 + + ${java.version} + full + + + org.hibernate.orm + hibernate-processor + 7.1.9.Final + + + + org.apache.maven.plugins maven-surefire-plugin @@ -176,6 +192,11 @@ hibernate-hikaricp 7.1.9.Final + + jakarta.data + jakarta.data-api + 1.0.1 + com.mysql mysql-connector-j diff --git a/src/main/java/me/bobert/netneutrality/db/AbstractRepository.java b/src/main/java/me/bobert/netneutrality/db/AbstractRepository.java deleted file mode 100644 index 5feb0b4..0000000 --- a/src/main/java/me/bobert/netneutrality/db/AbstractRepository.java +++ /dev/null @@ -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. - * - *

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 the entity type - * @param the entity identifier type - */ -public abstract class AbstractRepository { - - protected final SessionProvider sessions; - private final Class type; - - protected AbstractRepository(SessionProvider sessions, Class type) { - this.sessions = Objects.requireNonNull(sessions, "sessions"); - this.type = Objects.requireNonNull(type, "type"); - } - - protected Class type() { - return type; - } - - public List findAll() { - return read(session -> { - CriteriaQuery query = session.getCriteriaBuilder().createQuery(type); - query.from(type); - return session.createSelectionQuery(query).getResultList(); - }); - } - - public Optional 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 read(Function work) { - return sessions.getSessionFactory().fromSession(work); - } - - protected R write(Function work) { - return sessions.getSessionFactory().fromTransaction(work); - } - - protected void writeVoid(Consumer work) { - sessions.getSessionFactory().inTransaction(work); - } -} diff --git a/src/main/java/me/bobert/netneutrality/db/TransactionRunner.java b/src/main/java/me/bobert/netneutrality/db/TransactionRunner.java new file mode 100644 index 0000000..d82a79e --- /dev/null +++ b/src/main/java/me/bobert/netneutrality/db/TransactionRunner.java @@ -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. + * + *

{@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 read(Function work) { + return sessions.getSessionFactory().fromStatelessSession(work); + } + + public R inTx(Function work) { + return sessions.getSessionFactory().fromStatelessTransaction(work); + } +} diff --git a/src/main/java/me/bobert/netneutrality/db/data/BlacklistQueries.java b/src/main/java/me/bobert/netneutrality/db/data/BlacklistQueries.java new file mode 100644 index 0000000..c187692 --- /dev/null +++ b/src/main/java/me/bobert/netneutrality/db/data/BlacklistQueries.java @@ -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 { + + @Query("from OBlacklist where guild.guildId = :guildId order by id") + List byGuild(String guildId); + + @Query("select word from OBlacklist where guild.guildId = :guildId and enabled = true") + List enabledWords(String guildId); + + @Query("from OBlacklist where upper(word) = upper(:word) and guild.guildId = :guildId") + List findWord(String guildId, String word); +} diff --git a/src/main/java/me/bobert/netneutrality/db/data/GuildQueries.java b/src/main/java/me/bobert/netneutrality/db/data/GuildQueries.java new file mode 100644 index 0000000..67fbc57 --- /dev/null +++ b/src/main/java/me/bobert/netneutrality/db/data/GuildQueries.java @@ -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 { +} diff --git a/src/main/java/me/bobert/netneutrality/db/data/GuildSettingQueries.java b/src/main/java/me/bobert/netneutrality/db/data/GuildSettingQueries.java new file mode 100644 index 0000000..a324429 --- /dev/null +++ b/src/main/java/me/bobert/netneutrality/db/data/GuildSettingQueries.java @@ -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 { + + @Query("from OGuildSetting where guild.guildId = :guildId") + List byGuild(String guildId); + + @Query("from OGuildSetting where guild.guildId = :guildId and upper(name) = upper(:name)") + List byName(String guildId, String name); +} diff --git a/src/main/java/me/bobert/netneutrality/db/data/MessageQueries.java b/src/main/java/me/bobert/netneutrality/db/data/MessageQueries.java new file mode 100644 index 0000000..f187366 --- /dev/null +++ b/src/main/java/me/bobert/netneutrality/db/data/MessageQueries.java @@ -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 { + + @Find + Optional 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 byUser(String userId, String guildId, Limit limit); +} diff --git a/src/main/java/me/bobert/netneutrality/db/data/TaskQueries.java b/src/main/java/me/bobert/netneutrality/db/data/TaskQueries.java new file mode 100644 index 0000000..4ff4e65 --- /dev/null +++ b/src/main/java/me/bobert/netneutrality/db/data/TaskQueries.java @@ -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 { + + @Find + Optional byTaskId(String taskId); + + @Query("delete from OTask where taskId = :taskId") + int deleteByTaskId(String taskId); +} diff --git a/src/main/java/me/bobert/netneutrality/db/entities/OGuildSetting.java b/src/main/java/me/bobert/netneutrality/db/entities/OGuildSetting.java index 6ad83b3..770f9c3 100644 --- a/src/main/java/me/bobert/netneutrality/db/entities/OGuildSetting.java +++ b/src/main/java/me/bobert/netneutrality/db/entities/OGuildSetting.java @@ -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) diff --git a/src/main/java/me/bobert/netneutrality/db/repository/BlacklistRepository.java b/src/main/java/me/bobert/netneutrality/db/repository/BlacklistRepository.java index c3f0564..e43a343 100644 --- a/src/main/java/me/bobert/netneutrality/db/repository/BlacklistRepository.java +++ b/src/main/java/me/bobert/netneutrality/db/repository/BlacklistRepository.java @@ -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 { +public class BlacklistRepository { /** * Outcome of {@link #enableWord(String, String, String)}. @@ -30,23 +32,18 @@ public class BlacklistRepository extends AbstractRepository UNCHANGED } + private final TransactionRunner tx; + public BlacklistRepository(SessionProvider sessions) { - super(sessions, OBlacklist.class); + this.tx = new TransactionRunner(sessions); } public List 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 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 * @return what changed */ public EnableOutcome enableWord(String guildId, String creatorId, String word) { - return write(session -> { - List existing = findWord(session, guildId, word); + return tx.inTx(session -> { + BlacklistQueries_ queries = new BlacklistQueries_(session); + List 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 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 * @return {@code true} if at least one row was deleted */ public boolean deleteWord(String guildId, String word) { - return write(session -> { - List existing = findWord(session, guildId, word); - existing.forEach(session::remove); + return tx.inTx(session -> { + BlacklistQueries_ queries = new BlacklistQueries_(session); + List existing = queries.findWord(guildId, word); + queries.deleteAll(existing); return !existing.isEmpty(); }); } - private static List 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 findAll() { + return tx.read(session -> new BlacklistQueries_(session).findAll().toList()); + } + + public Optional 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); + }); } } diff --git a/src/main/java/me/bobert/netneutrality/db/repository/GuildRepository.java b/src/main/java/me/bobert/netneutrality/db/repository/GuildRepository.java index 3722b3a..63490fe 100644 --- a/src/main/java/me/bobert/netneutrality/db/repository/GuildRepository.java +++ b/src/main/java/me/bobert/netneutrality/db/repository/GuildRepository.java @@ -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 { +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 { * @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 guild; + return queries.insert(guild); + }); + }); + } + + public List findAll() { + return tx.read(session -> new GuildQueries_(session).findAll().toList()); + } + + public Optional 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); }); } } diff --git a/src/main/java/me/bobert/netneutrality/db/repository/GuildSettingRepository.java b/src/main/java/me/bobert/netneutrality/db/repository/GuildSettingRepository.java index a783ac4..bb8f332 100644 --- a/src/main/java/me/bobert/netneutrality/db/repository/GuildSettingRepository.java +++ b/src/main/java/me/bobert/netneutrality/db/repository/GuildSettingRepository.java @@ -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 { +public class GuildSettingRepository { + + private final TransactionRunner tx; public GuildSettingRepository(SessionProvider sessions) { - super(sessions, OGuildSetting.class); + this.tx = new TransactionRunner(sessions); } public List 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 { - List existing = session - .createNamedSelectionQuery("settings_findByName", OGuildSetting.class) - .setParameter("id", guildId) - .setParameter("name", setting.getName()) - .getResultList(); + tx.inTx(session -> { + GuildSettingQueries_ queries = new GuildSettingQueries_(session); + List 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 findAll() { + return tx.read(session -> new GuildSettingQueries_(session).findAll().toList()); + } + + public Optional 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); }); } } diff --git a/src/main/java/me/bobert/netneutrality/db/repository/MessageRepository.java b/src/main/java/me/bobert/netneutrality/db/repository/MessageRepository.java index d4f2651..3fffc44 100644 --- a/src/main/java/me/bobert/netneutrality/db/repository/MessageRepository.java +++ b/src/main/java/me/bobert/netneutrality/db/repository/MessageRepository.java @@ -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 { +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 * @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 findByMessageId(String messageId) { - return read(session -> findByMessageId(session, messageId)); + return tx.read(session -> new MessageQueries_(session).byMessageId(messageId)); } public Optional findMessageContent(String messageId) { @@ -63,19 +68,20 @@ public class MessageRepository extends AbstractRepository * @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); - return true; - }) - .orElse(false)); + 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); + }); } 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 * @return the messages */ public List 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 findByMessageId(Session session, String messageId) { - return session - .createSelectionQuery("FROM OLogMessage o WHERE o.messageId = :messageId", OLogMessage.class) - .setParameter("messageId", messageId) - .uniqueResultOptional(); + public List findAll() { + return tx.read(session -> new MessageQueries_(session).findAll().toList()); + } + + public Optional 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); + }); } } diff --git a/src/main/java/me/bobert/netneutrality/db/repository/TaskRepository.java b/src/main/java/me/bobert/netneutrality/db/repository/TaskRepository.java index 337af8b..a0704f9 100644 --- a/src/main/java/me/bobert/netneutrality/db/repository/TaskRepository.java +++ b/src/main/java/me/bobert/netneutrality/db/repository/TaskRepository.java @@ -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 { +public class TaskRepository { + + private final TransactionRunner tx; public TaskRepository(SessionProvider sessions) { - super(sessions, OTask.class); + this.tx = new TransactionRunner(sessions); } public Optional 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 { * @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 { + return tx.inTx(session -> { + TaskQueries_ queries = new TaskQueries_(session); + Optional existing = queries.byTaskId(taskId); + if (existing.isPresent()) { + OTask task = existing.get(); task.setPayload(payload); + return queries.update(task); } - return task; + OTask task = new OTask(); + task.setTaskId(taskId); + task.setTaskClass(typeId); + task.setPayload(payload); + 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 findByTaskId(Session session, String taskId) { - return session - .createSelectionQuery("FROM OTask o WHERE o.taskId = :taskId", OTask.class) - .setParameter("taskId", taskId) - .uniqueResultOptional(); + public List findAll() { + return tx.read(session -> new TaskQueries_(session).findAll().toList()); + } + + public Optional 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); + }); } } diff --git a/src/test/java/me/bobert/netneutrality/db/AbstractRepositoryTest.java b/src/test/java/me/bobert/netneutrality/db/TransactionRunnerTest.java similarity index 78% rename from src/test/java/me/bobert/netneutrality/db/AbstractRepositoryTest.java rename to src/test/java/me/bobert/netneutrality/db/TransactionRunnerTest.java index f773527..4657972 100644 --- a/src/test/java/me/bobert/netneutrality/db/AbstractRepositoryTest.java +++ b/src/test/java/me/bobert/netneutrality/db/TransactionRunnerTest.java @@ -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 { - 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; - }); - } - } }