From afa2a7ea75d8a728ff4cb20379633bb38f3f0979 Mon Sep 17 00:00:00 2001 From: lobo Date: Thu, 24 Sep 2026 13:17:12 +0200 Subject: [PATCH] feat(library): sort by rating and priority with a stable order Games without a rating or priority come last in both directions. Priority sorts HIGH, MEDIUM, LOW instead of by enum name, and appid breaks every tie, so pages no longer repeat or skip games with equal values. --- .../library/api/LibraryController.java | 2 +- .../library/repo/UserGameRepository.java | 21 +++++++++ .../library/service/LibraryQueryService.java | 5 +- .../library/repo/UserGameRepositoryTest.java | 46 +++++++++++++++++++ 4 files changed, 72 insertions(+), 2 deletions(-) diff --git a/src/main/java/core_service/modules/library/api/LibraryController.java b/src/main/java/core_service/modules/library/api/LibraryController.java index ef204e5..6722a02 100644 --- a/src/main/java/core_service/modules/library/api/LibraryController.java +++ b/src/main/java/core_service/modules/library/api/LibraryController.java @@ -39,7 +39,7 @@ public class LibraryController { private static final Set ALLOWED_SIZES = Set.of(20, 40, 80); private static final int DEFAULT_SIZE = 20; - private static final Set SORTABLE_FIELDS = Set.of("playtimeMinutes", "firstSeenAt", "lastSeenAt", "appid"); + private static final Set SORTABLE_FIELDS = Set.of("playtimeMinutes", "firstSeenAt", "lastSeenAt", "appid", "rating", "priority"); private final LibrarySyncService syncService; private final LibraryQueryService queryService; diff --git a/src/main/java/core_service/modules/library/repo/UserGameRepository.java b/src/main/java/core_service/modules/library/repo/UserGameRepository.java index e389074..59432a5 100644 --- a/src/main/java/core_service/modules/library/repo/UserGameRepository.java +++ b/src/main/java/core_service/modules/library/repo/UserGameRepository.java @@ -6,6 +6,8 @@ import core_service.modules.library.domain.UserGame; import core_service.modules.library.domain.UserGameId; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.domain.JpaSort; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; @@ -48,6 +50,25 @@ public interface UserGameRepository extends JpaRepository @Param("namePattern") String namePattern, Pageable pageable); + /** + * Makes a {@link #findOwnedGames} sort stable and meaningful: games without a rating or priority come last + * in both directions, priority sorts by HIGH, MEDIUM, LOW (the enum names would sort alphabetically), and + * appid breaks every tie so pages do not repeat or skip games. + */ + static Sort querySort(Sort requested) { + Sort result = Sort.unsorted(); + for (Sort.Order order : requested) { + result = result.and(switch (order.getProperty()) { + case "rating" -> Sort.by(order.nullsLast()); + case "priority" -> Sort.by(JpaSort.unsafe(order.getDirection(), + "(case ug.priority when 'HIGH' then 3 when 'MEDIUM' then 2 when 'LOW' then 1 end)") + .iterator().next().nullsLast()); + default -> Sort.by(order); + }); + } + return result.and(Sort.by("appid")); + } + /** * Case-insensitive "contains" pattern for {@link #findOwnedGames}; % and _ in the query match literally. */ diff --git a/src/main/java/core_service/modules/library/service/LibraryQueryService.java b/src/main/java/core_service/modules/library/service/LibraryQueryService.java index 0ddb2b7..a7754b7 100644 --- a/src/main/java/core_service/modules/library/service/LibraryQueryService.java +++ b/src/main/java/core_service/modules/library/service/LibraryQueryService.java @@ -11,6 +11,7 @@ import core_service.modules.library.repo.UserGameRepository; import core_service.modules.library.repo.UserSteamSyncRepository; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -35,8 +36,10 @@ public class LibraryQueryService { } public Page listOwnedGames(Long userId, GameStatus status, String nameQuery, Pageable pageable) { + Pageable stable = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), + UserGameRepository.querySort(pageable.getSort())); Page page = userGameRepository.findOwnedGames( - userId, status, UserGameRepository.containsPattern(nameQuery), pageable); + userId, status, UserGameRepository.containsPattern(nameQuery), stable); List appids = page.getContent().stream().map(UserGame::getAppid).toList(); Map meta = steamGameRepository.findAllById(appids).stream() .collect(Collectors.toMap(SteamGame::getAppid, g -> g)); diff --git a/src/test/java/core_service/modules/library/repo/UserGameRepositoryTest.java b/src/test/java/core_service/modules/library/repo/UserGameRepositoryTest.java index 4884dcf..5663464 100644 --- a/src/test/java/core_service/modules/library/repo/UserGameRepositoryTest.java +++ b/src/test/java/core_service/modules/library/repo/UserGameRepositoryTest.java @@ -1,5 +1,6 @@ package core_service.modules.library.repo; +import core_service.modules.library.domain.GamePriority; import core_service.modules.library.domain.GameStatus; import core_service.modules.library.domain.StatusCount; import core_service.modules.library.domain.SteamGame; @@ -95,6 +96,51 @@ class UserGameRepositoryTest extends AbstractPersistenceTest { assertEquals(2, page.getTotalElements()); } + private List sortedAppids(Sort sort) { + return userGameRepository.findOwnedGames(USER_ID, null, null, PageRequest.of(0, 20, UserGameRepository.querySort(sort))) + .map(UserGame::getAppid).getContent(); + } + + @Test + void querySort_RatingDesc_PutsUnratedLastAndBreaksTiesByAppid() { + setBacklog(220L, 3, null); + setBacklog(620L, 5, null); + setBacklog(700L, 3, null); + + assertEquals(List.of(620L, 220L, 700L, 380L), sortedAppids(Sort.by(Sort.Direction.DESC, "rating"))); + } + + @Test + void querySort_PriorityDesc_IsHighMediumLowThenNone() { + setBacklog(220L, null, GamePriority.LOW); + setBacklog(380L, null, GamePriority.HIGH); + setBacklog(700L, null, GamePriority.MEDIUM); + + assertEquals(List.of(380L, 700L, 220L, 620L), sortedAppids(Sort.by(Sort.Direction.DESC, "priority"))); + } + + @Test + void querySort_EqualPlaytime_BreaksTiesByAppid() { + assertEquals(List.of(620L, 220L, 380L, 700L), sortedAppids(Sort.by(Sort.Direction.DESC, "playtimeMinutes"))); + } + + // unrated LAST (nullsLast) and the querySort javadoc promises "then none" — one API, two opposite null rules. + @Test + void querySort_PriorityAsc_PutsNoneLast() { + setBacklog(220L, null, GamePriority.LOW); + setBacklog(380L, null, GamePriority.HIGH); + setBacklog(700L, null, GamePriority.MEDIUM); + + assertEquals(List.of(220L, 700L, 380L, 620L), sortedAppids(Sort.by(Sort.Direction.ASC, "priority"))); + } + + private void setBacklog(long appid, Integer rating, GamePriority priority) { + UserGame game = userGameRepository.findById(new UserGameId(USER_ID, appid)).orElseThrow(); + game.setRating(rating); + game.setPriority(priority); + userGameRepository.flush(); + } + @Test void containsPattern_BlankIsNoFilter() { assertNull(UserGameRepository.containsPattern(" "));