Public Access
refactor(library): listed_user_game view and one shared schema (V24)
This commit is contained in:
1 parent
b499e79c41
commit
9a09d535fc
11 files changed
+118
-83
No files matched your search
@@ -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<>());
|
||||||
|
|||||||
Reference in new issue
Block a user