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.
This commit is contained in:
lobo committed 2026-09-24 13:17:12 +02:00
1 parent d085080c13
commit 83b9c0e1cf
4 files changed
+72 -2

No files matched your search

@@ -39,7 +39,7 @@ public class LibraryController {
private static final Set<Integer> ALLOWED_SIZES = Set.of(20, 40, 80); private static final Set<Integer> ALLOWED_SIZES = Set.of(20, 40, 80);
private static final int DEFAULT_SIZE = 20; private static final int DEFAULT_SIZE = 20;
private static final Set<String> SORTABLE_FIELDS = Set.of("playtimeMinutes", "firstSeenAt", "lastSeenAt", "appid"); private static final Set<String> SORTABLE_FIELDS = Set.of("playtimeMinutes", "firstSeenAt", "lastSeenAt", "appid", "rating", "priority");
private final LibrarySyncService syncService; private final LibrarySyncService syncService;
private final LibraryQueryService queryService; private final LibraryQueryService queryService;
@@ -6,6 +6,8 @@ import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameId; import core_service.modules.library.domain.UserGameId;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable; 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.JpaRepository;
import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.Query;
@@ -48,6 +50,25 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
@Param("namePattern") String namePattern, @Param("namePattern") String namePattern,
Pageable pageable); 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. * Case-insensitive "contains" pattern for {@link #findOwnedGames}; % and _ in the query match literally.
*/ */
@@ -11,6 +11,7 @@ import core_service.modules.library.repo.UserGameRepository;
import core_service.modules.library.repo.UserSteamSyncRepository; import core_service.modules.library.repo.UserSteamSyncRepository;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -35,8 +36,10 @@ public class LibraryQueryService {
} }
public Page<GameDto> listOwnedGames(Long userId, GameStatus status, String nameQuery, Pageable pageable) { public Page<GameDto> listOwnedGames(Long userId, GameStatus status, String nameQuery, Pageable pageable) {
Pageable stable = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(),
UserGameRepository.querySort(pageable.getSort()));
Page<UserGame> page = userGameRepository.findOwnedGames( Page<UserGame> page = userGameRepository.findOwnedGames(
userId, status, UserGameRepository.containsPattern(nameQuery), pageable); userId, status, UserGameRepository.containsPattern(nameQuery), stable);
List<Long> appids = page.getContent().stream().map(UserGame::getAppid).toList(); List<Long> appids = page.getContent().stream().map(UserGame::getAppid).toList();
Map<Long, SteamGame> meta = steamGameRepository.findAllById(appids).stream() Map<Long, SteamGame> meta = steamGameRepository.findAllById(appids).stream()
.collect(Collectors.toMap(SteamGame::getAppid, g -> g)); .collect(Collectors.toMap(SteamGame::getAppid, g -> g));
@@ -1,5 +1,6 @@
package core_service.modules.library.repo; 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.GameStatus;
import core_service.modules.library.domain.StatusCount; import core_service.modules.library.domain.StatusCount;
import core_service.modules.library.domain.SteamGame; import core_service.modules.library.domain.SteamGame;
@@ -95,6 +96,51 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
assertEquals(2, page.getTotalElements()); assertEquals(2, page.getTotalElements());
} }
private List<Long> 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 @Test
void containsPattern_BlankIsNoFilter() { void containsPattern_BlankIsNoFilter() {
assertNull(UserGameRepository.containsPattern(" ")); assertNull(UserGameRepository.containsPattern(" "));