Public Access
perf(library): bind owned-games subqueries to user
Subqueries in OWNED_WHERE had no userId filter, so Postgres hashed rows of all users. Skip the source filter when all sources are set. Refs #40.
This commit is contained in:
1 parent
4d49b0f023
commit
e18b799501
2 files changed
+207
-19
No files matched your search
@@ -0,0 +1,177 @@
|
||||
package core_service.modules.library.repo;
|
||||
|
||||
import core_service.modules.library.domain.GameSource;
|
||||
import core_service.modules.library.domain.GameStatus;
|
||||
import core_service.modules.library.domain.GameTimeToBeat;
|
||||
import core_service.modules.library.domain.SteamGame;
|
||||
import core_service.modules.library.domain.UserGame;
|
||||
import core_service.shared.test.AbstractPersistenceTest;
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.Query;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.jpa.repository.query.QueryUtils;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.EnumSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Guards the rewrite of {@link UserGameRepository#OWNED_WHERE} for speed: it must return the same rows in the same
|
||||
* order with the same total as the frozen text of the old query, for every filter of the Library.
|
||||
*/
|
||||
class OwnedGamesQueryGuardTest extends AbstractPersistenceTest {
|
||||
|
||||
private static final long USER_ID = 1L;
|
||||
private static final long OTHER_USER_ID = 2L;
|
||||
private static final int PAGE_SIZE = 2;
|
||||
|
||||
private static final String OLD_FROM = "from UserGame ug join SteamGame sg on sg.appid = ug.appid ";
|
||||
private static final String OLD_SELECT = "select ug " + OLD_FROM
|
||||
+ "left join UserGameEntryPlaytime ep on ep.userId = ug.userId and ep.appid = ug.appid ";
|
||||
private static final String OLD_COUNT = "select count(ug) " + OLD_FROM;
|
||||
private static final String OLD_WHERE = "where ug.userId = :userId and ug.removedAt is null " +
|
||||
"and ((:hidden = true and ug.hiddenAt is not null) or (:hidden = false and ug.hiddenAt is null)) " +
|
||||
"and (:status is null or ug.status = :status) " +
|
||||
"and (:namePattern is null or (:hidden = true and lower(sg.name) like :namePattern escape '\\') " +
|
||||
" or (:hidden = false and exists (select 1 from UserGameEntryPlatform p " +
|
||||
" join SteamGame mg on mg.appid = p.memberAppid " +
|
||||
" where p.userId = ug.userId and p.appid = ug.appid and lower(mg.name) like :namePattern escape '\\'))) " +
|
||||
"and (:maxMinutes is null or exists (select 1 from GameTimeToBeat t " +
|
||||
" where t.appid = ug.appid and t.normallyMinutes <= :maxMinutes)) " +
|
||||
"and (:hidden = true or exists (select 1 from CountedUserGame c " +
|
||||
" where c.userId = ug.userId and c.appid = ug.appid)) " +
|
||||
"and (:hidden = true or exists (select 1 from UserGameEntryPlatform p " +
|
||||
" where p.userId = ug.userId and p.appid = ug.appid and p.source in :sources))";
|
||||
|
||||
private static final List<Sort> SORTS = List.of(
|
||||
Sort.by(Sort.Direction.DESC, "playtimeMinutes"), Sort.by(Sort.Direction.ASC, "playtimeMinutes"),
|
||||
Sort.by(Sort.Direction.DESC, "lastSeenAt"), Sort.by(Sort.Direction.DESC, "firstSeenAt"),
|
||||
Sort.by(Sort.Direction.DESC, "rating"), Sort.by(Sort.Direction.DESC, "priority"));
|
||||
|
||||
@Autowired
|
||||
private UserGameRepository userGameRepository;
|
||||
|
||||
@Autowired
|
||||
private SteamGameRepository steamGameRepository;
|
||||
|
||||
@Autowired
|
||||
private GameTimeToBeatRepository gameTimeToBeatRepository;
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbc;
|
||||
|
||||
@Autowired
|
||||
private EntityManager entityManager;
|
||||
|
||||
@BeforeEach
|
||||
void seed() {
|
||||
String[] names = {"Half-Life 2", "Half-Life 2: Episode One", "Portal 2", "Half-Life Alyx", "Episode Zero",
|
||||
"Manual Game", "Console Game", "Hidden Half-Life", "Removed Game", "Plain Game"};
|
||||
List<SteamGame> catalog = new ArrayList<>();
|
||||
for (int i = 0; i < names.length; i++) catalog.add(new SteamGame(100L + i, names[i], null, Instant.now()));
|
||||
steamGameRepository.saveAll(catalog);
|
||||
|
||||
Instant now = Instant.now();
|
||||
for (long user : new long[]{USER_ID, OTHER_USER_ID}) {
|
||||
for (int i = 0; i < names.length; i++) {
|
||||
userGameRepository.save(new UserGame(user, 100L + i, (i * 137) % 600, now.minusSeconds(i * 3600L),
|
||||
now.minusSeconds((names.length - i) * 7200L)));
|
||||
}
|
||||
}
|
||||
// 100 and 101 are two editions of one set; 101 is the lesser one whose name is the only "episode" match
|
||||
jdbc.update("delete from game_link");
|
||||
jdbc.update("insert into game_link (appid, set_id) values (100, 1), (101, 1), (103, 2), (104, 2)");
|
||||
entityManager.flush();
|
||||
for (long user : new long[]{USER_ID, OTHER_USER_ID}) {
|
||||
update(user, 100, "status = 'PLAYING', rating = 4, priority = 'HIGH'");
|
||||
update(user, 102, "status = 'BACKLOG', priority = 'LOW'");
|
||||
update(user, 103, "status = 'PLAYING', rating = 5");
|
||||
update(user, 104, "status = 'BACKLOG', priority = 'MEDIUM'");
|
||||
update(user, 105, "source = 'MANUAL', status = 'BACKLOG'");
|
||||
update(user, 106, "source = 'PSN', status = 'PLAYING', rating = 5");
|
||||
update(user, 107, "hidden_at = now()");
|
||||
update(user, 108, "removed_at = now()");
|
||||
jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid, platform, playtime_minutes) "
|
||||
+ "values (?, 'c1', 'STEAM_OWNED', 102, 'PS5', 700)", user);
|
||||
}
|
||||
// The other user's state differs, so a subquery without its userId filter returns other rows. Set 2 is
|
||||
// {103, 104}: 104 has more playtime and is the counted edition. User 1 lost 103, so only user 2's entry 104
|
||||
// matches the name "half". User 2 lost 101, so its edition 100 is counted (for user 1 it is not). User 2 has
|
||||
// 104 on PSN only, where user 1 has it on Steam.
|
||||
update(USER_ID, 103, "removed_at = now()");
|
||||
update(OTHER_USER_ID, 101, "removed_at = now()");
|
||||
update(OTHER_USER_ID, 104, "source = 'PSN'");
|
||||
gameTimeToBeatRepository.saveAll(List.of(
|
||||
new GameTimeToBeat(100L, 500, 700, 1000, now),
|
||||
new GameTimeToBeat(102L, 300, 480, 900, now),
|
||||
new GameTimeToBeat(103L, 300, 480, 900, now),
|
||||
new GameTimeToBeat(104L, null, null, null, now)));
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
}
|
||||
|
||||
private void update(long user, long appid, String set) {
|
||||
jdbc.update("update user_game set " + set + " where user_id = ? and appid = ?", user, appid);
|
||||
}
|
||||
|
||||
@Test
|
||||
void findOwnedGames_MatchesTheOldQueryForEveryFilter() {
|
||||
List<Set<GameSource>> sourceSets = List.of(EnumSet.allOf(GameSource.class), EnumSet.of(GameSource.PSN),
|
||||
EnumSet.of(GameSource.STEAM, GameSource.MANUAL));
|
||||
for (boolean hidden : new boolean[]{false, true}) {
|
||||
for (GameStatus status : new GameStatus[]{null, GameStatus.PLAYING, GameStatus.BACKLOG}) {
|
||||
for (String name : new String[]{null, "half", "episode"}) {
|
||||
for (Integer maxMinutes : new Integer[]{null, 600}) {
|
||||
for (Set<GameSource> sources : sourceSets) {
|
||||
for (Sort sort : SORTS) {
|
||||
for (int page = 0; page < 2; page++) {
|
||||
String combination = "hidden=" + hidden + " status=" + status + " name=" + name
|
||||
+ " maxMinutes=" + maxMinutes + " sources=" + sources + " sort=" + sort
|
||||
+ " page=" + page;
|
||||
String pattern = UserGameRepository.containsPattern(name);
|
||||
var actual = userGameRepository.findOwnedGames(USER_ID, hidden, status, pattern,
|
||||
maxMinutes, sources, PageRequest.of(page, PAGE_SIZE, UserGameRepository.querySort(sort)));
|
||||
assertThat(actual.getContent().stream().map(UserGame::getAppid).toList())
|
||||
.as(combination)
|
||||
.isEqualTo(oldAppids(hidden, status, pattern, maxMinutes, sources, sort, page));
|
||||
assertThat(actual.getTotalElements()).as(combination)
|
||||
.isEqualTo(oldTotal(hidden, status, pattern, maxMinutes, sources));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private List<Long> oldAppids(boolean hidden, GameStatus status, String pattern, Integer maxMinutes,
|
||||
Set<GameSource> sources, Sort sort, int page) {
|
||||
String jpql = QueryUtils.applySorting(OLD_SELECT + OLD_WHERE, UserGameRepository.querySort(sort), "ug");
|
||||
Query query = bind(entityManager.createQuery(jpql), hidden, status, pattern, maxMinutes, sources);
|
||||
query.setFirstResult(page * PAGE_SIZE).setMaxResults(PAGE_SIZE);
|
||||
return ((List<UserGame>) query.getResultList()).stream().map(UserGame::getAppid).toList();
|
||||
}
|
||||
|
||||
private long oldTotal(boolean hidden, GameStatus status, String pattern, Integer maxMinutes, Set<GameSource> sources) {
|
||||
return (Long) bind(entityManager.createQuery(OLD_COUNT + OLD_WHERE), hidden, status, pattern, maxMinutes, sources)
|
||||
.getSingleResult();
|
||||
}
|
||||
|
||||
private Query bind(Query query, boolean hidden, GameStatus status, String pattern, Integer maxMinutes,
|
||||
Set<GameSource> sources) {
|
||||
return query.setParameter("userId", USER_ID).setParameter("hidden", hidden).setParameter("status", status)
|
||||
.setParameter("namePattern", pattern).setParameter("maxMinutes", maxMinutes)
|
||||
.setParameter("sources", sources);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user