refactor(library): listed_user_game view and one shared schema (V24)

This commit is contained in:
lobo committed 2026-09-30 09:37:16 +02:00
1 parent b499e79c41
commit 9a09d535fc
11 files changed
+118 -83

No files matched your search

+11 -3
View File
@@ -52,7 +52,8 @@ To Be Played is split into three repositories:
40-hour weeks, oldest unplayed game); most played and most loyal game (most months with playtime); top IGDB genres 40-hour weeks, oldest unplayed game); most played and most loyal game (most months with playtime); top IGDB genres
and moods; moon trips (Apollo 11, 195.3 h), longest day, share under one hour; finished games per year (from the and moods; moon trips (Apollo 11, 195.3 h), longest day, share under one hour; finished games per year (from the
status history); the Pile of Shame trend (90 days of `backlog_snapshot`, plus "this month: added / done", where the status history); the Pile of Shame trend (90 days of `backlog_snapshot`, plus "this month: added / done", where the
first sync of a Steam account is no "added", see `user_steam_sync.history_since`). Every number counts only listed games. first sync of a Steam account is no "added", see `user_steam_sync.history_since`). Every number counts only listed games (still owned, not hidden: the view
`listed_user_game`, V24).
- **Status history**: a database trigger logs every status change of a library game in `user_game_status_change` - **Status history**: a database trigger logs every status change of a library game in `user_game_status_change`
(old and new status, time, whether the user chose it), whatever code path made it. It starts with V14; older (old and new status, time, whether the user chose it), whatever code path made it. It starts with V14; older
changes are not known. changes are not known.
@@ -172,9 +173,9 @@ flowchart LR
Igdb -->|"OAuth token"| Twitch["Twitch"] Igdb -->|"OAuth token"| Twitch["Twitch"]
Speedrun --> SpeedrunApi["speedrun.com API"] Speedrun --> SpeedrunApi["speedrun.com API"]
LibRepo --> Db[("PostgreSQL tbp_core")] LibRepo --> Db[("PostgreSQL tbp_core")]
Stats --> Db Stats -->|"listed_user_game, deleted_user"| Db
Auth -->|"service token<br/>/internal/accounts"| Account Auth -->|"service token<br/>/internal/accounts"| Account
Account --> Db Account -->|"per-user rows, deleted_user"| Db
``` ```
| Module | Content | | Module | Content |
@@ -187,6 +188,13 @@ flowchart LR
| `igdb_adapter` | IGDB client (Twitch OAuth): time to beat, game tags for moods, release years, game search | | `igdb_adapter` | IGDB client (Twitch OAuth): time to beat, game tags for moods, release years, game search |
| `speedrun_adapter` | speedrun.com client: game search by name, verified runs | | `speedrun_adapter` | speedrun.com client: game search by name, verified runs |
**One shared schema.** All modules use one database `tbp_core`, and some read or write another module's tables in
SQL, which ArchUnit cannot see: `stats` reads `listed_user_game`, `steam_game` and `deleted_user`; `account` deletes
the per-user rows of the library tables (`AccountCleanupRepository`; a new library table with `user_id` goes into
its list); `library` reads `deleted_user`. A module that moves to its own service takes these reads along as calls.
The listed rule ("still owned, not hidden") is `UserGameRepository.LISTED` for JPQL and the view
`listed_user_game` for native SQL; V24 says what a migration of `user_game` must do with the view.
Inside a module: `api` (controllers, DTOs), `service` (business logic), `domain` (JPA entities) and `repo` Inside a module: `api` (controllers, DTOs), `service` (business logic), `domain` (JPA entities) and `repo`
(repositories). `src/test/java/core_service/architecture/ArchitectureTest.java` checks these rules on every build: (repositories). `src/test/java/core_service/architecture/ArchitectureTest.java` checks these rules on every build:
@@ -22,8 +22,7 @@ public class BacklogSnapshotRepository {
count(*) filter (where ug.playtime_minutes = 0) as never_played, count(*) filter (where ug.playtime_minutes = 0) as never_played,
coalesce(sum(t.normally_minutes) filter (where ug.status = 'BACKLOG'), 0) as backlog_minutes, coalesce(sum(t.normally_minutes) filter (where ug.status = 'BACKLOG'), 0) as backlog_minutes,
count(*) as listed_games count(*) as listed_games
from user_game ug left join game_time_to_beat t on t.appid = ug.appid from listed_user_game ug left join game_time_to_beat t on t.appid = ug.appid
where ug.removed_at is null and ug.hidden_at is null
"""; """;
private final NamedParameterJdbcTemplate jdbc; private final NamedParameterJdbcTemplate jdbc;
@@ -37,7 +36,7 @@ public class BacklogSnapshotRepository {
return jdbc.update(""" return jdbc.update("""
insert into backlog_snapshot (user_id, day, backlog_games, never_played, backlog_minutes, listed_games) insert into backlog_snapshot (user_id, day, backlog_games, never_played, backlog_minutes, listed_games)
select ug.user_id, :day, %s select ug.user_id, :day, %s
and ug.user_id not in (select user_id from deleted_user) where ug.user_id not in (select user_id from deleted_user)
group by ug.user_id group by ug.user_id
on conflict (user_id, day) do update set backlog_games = excluded.backlog_games, on conflict (user_id, day) do update set backlog_games = excluded.backlog_games,
never_played = excluded.never_played, backlog_minutes = excluded.backlog_minutes, never_played = excluded.never_played, backlog_minutes = excluded.backlog_minutes,
@@ -61,7 +60,7 @@ public class BacklogSnapshotRepository {
* The pile of now, for the day that has no row yet. * The pile of now, for the day that has no row yet.
*/ */
public UserStats.PileDay live(long userId, LocalDate day) { public UserStats.PileDay live(long userId, LocalDate day) {
return jdbc.queryForObject("select " + PILE_COLUMNS + "and ug.user_id = :userId", return jdbc.queryForObject("select " + PILE_COLUMNS + "where ug.user_id = :userId",
new MapSqlParameterSource("userId", userId), new MapSqlParameterSource("userId", userId),
(rs, i) -> new UserStats.PileDay(day, rs.getLong("backlog_games"), rs.getLong("never_played"), (rs, i) -> new UserStats.PileDay(day, rs.getLong("backlog_games"), rs.getLong("never_played"),
rs.getLong("backlog_minutes"))); rs.getLong("backlog_minutes")));
@@ -39,7 +39,8 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
*/ */
String LISTED = "ug.userId = :userId and ug.removedAt is null and ug.hiddenAt is null "; String LISTED = "ug.userId = :userId and ug.removedAt is null and ug.hiddenAt is null ";
List<UserGame> findByUserIdAndRemovedAtIsNull(Long userId); @Query("select ug from UserGame ug where " + LISTED + "and ug.source = core_service.modules.library.domain.GameSource.STEAM")
List<UserGame> findListedSteamGames(@Param("userId") Long userId);
List<UserGame> findByUserId(Long userId); List<UserGame> findByUserId(Long userId);
@@ -103,8 +104,7 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
* *
* @param excludeAppid never picked; {@link #NO_GAME} excludes nothing * @param excludeAppid never picked; {@link #NO_GAME} excludes nothing
*/ */
@Query(value = "select * from user_game where user_id = :userId and removed_at is null and hidden_at is null " + @Query(value = "select * from listed_user_game where user_id = :userId and status = 'BACKLOG' " +
"and status = 'BACKLOG' " +
"and appid <> :excludeAppid " + "and appid <> :excludeAppid " +
// Weighted random pick: the smallest -ln(u) / weight wins. // Weighted random pick: the smallest -ln(u) / weight wins.
"order by -ln(1 - random()) / case priority when 'HIGH' then 4 when 'MEDIUM' then 2 else 1 end " + "order by -ln(1 - random()) / case priority when 'HIGH' then 4 when 'MEDIUM' then 2 else 1 end " +
@@ -136,8 +136,8 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
return queryPickerGames(userId, maxMinutes, storedMood, speedrun, excluded, count); return queryPickerGames(userId, maxMinutes, storedMood, speedrun, excluded, count);
} }
@Query(value = "select ug.* from user_game ug where ug.user_id = :userId and ug.removed_at is null " + @Query(value = "select ug.* from listed_user_game ug where ug.user_id = :userId " +
"and ug.hidden_at is null and ug.status = 'BACKLOG' and ug.appid not in (:excludeAppids) " + "and ug.status = 'BACKLOG' and ug.appid not in (:excludeAppids) " +
"and (cast(:maxMinutes as integer) is null or exists (select 1 from game_time_to_beat t " + "and (cast(:maxMinutes as integer) is null or exists (select 1 from game_time_to_beat t " +
" where t.appid = ug.appid and t.normally_minutes <= :maxMinutes)) " + " where t.appid = ug.appid and t.normally_minutes <= :maxMinutes)) " +
"and (cast(:mood as varchar) is null or exists (select 1 from game_mood m " + "and (cast(:mood as varchar) is null or exists (select 1 from game_mood m " +
@@ -220,7 +220,7 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
@Modifying(clearAutomatically = true) @Modifying(clearAutomatically = true)
@Transactional @Transactional
@Query("update UserGame ug set ug.status = core_service.modules.library.domain.GameStatus.FINISHED " + @Query("update UserGame ug set ug.status = core_service.modules.library.domain.GameStatus.FINISHED " +
"where ug.userId = :userId and ug.appid = :appid and ug.removedAt is null and ug.hiddenAt is null " + "where " + LISTED + "and ug.appid = :appid " +
"and ug.statusSetByUser = false and ug.finishedHintDismissed is null " + "and ug.statusSetByUser = false and ug.finishedHintDismissed is null " +
"and ug.source = core_service.modules.library.domain.GameSource.STEAM " + "and ug.source = core_service.modules.library.domain.GameSource.STEAM " +
"and ug.status in (core_service.modules.library.domain.GameStatus.BACKLOG, " + "and ug.status in (core_service.modules.library.domain.GameStatus.BACKLOG, " +
@@ -253,7 +253,7 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
*/ */
@Modifying @Modifying
@Query("update UserGame ug set ug.status = core_service.modules.library.domain.GameStatus.PLAYING " + @Query("update UserGame ug set ug.status = core_service.modules.library.domain.GameStatus.PLAYING " +
"where ug.userId = :userId and ug.appid in :appids and ug.removedAt is null and ug.hiddenAt is null " + "where " + LISTED + "and ug.appid in :appids " +
"and ug.statusSetByUser = false " + "and ug.statusSetByUser = false " +
"and ug.status = core_service.modules.library.domain.GameStatus.BACKLOG") "and ug.status = core_service.modules.library.domain.GameStatus.BACKLOG")
int playFromPileIfUntouched(@Param("userId") Long userId, @Param("appids") Collection<Long> appids); int playFromPileIfUntouched(@Param("userId") Long userId, @Param("appids") Collection<Long> appids);
@@ -23,7 +23,7 @@ import java.util.Optional;
@RequiredArgsConstructor @RequiredArgsConstructor
public class UserStatsRepository { public class UserStatsRepository {
private static final String LISTED = "ug.user_id = :userId and ug.removed_at is null and ug.hidden_at is null "; private static final String OF_USER = "ug.user_id = :userId ";
private static final String FINISHED = "('FINISHED', 'COMPLETED')"; private static final String FINISHED = "('FINISHED', 'COMPLETED')";
/** A status change that finished the game: from another status; not a game that came in finished. */ /** A status change that finished the game: from another status; not a game that came in finished. */
private static final String FINISH_CHANGE = private static final String FINISH_CHANGE =
@@ -46,9 +46,9 @@ public class UserStatsRepository {
return jdbc.queryForObject(""" return jdbc.queryForObject("""
select coalesce(sum(t.normally_minutes), 0) as normal, coalesce(sum(t.completely_minutes), 0) as complete, select coalesce(sum(t.normally_minutes), 0) as normal, coalesce(sum(t.completely_minutes), 0) as complete,
count(t.normally_minutes) as known count(t.normally_minutes) as known
from user_game ug join game_time_to_beat t on t.appid = ug.appid from listed_user_game ug join game_time_to_beat t on t.appid = ug.appid
where %s and ug.status = 'BACKLOG' where %s and ug.status = 'BACKLOG'
""".formatted(LISTED), user(userId), """.formatted(OF_USER), user(userId),
(rs, i) -> new UserStats.PileTimes(rs.getLong("normal"), rs.getLong("complete"), rs.getLong("known"))); (rs, i) -> new UserStats.PileTimes(rs.getLong("normal"), rs.getLong("complete"), rs.getLong("known")));
} }
@@ -62,10 +62,10 @@ public class UserStatsRepository {
public Optional<UserStats.OldestGame> oldestUnplayed(long userId) { public Optional<UserStats.OldestGame> oldestUnplayed(long userId) {
return jdbc.query(""" return jdbc.query("""
select ug.appid, sg.name, sg.image_url, ug.first_seen_at select ug.appid, sg.name, sg.image_url, ug.first_seen_at
from user_game ug join steam_game sg on sg.appid = ug.appid from listed_user_game ug join steam_game sg on sg.appid = ug.appid
where %s and ug.status = 'BACKLOG' and ug.playtime_minutes = 0 where %s and ug.status = 'BACKLOG' and ug.playtime_minutes = 0
order by ug.first_seen_at, ug.appid limit 1 order by ug.first_seen_at, ug.appid limit 1
""".formatted(LISTED), user(userId), """.formatted(OF_USER), user(userId),
(rs, i) -> new UserStats.OldestGame(rs.getLong("appid"), rs.getString("name"), (rs, i) -> new UserStats.OldestGame(rs.getLong("appid"), rs.getString("name"),
rs.getString("image_url"), rs.getTimestamp("first_seen_at").toInstant())) rs.getString("image_url"), rs.getTimestamp("first_seen_at").toInstant()))
.stream().findFirst(); .stream().findFirst();
@@ -74,10 +74,10 @@ public class UserStatsRepository {
public List<UserStats.GameMinutes> mostPlayed(long userId, int limit) { public List<UserStats.GameMinutes> mostPlayed(long userId, int limit) {
return jdbc.query(""" return jdbc.query("""
select ug.appid, sg.name, sg.image_url, ug.playtime_minutes select ug.appid, sg.name, sg.image_url, ug.playtime_minutes
from user_game ug join steam_game sg on sg.appid = ug.appid from listed_user_game ug join steam_game sg on sg.appid = ug.appid
where %s and ug.playtime_minutes > 0 where %s and ug.playtime_minutes > 0
order by ug.playtime_minutes desc, ug.appid limit :limit order by ug.playtime_minutes desc, ug.appid limit :limit
""".formatted(LISTED), user(userId).addValue("limit", limit), """.formatted(OF_USER), user(userId).addValue("limit", limit),
(rs, i) -> new UserStats.GameMinutes(rs.getLong("appid"), rs.getString("name"), (rs, i) -> new UserStats.GameMinutes(rs.getLong("appid"), rs.getString("name"),
rs.getString("image_url"), rs.getLong("playtime_minutes"))); rs.getString("image_url"), rs.getLong("playtime_minutes")));
} }
@@ -90,7 +90,7 @@ public class UserStatsRepository {
select l.appid, sg.name, sg.image_url, l.months, l.minutes select l.appid, sg.name, sg.image_url, l.months, l.minutes
from (select p.appid, count(distinct date_trunc('month', p.day)) as months, sum(p.minutes) as minutes from (select p.appid, count(distinct date_trunc('month', p.day)) as months, sum(p.minutes) as minutes
%s group by p.appid) l %s group by p.appid) l
join user_game ug on ug.user_id = :userId and ug.appid = l.appid and ug.removed_at is null join listed_user_game ug on ug.user_id = :userId and ug.appid = l.appid
join steam_game sg on sg.appid = l.appid join steam_game sg on sg.appid = l.appid
order by l.months desc, l.minutes desc, l.appid limit 1 order by l.months desc, l.minutes desc, l.appid limit 1
""".formatted(PLAYTIME_DAYS_NOT_HIDDEN), user(userId), """.formatted(PLAYTIME_DAYS_NOT_HIDDEN), user(userId),
@@ -110,10 +110,10 @@ public class UserStatsRepository {
private List<UserStats.TagCount> tagCounts(long userId, String column, int limit) { private List<UserStats.TagCount> tagCounts(long userId, String column, int limit) {
return jdbc.query(""" return jdbc.query("""
select tag.name, count(*) as games, coalesce(sum(ug.playtime_minutes), 0) as minutes select tag.name, count(*) as games, coalesce(sum(ug.playtime_minutes), 0) as minutes
from user_game ug join game_mood m on m.appid = ug.appid cross join unnest(m.%s) as tag(name) from listed_user_game ug join game_mood m on m.appid = ug.appid cross join unnest(m.%s) as tag(name)
where %s where %s
group by tag.name order by minutes desc, games desc, tag.name limit :limit group by tag.name order by minutes desc, games desc, tag.name limit :limit
""".formatted(column, LISTED), user(userId).addValue("limit", limit), """.formatted(column, OF_USER), user(userId).addValue("limit", limit),
(rs, i) -> new UserStats.TagCount(rs.getString("name"), rs.getLong("games"), rs.getLong("minutes"))); (rs, i) -> new UserStats.TagCount(rs.getString("name"), rs.getLong("games"), rs.getLong("minutes")));
} }
@@ -128,8 +128,8 @@ public class UserStatsRepository {
return jdbc.queryForObject(""" return jdbc.queryForObject("""
select count(*) filter (where ug.playtime_minutes > 0) as started, select count(*) filter (where ug.playtime_minutes > 0) as started,
count(*) filter (where ug.playtime_minutes between 1 and 59) as under_one_hour count(*) filter (where ug.playtime_minutes between 1 and 59) as under_one_hour
from user_game ug where %s from listed_user_game ug where %s
""".formatted(LISTED), user(userId), """.formatted(OF_USER), user(userId),
(rs, i) -> new UserStats.PlayedCounts(rs.getLong("started"), rs.getLong("under_one_hour"))); (rs, i) -> new UserStats.PlayedCounts(rs.getLong("started"), rs.getLong("under_one_hour")));
} }
@@ -141,20 +141,20 @@ public class UserStatsRepository {
return jdbc.query(""" return jdbc.query("""
select cast(extract(year from coalesce(ug.finished_at_estimate, f.finished_at) at time zone :zone) select cast(extract(year from coalesce(ug.finished_at_estimate, f.finished_at) at time zone :zone)
as integer) as year, count(*) as games as integer) as year, count(*) as games
from user_game ug left join (%s) f on f.appid = ug.appid from listed_user_game ug left join (%s) f on f.appid = ug.appid
where %s and ug.status in %s and coalesce(ug.finished_at_estimate, f.finished_at) is not null where %s and ug.status in %s and coalesce(ug.finished_at_estimate, f.finished_at) is not null
group by year order by year group by year order by year
""".formatted(FIRST_FINISH, LISTED, FINISHED), user(userId).addValue("zone", zone.getId()), """.formatted(FIRST_FINISH, OF_USER, FINISHED), user(userId).addValue("zone", zone.getId()),
(rs, i) -> new UserStats.YearCount(rs.getInt("year"), rs.getLong("games"))); (rs, i) -> new UserStats.YearCount(rs.getInt("year"), rs.getLong("games")));
} }
public long finishedBeforeTracking(long userId) { public long finishedBeforeTracking(long userId) {
Long games = jdbc.queryForObject(""" Long games = jdbc.queryForObject("""
select count(*) from user_game ug select count(*) from listed_user_game ug
where %s and ug.status in %s and ug.finished_at_estimate is null where %s and ug.status in %s and ug.finished_at_estimate is null
and not exists (select 1 from user_game_status_change sc and not exists (select 1 from user_game_status_change sc
where sc.user_id = ug.user_id and sc.appid = ug.appid and %s) where sc.user_id = ug.user_id and sc.appid = ug.appid and %s)
""".formatted(LISTED, FINISHED, FINISH_CHANGE), user(userId), Long.class); """.formatted(OF_USER, FINISHED, FINISH_CHANGE), user(userId), Long.class);
return games != null ? games : 0L; return games != null ? games : 0L;
} }
@@ -170,10 +170,10 @@ public class UserStatsRepository {
or sc.from_status is not null or sc.changed_at > uss.history_since)) as added, or sc.from_status is not null or sc.changed_at > uss.history_since)) as added,
count(distinct sc.appid) filter (where sc.from_status = 'BACKLOG' and sc.to_status <> 'BACKLOG') count(distinct sc.appid) filter (where sc.from_status = 'BACKLOG' and sc.to_status <> 'BACKLOG')
as done as done
from user_game_status_change sc join user_game ug on ug.user_id = sc.user_id and ug.appid = sc.appid from user_game_status_change sc join listed_user_game ug on ug.user_id = sc.user_id and ug.appid = sc.appid
left join user_steam_sync uss on uss.user_id = sc.user_id left join user_steam_sync uss on uss.user_id = sc.user_id
where %s and sc.changed_at >= :monthStart where %s and sc.changed_at >= :monthStart
""".formatted(LISTED), user(userId).addValue("monthStart", Timestamp.from(monthStart)), """.formatted(OF_USER), user(userId).addValue("monthStart", Timestamp.from(monthStart)),
(rs, i) -> new UserStats.MonthMoves(rs.getLong("added"), rs.getLong("done"))); (rs, i) -> new UserStats.MonthMoves(rs.getLong("added"), rs.getLong("done")));
} }
@@ -3,7 +3,6 @@ package core_service.modules.library.service;
import core_service.modules.library.domain.GameAchievementSchema; import core_service.modules.library.domain.GameAchievementSchema;
import core_service.modules.library.domain.EndingAchievement; import core_service.modules.library.domain.EndingAchievement;
import core_service.modules.library.domain.GameAchievementSchemaMeta; import core_service.modules.library.domain.GameAchievementSchemaMeta;
import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.GameStatus; import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.UserGame; import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameAchievement; import core_service.modules.library.domain.UserGameAchievement;
@@ -163,8 +162,7 @@ public class AchievementSyncService {
} }
private void runLibrarySync(Long userId, String steamId) { private void runLibrarySync(Long userId, String steamId) {
List<UserGame> games = userGameRepository.findByUserIdAndRemovedAtIsNull(userId).stream() List<UserGame> games = userGameRepository.findListedSteamGames(userId).stream()
.filter(game -> game.getSource() == GameSource.STEAM && game.getHiddenAt() == null)
.sorted(Comparator.comparingInt(UserGame::getPlaytimeMinutes).reversed()) .sorted(Comparator.comparingInt(UserGame::getPlaytimeMinutes).reversed())
.toList(); .toList();
Map<Long, GameAchievementSchemaMeta> schemas = schemaMetaRepository Map<Long, GameAchievementSchemaMeta> schemas = schemaMetaRepository
@@ -10,8 +10,7 @@ import java.util.Map;
@Repository @Repository
public class StatsRepository { public class StatsRepository {
private static final String LISTED = private static final String NOT_DELETED = "user_id NOT IN (SELECT user_id FROM deleted_user)";
"removed_at IS NULL AND hidden_at IS NULL AND user_id NOT IN (SELECT user_id FROM deleted_user)";
private final JdbcTemplate jdbc; private final JdbcTemplate jdbc;
@@ -22,77 +21,75 @@ public class StatsRepository {
public long countDistinctGames() { public long countDistinctGames() {
Long v = jdbc.queryForObject( Long v = jdbc.queryForObject(
"SELECT COUNT(DISTINCT appid) FROM user_game WHERE " + LISTED, Long.class); "SELECT COUNT(DISTINCT appid) FROM listed_user_game WHERE " + NOT_DELETED, Long.class);
return v != null ? v : 0L; return v != null ? v : 0L;
} }
public long countLibraryEntries() { public long countLibraryEntries() {
Long v = jdbc.queryForObject( Long v = jdbc.queryForObject(
"SELECT COUNT(*) FROM user_game WHERE " + LISTED, Long.class); "SELECT COUNT(*) FROM listed_user_game WHERE " + NOT_DELETED, Long.class);
return v != null ? v : 0L; return v != null ? v : 0L;
} }
public long countGamesPlayed() { public long countGamesPlayed() {
Long v = jdbc.queryForObject( Long v = jdbc.queryForObject(
"SELECT COUNT(*) FROM user_game WHERE " + LISTED + " AND playtime_minutes > 0", Long.class); "SELECT COUNT(*) FROM listed_user_game WHERE " + NOT_DELETED + " AND playtime_minutes > 0", Long.class);
return v != null ? v : 0L; return v != null ? v : 0L;
} }
public long sumPlaytimeMinutes() { public long sumPlaytimeMinutes() {
Long v = jdbc.queryForObject( Long v = jdbc.queryForObject(
"SELECT COALESCE(SUM(playtime_minutes), 0) FROM user_game WHERE " + LISTED, Long.class); "SELECT COALESCE(SUM(playtime_minutes), 0) FROM listed_user_game WHERE " + NOT_DELETED, Long.class);
return v != null ? v : 0L; return v != null ? v : 0L;
} }
public long countUsersWithLibrary() { public long countUsersWithLibrary() {
Long v = jdbc.queryForObject( Long v = jdbc.queryForObject(
"SELECT COUNT(DISTINCT user_id) FROM user_game WHERE " + LISTED, Long.class); "SELECT COUNT(DISTINCT user_id) FROM listed_user_game WHERE " + NOT_DELETED, Long.class);
return v != null ? v : 0L; return v != null ? v : 0L;
} }
public long biggestLibrary() { public long biggestLibrary() {
Long v = jdbc.queryForObject(""" Long v = jdbc.queryForObject("""
SELECT COALESCE(MAX(c), 0) FROM ( SELECT COALESCE(MAX(c), 0) FROM (
SELECT COUNT(*) AS c FROM user_game SELECT COUNT(*) AS c FROM listed_user_game
WHERE %s GROUP BY user_id WHERE %s GROUP BY user_id
) t ) t
""".formatted(LISTED), Long.class); """.formatted(NOT_DELETED), Long.class);
return v != null ? v : 0L; return v != null ? v : 0L;
} }
public long biggestBacklog() { public long biggestBacklog() {
Long v = jdbc.queryForObject(""" Long v = jdbc.queryForObject("""
SELECT COALESCE(MAX(c), 0) FROM ( SELECT COALESCE(MAX(c), 0) FROM (
SELECT COUNT(*) AS c FROM user_game SELECT COUNT(*) AS c FROM listed_user_game
WHERE %s AND playtime_minutes = 0 GROUP BY user_id WHERE %s AND playtime_minutes = 0 GROUP BY user_id
) t ) t
""".formatted(LISTED), Long.class); """.formatted(NOT_DELETED), Long.class);
return v != null ? v : 0L; return v != null ? v : 0L;
} }
public List<Map<String, Object>> topOwnedGames(int limit) { public List<Map<String, Object>> topOwnedGames(int limit) {
return jdbc.queryForList(""" return jdbc.queryForList("""
SELECT ug.appid, sg.name, sg.img_icon_url, sg.image_url, COUNT(*) AS value SELECT ug.appid, sg.name, sg.img_icon_url, sg.image_url, COUNT(*) AS value
FROM user_game ug FROM listed_user_game ug
LEFT JOIN steam_game sg ON sg.appid = ug.appid LEFT JOIN steam_game sg ON sg.appid = ug.appid
WHERE ug.removed_at IS NULL AND ug.hidden_at IS NULL WHERE %s
AND ug.user_id NOT IN (SELECT user_id FROM deleted_user)
GROUP BY ug.appid, sg.name, sg.img_icon_url, sg.image_url GROUP BY ug.appid, sg.name, sg.img_icon_url, sg.image_url
ORDER BY value DESC ORDER BY value DESC
LIMIT ? LIMIT ?
""", limit); """.formatted(NOT_DELETED), limit);
} }
public List<Map<String, Object>> topPlayedGames(int limit) { public List<Map<String, Object>> topPlayedGames(int limit) {
return jdbc.queryForList(""" return jdbc.queryForList("""
SELECT ug.appid, sg.name, sg.img_icon_url, sg.image_url, SUM(ug.playtime_minutes) AS value SELECT ug.appid, sg.name, sg.img_icon_url, sg.image_url, SUM(ug.playtime_minutes) AS value
FROM user_game ug FROM listed_user_game ug
LEFT JOIN steam_game sg ON sg.appid = ug.appid LEFT JOIN steam_game sg ON sg.appid = ug.appid
WHERE ug.removed_at IS NULL AND ug.hidden_at IS NULL WHERE %s
AND ug.user_id NOT IN (SELECT user_id FROM deleted_user)
GROUP BY ug.appid, sg.name, sg.img_icon_url, sg.image_url GROUP BY ug.appid, sg.name, sg.img_icon_url, sg.image_url
ORDER BY value DESC ORDER BY value DESC
LIMIT ? LIMIT ?
""", limit); """.formatted(NOT_DELETED), limit);
} }
} }
@@ -0,0 +1,6 @@
-- The games a user's lists and numbers show: still owned and not hidden. Native SQL reads this view; JPQL uses
-- UserGameRepository.LISTED. Postgres fixes the columns of "select *" at creation and blocks dropping or retyping a
-- column the view uses: a migration that adds, drops or retypes a column of user_game drops this view first and
-- creates it again.
CREATE VIEW listed_user_game AS
SELECT * FROM user_game WHERE removed_at IS NULL AND hidden_at IS NULL;
@@ -118,8 +118,11 @@ class AccountCleanupRepositoryTest extends AbstractPersistenceTest {
// A new table with user rows must be in the purge list, or "Delete my account" leaves its rows behind. // A new table with user rows must be in the purge list, or "Delete my account" leaves its rows behind.
@Test @Test
void tables_CoverEveryTableWithAUserId() { void tables_CoverEveryTableWithAUserId() {
List<String> withUserId = jdbc.queryForList("select table_name from information_schema.columns " // Base tables only: a view like listed_user_game has no rows of its own.
+ "where table_schema = 'public' and column_name = 'user_id' order by table_name", String.class); List<String> withUserId = jdbc.queryForList("select c.table_name from information_schema.columns c "
+ "join information_schema.tables t on t.table_schema = c.table_schema and t.table_name = c.table_name "
+ "where c.table_schema = 'public' and c.column_name = 'user_id' and t.table_type = 'BASE TABLE' "
+ "order by c.table_name", String.class);
assertThat(AccountCleanupRepository.TABLES).containsExactlyInAnyOrderElementsOf(withUserId); assertThat(AccountCleanupRepository.TABLES).containsExactlyInAnyOrderElementsOf(withUserId);
} }
@@ -0,0 +1,23 @@
package core_service.modules.library.repo;
import core_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import static org.junit.jupiter.api.Assertions.assertEquals;
class ListedUserGameViewTest extends AbstractPersistenceTest {
private static final String COLUMNS = "select column_name from information_schema.columns "
+ "where table_schema = 'public' and table_name = ? order by ordinal_position";
@Autowired
private JdbcTemplate jdbc;
@Test
void view_HasEveryColumnOfUserGame() {
assertEquals(jdbc.queryForList(COLUMNS, String.class, "user_game"),
jdbc.queryForList(COLUMNS, String.class, "listed_user_game"));
}
}
@@ -124,6 +124,29 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
assertEquals(Set.of(220L, 380L), Set.copyOf(userGameRepository.findBacklogAppids(USER_ID))); assertEquals(Set.of(220L, 380L), Set.copyOf(userGameRepository.findBacklogAppids(USER_ID)));
} }
@Test
void findListedSteamGames_LeavesOutHiddenRemovedAndHandAddedGames() {
userGameRepository.findById(new UserGameId(USER_ID, 380L)).orElseThrow().setHiddenAt(Instant.now());
userGameRepository.findById(new UserGameId(USER_ID, 700L)).orElseThrow().setRemovedAt(Instant.now());
steamGameRepository.save(new SteamGame(999L, "Elden Ring", null, Instant.now()));
userGameRepository.save(UserGame.manual(USER_ID, 999L, "PS5", GameStatus.PLAYING, 60, Instant.now()));
userGameRepository.flush();
assertEquals(Set.of(220L, 620L),
Set.copyOf(userGameRepository.findListedSteamGames(USER_ID).stream().map(UserGame::getAppid).toList()));
}
@Test
void listedUserGameView_LeavesOutHiddenAndRemovedGames() {
userGameRepository.findById(new UserGameId(USER_ID, 380L)).orElseThrow().setHiddenAt(Instant.now());
userGameRepository.findById(new UserGameId(USER_ID, 700L)).orElseThrow().setRemovedAt(Instant.now());
userGameRepository.flush();
assertEquals(Set.of(220L, 620L), Set.copyOf(entityManager.createNativeQuery(
"select appid from listed_user_game where user_id = :userId", Long.class)
.setParameter("userId", USER_ID).getResultList()));
}
@Test @Test
void findAchievementProgress_LeavesOutGamesAddedByHand() { void findAchievementProgress_LeavesOutGamesAddedByHand() {
steamGameRepository.save(new SteamGame(999L, "Elden Ring", null, Instant.now())); steamGameRepository.save(new SteamGame(999L, "Elden Ring", null, Instant.now()));
@@ -96,7 +96,7 @@ class AchievementSyncServiceTest extends AbstractUnitTest {
} }
private void library(List<UserGame> games, List<GameAchievementSchemaMeta> schemas, List<UserGameAchievementMeta> metas) { private void library(List<UserGame> games, List<GameAchievementSchemaMeta> schemas, List<UserGameAchievementMeta> metas) {
when(userGameRepository.findByUserIdAndRemovedAtIsNull(USER_ID)).thenReturn(games); when(userGameRepository.findListedSteamGames(USER_ID)).thenReturn(games);
when(schemaMetaRepository.findAllById(any())).thenReturn(schemas); when(schemaMetaRepository.findAllById(any())).thenReturn(schemas);
when(userAchievementMetaRepository.findByUserId(USER_ID)).thenReturn(metas); when(userAchievementMetaRepository.findByUserId(USER_ID)).thenReturn(metas);
} }
@@ -117,28 +117,6 @@ class AchievementSyncServiceTest extends AbstractUnitTest {
verify(userAchievementMetaRepository, never()).save(any()); verify(userAchievementMetaRepository, never()).save(any());
} }
@Test
void syncLibrary_GameAddedByHand_AsksSteamNothing() {
UserGame manual = UserGame.manual(USER_ID, 620L, "PS5", core_service.modules.library.domain.GameStatus.PLAYING,
150, Instant.now());
library(List.of(manual), List.of(schema(620L, 10)), List.of());
service.syncLibrary(USER_ID, STEAM_ID);
verifyNoInteractions(steamApiService);
}
@Test
void syncLibrary_HiddenGame_AsksSteamNothing() {
UserGame hidden = game(620L, 120);
hidden.setHiddenAt(Instant.now());
library(List.of(hidden), List.of(), List.of());
service.syncLibrary(USER_ID, STEAM_ID);
verifyNoInteractions(steamApiService);
}
@Test @Test
void syncLibrary_ChangedPlaytime_StoresUnlocksAndThePlaytime() { void syncLibrary_ChangedPlaytime_StoresUnlocksAndThePlaytime() {
library(List.of(game(620L, 150)), List.of(schema(620L, 10)), List.of(unlocks(620L, 3, 120))); library(List.of(game(620L, 150)), List.of(schema(620L, 10)), List.of(unlocks(620L, 3, 120)));
@@ -358,7 +336,7 @@ class AchievementSyncServiceTest extends AbstractUnitTest {
@Test @Test
void syncLibrary_CallWhileRunning_RunsOnceMoreAfterwards() throws Exception { void syncLibrary_CallWhileRunning_RunsOnceMoreAfterwards() throws Exception {
when(userGameRepository.findByUserIdAndRemovedAtIsNull(USER_ID)).thenReturn(List.of(game(620L, 60))); when(userGameRepository.findListedSteamGames(USER_ID)).thenReturn(List.of(game(620L, 60)));
when(schemaMetaRepository.findAllById(any())).thenReturn(List.of(schema(620L, 10))); when(schemaMetaRepository.findAllById(any())).thenReturn(List.of(schema(620L, 10)));
// The second run sees the row the first run stored. // The second run sees the row the first run stored.
when(userAchievementMetaRepository.findByUserId(USER_ID)) when(userAchievementMetaRepository.findByUserId(USER_ID))
@@ -378,7 +356,7 @@ class AchievementSyncServiceTest extends AbstractUnitTest {
first.join(5_000); first.join(5_000);
verify(steamApiService, times(1)).getPlayerAchievements(STEAM_ID, 620L); verify(steamApiService, times(1)).getPlayerAchievements(STEAM_ID, 620L);
verify(userGameRepository, times(2)).findByUserIdAndRemovedAtIsNull(USER_ID); verify(userGameRepository, times(2)).findListedSteamGames(USER_ID);
} }
@Test @Test
@@ -399,8 +377,8 @@ class AchievementSyncServiceTest extends AbstractUnitTest {
AchievementSyncService paced = new AchievementSyncService(userGameRepository, schemaRepository, schemaMetaRepository, AchievementSyncService paced = new AchievementSyncService(userGameRepository, schemaRepository, schemaMetaRepository,
userAchievementRepository, userAchievementMetaRepository, steamApiService, userAchievementRepository, userAchievementMetaRepository, steamApiService,
mock(PlatformTransactionManager.class), 30, pauseMillis); mock(PlatformTransactionManager.class), 30, pauseMillis);
when(userGameRepository.findByUserIdAndRemovedAtIsNull(1L)).thenReturn(List.of(new UserGame(1L, 620L, 60, Instant.now(), Instant.now()))); when(userGameRepository.findListedSteamGames(1L)).thenReturn(List.of(new UserGame(1L, 620L, 60, Instant.now(), Instant.now())));
when(userGameRepository.findByUserIdAndRemovedAtIsNull(2L)).thenReturn(List.of(new UserGame(2L, 620L, 60, Instant.now(), Instant.now()))); when(userGameRepository.findListedSteamGames(2L)).thenReturn(List.of(new UserGame(2L, 620L, 60, Instant.now(), Instant.now())));
when(schemaMetaRepository.findAllById(any())).thenReturn(List.of(schema(620L, 10))); when(schemaMetaRepository.findAllById(any())).thenReturn(List.of(schema(620L, 10)));
when(userAchievementMetaRepository.findByUserId(anyLong())).thenReturn(List.of()); when(userAchievementMetaRepository.findByUserId(anyLong())).thenReturn(List.of());
List<Long> steamCallsAtNanos = Collections.synchronizedList(new ArrayList<>()); List<Long> steamCallsAtNanos = Collections.synchronizedList(new ArrayList<>());